From 8fdb6a41975f491562bf7f3471e4940b01fbd89c Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 16 Aug 2026 07:58:55 +0200 Subject: [PATCH] fix(edit): dropped compact seen-line retry tokens (#8461) Reverted the retry-token continuation flow (merge 34628528bf and prompt follow-up 446e745bf4): seen-line rejections resend the full patch again. --- packages/coding-agent/CHANGELOG.md | 1 - .../coding-agent/src/edit/hashline/execute.ts | 103 ++------------ .../src/edit/hashline/seen-line-retry.md | 5 - .../coding-agent/src/tools/output-meta.ts | 5 - .../test/edit/seen-line-guard.test.ts | 130 ------------------ packages/hashline/CHANGELOG.md | 6 +- packages/hashline/src/grammar.lark | 5 +- packages/hashline/src/patcher.ts | 36 ++--- packages/hashline/src/prompt.md | 4 - packages/hashline/src/snapshots.ts | 3 +- packages/hashline/test/patcher.test.ts | 33 +---- 11 files changed, 28 insertions(+), 303 deletions(-) delete mode 100644 packages/coding-agent/src/edit/hashline/seen-line-retry.md diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 24350e2b8..3abcad84f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -31,7 +31,6 @@ - Continued Cursor turns that died with `NGHTTP2_INTERNAL_ERROR` / `NGHTTP2_REFUSED_STREAM` after tool calls already had results, instead of leaving the agent idle until the user typed "continue". HTTP/2 stream resets now use the same preserve-and-continue path as idle stream stalls, without requiring the Cursor exec-resolved marker that MCP/todo blocks never carry. - Stopped mixed-case plugin tool names from being lowercased during tool-set refresh, which unmounted them from `xd://` whenever MCP tools connected. - Kept Exa MCP servers mounted when their config explicitly requests tools the native Exa integration does not provide (`web_fetch_exa`, `web_search_advanced_exa`), instead of filtering them out so `/mcp reconnect exa` could never connect. -- Fixed seen-line guard retries forcing agents to resend entire unchanged patches. Complete inline reveals now issue one-shot `RETRY ` continuations that rerun validation against live files, while numbered lines in successful edit output join the returned snapshot's seen-line provenance only when the written content exactly matches that output. - Fixed Claude Code custom tool discovery attempting to import non-module files from `.claude/tools` ([#8471](https://github.com/can1357/oh-my-pi/pull/8471) by [@Kigbnajd](https://github.com/Kigbnajd)). - Fixed Agent Hub parking a mid-spawn child JSONL (title + session header only) so `task` then fails with `already owned by another session generation` and the row cannot be revived. - Fixed the welcome banner displaying a stale model name when the session's active model changes after startup (e.g. after a delayed config load or an explicit `/model` switch). The banner now subscribes to `model_changed` events and calls `WelcomeComponent.setModel()`, which already existed but was never wired up. diff --git a/packages/coding-agent/src/edit/hashline/execute.ts b/packages/coding-agent/src/edit/hashline/execute.ts index ac573866d..4c63499f8 100644 --- a/packages/coding-agent/src/edit/hashline/execute.ts +++ b/packages/coding-agent/src/edit/hashline/execute.ts @@ -10,7 +10,6 @@ * batch's `flush` flag to true only for the final write so diagnostics * round-trip once. */ -import { randomUUID } from "node:crypto"; import { type BlockResolution, buildCompactDiffPreview, @@ -18,31 +17,27 @@ import { commitClipboard, forkClipboard, MismatchError as HashlineMismatchError, - normalizeToLF, Patch, Patcher, type PatchSectionResult, type PreparedSection, startClipboardBatch, - stripBom, - UnseenLinesError, } from "@oh-my-pi/hashline"; import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; import { prompt } from "@oh-my-pi/pi-utils"; import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp"; import type { ToolSession } from "../../tools"; -import { outputMeta, resolveArtifactSpillThresholdBytes } from "../../tools/output-meta"; +import { outputMeta } from "../../tools/output-meta"; import { ToolError } from "../../tools/tool-errors"; import { generateDiffString } from "../diff"; import { getEditClipboard } from "../edit-clipboard"; -import { canonicalSnapshotKey, getFileSnapshotStore, recordSeenLinesFromBody } from "../file-snapshot-store"; +import { getFileSnapshotStore } from "../file-snapshot-store"; import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer"; import { pruneOversizedEditSnapshots } from "../snapshot-details"; import { nativeBlockResolver } from "./block-resolver"; import { HashlineFilesystem } from "./filesystem"; import { hashPatchInput, NOOP_HARD_LIMIT, recordNoopEdit, resetNoopEdit } from "./noop-loop-guard"; import { type HashlineParams, hashlineEditParamsSchema } from "./params"; -import seenLineRetryPrompt from "./seen-line-retry.md" with { type: "text" }; export interface ExecuteHashlineSingleOptions { session: ToolSession; @@ -53,47 +48,6 @@ export interface ExecuteHashlineSingleOptions { beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle; } -interface PendingSeenLineRetry { - token: string; - input: string; -} - -const pendingSeenLineRetries = new WeakMap(); -const SEEN_LINE_RETRY_INPUT = /^RETRY ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n?$/i; - -function resolveHashlineInput(session: ToolSession, input: string): string { - const match = SEEN_LINE_RETRY_INPUT.exec(input); - if (!match) { - pendingSeenLineRetries.delete(session); - return input; - } - const pending = pendingSeenLineRetries.get(session); - if (!pending || pending.token.toLowerCase() !== match[1].toLowerCase()) { - throw new ToolError( - "Unknown or expired edit retry token. Use the token from the latest seen-line rejection, or submit a full patch.", - ); - } - pendingSeenLineRetries.delete(session); - return pending.input; -} - -async function prepareWithSeenLineRetry( - patcher: Patcher, - section: Parameters[0], - clipboard: Clipboard, - session: ToolSession, - input: string, -): Promise { - try { - return await patcher.prepare(section, clipboard); - } catch (error) { - if (!(error instanceof UnseenLinesError) || !error.retryable) throw error; - const token = randomUUID(); - pendingSeenLineRetries.set(session, { token, input }); - throw new ToolError(prompt.render(seenLineRetryPrompt, { error: error.message, token })); - } -} - function noChangeDiagnostic(path: string): string { // The patch parsed and applied cleanly but produced no change — the // `+TEXT` body rows matched the file content at the targeted lines @@ -145,33 +99,9 @@ function narrowBatchRequest(outer: LspBatchRequest | undefined, isLast: boolean) return { id: outer.id, flush: isLast && outer.flush }; } -interface SeenLineProvenance { - absolutePath: string; - tag: string; - body: string; -} - interface RenderedSection { toolResult: AgentToolResult; perFileResult: EditToolPerFileResult; - seenLineProvenance?: SeenLineProvenance; -} - -function recordRenderedSeenLines( - session: ToolSession, - result: AgentToolResult, - rendered: readonly RenderedSection[], -): void { - const fullText = result.content - .filter(part => part.type === "text" && part.text) - .map(part => (part.type === "text" ? part.text : "")) - .join("\n"); - if (Buffer.byteLength(fullText, "utf8") > resolveArtifactSpillThresholdBytes(session.settings)) return; - for (const section of rendered) { - const provenance = section.seenLineProvenance; - if (!provenance) continue; - recordSeenLinesFromBody(session, provenance.absolutePath, provenance.tag, provenance.body); - } } const BLOCK_OP_LABELS: Record = { @@ -247,22 +177,12 @@ function renderSection( : ""; const moveBlock = result.moveDest ? `\nMoved to ${result.moveDest}` : ""; const firstChangedLine = result.firstChangedLine ?? diff.firstChangedLine; - const text = `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`; - const seenLineProvenance = - normalizeToLF(stripBom(result.written).text) === result.after - ? { - absolutePath: canonicalSnapshotKey(result.canonicalPath), - tag: result.fileHash, - body: text, - } - : undefined; return { - seenLineProvenance, toolResult: { content: [ { type: "text", - text, + text: `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`, }, ], details: pruneOversizedEditSnapshots({ @@ -295,8 +215,7 @@ function renderSection( export async function executeHashlineSingle( options: ExecuteHashlineSingleOptions, ): Promise> { - const input = resolveHashlineInput(options.session, options.input); - const patch = Patch.parse(input, { cwd: options.session.cwd }); + const patch = Patch.parse(options.input, { cwd: options.session.cwd }); if (patch.sections.length === 0) { throw new Error("No hashline sections found in input."); } @@ -319,10 +238,10 @@ export async function executeHashlineSingle( const clipboard = startClipboardBatch(sessionClipboard); // Single-section fast path: prepare, commit, render. - const inputHash = hashPatchInput(input); + const inputHash = hashPatchInput(options.input); if (patch.sections.length === 1) { fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true)); - const prepared = await prepareWithSeenLineRetry(patcher, patch.sections[0], clipboard, options.session, input); + const prepared = await patcher.prepare(patch.sections[0], clipboard); const sectionResult = await patcher.commit(prepared); commitClipboard(clipboard, sessionClipboard); if (sectionResult.op === "noop") { @@ -333,9 +252,7 @@ export async function executeHashlineSingle( return renderSection(sectionResult, undefined, prepared.section.path).toolResult; } resetNoopEdit(options.session, sectionResult.canonicalPath); - const rendered = renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared.section.path); - recordRenderedSeenLines(options.session, rendered.toolResult, [rendered]); - return rendered.toolResult; + return renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared.section.path).toolResult; } // Multi-section: prepare every section up front so we fail fast before @@ -348,7 +265,7 @@ export async function executeHashlineSingle( // deleted would otherwise be lost. const sectionStates: Clipboard[] = []; for (const section of patch.sections) { - prepared.push(await prepareWithSeenLineRetry(patcher, section, clipboard, options.session, input)); + prepared.push(await patcher.prepare(section, clipboard)); sectionStates.push(forkClipboard(clipboard)); } assertUniqueCanonicalPaths(prepared); @@ -378,7 +295,7 @@ export async function executeHashlineSingle( resetNoopEdit(options.session, sectionResult.canonicalPath); rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared[i].section.path)); } - const result: AgentToolResult = { + return { content: [ { type: "text", @@ -392,8 +309,6 @@ export async function executeHashlineSingle( perFileResults: rendered.map(r => r.perFileResult), }), }; - recordRenderedSeenLines(options.session, result, rendered); - return result; } export { HashlineMismatchError, type HashlineParams, hashlineEditParamsSchema }; diff --git a/packages/coding-agent/src/edit/hashline/seen-line-retry.md b/packages/coding-agent/src/edit/hashline/seen-line-retry.md deleted file mode 100644 index 8b8929c7a..000000000 --- a/packages/coding-agent/src/edit/hashline/seen-line-retry.md +++ /dev/null @@ -1,5 +0,0 @@ -{{error}} - -The original patch is stored for a one-shot retry. After verifying the revealed lines match your intent, call edit with only: -RETRY {{token}} -If your intent changed, submit a revised full patch instead. The retry revalidates the live files. diff --git a/packages/coding-agent/src/tools/output-meta.ts b/packages/coding-agent/src/tools/output-meta.ts index e403983b5..e2f8e301e 100644 --- a/packages/coding-agent/src/tools/output-meta.ts +++ b/packages/coding-agent/src/tools/output-meta.ts @@ -625,11 +625,6 @@ function getSpillConfig(s: Settings | undefined) { }; } -/** Resolve the byte threshold above which ordinary tool output spills to an artifact. */ -export function resolveArtifactSpillThresholdBytes(s: Settings | undefined): number { - return getSpillConfig(s).threshold; -} - /** * Resolve the OutputSink `headBytes` budget from session settings. * Exposed so streaming executors (bash/python/ssh/eval) can opt into diff --git a/packages/coding-agent/test/edit/seen-line-guard.test.ts b/packages/coding-agent/test/edit/seen-line-guard.test.ts index 1ce314be2..d5a85cea5 100644 --- a/packages/coding-agent/test/edit/seen-line-guard.test.ts +++ b/packages/coding-agent/test/edit/seen-line-guard.test.ts @@ -302,135 +302,6 @@ describe("read → edit seen-line guard", () => { expect(after).not.toContain("line 10"); }); - it("retries an unchanged patch through a one-shot token without resending its body", async () => { - const file = path.join(tmpDir, "notes.txt"); - await Bun.write(file, CONTENT); - const session = createSession(tmpDir); - - const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` }); - const tag = tagFromOutput(resultText(read)); - const input = `[notes.txt#${tag}]\nPUT 10.=12:\n+X10\n+X11\n+X12`; - - let message: string | undefined; - try { - await executeHashlineSingle(execOptions(input, session)); - } catch (err) { - message = (err as Error).message; - } - const retry = message?.match(/(?:^|\n)(RETRY [0-9a-f-]{36})(?:\n|$)/)?.[1]; - expect(retry).toBeDefined(); - expect(await Bun.file(file).text()).toBe(CONTENT); - - await executeHashlineSingle(execOptions((retry as string).toUpperCase(), session)); - const after = await Bun.file(file).text(); - expect(after).toContain("X10\nX11\nX12"); - expect(after).not.toContain("line 10"); - - await expect(executeHashlineSingle(execOptions(retry as string, session))).rejects.toThrow( - /Unknown or expired edit retry token/, - ); - }); - - it("revalidates live content before consuming a retry token", async () => { - const file = path.join(tmpDir, "notes.txt"); - await Bun.write(file, CONTENT); - const session = createSession(tmpDir); - - const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` }); - const tag = tagFromOutput(resultText(read)); - const input = `[notes.txt#${tag}]\nPUT 10.=12:\n+X10\n+X11\n+X12`; - - let message: string | undefined; - try { - await executeHashlineSingle(execOptions(input, session)); - } catch (err) { - message = (err as Error).message; - } - const retry = message?.match(/(?:^|\n)(RETRY [0-9a-f-]{36})(?:\n|$)/)?.[1]; - expect(retry).toBeDefined(); - - const drifted = CONTENT.replace("line 10", "EXTERNAL"); - await Bun.write(file, drifted); - await expect(executeHashlineSingle(execOptions(retry as string, session))).rejects.toThrow(); - expect(await Bun.file(file).text()).toBe(drifted); - }); - - it("records numbered edit output under the returned tag and keeps undisplayed lines guarded", async () => { - const file = path.join(tmpDir, "notes.txt"); - await Bun.write(file, CONTENT); - const session = createSession(tmpDir); - const store = getFileSnapshotStore(session); - - const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` }); - const tag = tagFromOutput(resultText(read)); - const edit = await executeHashlineSingle(execOptions(`[notes.txt#${tag}]\nPUT 2.=2:\n+EDITED`, session)); - const nextTag = tagFromOutput(resultText(edit)); - const seen = store.byHash(canonicalSnapshotKey(file), nextTag)?.seenLines; - - expect(seen?.has(2)).toBe(true); - expect(seen?.has(12)).toBe(false); - await expect( - executeHashlineSingle(execOptions(`[notes.txt#${nextTag}]\nPUT 12.=12:\n+UNSEEN`, session)), - ).rejects.toThrow(/never displayed \(it showed/); - }); - - it("does not record edit-output lines when the result will spill", async () => { - const file = path.join(tmpDir, "notes.txt"); - await Bun.write(file, CONTENT); - const session = { - ...createSession(tmpDir), - settings: Settings.isolated({ - "edit.enforceSeenLines": true, - "tools.artifactSpillThreshold": 0.1, - "tools.artifactHeadBytes": 0.03, - "tools.artifactTailBytes": 0.03, - }), - } as ToolSession; - const store = getFileSnapshotStore(session); - - const read = await new ReadTool(session).execute("r1", { path: `${file}:1-3` }); - const tag = tagFromOutput(resultText(read)); - const edit = await executeHashlineSingle( - execOptions(`[notes.txt#${tag}]\nPUT 2.=2:\n+${"EDITED ".repeat(80)}`, session), - ); - const text = resultText(edit); - const nextTag = tagFromOutput(text); - expect(text).toContain("2:"); - - const seen = store.byHash(canonicalSnapshotKey(file), nextTag)?.seenLines; - expect(seen?.has(2) ?? false).toBe(false); - }); - - it("does not trust requested diff lines after an ACP client transforms the write", async () => { - const file = path.join(tmpDir, "notes.txt"); - const content = "ONE\nTWO\nTHREE\nFOUR\nFIVE\n"; - await Bun.write(file, content); - const bridge = { - capabilities: { writeTextFile: true }, - writeTextFile: async ({ path: target, content: requested }: { path: string; content: string }) => { - await Bun.write(target, `CLIENT\n${requested}`); - }, - }; - const session = { - ...createSession(tmpDir), - getClientBridge: () => bridge, - } as ToolSession; - const store = getFileSnapshotStore(session); - const originalTag = store.record(canonicalSnapshotKey(file), content, [2]); - - const first = await executeHashlineSingle( - execOptions(`[notes.txt#${originalTag}]\nPUT 2.=2:\n+TWO EDITED`, session), - ); - const persistedTag = tagFromOutput(resultText(first)); - const drifted = "CLIENT\nONE\nTWO EDITED\nTHREE\nFOUR\nFIVE\n"; - expect(await Bun.file(file).text()).toBe(drifted); - - await expect( - executeHashlineSingle(execOptions(`[notes.txt#${persistedTag}]\nPUT 1.=1:\n+OVERWRITE`, session)), - ).rejects.toThrow(/never displayed/); - expect(await Bun.file(file).text()).toBe(drifted); - }); - it("keeps the re-read fallback when the anchor set exceeds the inline reveal cap", async () => { const file = path.join(tmpDir, "long.txt"); const lines = Array.from({ length: 200 }, (_, i) => `line ${i + 1}`); @@ -457,7 +328,6 @@ describe("read → edit seen-line guard", () => { expect(message).not.toContain("140:line 140"); // Guidance directs at a range re-read of the FULL anchor range. expect(message).toMatch(/long\.txt:100-159/); - expect(message).not.toMatch(/(?:^|\n)RETRY [0-9a-f-]{36}(?:\n|$)/); expect(await Bun.file(file).text()).toBe(`${lines.join("\n")}\n`); }); diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index 136b2bd83..64e84f677 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,13 +2,11 @@ ## [Unreleased] -### Added - -- Added structured `UnseenLinesError.retryable` metadata so hosts can offer compact retry continuations only when every unseen anchor was revealed in full. +## [17.3.3] - 2026-08-14 ### Fixed -- Distinguished absent seen-line provenance from an explicitly observed empty set, so transformed writes can require a fresh read without weakening the guard. +- Recovered dangling range separators in hunk headers (`PUT 244.=:`, `CUT 5.=`) as single-line ranges (`N.=N`) instead of rejecting the header as an orphan payload line. ## [17.3.3] - 2026-08-14 diff --git a/packages/hashline/src/grammar.lark b/packages/hashline/src/grammar.lark index 6cfdfee94..1b4ed17e4 100644 --- a/packages/hashline/src/grammar.lark +++ b/packages/hashline/src/grammar.lark @@ -1,9 +1,6 @@ -start: retry_patch | begin_patch file_patch+ end_patch +start: begin_patch file_patch+ end_patch begin_patch: "*** Begin Patch" LF end_patch: "*** End Patch" LF? -retry_patch: "RETRY " UUID LF? -UUID: /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/ - file_patch: file_header hunk+ file_header: "[" filename "#" file_hash "]" LF diff --git a/packages/hashline/src/patcher.ts b/packages/hashline/src/patcher.ts index 08ca22a07..f93c18ef7 100644 --- a/packages/hashline/src/patcher.ts +++ b/packages/hashline/src/patcher.ts @@ -65,20 +65,6 @@ const SEEN_LINE_REVEAL_CAP = 40; */ const SEEN_LINE_REVEAL_MAX_COLUMNS = 512; -/** - * Seen-line rejection metadata for hosts that can offer an explicit retry - * continuation after presenting the revealed source to the caller. - */ -export class UnseenLinesError extends Error { - constructor( - message: string, - readonly retryable: boolean, - ) { - super(message); - this.name = "UnseenLinesError"; - } -} - export interface PatcherOptions { /** Storage backend used for all reads and writes. */ fs: Filesystem; @@ -569,7 +555,7 @@ export class Patcher { // false "drift" purely from BOM/line-ending restoration asymmetry. const recorded = normalizeToLF(stripBom(write.text).text); const driftedOnWrite = recorded !== after; - const fileHash = this.#recordFullSnapshot(canonicalPath, recorded, driftedOnWrite ? [] : undefined); + const fileHash = this.#recordFullSnapshot(canonicalPath, recorded); const allWarnings = driftedOnWrite ? [...warnings, writeDriftWarning(section.path)] : warnings; return { @@ -604,19 +590,18 @@ export class Patcher { } } - #recordFullSnapshot(canonicalPath: string, normalized: string, seenLines?: Iterable): string { - return this.snapshots.record(canonicalPath, normalized, seenLines); + #recordFullSnapshot(canonicalPath: string, normalized: string): string { + return this.snapshots.record(canonicalPath, normalized); } /** * Reject an anchored edit that references a line the read which minted * `expected` never displayed. `matchedSnapshot` is the store version whose * text equals the live normalized content — the exact snapshot the model - * anchored against. A missing snapshot or undefined `seenLines` means no - * provenance was recorded (the tag was externally minted or aged out), so - * the edit applies as before. An empty set means provenance is active but no - * exact lines were displayed, so every anchor remains guarded. Only runs on - * the no-drift path, where anchor line numbers index the tagged content 1:1. + * anchored against. Absent means no provenance was recorded (the tag was + * externally minted or aged out), so the edit applies as before. Only runs + * on the no-drift path, where anchor line numbers index the tagged content + * 1:1. * * The rejection inlines the actual file content at the unseen anchor lines * (from `matchedSnapshot.text`, which by definition equals the live @@ -636,7 +621,7 @@ export class Patcher { */ #assertSeenLines(section: PatchSection, expected: string, matchedSnapshot: Snapshot | null): void { const seen = matchedSnapshot?.seenLines; - if (seen === undefined) return; + if (!seen || seen.size === 0) return; const unseen = section.collectAnchorLines().filter(line => !seen.has(line)); if (unseen.length === 0) return; const sourceLines = matchedSnapshot?.text.split("\n") ?? []; @@ -665,10 +650,7 @@ export class Patcher { if (!truncated) { for (const { line } of revealed) seen.add(line); } - throw new UnseenLinesError( - unseenLinesMessage(section.path, unseen, expected, { lines: revealed, truncated }), - !truncated, - ); + throw new Error(unseenLinesMessage(section.path, unseen, expected, { lines: revealed, truncated })); } #mismatchError( section: PatchSection, diff --git a/packages/hashline/src/prompt.md b/packages/hashline/src/prompt.md index fe8e20773..0811096e1 100644 --- a/packages/hashline/src/prompt.md +++ b/packages/hashline/src/prompt.md @@ -4,10 +4,6 @@ Line-anchored patch language: name original lines/gaps to replace, insert, cut, Section: `[PATH#TAG]`; `TAG`: 4-hex snapshot from latest `read`/`search`, REQUIRED each section. New files: `write`; hashline edits existing files only. - -Only after a seen-line rejection supplies a retry token: verify its revealed lines, then use exactly `RETRY TOKEN` to apply the stored unchanged patch without resending it. A retry is one-shot and revalidates live files. If intent changes, submit a revised full patch instead. NEVER invent or reuse tokens. - - `PUT N.=M:`: replace original inclusive lines N–M with body. `PUT N*:`: replace syntactic block beginning N; closing line resolved. diff --git a/packages/hashline/src/snapshots.ts b/packages/hashline/src/snapshots.ts index 33935b8d8..6cae609c4 100644 --- a/packages/hashline/src/snapshots.ts +++ b/packages/hashline/src/snapshots.ts @@ -41,8 +41,7 @@ export interface Snapshot { * bodies) leaves this sparse; a whole-file read fills every line. Multiple * reads of the same content union into one set. `undefined` means "no * provenance recorded" — the patcher then skips the seen-line check and - * applies as before. An empty set means provenance is active but no exact - * lines were displayed, so every anchored line remains guarded. + * applies as before. Mutated in place as more of the same content is read. */ seenLines?: Set; } diff --git a/packages/hashline/test/patcher.test.ts b/packages/hashline/test/patcher.test.ts index 2be912c1f..452d91c60 100644 --- a/packages/hashline/test/patcher.test.ts +++ b/packages/hashline/test/patcher.test.ts @@ -12,7 +12,6 @@ import { NodeFilesystem, Patch, Patcher, - UnseenLinesError, type WriteResult, } from "@oh-my-pi/hashline"; @@ -193,13 +192,9 @@ describe("Patcher snapshot tag stays honest across a write-time content transfor // what turned a one-line edit into unexplained whole-file corruption. expect(section.warnings.some(w => w.includes(PATH) && /reformatted it on save/.test(w))).toBe(true); - // The returned tag must still resolve against the real drifted file. - // Because no exact persisted lines were displayed after the transform, - // the seen-line guard first reveals the anchor, then the same-tag retry - // succeeds instead of failing a stale-tag mismatch. - const followUp = `[${PATH}#${section.fileHash}]\nPUT 1-1:\n+function g() {`; - await expect(patcher.apply(Patch.parse(followUp))).rejects.toThrow(/never displayed/); - await patcher.apply(Patch.parse(followUp)); + // A follow-up edit anchored on the returned tag must succeed against + // the real (drifted) file instead of failing a stale-tag mismatch. + await patcher.apply(Patch.parse(`[${PATH}#${section.fileHash}]\nPUT 1-1:\n+function g() {`)); expect(fs.get(PATH)).toBe("function g() {\n\treturn 2;\n}\n"); }); }); @@ -277,13 +272,9 @@ describe("Patcher seen-line provenance", () => { const tag = snapshots.record(PATH, CONTENT, [1, 2]); const patcher = new Patcher({ fs, snapshots }); - const error = await patcher - .apply(Patch.parse(`[${PATH}#${tag}]\nPUT 4-4:\n+L4`)) - .then(() => undefined) - .catch((cause: unknown) => cause); - expect(error).toBeInstanceOf(UnseenLinesError); - expect((error as UnseenLinesError).retryable).toBe(true); - expect((error as Error).message).toMatch(/never displayed \(it showed/); + await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 4-4:\n+L4`))).rejects.toThrow( + /never displayed \(it showed/, + ); expect(fs.get(PATH)).toBe(CONTENT); }); @@ -421,18 +412,6 @@ describe("Patcher seen-line provenance", () => { expect(fs.get(PATH)).toBe(wideContent); }); - it("guards every anchor when provenance recorded no displayed lines", async () => { - const fs = new InMemoryFilesystem([[PATH, CONTENT]]); - const snapshots = new InMemorySnapshotStore(); - const tag = snapshots.record(PATH, CONTENT, []); - const patcher = new Patcher({ fs, snapshots }); - - await expect(patcher.apply(Patch.parse(`[${PATH}#${tag}]\nPUT 4-4:\n+L4`))).rejects.toThrow( - /never displayed \(it showed/, - ); - expect(fs.get(PATH)).toBe(CONTENT); - }); - it("skips the check when no seen lines were recorded (absent → allow)", async () => { const fs = new InMemoryFilesystem([[PATH, CONTENT]]); const snapshots = new InMemorySnapshotStore();