fix(edit): add compact seen-line retries

This commit is contained in:
Kigbnajd
2026-08-13 20:15:18 +02:00
parent 0bc2c342f4
commit dadaceaa6f
8 changed files with 189 additions and 14 deletions
+4
View File
@@ -2,6 +2,10 @@
## [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.0] - 2026-08-13
### Fixed
+4 -1
View File
@@ -1,6 +1,9 @@
start: begin_patch file_patch+ end_patch
start: retry_patch | 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
+18 -1
View File
@@ -65,6 +65,20 @@ 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;
@@ -650,7 +664,10 @@ export class Patcher {
if (!truncated) {
for (const { line } of revealed) seen.add(line);
}
throw new Error(unseenLinesMessage(section.path, unseen, expected, { lines: revealed, truncated }));
throw new UnseenLinesError(
unseenLinesMessage(section.path, unseen, expected, { lines: revealed, truncated }),
!truncated,
);
}
#mismatchError(
section: PatchSection,
+4
View File
@@ -4,6 +4,10 @@ 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.
+8 -3
View File
@@ -12,6 +12,7 @@ import {
NodeFilesystem,
Patch,
Patcher,
UnseenLinesError,
type WriteResult,
} from "@oh-my-pi/hashline";
@@ -272,9 +273,13 @@ describe("Patcher seen-line provenance", () => {
const tag = snapshots.record(PATH, CONTENT, [1, 2]);
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/,
);
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/);
expect(fs.get(PATH)).toBe(CONTENT);
});