pi-hashline-edit-pro 4.2.1 → 4.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/index.ts +3 -2
- package/package.json +1 -1
- package/src/anchor-registry.ts +65 -6
- package/src/batch.ts +126 -29
- package/src/commit.ts +29 -7
- package/src/config.ts +88 -37
- package/src/grep.ts +14 -6
- package/src/hashline/alphabet.ts +5 -5
- package/src/hashline/anchor-table.json +1 -1
- package/src/hashline/apply.ts +3 -1
- package/src/hashline/resolve.ts +1 -1
- package/src/insert.ts +42 -38
- package/src/replace.ts +29 -20
- package/src/validation.ts +2 -2
- package/src/write-hook.ts +1 -1
package/README.md
CHANGED
|
@@ -93,11 +93,11 @@ Common copy-paste slips are fixed automatically and reported as warnings: a left
|
|
|
93
93
|
|
|
94
94
|
Every line in the removed range must match what was last shown to you. The extension records the `anchor│content` rows it serves (`read` output, `anchor_grep` output, the auto-read block after `write`, the `+anchor│` and ` anchor│` rows of post-edit diffs, the current-range rows of `[E_RANGE_STALE]` feedback, and the context rows of stale-anchor feedback) and verifies the whole range against that record before writing. A line that changed on disk since it was shown, or an anchor that is not owned in this session, refuses the edit with `[E_RANGE_STALE]` or `[E_STALE_ANCHOR]` and returns the current range with fresh anchors, so the retry needs no `read`. An owned anchor enters the served record when its row is shown (after a restart, restored ownership counts as shown), so a file with no owned anchors cannot be edited by anchor at all; call `read` first. An owned line that was never shown — for example beyond an auto-read preview's truncation cap — is refused with `[E_RANGE_STALE]` and returns the current range, so the retry still needs no `read`.
|
|
95
95
|
|
|
96
|
-
An edit that produces identical content reports `No changes made` and leaves the anchors alone. When a noop happened because the boundary anti-duplication cut a line from the replacement, sending the same replacement once more runs with that dedup turned off for the single call and applies the lines literally; the result carries a `[W_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to the payload; copied prefixes, diff markers, and stray whitespace are normalized before matching. Any applied edit or successful `write` clears it.
|
|
96
|
+
An edit that produces identical content reports `No changes made` and leaves the anchors alone. When a noop happened because the boundary anti-duplication cut a line from the replacement, sending the same replacement once more runs with that dedup turned off for the single call and applies the lines literally; the result carries a `[W_BOUNDARY_BYPASS]` notice. The pending bypass is per file and keyed to the payload; copied prefixes, diff markers, and stray whitespace are normalized before matching. Any applied edit or successful `write` clears it. A pending bypass overrides `strict` mode for its one resend; an aborted batch preserves a consumed bypass for retry.
|
|
97
97
|
|
|
98
98
|
After a successful edit, the diff is capped at 50KB. A row over 50KB is shown as a marker that keeps the row's anchor, and only the rows shown in the capped diff are recorded as served. The same caps apply to the `insert` and `undo_last_change` diffs, to the interactive previews, and to `details.patch`.
|
|
99
99
|
|
|
100
|
-
Multiple `replace` and `insert` calls on the same file in one message are grouped per file into one batch that validates every call against the pre-batch state and then applies them together on the batch's last call: earlier calls reply `In batch` (`In batch N` when several files batch) and the batch's last call shows the combined diff, with one undo reverting the whole batch. Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch with
|
|
100
|
+
Multiple `replace` and `insert` calls on the same file in one message are grouped per file into one batch that validates every call against the pre-batch state and then applies them together on the batch's last call: earlier calls reply `In batch` (`In batch N` when several files batch) and the batch's last call shows the combined diff, with one undo reverting the whole batch. Batched calls must target disjoint ranges; overlapping ranges, or any failing call, aborts the whole batch. Calls with stale anchors join their file's batch through a `requirePath` path hint or a valid co-anchor and abort it instead of applying partially; a same-turn sibling whose anchors resolve nowhere still aborts the batch when no other file is being edited. An error that aborts a batch ends with `Aborts batch N.`, while the abort itself reads `[E_OP_ABORTED] Batch N aborted.` Anchor capacity is preflighted before writing; if anchor finalization fails after the write, the error states the file was written with one undo available. Verify each batch diff before the next turn's edits on that file.
|
|
101
101
|
|
|
102
102
|
### insert
|
|
103
103
|
|
|
@@ -236,8 +236,8 @@ Codes starting with `E_` are errors (the operation failed); codes starting with
|
|
|
236
236
|
| `[E_REGISTRY]` | The anchor registry was not initialized; a serve or edit ran outside an initialized session. |
|
|
237
237
|
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
238
238
|
| `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
|
|
239
|
-
| `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused
|
|
240
|
-
| `[
|
|
239
|
+
| `[E_BATCH_OVERLAP]` | Batched `replace`/`insert` calls target overlapping ranges; the whole batch was refused. Retry with disjoint ranges. |
|
|
240
|
+
| `[E_OP_ABORTED]` | An edit aborted (a same-turn batch member failed, or the file changed or was deleted after the edit started). Fix the sibling failure and retry the batch, otherwise call `read` for fresh anchors and retry. |
|
|
241
241
|
| `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
|
|
242
242
|
|
|
243
243
|
## Troubleshooting
|
package/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { ReplaceDetails } from "./src/replace";
|
|
|
11
11
|
import { extractWarnings } from "./src/replace-render";
|
|
12
12
|
import { MAX_HASH_LINES } from "./src/hashline";
|
|
13
13
|
import {
|
|
14
|
-
|
|
14
|
+
readConfigWithStatus,
|
|
15
15
|
toggleAutoRead,
|
|
16
16
|
toggleAnchorGrep,
|
|
17
17
|
toggleRequirePath,
|
|
@@ -75,7 +75,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
75
75
|
const sessionFile = sessionManager?.getSessionFile?.();
|
|
76
76
|
await initRegistry(sessionFile);
|
|
77
77
|
await gcRegistrySidecars();
|
|
78
|
-
const config = await
|
|
78
|
+
const { config, corrupted } = await readConfigWithStatus();
|
|
79
|
+
if (corrupted && (ctx as { hasUI?: boolean }).hasUI) ctx.ui.notify("Hashline config was corrupt and was reset to defaults", "warning");
|
|
79
80
|
autoRead = config.autoRead;
|
|
80
81
|
await refreshEditTools();
|
|
81
82
|
pi.setActiveTools(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 4-char tokenizer-friendly anchor that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
package/src/anchor-registry.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmod, mkdir, readFile, readdir, rm, stat } from "fs/promises";
|
|
1
|
+
import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
|
|
2
2
|
import { appendFileSync, chmodSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { createHash } from "crypto";
|
|
@@ -16,6 +16,7 @@ export type RegistryEvent =
|
|
|
16
16
|
| { kind: "session"; sessionFile: string }
|
|
17
17
|
| { kind: "allocate"; path: string; rows: [string, string][] }
|
|
18
18
|
| { kind: "free"; path: string; anchors?: string[] }
|
|
19
|
+
| { kind: "minted"; anchors: string[] }
|
|
19
20
|
| { kind: "clear" };
|
|
20
21
|
|
|
21
22
|
export interface OwnedAnchor {
|
|
@@ -31,7 +32,9 @@ interface SessionState {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
const SIDECAR_SUFFIX = ".registry.jsonl";
|
|
34
|
-
|
|
35
|
+
const SIDECAR_COMPACT_LINES = 5000;
|
|
36
|
+
const SIDECAR_COMPACT_BYTES = 1024 * 1024;
|
|
37
|
+
const SIDECAR_COMPACT_CHUNK = 5000;
|
|
35
38
|
let currentKey: string | undefined;
|
|
36
39
|
let currentSidecar: string | undefined;
|
|
37
40
|
const registries = new Map<string, SessionState>();
|
|
@@ -62,6 +65,8 @@ export function foldRegistryEvents(events: RegistryEvent[]): SessionState {
|
|
|
62
65
|
for (const event of events) {
|
|
63
66
|
if (event.kind === "clear") {
|
|
64
67
|
state.owned.clear();
|
|
68
|
+
} else if (event.kind === "minted") {
|
|
69
|
+
for (const anchor of event.anchors) state.everMinted.add(anchor);
|
|
65
70
|
} else if (event.kind === "allocate") {
|
|
66
71
|
for (const [anchor, checksum] of event.rows) {
|
|
67
72
|
state.owned.set(anchor, { path: event.path, checksum });
|
|
@@ -90,7 +95,7 @@ export function parseRegistryLog(raw: string): RegistryEvent[] {
|
|
|
90
95
|
if (!line.trim()) continue;
|
|
91
96
|
try {
|
|
92
97
|
const parsed = JSON.parse(line) as RegistryEvent;
|
|
93
|
-
if (parsed && (parsed.kind === "allocate" || parsed.kind === "free" || parsed.kind === "clear" || parsed.kind === "session")) {
|
|
98
|
+
if (parsed && (parsed.kind === "allocate" || parsed.kind === "free" || parsed.kind === "clear" || parsed.kind === "session" || parsed.kind === "minted")) {
|
|
94
99
|
events.push(parsed);
|
|
95
100
|
}
|
|
96
101
|
} catch {
|
|
@@ -107,6 +112,46 @@ function sidecarPath(key: string): string {
|
|
|
107
112
|
function sidecarKeyFor(sessionFile: string): string {
|
|
108
113
|
return createHash("sha256").update(sessionFile).digest("hex").slice(0, 24);
|
|
109
114
|
}
|
|
115
|
+
export function shouldCompactSidecar(raw: string): boolean {
|
|
116
|
+
if (raw.length >= SIDECAR_COMPACT_BYTES) return true;
|
|
117
|
+
let lines = 0;
|
|
118
|
+
for (let i = 0; i < raw.length; i++) if (raw.charCodeAt(i) === 10) lines += 1;
|
|
119
|
+
return lines >= SIDECAR_COMPACT_LINES;
|
|
120
|
+
}
|
|
121
|
+
export function buildCompactedLog(sessionFile: string, state: SessionState): string {
|
|
122
|
+
const byPath = new Map<string, Array<[string, string]>>();
|
|
123
|
+
for (const [anchor, entry] of state.owned) {
|
|
124
|
+
const rows = byPath.get(entry.path) ?? [];
|
|
125
|
+
rows.push([anchor, entry.checksum]);
|
|
126
|
+
byPath.set(entry.path, rows);
|
|
127
|
+
}
|
|
128
|
+
const out: string[] = [JSON.stringify({ kind: "session", sessionFile })];
|
|
129
|
+
for (const [path, rows] of byPath) {
|
|
130
|
+
for (let i = 0; i < rows.length; i += SIDECAR_COMPACT_CHUNK) {
|
|
131
|
+
out.push(JSON.stringify({ kind: "allocate", path, rows: rows.slice(i, i + SIDECAR_COMPACT_CHUNK) }));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const freedHistory = [...state.everMinted].filter((anchor) => !state.owned.has(anchor));
|
|
135
|
+
for (let i = 0; i < freedHistory.length; i += SIDECAR_COMPACT_CHUNK) {
|
|
136
|
+
out.push(JSON.stringify({ kind: "minted", anchors: freedHistory.slice(i, i + SIDECAR_COMPACT_CHUNK) }));
|
|
137
|
+
}
|
|
138
|
+
return out.join("\n") + "\n";
|
|
139
|
+
}
|
|
140
|
+
async function compactSidecarIfNeeded(sidecar: string, raw: string, sessionFile: string, state: SessionState): Promise<void> {
|
|
141
|
+
if (!shouldCompactSidecar(raw)) return;
|
|
142
|
+
const tmp = `${sidecar}.compact-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
143
|
+
try {
|
|
144
|
+
const compacted = buildCompactedLog(sessionFile, state);
|
|
145
|
+
await writeFile(tmp, compacted, { mode: 0o600 });
|
|
146
|
+
if (process.platform !== "win32") {
|
|
147
|
+
try { await chmod(tmp, 0o600); } catch { }
|
|
148
|
+
}
|
|
149
|
+
await rename(tmp, sidecar);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error("Failed to compact anchor registry sidecar:", error);
|
|
152
|
+
try { await rm(tmp, { force: true }); } catch { }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
110
155
|
|
|
111
156
|
export async function initRegistry(sessionFile: string | undefined): Promise<void> {
|
|
112
157
|
if (!sessionFile) {
|
|
@@ -119,9 +164,10 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
|
|
|
119
164
|
currentKey = key;
|
|
120
165
|
currentSidecar = sidecarPath(key);
|
|
121
166
|
let events: RegistryEvent[] = [];
|
|
167
|
+
let rawLog = "";
|
|
122
168
|
try {
|
|
123
|
-
|
|
124
|
-
events = parseRegistryLog(
|
|
169
|
+
rawLog = await readFile(currentSidecar, "utf-8");
|
|
170
|
+
events = parseRegistryLog(rawLog);
|
|
125
171
|
} catch (error) {
|
|
126
172
|
if (errCode(error) !== "ENOENT") {
|
|
127
173
|
console.error("Failed to read anchor registry sidecar:", error);
|
|
@@ -130,6 +176,9 @@ export async function initRegistry(sessionFile: string | undefined): Promise<voi
|
|
|
130
176
|
const folded = foldRegistryEvents(events);
|
|
131
177
|
seedServedFromOwned(folded);
|
|
132
178
|
registries.set(key, folded);
|
|
179
|
+
if (rawLog.length > 0) {
|
|
180
|
+
await compactSidecarIfNeeded(currentSidecar, rawLog, sessionFile, folded);
|
|
181
|
+
}
|
|
133
182
|
try {
|
|
134
183
|
await mkdir(sessionClaimsDir(), { recursive: true, mode: 0o700 });
|
|
135
184
|
if (process.platform !== "win32") {
|
|
@@ -170,7 +219,7 @@ function fingerprintIndex(state: SessionState, path: string): Map<string, string
|
|
|
170
219
|
return index;
|
|
171
220
|
}
|
|
172
221
|
|
|
173
|
-
const MINT_PROBE_LIMIT = 8192;
|
|
222
|
+
export const MINT_PROBE_LIMIT = 8192;
|
|
174
223
|
|
|
175
224
|
export function mintAnchor(state: SessionState): string {
|
|
176
225
|
for (let probe = 0; probe < MINT_PROBE_LIMIT; probe++) {
|
|
@@ -595,6 +644,16 @@ export async function gcRegistrySidecars(): Promise<void> {
|
|
|
595
644
|
return;
|
|
596
645
|
}
|
|
597
646
|
for (const name of names) {
|
|
647
|
+
if (name.includes(`${SIDECAR_SUFFIX}.compact-`)) {
|
|
648
|
+
const tmpPath = join(sessionClaimsDir(), name);
|
|
649
|
+
try {
|
|
650
|
+
const tmpStat = await stat(tmpPath);
|
|
651
|
+
if (Date.now() - tmpStat.mtimeMs > 60 * 60 * 1000) await rm(tmpPath, { force: true });
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (errCode(error) !== "ENOENT") console.error("Failed to inspect registry sidecar:", error);
|
|
654
|
+
}
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
598
657
|
if (!name.endsWith(SIDECAR_SUFFIX)) continue;
|
|
599
658
|
const sidecar = join(sessionClaimsDir(), name);
|
|
600
659
|
try {
|
package/src/batch.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile } from "fs/promises";
|
|
|
2
2
|
import { constants } from "fs";
|
|
3
3
|
import { relative } from "path";
|
|
4
4
|
import { readConfig, getDiffContextLines } from "./config";
|
|
5
|
-
import { throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
|
|
5
|
+
import { resolveEditTarget, throwIfStrictInput, tryResolveEditTarget } from "./edit-common";
|
|
6
6
|
import { readNormFile, safeSnapId } from "./file-reader";
|
|
7
7
|
import { resolveInCwd, writeAtomic, type FileIdentity } from "./fs-write";
|
|
8
8
|
import {
|
|
@@ -50,6 +50,7 @@ interface BatchBase {
|
|
|
50
50
|
hadUtf8DecodeErrors: boolean;
|
|
51
51
|
absolutePath: string;
|
|
52
52
|
snapshotId?: string;
|
|
53
|
+
baseLines: string[];
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export interface BatchPiece {
|
|
@@ -65,6 +66,7 @@ export interface BatchPiece {
|
|
|
65
66
|
noop: boolean;
|
|
66
67
|
noopPayload?: string;
|
|
67
68
|
foldedLines: number;
|
|
69
|
+
bypassConsumed?: boolean;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
export interface BatchMemberInput {
|
|
@@ -80,6 +82,7 @@ export interface BatchMemberInput {
|
|
|
80
82
|
strictBoundaryDedup: boolean;
|
|
81
83
|
noopPayload?: string;
|
|
82
84
|
foldedLines?: number;
|
|
85
|
+
bypassConsumed?: boolean;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
interface BatchState {
|
|
@@ -97,6 +100,7 @@ interface BatchState {
|
|
|
97
100
|
failures: number;
|
|
98
101
|
failed: boolean;
|
|
99
102
|
firstError?: unknown;
|
|
103
|
+
poisonedBy?: string;
|
|
100
104
|
warnings: string[];
|
|
101
105
|
}
|
|
102
106
|
|
|
@@ -144,13 +148,39 @@ function normalizeEditArgs(args: unknown): NormalizedEditArgs | undefined {
|
|
|
144
148
|
}
|
|
145
149
|
return undefined;
|
|
146
150
|
}
|
|
147
|
-
|
|
148
151
|
function anchorTargetFor(args: unknown): string | undefined {
|
|
149
152
|
const normalized = normalizeEditArgs(args);
|
|
150
153
|
if (!normalized) return undefined;
|
|
151
154
|
if (normalized.kind === "replace") return tryResolveEditTarget(normalized.removeFrom, normalized.removeTo);
|
|
152
155
|
return tryResolveEditTarget(normalized.anchor);
|
|
153
156
|
}
|
|
157
|
+
function unresolvedErrorFor(call: EditCall): Error {
|
|
158
|
+
const normalized = normalizeEditArgs(call.args);
|
|
159
|
+
if (!normalized) return new Error(`sibling invalid`);
|
|
160
|
+
const refs = normalized.kind === "replace" ? [normalized.removeFrom, normalized.removeTo].filter((ref): ref is string => typeof ref === "string").join("→") : normalized.anchor;
|
|
161
|
+
try {
|
|
162
|
+
if (normalized.kind === "replace") resolveEditTarget(normalized.removeFrom, normalized.removeTo);
|
|
163
|
+
else resolveEditTarget(normalized.anchor);
|
|
164
|
+
} catch {
|
|
165
|
+
return new Error(`sibling stale (${refs})`);
|
|
166
|
+
}
|
|
167
|
+
return new Error(`sibling stale (${refs})`);
|
|
168
|
+
}
|
|
169
|
+
async function inferredTargetFor(args: unknown, cwd: string, requirePath: boolean): Promise<string | undefined> {
|
|
170
|
+
const normalized = normalizeEditArgs(args);
|
|
171
|
+
if (!normalized) return undefined;
|
|
172
|
+
if (requirePath && normalized.path) {
|
|
173
|
+
try {
|
|
174
|
+
return (await resolveInCwd(normalized.path, cwd)).resolved;
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (normalized.kind === "replace") {
|
|
180
|
+
return tryResolveEditTarget(normalized.removeFrom) ?? (normalized.removeTo ? tryResolveEditTarget(normalized.removeTo) : undefined);
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
154
184
|
|
|
155
185
|
async function verifyPaths(
|
|
156
186
|
group: Array<{ id: string; target: string; kind: BatchKind; args: unknown }>,
|
|
@@ -191,10 +221,12 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
191
221
|
calls.push({ id: block.id, name: block.name, args: block.arguments });
|
|
192
222
|
}
|
|
193
223
|
if (calls.length < 2) return;
|
|
224
|
+
const earlyConfig = await readConfig();
|
|
225
|
+
const requirePath = earlyConfig.requirePath === true;
|
|
194
226
|
interface ResolvedCall { id: string; target: string; kind: BatchKind; args: unknown }
|
|
195
227
|
const resolved: ResolvedCall[] = [];
|
|
196
228
|
for (const call of calls) {
|
|
197
|
-
const target = anchorTargetFor(call.args);
|
|
229
|
+
const target = anchorTargetFor(call.args) ?? await inferredTargetFor(call.args, cwd, requirePath);
|
|
198
230
|
if (target) resolved.push({ id: call.id, target, kind: call.name as BatchKind, args: call.args });
|
|
199
231
|
}
|
|
200
232
|
const groups = new Map<string, ResolvedCall[]>();
|
|
@@ -204,10 +236,8 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
204
236
|
groups.set(item.target, group);
|
|
205
237
|
}
|
|
206
238
|
const multi = [...groups.values()].filter((group) => group.length >= 2);
|
|
207
|
-
if (multi.length === 0) return;
|
|
208
|
-
const config = await readConfig();
|
|
209
239
|
const finalGroups: ResolvedCall[][] = [];
|
|
210
|
-
if (
|
|
240
|
+
if (requirePath) {
|
|
211
241
|
for (const group of multi) {
|
|
212
242
|
const verified = await verifyPaths(group, cwd);
|
|
213
243
|
if (verified.length >= 2) finalGroups.push(verified);
|
|
@@ -215,11 +245,22 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
215
245
|
} else {
|
|
216
246
|
finalGroups.push(...multi);
|
|
217
247
|
}
|
|
218
|
-
|
|
248
|
+
const resolvedIds = new Set(resolved.map((item) => item.id));
|
|
249
|
+
const unplanned = calls.filter((call) => !resolvedIds.has(call.id));
|
|
250
|
+
let poison: { target: string; error: unknown; callId: string } | undefined;
|
|
251
|
+
if (unplanned.length > 0 && groups.size === 1) {
|
|
252
|
+
const sole = [...groups.values()][0]!;
|
|
253
|
+
const poisonTarget = sole[0]!.target;
|
|
254
|
+
const poisonCall = unplanned[0]!;
|
|
255
|
+
poison = { target: poisonTarget, error: unresolvedErrorFor(poisonCall), callId: poisonCall.id };
|
|
256
|
+
if (!finalGroups.some((group) => group[0]!.target === poisonTarget)) finalGroups.push(sole);
|
|
257
|
+
}
|
|
219
258
|
let display = 0;
|
|
220
259
|
for (const group of finalGroups) {
|
|
221
260
|
display += 1;
|
|
222
261
|
const key = nextBatchKey++;
|
|
262
|
+
const matchingPoison = poison !== undefined && group[0]!.target === poison.target ? poison : undefined;
|
|
263
|
+
const poisoned = matchingPoison !== undefined;
|
|
223
264
|
batches.set(key, {
|
|
224
265
|
display,
|
|
225
266
|
target: group[0]!.target,
|
|
@@ -229,8 +270,9 @@ export async function planAssistantMessage(message: unknown, cwd: string): Promi
|
|
|
229
270
|
pieces: [],
|
|
230
271
|
applied: 0,
|
|
231
272
|
noops: 0,
|
|
232
|
-
failures: 0,
|
|
233
|
-
failed:
|
|
273
|
+
failures: poisoned ? 1 : 0,
|
|
274
|
+
failed: poisoned,
|
|
275
|
+
...(matchingPoison ? { firstError: matchingPoison.error, poisonedBy: matchingPoison.callId } : {}),
|
|
234
276
|
warnings: [],
|
|
235
277
|
});
|
|
236
278
|
group.forEach((item, index) => {
|
|
@@ -310,9 +352,21 @@ function batchPlaceholder(member: PlannedMember, piece: BatchPiece, snapshotId:
|
|
|
310
352
|
};
|
|
311
353
|
}
|
|
312
354
|
|
|
355
|
+
export function withAbortSuffix(message: string, display: number): string {
|
|
356
|
+
const suffix = `Aborts batch ${display}.`;
|
|
357
|
+
if (message.includes(suffix)) return message;
|
|
358
|
+
return message.endsWith(".") ? `${message} ${suffix}` : `${message}. ${suffix}`;
|
|
359
|
+
}
|
|
360
|
+
export function suffixPoisonCause(toolCallId: string, error: unknown): void {
|
|
361
|
+
if (!(error instanceof Error)) return;
|
|
362
|
+
for (const runtime of batches.values()) {
|
|
363
|
+
if (runtime.poisonedBy === toolCallId) error.message = withAbortSuffix(error.message, runtime.display);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
313
366
|
export function noteBatchFailure(member: PlannedMember, error: unknown): void {
|
|
314
367
|
const runtime = batches.get(member.batchKey);
|
|
315
368
|
if (!runtime) return;
|
|
369
|
+
if (error instanceof Error && !error.message.startsWith("[E_OP_ABORTED]")) error.message = withAbortSuffix(error.message, member.display);
|
|
316
370
|
runtime.failures += 1;
|
|
317
371
|
if (!runtime.failed) {
|
|
318
372
|
runtime.failed = true;
|
|
@@ -320,9 +374,15 @@ export function noteBatchFailure(member: PlannedMember, error: unknown): void {
|
|
|
320
374
|
}
|
|
321
375
|
}
|
|
322
376
|
|
|
323
|
-
function batchAbortedError(runtime: BatchState): Error {
|
|
324
|
-
|
|
325
|
-
return new Error(`[
|
|
377
|
+
function batchAbortedError(runtime: BatchState, input?: BatchMemberInput): Error {
|
|
378
|
+
restoreBatchBypasses(runtime, input);
|
|
379
|
+
return new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted.`);
|
|
380
|
+
}
|
|
381
|
+
function restoreBatchBypasses(runtime: BatchState, input?: BatchMemberInput): void {
|
|
382
|
+
for (const piece of runtime.pieces) {
|
|
383
|
+
if (piece.bypassConsumed && piece.noopPayload) markBoundaryNoop(runtime.target, piece.noopPayload);
|
|
384
|
+
}
|
|
385
|
+
if (input?.bypassConsumed && input.noopPayload) markBoundaryNoop(input.mutationTargetPath, input.noopPayload);
|
|
326
386
|
}
|
|
327
387
|
|
|
328
388
|
export async function ensureBatchBase(input: {
|
|
@@ -351,6 +411,7 @@ export async function ensureBatchBase(input: {
|
|
|
351
411
|
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors,
|
|
352
412
|
absolutePath: file.absolutePath,
|
|
353
413
|
...(snapshotId !== undefined ? { snapshotId } : {}),
|
|
414
|
+
baseLines: splitLines(file.normalized),
|
|
354
415
|
};
|
|
355
416
|
runtime.base = base;
|
|
356
417
|
runtime.served = servedForPath(file.absolutePath);
|
|
@@ -365,7 +426,7 @@ export async function ensureBatchBase(input: {
|
|
|
365
426
|
export async function executeBatchMember(input: BatchMemberInput): Promise<TResult> {
|
|
366
427
|
const runtime = batches.get(input.member.batchKey);
|
|
367
428
|
if (!runtime) throw new Error(`[E_STALE_ANCHOR] Batch ${input.member.display} is no longer tracked. Call read for fresh anchors.`);
|
|
368
|
-
if (runtime.failed) throw batchAbortedError(runtime);
|
|
429
|
+
if (runtime.failed) throw batchAbortedError(runtime, input);
|
|
369
430
|
let base: BatchBase;
|
|
370
431
|
try {
|
|
371
432
|
base = await ensureBatchBase({
|
|
@@ -377,11 +438,13 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
377
438
|
});
|
|
378
439
|
} catch (error) {
|
|
379
440
|
noteBatchFailure(input.member, error);
|
|
441
|
+
restoreBatchBypasses(runtime, input);
|
|
380
442
|
throw error;
|
|
381
443
|
}
|
|
382
444
|
if (input.mutationTargetPath !== input.member.target) {
|
|
383
445
|
const error = new Error(`[E_STALE_ANCHOR] "${input.hedit.hash_bounds[0].hash}" is no longer owned by ${input.member.target}. Call read for fresh anchors.`);
|
|
384
446
|
noteBatchFailure(input.member, error);
|
|
447
|
+
restoreBatchBypasses(runtime, input);
|
|
385
448
|
throw error;
|
|
386
449
|
}
|
|
387
450
|
const displayPath = runtime.paths?.displayPath ?? input.targetPath;
|
|
@@ -393,6 +456,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
393
456
|
skipBoundaryDedup: input.skipBoundaryDedup,
|
|
394
457
|
strictBoundaryDedup: input.strictBoundaryDedup,
|
|
395
458
|
signal: input.signal,
|
|
459
|
+
baseFileLines: base.baseLines,
|
|
396
460
|
});
|
|
397
461
|
} catch (error) {
|
|
398
462
|
if (error instanceof RangeStaleError) adoptAnchors(base.absolutePath, error.rangeServedMap);
|
|
@@ -400,15 +464,17 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
400
464
|
else if (error instanceof Error && error.message.startsWith("[E_BOUNDARY_STRICT]")) {
|
|
401
465
|
const indexed = new Error(`edit #${input.member.order} strict boundary-dedup rejection: ${error.message}`);
|
|
402
466
|
noteBatchFailure(input.member, indexed);
|
|
467
|
+
restoreBatchBypasses(runtime, input);
|
|
403
468
|
throw indexed;
|
|
404
469
|
}
|
|
405
470
|
noteBatchFailure(input.member, error);
|
|
471
|
+
restoreBatchBypasses(runtime, input);
|
|
406
472
|
throw error;
|
|
407
473
|
}
|
|
408
474
|
const start = planned.resolved.hash_bounds[0].line;
|
|
409
475
|
const end = planned.resolved.hash_bounds[1].line;
|
|
410
476
|
const newLines = planned.resolved.content_lines;
|
|
411
|
-
const baseLines =
|
|
477
|
+
const baseLines = base.baseLines;
|
|
412
478
|
const originalSlice = baseLines.slice(start - 1, end);
|
|
413
479
|
const noop = originalSlice.length === newLines.length && originalSlice.every((line, index) => line === newLines[index]);
|
|
414
480
|
const autoFixes = planned.autoFixes?.length ?? 0;
|
|
@@ -425,6 +491,7 @@ export async function executeBatchMember(input: BatchMemberInput): Promise<TResu
|
|
|
425
491
|
noop,
|
|
426
492
|
...(input.noopPayload !== undefined ? { noopPayload: input.noopPayload } : {}),
|
|
427
493
|
foldedLines: input.foldedLines ?? 0,
|
|
494
|
+
...(input.bypassConsumed ? { bypassConsumed: true as const } : {}),
|
|
428
495
|
};
|
|
429
496
|
runtime.pieces.push(piece);
|
|
430
497
|
if (input.kind === "replace") runtime.replaceCount += 1;
|
|
@@ -476,20 +543,27 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
476
543
|
const prev = ordered[i - 1]!;
|
|
477
544
|
const current = ordered[i]!;
|
|
478
545
|
if (current.start <= prev.end) {
|
|
546
|
+
restoreBatchBypasses(runtime);
|
|
479
547
|
throw new Error(`[E_BATCH_OVERLAP] Batch ${runtime.display} has overlapping ranges: ${formatBatchPiece(prev)} overlaps ${formatBatchPiece(current)}`);
|
|
480
548
|
}
|
|
481
549
|
}
|
|
550
|
+
const composed = composeBatchLines(base.content, appliedPieces);
|
|
482
551
|
const warnings = [...runtime.warnings];
|
|
483
552
|
if (base.hadUtf8DecodeErrors) warnings.push("Non-UTF-8 bytes were shown as U+FFFD; this edit rewrote the file as UTF-8.");
|
|
484
553
|
const dedupTotal = runtime.pieces.reduce((sum, piece) => sum + piece.autoFixes, 0);
|
|
485
554
|
if (dedupTotal > 0) warnings.push(boundaryDedupWarning(dedupTotal));
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
555
|
+
try {
|
|
556
|
+
await throwIfStrictInput(dedupeWarnings(warnings));
|
|
557
|
+
assertNotEmpty(base.content, composed);
|
|
558
|
+
assertLineLimit(composed, paths.displayPath, MAX_HASH_LINES);
|
|
559
|
+
const finalBytes = base.bom + restoreEndings(composed, base.ending);
|
|
560
|
+
if (Buffer.byteLength(finalBytes, "utf-8") > MAX_BYTES) {
|
|
561
|
+
throw new Error(`[E_FILE_TOO_LARGE] File is too large: ${paths.displayPath} (exceeds the ${MAX_BYTES / (1024 * 1024)}MB size limit). For very large files, use write.`);
|
|
562
|
+
}
|
|
563
|
+
} catch (error) {
|
|
564
|
+
restoreBatchBypasses(runtime);
|
|
565
|
+
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
566
|
+
throw error;
|
|
493
567
|
}
|
|
494
568
|
if (composed === base.content) {
|
|
495
569
|
const snapshotId = await safeSnapId(paths.absolutePath, "noop edit");
|
|
@@ -501,10 +575,24 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
501
575
|
currentRaw = await readFile(runtime.target, "utf-8");
|
|
502
576
|
} catch (error) {
|
|
503
577
|
if (errCode(error) !== "ENOENT") throw error;
|
|
504
|
-
|
|
578
|
+
restoreBatchBypasses(runtime);
|
|
579
|
+
throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file was deleted after the batch started.`);
|
|
505
580
|
}
|
|
506
581
|
if (toLF(stripBOM(currentRaw).text) !== base.content) {
|
|
507
|
-
|
|
582
|
+
restoreBatchBypasses(runtime);
|
|
583
|
+
throw new Error(`[E_OP_ABORTED] Batch ${runtime.display} aborted: the file changed after the batch started. Call read for fresh anchors and retry.`);
|
|
584
|
+
}
|
|
585
|
+
const preflightSpans = appliedPieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
|
|
586
|
+
try {
|
|
587
|
+
await lineHashes(composed, runtime.target, {
|
|
588
|
+
content: base.content,
|
|
589
|
+
hashes: base.hashes,
|
|
590
|
+
spans: preflightSpans,
|
|
591
|
+
}, undefined, false, true);
|
|
592
|
+
} catch (error) {
|
|
593
|
+
restoreBatchBypasses(runtime);
|
|
594
|
+
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
595
|
+
throw error;
|
|
508
596
|
}
|
|
509
597
|
const undo = await saveUndo(runtime.target, {
|
|
510
598
|
content: base.content,
|
|
@@ -514,23 +602,32 @@ async function finishBatch(member: PlannedMember, signal?: AbortSignal): Promise
|
|
|
514
602
|
resultContent: composed,
|
|
515
603
|
});
|
|
516
604
|
if (!undo.persisted) {
|
|
517
|
-
|
|
605
|
+
restoreBatchBypasses(runtime);
|
|
606
|
+
throw new Error(`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${paths.displayPath}. Aborts batch ${runtime.display}.`);
|
|
518
607
|
}
|
|
519
608
|
try {
|
|
520
609
|
abortIf(signal);
|
|
521
610
|
await writeAtomic(paths.absolutePath, base.bom + restoreEndings(composed, base.ending), base.identity);
|
|
522
611
|
} catch (error) {
|
|
523
612
|
await undo.restore();
|
|
613
|
+
restoreBatchBypasses(runtime);
|
|
614
|
+
if (error instanceof Error) error.message = withAbortSuffix(error.message, runtime.display);
|
|
524
615
|
throw error;
|
|
525
616
|
}
|
|
526
617
|
clearBoundaryBypass(runtime.target);
|
|
527
618
|
const updatedSnapshotId = await safeSnapId(paths.absolutePath, "post-edit");
|
|
528
619
|
const spans = appliedPieces.map((piece) => ({ start: piece.start - 1, end: piece.end - 1, replacementCount: piece.newLines.length }));
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
620
|
+
let resultHashes: string[];
|
|
621
|
+
try {
|
|
622
|
+
resultHashes = await lineHashes(composed, runtime.target, {
|
|
623
|
+
content: base.content,
|
|
624
|
+
hashes: base.hashes,
|
|
625
|
+
spans,
|
|
626
|
+
});
|
|
627
|
+
} catch (error) {
|
|
628
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
629
|
+
throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
|
|
630
|
+
}
|
|
534
631
|
const range = changedRange(base.content, composed);
|
|
535
632
|
let added = 0;
|
|
536
633
|
let removed = 0;
|
package/src/commit.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
1
2
|
import type { PipelineResult } from "./replace";
|
|
2
|
-
import { abortIf } from "./utils";
|
|
3
|
+
import { abortIf, errCode, splitLines } from "./utils";
|
|
3
4
|
import { DEDUP_ANCHOR } from "./constants";
|
|
4
5
|
import { HASH_SEP } from "./hashline";
|
|
5
6
|
import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-response";
|
|
@@ -10,8 +11,7 @@ import { writeAtomic } from "./fs-write";
|
|
|
10
11
|
import { servedHashesFromDiff, buildServedMap } from "./served";
|
|
11
12
|
import { lineHashes } from "./hashline";
|
|
12
13
|
import { hashSpan } from "./replace";
|
|
13
|
-
import { restoreEndings } from "./normalize";
|
|
14
|
-
import { splitLines } from "./utils";
|
|
14
|
+
import { restoreEndings, stripBOM, toLF } from "./normalize";
|
|
15
15
|
import { markServed as markServedScoped } from "./anchor-registry";
|
|
16
16
|
export interface CommitMeta {
|
|
17
17
|
editAnchors?: [string, string];
|
|
@@ -71,6 +71,22 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
abortIf(signal);
|
|
74
|
+
let currentRaw: string | undefined;
|
|
75
|
+
try {
|
|
76
|
+
currentRaw = await readFile(mutationTargetPath, "utf-8");
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const code = errCode(error);
|
|
79
|
+
if (code === "ENOENT") currentRaw = undefined;
|
|
80
|
+
else if (code === "EACCES" || code === "EPERM") throw new Error(`[E_ACCESS] File is not readable: ${path}`);
|
|
81
|
+
else if (code === "ELOOP") throw new Error(`[E_ACCESS] Too many symbolic links while resolving: ${path}`);
|
|
82
|
+
else throw error;
|
|
83
|
+
}
|
|
84
|
+
if (currentRaw === undefined) {
|
|
85
|
+
throw new Error(`[E_OP_ABORTED] Edit aborted: the file was deleted after the edit started.`);
|
|
86
|
+
}
|
|
87
|
+
if (toLF(stripBOM(currentRaw).text) !== pipe.originalNormalized) {
|
|
88
|
+
throw new Error(`[E_OP_ABORTED] Edit aborted: the file changed after the edit started. Call read for fresh anchors and retry.`);
|
|
89
|
+
}
|
|
74
90
|
const undo = await saveUndo(mutationTargetPath, {
|
|
75
91
|
content: pipe.originalNormalized,
|
|
76
92
|
bom: pipe.bom,
|
|
@@ -80,7 +96,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
80
96
|
});
|
|
81
97
|
if (!undo.persisted) {
|
|
82
98
|
throw new Error(
|
|
83
|
-
`[E_UNDO_UNAVAILABLE] Could not persist undo history
|
|
99
|
+
`[E_UNDO_UNAVAILABLE] Could not persist undo history for ${path}.`
|
|
84
100
|
);
|
|
85
101
|
}
|
|
86
102
|
try {
|
|
@@ -109,13 +125,19 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
109
125
|
const span = meta.editAnchors ? hashSpan(pipe.originalHashes, meta.editAnchors[0], meta.editAnchors[1]) : undefined;
|
|
110
126
|
const resultCount = splitLines(pipe.result).length;
|
|
111
127
|
const replacementCount = span ? resultCount - (pipe.originalHashes.length - (span[1] - span[0] + 1)) : 0;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
128
|
+
let resultHashes: string[];
|
|
129
|
+
try {
|
|
130
|
+
resultHashes = pipe.result === pipe.originalNormalized
|
|
131
|
+
? pipe.originalHashes
|
|
132
|
+
: await lineHashes(pipe.result, mutationTargetPath, {
|
|
115
133
|
content: pipe.originalNormalized,
|
|
116
134
|
hashes: pipe.originalHashes,
|
|
117
135
|
spans: span ? [{ start: span[0], end: span[1], replacementCount }] : undefined,
|
|
118
136
|
});
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
139
|
+
throw new Error(`${detail} File was written; anchor finalization failed. One undo reverts. Call read for fresh anchors.`);
|
|
140
|
+
}
|
|
119
141
|
const successInput = {
|
|
120
142
|
path,
|
|
121
143
|
originalNormalized: pipe.originalNormalized,
|