@cruxy/cli 1.11.1 → 1.11.2
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/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/errors/constructors.js +15 -6
- package/dist/errors/types.js +7 -0
- package/dist/indexing/embedder.js +34 -11
- package/dist/indexing/model-cache.js +399 -0
- package/dist/session/index.js +1 -0
- package/dist/session/log.js +66 -0
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/resume.js +22 -3
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/tools/file/apply-patch.js +49 -23
- package/dist/tools/file/edit-file.js +15 -1
- package/dist/tools/file/snapshot.js +63 -0
- package/dist/tools/file/write-file.js +26 -5
- package/dist/utils/process-owner.js +107 -0
- package/package.json +3 -2
package/dist/session/log.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { APP_VERSION } from "../constants.js";
|
|
4
4
|
import { formatBytes } from "../utils/disk.js";
|
|
5
5
|
import { sessionFile } from "./paths.js";
|
|
6
|
+
import { claimSession, describeHolder, releaseSession, sessionHeldBy, } from "./owner.js";
|
|
6
7
|
import { pruneSessions } from "./prune.js";
|
|
7
8
|
import { SESSION_FILE_VERSION, } from "./types.js";
|
|
8
9
|
/**
|
|
@@ -20,6 +21,8 @@ export class SessionLog {
|
|
|
20
21
|
currentRunId;
|
|
21
22
|
/** Set once a write fails: the log goes inert rather than warning per turn. */
|
|
22
23
|
broken = false;
|
|
24
|
+
/** Whether this process has stamped itself as the file's owner (P1). */
|
|
25
|
+
claimed = false;
|
|
23
26
|
/**
|
|
24
27
|
* A NEW session's `meta` line, held until the session records something.
|
|
25
28
|
* Null on a reopen (the file already has its meta) and null again the moment
|
|
@@ -77,6 +80,14 @@ export class SessionLog {
|
|
|
77
80
|
const log = new SessionLog(file, opts);
|
|
78
81
|
log.pruneOnce(opts);
|
|
79
82
|
if (hasContent(file)) {
|
|
83
|
+
// OWNERSHIP (P1). A reopen is the moment a second process would start
|
|
84
|
+
// interleaving its turns into this file, so the stamp is taken here,
|
|
85
|
+
// eagerly, like the `resumed` event below. `loadResume` has already
|
|
86
|
+
// refused a file another live cruxy holds; this is the claim that makes
|
|
87
|
+
// the NEXT resume see us. A fresh session claims at its first flush
|
|
88
|
+
// instead — see {@link write} — for the same reason `meta` is buffered:
|
|
89
|
+
// a conversation with nothing in it should leave nothing on disk.
|
|
90
|
+
log.claim();
|
|
80
91
|
log.write({
|
|
81
92
|
kind: "resumed",
|
|
82
93
|
at: new Date().toISOString(),
|
|
@@ -232,9 +243,46 @@ export class SessionLog {
|
|
|
232
243
|
this.pendingMeta = null;
|
|
233
244
|
if (!this.writeLine(meta))
|
|
234
245
|
return false;
|
|
246
|
+
this.claim(); // the file now exists — so can a second `--resume` of it
|
|
235
247
|
}
|
|
236
248
|
return this.writeLine(event);
|
|
237
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Stamp this process as the file's owner (P1 — see `owner.ts`). Idempotent.
|
|
252
|
+
* The stamp is released on process exit; a crash leaves it behind, and that
|
|
253
|
+
* is fine — a stamp is pid + start-time, so the next reader sees a dead
|
|
254
|
+
* owner and ignores it. Never fatal: an unwritable stamp is the pre-P1
|
|
255
|
+
* behaviour (no ownership), and a session must still run without one.
|
|
256
|
+
*/
|
|
257
|
+
claim() {
|
|
258
|
+
if (this.claimed)
|
|
259
|
+
return;
|
|
260
|
+
// Never overwrite a LIVE owner's stamp. `loadResume` refuses that file
|
|
261
|
+
// before we get here, so this is the guard for any other writer that
|
|
262
|
+
// opens a log — and for the race two resumes in the same instant would
|
|
263
|
+
// be. Recording continues (the format tolerates it); ownership does not
|
|
264
|
+
// move, so the third process to come along still sees the real holder.
|
|
265
|
+
const holder = sessionHeldBy(this.file);
|
|
266
|
+
if (holder) {
|
|
267
|
+
this.logger?.warn(`session is open in another cruxy (${describeHolder(holder)}) — this process is not taking it over`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
this.claimed = claimSession(this.file);
|
|
271
|
+
if (this.claimed)
|
|
272
|
+
releaseOnExit(this.file);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Give the file up: remove our stamp. The exit hook does this for the
|
|
276
|
+
* normal case; this is for a caller that ends a session while the process
|
|
277
|
+
* lives on (tests, and any future in-process session switch).
|
|
278
|
+
*/
|
|
279
|
+
close() {
|
|
280
|
+
if (!this.claimed)
|
|
281
|
+
return;
|
|
282
|
+
this.claimed = false;
|
|
283
|
+
releaseSession(this.file);
|
|
284
|
+
claimedFiles.delete(this.file);
|
|
285
|
+
}
|
|
238
286
|
/**
|
|
239
287
|
* Append one event as a single line. Returns whether it landed. The first
|
|
240
288
|
* failure warns and latches `broken`, so a persistent problem (a full disk)
|
|
@@ -255,6 +303,24 @@ export class SessionLog {
|
|
|
255
303
|
}
|
|
256
304
|
}
|
|
257
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Every session file this process has stamped, released together on exit.
|
|
308
|
+
* ONE listener for the process rather than one per log: a test opens dozens
|
|
309
|
+
* of logs, and `process.on` warns past ten listeners.
|
|
310
|
+
*/
|
|
311
|
+
const claimedFiles = new Set();
|
|
312
|
+
let exitHookInstalled = false;
|
|
313
|
+
function releaseOnExit(file) {
|
|
314
|
+
claimedFiles.add(file);
|
|
315
|
+
if (exitHookInstalled)
|
|
316
|
+
return;
|
|
317
|
+
exitHookInstalled = true;
|
|
318
|
+
process.on("exit", () => {
|
|
319
|
+
for (const f of claimedFiles)
|
|
320
|
+
releaseSession(f);
|
|
321
|
+
claimedFiles.clear();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
258
324
|
/**
|
|
259
325
|
* Whether `file` is an existing log with content — i.e. this is a reopen.
|
|
260
326
|
*
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { describeOwner, selfStamp, } from "../utils/process-owner.js";
|
|
5
|
+
import { SESSION_FILE_EXT } from "./paths.js";
|
|
6
|
+
/**
|
|
7
|
+
* Session ownership across processes (P1).
|
|
8
|
+
*
|
|
9
|
+
* A session log is one append-only file, and `SessionLog` was built so that
|
|
10
|
+
* concurrent appenders interleave whole lines rather than tearing them. That
|
|
11
|
+
* keeps the file PARSEABLE under two writers; it does not keep it MEANINGFUL.
|
|
12
|
+
* Two processes that `--resume` the same id each replay the same history,
|
|
13
|
+
* each append their own turns, and the result is one transcript with two
|
|
14
|
+
* conversations shuffled into it — replayable, and wrong.
|
|
15
|
+
*
|
|
16
|
+
* The fix is ownership, and the minimum that prevents the observed failure is
|
|
17
|
+
* a stamp, not a lock:
|
|
18
|
+
*
|
|
19
|
+
* - `<id>.owner.json` beside the log holds the {@link ProcessStamp} of the
|
|
20
|
+
* cruxy that has it open. It is written when the session first lands on
|
|
21
|
+
* disk (a new session) or on reopen (a resume), and removed on exit.
|
|
22
|
+
* - `--resume` reads it BEFORE replaying. A stamp whose process is still
|
|
23
|
+
* running is a refusal; a stamp whose process is gone — or whose pid has
|
|
24
|
+
* been recycled onto something else — is ignored and overwritten.
|
|
25
|
+
*
|
|
26
|
+
* WHY A STAMP AND NOT A LOCK. A lock file that must be deleted to be released
|
|
27
|
+
* outlives every crash, and a lock that outlives a crash is worse than no
|
|
28
|
+
* lock: the next `--resume` is refused with nothing to refuse it for, and the
|
|
29
|
+
* user learns to `rm` it — at which point it stops meaning anything. A pid +
|
|
30
|
+
* start-time stamp is self-invalidating: liveness is decided by asking the OS,
|
|
31
|
+
* never by whether cleanup ran. See `utils/process-owner.ts` for why the pid
|
|
32
|
+
* marker refused for JOB logs is category-correct for sessions.
|
|
33
|
+
*
|
|
34
|
+
* WHY NOT A PROJECT-LEVEL LOCK. Several cruxy processes in one project is the
|
|
35
|
+
* multi-agent case, not a misuse of it. What must not happen is two of them
|
|
36
|
+
* on ONE session; the stamp is scoped to exactly that.
|
|
37
|
+
*/
|
|
38
|
+
const OwnerSchema = z.object({
|
|
39
|
+
pid: z.number().int().positive(),
|
|
40
|
+
token: z.string().min(1),
|
|
41
|
+
startedAt: z.string(),
|
|
42
|
+
/** When the stamp was written — for messages. */
|
|
43
|
+
claimedAt: z.string(),
|
|
44
|
+
});
|
|
45
|
+
/** `<id>.owner.json` next to `<id>.jsonl`. */
|
|
46
|
+
export function ownerFile(sessionFile) {
|
|
47
|
+
const dir = path.dirname(sessionFile);
|
|
48
|
+
const id = path.basename(sessionFile, SESSION_FILE_EXT);
|
|
49
|
+
return path.join(dir, `${id}.owner.json`);
|
|
50
|
+
}
|
|
51
|
+
/** The recorded owner, or null when there is none (absent, unreadable, malformed). */
|
|
52
|
+
export function readOwner(sessionFile) {
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = readFileSync(ownerFile(sessionFile), "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const parsed = OwnerSchema.safeParse(JSON.parse(raw));
|
|
62
|
+
return parsed.success ? parsed.data : null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Who has the session open right now, if anyone other than this process.
|
|
70
|
+
* A stale stamp is not a holder; neither is our own.
|
|
71
|
+
*/
|
|
72
|
+
export function sessionHeldBy(sessionFile) {
|
|
73
|
+
const owner = readOwner(sessionFile);
|
|
74
|
+
if (!owner)
|
|
75
|
+
return null;
|
|
76
|
+
return describeOwner(owner) === "live" ? owner : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Record this process as the session's owner. Temp-then-rename so a reader
|
|
80
|
+
* never sees a half-written stamp; `0600` like everything else in `~/.cruxy`.
|
|
81
|
+
* Never throws: a stamp that cannot be written degrades to the pre-P1
|
|
82
|
+
* behaviour (no ownership), and the session must still run.
|
|
83
|
+
*/
|
|
84
|
+
export function claimSession(sessionFile) {
|
|
85
|
+
const file = ownerFile(sessionFile);
|
|
86
|
+
const me = selfStamp();
|
|
87
|
+
const record = { ...me, claimedAt: new Date().toISOString() };
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
90
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
91
|
+
writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 });
|
|
92
|
+
renameSync(tmp, file);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Remove our stamp — only ours; a later owner's is left alone. Never throws. */
|
|
100
|
+
export function releaseSession(sessionFile) {
|
|
101
|
+
const owner = readOwner(sessionFile);
|
|
102
|
+
if (!owner || describeOwner(owner) !== "self")
|
|
103
|
+
return;
|
|
104
|
+
try {
|
|
105
|
+
unlinkSync(ownerFile(sessionFile));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// already gone, or unwritable — nothing to do either way
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Drop a session's stamp unconditionally — for deleting the session itself. */
|
|
112
|
+
export function removeOwnerFile(sessionFile) {
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(ownerFile(sessionFile));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// no stamp to remove
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** One line for a refusal or a picker row: which process, since when. */
|
|
121
|
+
export function describeHolder(stamp) {
|
|
122
|
+
return `pid ${stamp.pid}, started ${stamp.startedAt}`;
|
|
123
|
+
}
|
package/dist/session/prune.js
CHANGED
|
@@ -4,6 +4,7 @@ import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKe
|
|
|
4
4
|
import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
|
|
5
5
|
import { sessionFilesByRecency } from "./list.js";
|
|
6
6
|
import { SESSION_FILE_EXT } from "./paths.js";
|
|
7
|
+
import { removeOwnerFile, sessionHeldBy } from "./owner.js";
|
|
7
8
|
/** `<sessionId>.jsonl` → `<sessionId>`. */
|
|
8
9
|
function idOf(file) {
|
|
9
10
|
return path.basename(file, SESSION_FILE_EXT);
|
|
@@ -59,6 +60,15 @@ export function pruneSessions(cwd, opts) {
|
|
|
59
60
|
result.kept++;
|
|
60
61
|
continue;
|
|
61
62
|
}
|
|
63
|
+
// A session ANOTHER live cruxy has open is spared for the same reason our
|
|
64
|
+
// own is (P1): its writer would recreate the file on its next append with
|
|
65
|
+
// no `meta` line, and that session would be unloadable from then on. The
|
|
66
|
+
// stamp is pid + start-time, so a crashed owner's file is prunable again
|
|
67
|
+
// the moment it is looked at — nothing outlives the crash.
|
|
68
|
+
if (sessionHeldBy(ref.file)) {
|
|
69
|
+
result.kept++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
62
72
|
const tooOld = ref.mtimeMs < cutoff;
|
|
63
73
|
const beyondCap = index >= retention;
|
|
64
74
|
if (!tooOld && !beyondCap) {
|
|
@@ -67,6 +77,7 @@ export function pruneSessions(cwd, opts) {
|
|
|
67
77
|
}
|
|
68
78
|
try {
|
|
69
79
|
unlinkSync(ref.file);
|
|
80
|
+
removeOwnerFile(ref.file); // a stale stamp has nothing left to own
|
|
70
81
|
result.removed.push({ ...ref, sessionId });
|
|
71
82
|
result.bytesFreed += ref.size;
|
|
72
83
|
}
|
package/dist/session/resume.js
CHANGED
|
@@ -2,6 +2,7 @@ import { selectList } from "../components/index.js";
|
|
|
2
2
|
import { usageError } from "../errors/index.js";
|
|
3
3
|
import { listSessionRefs, listSessions, matchSessionRefs, summarizeSession, } from "./list.js";
|
|
4
4
|
import { replaySession } from "./replay.js";
|
|
5
|
+
import { describeHolder, ownerFile, sessionHeldBy } from "./owner.js";
|
|
5
6
|
/** How many sessions the bare-`--resume` picker offers. */
|
|
6
7
|
export const PICKER_LIMIT = 10;
|
|
7
8
|
/** Short, stable id form — enough to identify a session, short enough to type. */
|
|
@@ -75,6 +76,18 @@ export function priorDirectoriesWarning(state, cwd) {
|
|
|
75
76
|
* worth failing loudly on, unlike an individual torn line.
|
|
76
77
|
*/
|
|
77
78
|
export function loadResume(session, cwd) {
|
|
79
|
+
// OWNERSHIP (P1). Asked BEFORE the replay, and answered by the OS rather
|
|
80
|
+
// than by a file's presence: a stamp whose process is gone is not a holder.
|
|
81
|
+
// Two processes appending to one log produce a transcript that replays
|
|
82
|
+
// cleanly and means nothing, so this is a refusal, not a warning.
|
|
83
|
+
const holder = sessionHeldBy(session.file);
|
|
84
|
+
if (holder) {
|
|
85
|
+
throw usageError(`session ${shortId(session.sessionId)} is open in another cruxy (${describeHolder(holder)})`, [
|
|
86
|
+
"continue it there, or quit that cruxy and resume here",
|
|
87
|
+
"start a new session with `cruxy`",
|
|
88
|
+
`if that process is not a cruxy, remove ${ownerFile(session.file)}`,
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
78
91
|
let state;
|
|
79
92
|
try {
|
|
80
93
|
state = replaySession(session.file);
|
|
@@ -189,9 +202,15 @@ export async function resumePicker(cwd, opts = {}) {
|
|
|
189
202
|
];
|
|
190
203
|
const picked = await selectList(rows, {
|
|
191
204
|
title: "resume a session",
|
|
192
|
-
toLabel: (row) =>
|
|
193
|
-
|
|
194
|
-
|
|
205
|
+
toLabel: (row) => {
|
|
206
|
+
if (row.kind === "new")
|
|
207
|
+
return "+ new session";
|
|
208
|
+
const label = describeSession(row.session, now);
|
|
209
|
+
// A row another live cruxy holds is offered — the user may want to
|
|
210
|
+
// see it — but says so, so picking it is not a surprise refusal.
|
|
211
|
+
const holder = sessionHeldBy(row.session.file);
|
|
212
|
+
return holder ? `${label} · open in another cruxy` : label;
|
|
213
|
+
},
|
|
195
214
|
// Non-interactive with no id named: starting fresh is the safe default,
|
|
196
215
|
// never an arbitrary session picked on the user's behalf.
|
|
197
216
|
defaultValue: { kind: "new" },
|
|
@@ -7,7 +7,7 @@ import { UNRESOLVED_TIER, } from "../budget/index.js";
|
|
|
7
7
|
import { resolveTaskModel } from "../routing/index.js";
|
|
8
8
|
import { Workspace } from "../workspace/index.js";
|
|
9
9
|
import { Budget, resolveBudget } from "../agent/budget.js";
|
|
10
|
-
import {
|
|
10
|
+
import { isWriteTool, scopeRegistry } from "./registry-scope.js";
|
|
11
11
|
import { Semaphore } from "./semaphore.js";
|
|
12
12
|
import { makeSpawnSubagentTool } from "./spawn-tool.js";
|
|
13
13
|
/** Longest task excerpt shown in render chrome — display, not record. */
|
|
@@ -556,7 +556,7 @@ function taskLabel(task) {
|
|
|
556
556
|
/** A child that holds any mutating tool — the disjoint-scope check's unit. A
|
|
557
557
|
* spec with no `tools` gets the default READ-ONLY set, so it is never a writer. */
|
|
558
558
|
function isWriter(spec) {
|
|
559
|
-
return (spec.tools ?? []).some(
|
|
559
|
+
return (spec.tools ?? []).some(isWriteTool);
|
|
560
560
|
}
|
|
561
561
|
/**
|
|
562
562
|
* The tokens a child is KNOWN to have spent, or `undefined` when no request
|
|
@@ -17,8 +17,20 @@ export const SPAWN_SUBAGENTS_TOOL_NAME = "spawn_subagents";
|
|
|
17
17
|
/**
|
|
18
18
|
* Mutating tools (C.33): a child holding ANY of these is a "writer" for the
|
|
19
19
|
* disjoint-scope check. Two writers in one parallel batch must target distinct
|
|
20
|
-
* roots, or the batch is refused pre-dispatch.
|
|
21
|
-
*
|
|
20
|
+
* roots, or the batch is refused pre-dispatch.
|
|
21
|
+
*
|
|
22
|
+
* This is the set of REGISTERED tool names that gate on `ctx.requestApproval`
|
|
23
|
+
* and act on a workspace root: file writes, shell/test, the PR tool, and the
|
|
24
|
+
* background-job dispatcher (a job is a whole agent run that may be granted
|
|
25
|
+
* any of the others). `registry-scope.test.ts` pins every name here to a tool
|
|
26
|
+
* that actually exists — until P1 this set named three tools that never did
|
|
27
|
+
* (`git_commit`, `git_branch`, `open_pr`) and omitted `create_pull_request`,
|
|
28
|
+
* so a child holding the PR tool was never counted as a writer and two of them
|
|
29
|
+
* could be dispatched against one root.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately NOT here: `remember` (writes the memory store, not a root —
|
|
32
|
+
* root-disjointness says nothing about it) and the spawn tools (stripped from
|
|
33
|
+
* every child scope). MCP tools are covered by prefix in {@link isWriteTool}.
|
|
22
34
|
*/
|
|
23
35
|
export const SUBAGENT_WRITE_TOOLS = new Set([
|
|
24
36
|
"write_file",
|
|
@@ -26,10 +38,21 @@ export const SUBAGENT_WRITE_TOOLS = new Set([
|
|
|
26
38
|
"apply_patch",
|
|
27
39
|
"run_command",
|
|
28
40
|
"run_tests",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"open_pr",
|
|
41
|
+
"create_pull_request",
|
|
42
|
+
"run_in_background",
|
|
32
43
|
]);
|
|
44
|
+
/** The wire-name prefix every MCP-proxied tool carries (see `mcp/adapter.ts`). */
|
|
45
|
+
const MCP_TOOL_PREFIX = "mcp__";
|
|
46
|
+
/**
|
|
47
|
+
* Whether granting `name` to a child makes it a writer. The named set above,
|
|
48
|
+
* plus every MCP tool: the adapter gates each call on approval precisely
|
|
49
|
+
* because a server's tool can mutate anything, and the classifier never lets
|
|
50
|
+
* the server declare itself read-only — so the disjointness check must not
|
|
51
|
+
* either.
|
|
52
|
+
*/
|
|
53
|
+
export function isWriteTool(name) {
|
|
54
|
+
return SUBAGENT_WRITE_TOOLS.has(name) || name.startsWith(MCP_TOOL_PREFIX);
|
|
55
|
+
}
|
|
33
56
|
/**
|
|
34
57
|
* The default child toolset: read-only investigation plus skills. Mirrors the
|
|
35
58
|
* C.31 propose-phase set — no writes, no shell, no VCS unless the parent
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveToolPath, toPosix } from "./paths.js";
|
|
5
5
|
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
6
|
+
import { changedSince, snapshotFile, snapshotOf, } from "./snapshot.js";
|
|
6
7
|
/** How many leading lines of a created file the approval preview shows. */
|
|
7
8
|
const PREVIEW_LINES = 20;
|
|
8
9
|
/**
|
|
@@ -162,6 +163,20 @@ export const applyPatchTool = {
|
|
|
162
163
|
if (!decision.allow) {
|
|
163
164
|
return { ok: false, error: decision.feedback ?? "patch denied" };
|
|
164
165
|
}
|
|
166
|
+
// The approval covered THESE files in THE states they were read in. Any
|
|
167
|
+
// path that moved during the wait — rewritten, deleted, or created by
|
|
168
|
+
// something else — voids the whole patch: nothing is applied, so the model
|
|
169
|
+
// re-reads and resubmits rather than landing a half-stale patch (P1).
|
|
170
|
+
// Every drifted path is named, not just the first: a retry that knows one
|
|
171
|
+
// of two moved re-reads one file and trips over the other.
|
|
172
|
+
const drifted = [];
|
|
173
|
+
for (const p of planned) {
|
|
174
|
+
const moved = await changedSince(p.abs, p.approved, p.rel);
|
|
175
|
+
if (moved)
|
|
176
|
+
drifted.push(moved);
|
|
177
|
+
}
|
|
178
|
+
if (drifted.length > 0)
|
|
179
|
+
return { ok: false, error: drifted.join("\n") };
|
|
165
180
|
// Validation passed and the user approved; apply everything. A mid-apply I/O
|
|
166
181
|
// failure is rare but reported with what already landed.
|
|
167
182
|
const applied = [];
|
|
@@ -195,28 +210,44 @@ async function openTrack(i, op, abs, ctx) {
|
|
|
195
210
|
// messages), consistent with every other path tool — see {@link toPosix}.
|
|
196
211
|
const rel = toPosix(path.relative(ctx.cwd, abs));
|
|
197
212
|
const base = { abs, rel, firstOp: i, hunks: [] };
|
|
198
|
-
if (op.type === "create") {
|
|
199
|
-
|
|
200
|
-
|
|
213
|
+
if (op.type === "create" || op.type === "delete") {
|
|
214
|
+
let approved;
|
|
215
|
+
try {
|
|
216
|
+
approved = await snapshotFile(abs);
|
|
201
217
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
218
|
+
catch (err) {
|
|
219
|
+
return { ok: false, error: opError(i, op, err.message) };
|
|
220
|
+
}
|
|
221
|
+
if (op.type === "create") {
|
|
222
|
+
if (approved.kind === "present") {
|
|
223
|
+
return { ok: false, error: opError(i, op, "file already exists") };
|
|
224
|
+
}
|
|
225
|
+
const content = op.content ?? "";
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
track: {
|
|
229
|
+
...base,
|
|
230
|
+
kind: "create",
|
|
231
|
+
content,
|
|
232
|
+
eol: detectEol(content),
|
|
233
|
+
approved,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (approved.kind === "absent") {
|
|
210
238
|
return { ok: false, error: opError(i, op, "file not found") };
|
|
211
239
|
}
|
|
212
240
|
return {
|
|
213
241
|
ok: true,
|
|
214
|
-
track: { ...base, kind: "delete", content: "", eol: "\n" },
|
|
242
|
+
track: { ...base, kind: "delete", content: "", eol: "\n", approved },
|
|
215
243
|
};
|
|
216
244
|
}
|
|
217
245
|
let content;
|
|
246
|
+
let approved;
|
|
218
247
|
try {
|
|
219
|
-
|
|
248
|
+
const bytes = await fs.readFile(abs);
|
|
249
|
+
approved = snapshotOf(bytes);
|
|
250
|
+
content = bytes.toString("utf8");
|
|
220
251
|
}
|
|
221
252
|
catch (err) {
|
|
222
253
|
if (err.code === "ENOENT") {
|
|
@@ -229,6 +260,7 @@ async function openTrack(i, op, abs, ctx) {
|
|
|
229
260
|
kind: "update",
|
|
230
261
|
content,
|
|
231
262
|
eol: detectEol(content),
|
|
263
|
+
approved,
|
|
232
264
|
};
|
|
233
265
|
const failure = applyHunk(i, op, track);
|
|
234
266
|
return failure ? { ok: false, error: failure } : { ok: true, track };
|
|
@@ -261,12 +293,12 @@ function applyHunk(i, op, track) {
|
|
|
261
293
|
}
|
|
262
294
|
/** Collapse a finished track into the single write it represents. */
|
|
263
295
|
function toPlanned(track) {
|
|
264
|
-
const { kind, abs, rel, content, hunks } = track;
|
|
296
|
+
const { kind, abs, rel, content, hunks, approved } = track;
|
|
265
297
|
if (kind === "delete")
|
|
266
|
-
return { op: "delete", abs, rel };
|
|
298
|
+
return { op: "delete", abs, rel, approved };
|
|
267
299
|
if (kind === "create")
|
|
268
|
-
return { op: "create", abs, rel, content };
|
|
269
|
-
return { op: "update", abs, rel, content, hunks };
|
|
300
|
+
return { op: "create", abs, rel, content, approved };
|
|
301
|
+
return { op: "update", abs, rel, content, hunks, approved };
|
|
270
302
|
}
|
|
271
303
|
/** Shape a planned op into its approval-preview form. */
|
|
272
304
|
function toPreview(p) {
|
|
@@ -287,9 +319,3 @@ function toPreview(p) {
|
|
|
287
319
|
function opError(i, op, reason) {
|
|
288
320
|
return `operation ${i + 1} (${op.type} ${op.path}): ${reason}`;
|
|
289
321
|
}
|
|
290
|
-
async function exists(abs) {
|
|
291
|
-
return fs
|
|
292
|
-
.access(abs)
|
|
293
|
-
.then(() => true)
|
|
294
|
-
.catch(() => false);
|
|
295
|
-
}
|
|
@@ -2,10 +2,15 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { resolveToolPath } from "./paths.js";
|
|
4
4
|
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
5
|
+
import { changedSince, snapshotOf } from "./snapshot.js";
|
|
5
6
|
/**
|
|
6
7
|
* Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
|
|
7
8
|
* The uniqueness requirement is checked before approval so the model can fix an
|
|
8
9
|
* ambiguous match without burning a prompt; gated on `ctx.approve` before writing.
|
|
10
|
+
*
|
|
11
|
+
* The bytes read before approval are the state the approval is granted
|
|
12
|
+
* against; the write is refused if the file is no longer in that state when
|
|
13
|
+
* the write is about to happen (P1 — see `snapshot.ts`).
|
|
9
14
|
*/
|
|
10
15
|
export const editFileTool = {
|
|
11
16
|
name: "edit_file",
|
|
@@ -29,8 +34,11 @@ export const editFileTool = {
|
|
|
29
34
|
return { ok: false, error: err.message };
|
|
30
35
|
}
|
|
31
36
|
let content;
|
|
37
|
+
let approvedState;
|
|
32
38
|
try {
|
|
33
|
-
|
|
39
|
+
const bytes = await fs.readFile(abs);
|
|
40
|
+
approvedState = snapshotOf(bytes);
|
|
41
|
+
content = bytes.toString("utf8");
|
|
34
42
|
}
|
|
35
43
|
catch (err) {
|
|
36
44
|
if (err.code === "ENOENT") {
|
|
@@ -64,6 +72,12 @@ export const editFileTool = {
|
|
|
64
72
|
const updated = content.slice(0, match.start) +
|
|
65
73
|
applyEol(input.new_str, detectEol(content)) +
|
|
66
74
|
content.slice(match.end);
|
|
75
|
+
// The approval covered a diff against the bytes read above. Anything that
|
|
76
|
+
// changed the file during the wait makes `updated` a splice of stale
|
|
77
|
+
// content — refuse rather than overwrite what is there now (P1).
|
|
78
|
+
const moved = await changedSince(abs, approvedState, input.path);
|
|
79
|
+
if (moved)
|
|
80
|
+
return { ok: false, error: moved };
|
|
67
81
|
try {
|
|
68
82
|
await fs.writeFile(abs, updated, "utf8");
|
|
69
83
|
return { ok: true, output: `edited ${input.path}` };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { ErrorCode } from "../../errors/index.js";
|
|
4
|
+
/** Snapshot the bytes a tool has ALREADY read (no second read). */
|
|
5
|
+
export function snapshotOf(bytes) {
|
|
6
|
+
return {
|
|
7
|
+
kind: "present",
|
|
8
|
+
digest: createHash("sha256").update(bytes).digest("hex"),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read `abs` and snapshot it. A missing path is a legitimate state (`absent`)
|
|
13
|
+
* — it is what `write_file` and a patch `create` are approved against. Every
|
|
14
|
+
* other failure (a directory at the path, EACCES) propagates: the caller
|
|
15
|
+
* cannot build a truthful preview of a file it cannot read.
|
|
16
|
+
*/
|
|
17
|
+
export async function snapshotFile(abs) {
|
|
18
|
+
try {
|
|
19
|
+
return snapshotOf(await fs.readFile(abs));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
if (err.code === "ENOENT") {
|
|
23
|
+
return { kind: "absent" };
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Re-read `abs` and compare it with the state the approval was granted
|
|
30
|
+
* against. Returns `null` when the file is exactly as it was, otherwise the
|
|
31
|
+
* refusal to hand back as the tool's error — one sentence on what moved, and
|
|
32
|
+
* the same next step every time: read it again and retry.
|
|
33
|
+
*
|
|
34
|
+
* Call this AFTER approval and IMMEDIATELY before the write, with nothing
|
|
35
|
+
* awaited in between — the point is to make the gap as small as the platform
|
|
36
|
+
* allows, not to check early and then wait.
|
|
37
|
+
*/
|
|
38
|
+
export async function changedSince(abs, approved, rel) {
|
|
39
|
+
let now;
|
|
40
|
+
try {
|
|
41
|
+
now = await snapshotFile(abs);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return refusal(rel, `could not be re-read before the approved write (${err.message})`);
|
|
45
|
+
}
|
|
46
|
+
if (approved.kind === "absent") {
|
|
47
|
+
return now.kind === "absent"
|
|
48
|
+
? null
|
|
49
|
+
: refusal(rel, "was created by something else after it was approved as a new file");
|
|
50
|
+
}
|
|
51
|
+
if (now.kind === "absent") {
|
|
52
|
+
return refusal(rel, "was deleted after it was read");
|
|
53
|
+
}
|
|
54
|
+
if (now.digest !== approved.digest) {
|
|
55
|
+
return refusal(rel, "changed on disk after it was read");
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
/** The one refusal shape: what moved, that nothing was written, what to do. */
|
|
60
|
+
function refusal(rel, what) {
|
|
61
|
+
return (`${ErrorCode.FileChangedSinceRead}: ${rel} ${what}, so the approval no longer ` +
|
|
62
|
+
`covers this write; nothing was written — read the file again and retry`);
|
|
63
|
+
}
|
|
@@ -2,11 +2,18 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveToolPath } from "./paths.js";
|
|
5
|
+
import { changedSince, snapshotFile } from "./snapshot.js";
|
|
5
6
|
/** How many leading lines of new content the approval preview shows. */
|
|
6
7
|
const PREVIEW_LINES = 20;
|
|
7
8
|
/**
|
|
8
9
|
* Create or overwrite a file within the project root. Gated on `ctx.approve`
|
|
9
10
|
* before anything is written.
|
|
11
|
+
*
|
|
12
|
+
* The target is snapshotted before approval — its current bytes, or its
|
|
13
|
+
* absence — and the write is refused if that state has moved by the time the
|
|
14
|
+
* write is about to happen (P1 — see `snapshot.ts`). This tool used to have
|
|
15
|
+
* no read at all: an "overwrite" approved against one version of a file would
|
|
16
|
+
* land on whatever version was there by the time the user pressed `y`.
|
|
10
17
|
*/
|
|
11
18
|
export const writeFileTool = {
|
|
12
19
|
name: "write_file",
|
|
@@ -25,11 +32,19 @@ export const writeFileTool = {
|
|
|
25
32
|
catch (err) {
|
|
26
33
|
return { ok: false, error: err.message };
|
|
27
34
|
}
|
|
28
|
-
// Does the target already exist? Drives create-vs-overwrite in the preview
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
// Does the target already exist? Drives create-vs-overwrite in the preview,
|
|
36
|
+
// and its bytes (or absence) are the state the approval is granted against.
|
|
37
|
+
let approvedState;
|
|
38
|
+
try {
|
|
39
|
+
approvedState = await snapshotFile(abs);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: `cannot read ${input.path}: ${err.message}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const exists = approvedState.kind === "present";
|
|
33
48
|
// First N lines of the new content, with a count of what's omitted.
|
|
34
49
|
const allLines = input.content.split("\n");
|
|
35
50
|
const lines = allLines.slice(0, PREVIEW_LINES);
|
|
@@ -46,6 +61,12 @@ export const writeFileTool = {
|
|
|
46
61
|
error: decision.feedback ?? `write to ${input.path} denied`,
|
|
47
62
|
};
|
|
48
63
|
}
|
|
64
|
+
// A create approved against "no file here" must not overwrite a file that
|
|
65
|
+
// appeared during the wait; an overwrite approved against one version must
|
|
66
|
+
// not land on another (P1).
|
|
67
|
+
const moved = await changedSince(abs, approvedState, input.path);
|
|
68
|
+
if (moved)
|
|
69
|
+
return { ok: false, error: moved };
|
|
49
70
|
try {
|
|
50
71
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
51
72
|
await fs.writeFile(abs, input.content, "utf8");
|