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.
This commit is contained in:
can1357
2026-08-16 07:58:55 +02:00
parent ee4205e2a1
commit 8fdb6a4197
11 changed files with 28 additions and 303 deletions
+2 -4
View File
@@ -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
+1 -4
View File
@@ -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
+9 -27
View File
@@ -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<number>): 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,
-4
View File
@@ -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.
</headers>
<retry>
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.
</retry>
<ops>
`PUT N.=M:`: replace original inclusive lines N–M with body.
`PUT N*:`: replace syntactic block beginning N; closing line resolved.
+1 -2
View File
@@ -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<number>;
}
+6 -27
View File
@@ -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();