Merge PR #8461: fix(edit): add compact seen-line retries (@Kigbnajd)
This commit is contained in:
@@ -78,6 +78,10 @@
|
||||
- Fixed `omp update` misclassifying foreign npm/bun bin aliases while preserving package-manager ownership for globally linked checkouts ([#8468](https://github.com/can1357/oh-my-pi/issues/8468)).
|
||||
- Fixed `read` hashline headers collapsing nested in-workspace paths to the bare basename, which let a same-basename file at the session cwd capture a verbatim follow-up `edit` and deterministically reject it with `hash is not from this session`. Headers now retain the workspace-relative path (e.g. `[src/settings.json#0063]`) ([#8482](https://github.com/can1357/oh-my-pi/issues/8482)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed seen-line guard retries forcing agents to resend entire unchanged patches. Complete inline reveals now issue one-shot `RETRY <token>` 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.
|
||||
|
||||
## [17.3.1] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* 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,
|
||||
@@ -17,20 +18,23 @@ 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 type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { outputMeta } from "../../tools/output-meta";
|
||||
import { outputMeta, resolveArtifactSpillThresholdBytes } from "../../tools/output-meta";
|
||||
import { ToolError } from "../../tools/tool-errors";
|
||||
import { generateDiffString } from "../diff";
|
||||
import { getEditClipboard } from "../edit-clipboard";
|
||||
import { getFileSnapshotStore } from "../file-snapshot-store";
|
||||
import { canonicalSnapshotKey, getFileSnapshotStore, recordSeenLinesFromBody } from "../file-snapshot-store";
|
||||
import type { EditToolDetails, EditToolPerFileResult, LspBatchRequest } from "../renderer";
|
||||
import { pruneOversizedEditSnapshots } from "../snapshot-details";
|
||||
import { nativeBlockResolver } from "./block-resolver";
|
||||
@@ -47,6 +51,53 @@ export interface ExecuteHashlineSingleOptions {
|
||||
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
|
||||
}
|
||||
|
||||
interface PendingSeenLineRetry {
|
||||
token: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
const pendingSeenLineRetries = new WeakMap<ToolSession, PendingSeenLineRetry>();
|
||||
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<Patcher["prepare"]>[0],
|
||||
clipboard: Clipboard,
|
||||
session: ToolSession,
|
||||
input: string,
|
||||
): Promise<PreparedSection> {
|
||||
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(
|
||||
`${error.message}\n\n` +
|
||||
"The original patch is stored for a one-shot retry. After verifying the revealed lines match your intent, " +
|
||||
"call edit with only:\n" +
|
||||
`RETRY ${token}\n` +
|
||||
"If your intent changed, submit a revised full patch instead. The retry revalidates the live files.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -98,9 +149,33 @@ 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<EditToolDetails, typeof hashlineEditParamsSchema>;
|
||||
perFileResult: EditToolPerFileResult;
|
||||
seenLineProvenance?: SeenLineProvenance;
|
||||
}
|
||||
|
||||
function recordRenderedSeenLines(
|
||||
session: ToolSession,
|
||||
result: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>,
|
||||
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<BlockResolution["op"], string> = {
|
||||
@@ -176,12 +251,22 @@ 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: `${result.header}${blockBlock}${moveBlock}${previewBlock}${warningsBlock}`,
|
||||
text,
|
||||
},
|
||||
],
|
||||
details: pruneOversizedEditSnapshots({
|
||||
@@ -214,7 +299,8 @@ function renderSection(
|
||||
export async function executeHashlineSingle(
|
||||
options: ExecuteHashlineSingleOptions,
|
||||
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
||||
const patch = Patch.parse(options.input, { cwd: options.session.cwd });
|
||||
const input = resolveHashlineInput(options.session, options.input);
|
||||
const patch = Patch.parse(input, { cwd: options.session.cwd });
|
||||
if (patch.sections.length === 0) {
|
||||
throw new Error("No hashline sections found in input.");
|
||||
}
|
||||
@@ -237,10 +323,10 @@ export async function executeHashlineSingle(
|
||||
const clipboard = startClipboardBatch(sessionClipboard);
|
||||
|
||||
// Single-section fast path: prepare, commit, render.
|
||||
const inputHash = hashPatchInput(options.input);
|
||||
const inputHash = hashPatchInput(input);
|
||||
if (patch.sections.length === 1) {
|
||||
fs.setBatchRequest(narrowBatchRequest(options.batchRequest, true));
|
||||
const prepared = await patcher.prepare(patch.sections[0], clipboard);
|
||||
const prepared = await prepareWithSeenLineRetry(patcher, patch.sections[0], clipboard, options.session, input);
|
||||
const sectionResult = await patcher.commit(prepared);
|
||||
commitClipboard(clipboard, sessionClipboard);
|
||||
if (sectionResult.op === "noop") {
|
||||
@@ -251,7 +337,9 @@ export async function executeHashlineSingle(
|
||||
return renderSection(sectionResult, undefined, prepared.section.path).toolResult;
|
||||
}
|
||||
resetNoopEdit(options.session, sectionResult.canonicalPath);
|
||||
return renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared.section.path).toolResult;
|
||||
const rendered = renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared.section.path);
|
||||
recordRenderedSeenLines(options.session, rendered.toolResult, [rendered]);
|
||||
return rendered.toolResult;
|
||||
}
|
||||
|
||||
// Multi-section: prepare every section up front so we fail fast before
|
||||
@@ -264,7 +352,7 @@ export async function executeHashlineSingle(
|
||||
// deleted would otherwise be lost.
|
||||
const sectionStates: Clipboard[] = [];
|
||||
for (const section of patch.sections) {
|
||||
prepared.push(await patcher.prepare(section, clipboard));
|
||||
prepared.push(await prepareWithSeenLineRetry(patcher, section, clipboard, options.session, input));
|
||||
sectionStates.push(forkClipboard(clipboard));
|
||||
}
|
||||
assertUniqueCanonicalPaths(prepared);
|
||||
@@ -294,7 +382,7 @@ export async function executeHashlineSingle(
|
||||
resetNoopEdit(options.session, sectionResult.canonicalPath);
|
||||
rendered.push(renderSection(sectionResult, fs.consumeDiagnostics(sectionResult.path), prepared[i].section.path));
|
||||
}
|
||||
return {
|
||||
const result: AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema> = {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
@@ -308,6 +396,8 @@ export async function executeHashlineSingle(
|
||||
perFileResults: rendered.map(r => r.perFileResult),
|
||||
}),
|
||||
};
|
||||
recordRenderedSeenLines(options.session, result, rendered);
|
||||
return result;
|
||||
}
|
||||
|
||||
export { HashlineMismatchError, type HashlineParams, hashlineEditParamsSchema };
|
||||
|
||||
@@ -625,6 +625,11 @@ 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
|
||||
|
||||
@@ -302,6 +302,135 @@ 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}`);
|
||||
@@ -328,6 +457,7 @@ 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`);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
### Fixed
|
||||
|
||||
- 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.
|
||||
### Added
|
||||
|
||||
- Added structured `UnseenLinesError.retryable` metadata so hosts can offer compact retry continuations only when every unseen anchor was revealed in full.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Distinguished absent seen-line provenance from an explicitly observed empty set, so transformed writes can require a fresh read without weakening the guard.
|
||||
|
||||
## [17.3.0] - 2026-08-13
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -555,7 +569,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);
|
||||
const fileHash = this.#recordFullSnapshot(canonicalPath, recorded, driftedOnWrite ? [] : undefined);
|
||||
const allWarnings = driftedOnWrite ? [...warnings, writeDriftWarning(section.path)] : warnings;
|
||||
|
||||
return {
|
||||
@@ -590,18 +604,19 @@ export class Patcher {
|
||||
}
|
||||
}
|
||||
|
||||
#recordFullSnapshot(canonicalPath: string, normalized: string): string {
|
||||
return this.snapshots.record(canonicalPath, normalized);
|
||||
#recordFullSnapshot(canonicalPath: string, normalized: string, seenLines?: Iterable<number>): string {
|
||||
return this.snapshots.record(canonicalPath, normalized, seenLines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. 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.
|
||||
* 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.
|
||||
*
|
||||
* The rejection inlines the actual file content at the unseen anchor lines
|
||||
* (from `matchedSnapshot.text`, which by definition equals the live
|
||||
@@ -621,7 +636,7 @@ export class Patcher {
|
||||
*/
|
||||
#assertSeenLines(section: PatchSection, expected: string, matchedSnapshot: Snapshot | null): void {
|
||||
const seen = matchedSnapshot?.seenLines;
|
||||
if (!seen || seen.size === 0) return;
|
||||
if (seen === undefined) return;
|
||||
const unseen = section.collectAnchorLines().filter(line => !seen.has(line));
|
||||
if (unseen.length === 0) return;
|
||||
const sourceLines = matchedSnapshot?.text.split("\n") ?? [];
|
||||
@@ -650,7 +665,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,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.
|
||||
|
||||
@@ -41,7 +41,8 @@ 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. Mutated in place as more of the same content is read.
|
||||
* applies as before. An empty set means provenance is active but no exact
|
||||
* lines were displayed, so every anchored line remains guarded.
|
||||
*/
|
||||
seenLines?: Set<number>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NodeFilesystem,
|
||||
Patch,
|
||||
Patcher,
|
||||
UnseenLinesError,
|
||||
type WriteResult,
|
||||
} from "@oh-my-pi/hashline";
|
||||
|
||||
@@ -192,9 +193,13 @@ 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);
|
||||
|
||||
// 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() {`));
|
||||
// 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));
|
||||
expect(fs.get(PATH)).toBe("function g() {\n\treturn 2;\n}\n");
|
||||
});
|
||||
});
|
||||
@@ -272,9 +277,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);
|
||||
});
|
||||
|
||||
@@ -412,6 +421,18 @@ 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();
|
||||
|
||||
Reference in New Issue
Block a user