@cruxy/cli 1.11.1 → 1.11.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/dist/agent/instruction-loss.js +204 -0
- package/dist/agent/prompts.js +25 -4
- package/dist/agent/session.js +165 -33
- package/dist/agent/status.js +18 -0
- package/dist/checkpoint/service.js +44 -3
- package/dist/cli/commands/pr.js +14 -0
- package/dist/cli/commands/run.js +35 -0
- package/dist/cli/commands/sessions.js +8 -0
- package/dist/cli/session-commands.js +3 -1
- package/dist/cli/session-factory.js +54 -6
- package/dist/config/schema.js +9 -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/mcp/bounds.js +8 -1
- package/dist/plan/execute.js +4 -1
- package/dist/plan/service.js +42 -5
- package/dist/plan/step-message.js +49 -0
- package/dist/render/context-view.js +44 -1
- package/dist/render/status-view.js +13 -0
- package/dist/session/index.js +7 -3
- package/dist/session/log.js +163 -2
- package/dist/session/owner.js +123 -0
- package/dist/session/prune.js +11 -0
- package/dist/session/recorded-runs.js +56 -0
- package/dist/session/replay.js +75 -1
- package/dist/session/resume.js +110 -3
- package/dist/session/types.js +158 -0
- package/dist/subagent/orchestrator.js +2 -2
- package/dist/subagent/registry-scope.js +28 -5
- package/dist/testing/run-tests-tool.js +3 -1
- package/dist/tools/create-pull-request.js +8 -1
- package/dist/tools/file/apply-patch.js +53 -23
- package/dist/tools/file/edit-file.js +19 -1
- package/dist/tools/file/snapshot.js +68 -0
- package/dist/tools/file/write-file.js +31 -5
- package/dist/tools/registry.js +39 -8
- package/dist/tools/schema-depth.js +79 -6
- package/dist/tools/shell/exec.js +7 -0
- package/dist/tools/shell/run-command.js +45 -21
- package/dist/utils/process-owner.js +107 -0
- package/dist/vcs/generate.js +48 -6
- package/dist/verification/index.js +15 -0
- package/dist/verification/ledger.js +99 -0
- package/dist/verification/types.js +26 -0
- package/dist/verification/view.js +87 -0
- package/package.json +3 -2
package/dist/session/index.js
CHANGED
|
@@ -7,17 +7,21 @@
|
|
|
7
7
|
* cruxy's fields never make an older one discard a session;
|
|
8
8
|
* - `log.ts` — the writer: one line per event, `0600`, non-fatal on failure;
|
|
9
9
|
* - `replay.ts` — the fold back to state, tolerant of torn/unknown lines;
|
|
10
|
+
* - `recorded-runs.ts` — an ended session's verification record, for
|
|
11
|
+
* `cruxy pr` (P2 verification);
|
|
10
12
|
* - `list.ts` — what the picker and the TUI sidebar both read;
|
|
11
13
|
* - `prune.ts` — retention: what the tree is allowed to keep (#257);
|
|
12
14
|
* - `resume.ts` — `--resume <id>` and the bare-`--resume` picker;
|
|
13
15
|
* - `paths.ts` — the layout, including the subtrees reserved for P3+.
|
|
14
16
|
*/
|
|
15
17
|
export { PROJECTS_DIR_NAME, RESERVED_SUBDIRS, SESSION_FILE_EXT, projectDir, projectKey, projectsDir, reservedDir, sessionFile, } from "./paths.js";
|
|
16
|
-
export { SessionLog } from "./log.js";
|
|
18
|
+
export { SessionLog, } from "./log.js";
|
|
19
|
+
export { claimSession, describeHolder, ownerFile, readOwner, releaseSession, removeOwnerFile, sessionHeldBy, } from "./owner.js";
|
|
17
20
|
export { defaultExportName, exportMarkdown, } from "./export.js";
|
|
18
21
|
export { foldEvents, readEvents, readMeta, replaySession } from "./replay.js";
|
|
22
|
+
export { latestRecordedRuns, recordedRuns } from "./recorded-runs.js";
|
|
19
23
|
export { redactMessages } from "./redact.js";
|
|
20
24
|
export { findSession, isAmbiguous, listSessionRefs, listSessions, matchSessionRefs, sessionFilesByRecency, summarizeSession, } from "./list.js";
|
|
21
25
|
export { pruneSessions, } from "./prune.js";
|
|
22
|
-
export { cwdMismatchWarning, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
23
|
-
export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
26
|
+
export { cwdMismatchWarning, describeCompactions, describeSession, loadResume, priorDirectoriesWarning, relativeAge, resolveSessionId, resumeById, resumePicker, shortId, PICKER_LIMIT, } from "./resume.js";
|
|
27
|
+
export { KNOWN_EVENT_KINDS, SESSION_FILE_VERSION, emptyCompactionTally, ExternalChangeEventSchema, InstructionLossEventSchema, ResumedEventSchema, SessionEventSchema, SessionMetaSchema, VerificationEventSchema, } from "./types.js";
|
package/dist/session/log.js
CHANGED
|
@@ -3,7 +3,9 @@ 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";
|
|
8
|
+
import { MAX_FAILURE_NAMES } from "../verification/types.js";
|
|
7
9
|
import { SESSION_FILE_VERSION, } from "./types.js";
|
|
8
10
|
/**
|
|
9
11
|
* How many pruned sessions is worth telling the user about unprompted.
|
|
@@ -20,6 +22,8 @@ export class SessionLog {
|
|
|
20
22
|
currentRunId;
|
|
21
23
|
/** Set once a write fails: the log goes inert rather than warning per turn. */
|
|
22
24
|
broken = false;
|
|
25
|
+
/** Whether this process has stamped itself as the file's owner (P1). */
|
|
26
|
+
claimed = false;
|
|
23
27
|
/**
|
|
24
28
|
* A NEW session's `meta` line, held until the session records something.
|
|
25
29
|
* Null on a reopen (the file already has its meta) and null again the moment
|
|
@@ -77,6 +81,14 @@ export class SessionLog {
|
|
|
77
81
|
const log = new SessionLog(file, opts);
|
|
78
82
|
log.pruneOnce(opts);
|
|
79
83
|
if (hasContent(file)) {
|
|
84
|
+
// OWNERSHIP (P1). A reopen is the moment a second process would start
|
|
85
|
+
// interleaving its turns into this file, so the stamp is taken here,
|
|
86
|
+
// eagerly, like the `resumed` event below. `loadResume` has already
|
|
87
|
+
// refused a file another live cruxy holds; this is the claim that makes
|
|
88
|
+
// the NEXT resume see us. A fresh session claims at its first flush
|
|
89
|
+
// instead — see {@link write} — for the same reason `meta` is buffered:
|
|
90
|
+
// a conversation with nothing in it should leave nothing on disk.
|
|
91
|
+
log.claim();
|
|
80
92
|
log.write({
|
|
81
93
|
kind: "resumed",
|
|
82
94
|
at: new Date().toISOString(),
|
|
@@ -113,14 +125,44 @@ export class SessionLog {
|
|
|
113
125
|
messages,
|
|
114
126
|
});
|
|
115
127
|
}
|
|
116
|
-
/**
|
|
117
|
-
|
|
128
|
+
/**
|
|
129
|
+
* An older prefix of `replaced` messages was folded into `summary`. `cost`
|
|
130
|
+
* (P3 context quality) is what the compaction cost — the estimates either
|
|
131
|
+
* side of it and the summarize call's reported usage; the usage halves are
|
|
132
|
+
* omitted, not zeroed, when the provider reported nothing.
|
|
133
|
+
*/
|
|
134
|
+
compaction(replaced, summary, cost) {
|
|
118
135
|
this.write({
|
|
119
136
|
kind: "compaction",
|
|
120
137
|
at: new Date().toISOString(),
|
|
121
138
|
...this.runId(),
|
|
122
139
|
replaced,
|
|
123
140
|
summary,
|
|
141
|
+
...(cost
|
|
142
|
+
? {
|
|
143
|
+
estimatedBefore: cost.estimatedBefore,
|
|
144
|
+
estimatedAfter: cost.estimatedAfter,
|
|
145
|
+
...(cost.summaryInputTokens !== undefined
|
|
146
|
+
? { summaryInputTokens: cost.summaryInputTokens }
|
|
147
|
+
: {}),
|
|
148
|
+
...(cost.summaryOutputTokens !== undefined
|
|
149
|
+
? { summaryOutputTokens: cost.summaryOutputTokens }
|
|
150
|
+
: {}),
|
|
151
|
+
}
|
|
152
|
+
: {}),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A compaction after which the user's instructions could not all be found
|
|
157
|
+
* in the synopsis (P3 context quality) — the heuristic's finding, recorded
|
|
158
|
+
* so it can be audited after the fact. Bounded by the detector, not here.
|
|
159
|
+
*/
|
|
160
|
+
instructionLoss(sentences) {
|
|
161
|
+
this.write({
|
|
162
|
+
kind: "instruction-loss",
|
|
163
|
+
at: new Date().toISOString(),
|
|
164
|
+
...this.runId(),
|
|
165
|
+
sentences,
|
|
124
166
|
});
|
|
125
167
|
}
|
|
126
168
|
/** `/clear` — history dropped, session kept. */
|
|
@@ -135,6 +177,30 @@ export class SessionLog {
|
|
|
135
177
|
mode(mode) {
|
|
136
178
|
this.write({ kind: "mode", at: new Date().toISOString(), mode });
|
|
137
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* The user approved a plan (plan-durability): the decision kind and the
|
|
182
|
+
* steps as approved. A fact about the session, dated — see the schema for
|
|
183
|
+
* why it is recorded and why a resume never acts on it.
|
|
184
|
+
*/
|
|
185
|
+
planApproved(decision, steps) {
|
|
186
|
+
this.write({
|
|
187
|
+
kind: "plan-approved",
|
|
188
|
+
at: new Date().toISOString(),
|
|
189
|
+
...this.runId(),
|
|
190
|
+
decision,
|
|
191
|
+
steps: steps.map((s) => ({ id: s.id, title: s.title, kind: s.kind })),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
/** One step of the approved plan changed status (plan-durability). */
|
|
195
|
+
planStep(stepId, status) {
|
|
196
|
+
this.write({
|
|
197
|
+
kind: "plan-step",
|
|
198
|
+
at: new Date().toISOString(),
|
|
199
|
+
...this.runId(),
|
|
200
|
+
stepId,
|
|
201
|
+
status,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
138
204
|
/**
|
|
139
205
|
* One turn's token usage. Copied here rather than referenced, because the
|
|
140
206
|
* usage store keeps only its newest 50 runs while a session keeps its own
|
|
@@ -171,6 +237,46 @@ export class SessionLog {
|
|
|
171
237
|
count,
|
|
172
238
|
});
|
|
173
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* One observation (P2 verification): a run that actually executed, or a
|
|
242
|
+
* write refused because its target moved. Written as its own event kind so
|
|
243
|
+
* the fold treats the two apart, and stamped with the turn's run id like
|
|
244
|
+
* every other per-turn event, so "what ran in the turn `cruxy rollback <id>`
|
|
245
|
+
* would undo" is one join, not a guess.
|
|
246
|
+
*
|
|
247
|
+
* The failure NAMES are bounded and the output is not copied: the `append`
|
|
248
|
+
* event already holds the tool_result the model saw. This is an index over
|
|
249
|
+
* what happened.
|
|
250
|
+
*/
|
|
251
|
+
observe(obs) {
|
|
252
|
+
const at = new Date().toISOString();
|
|
253
|
+
if (obs.kind === "verification") {
|
|
254
|
+
this.write({
|
|
255
|
+
kind: "verification",
|
|
256
|
+
at,
|
|
257
|
+
...this.runId(),
|
|
258
|
+
tool: obs.tool,
|
|
259
|
+
command: obs.command,
|
|
260
|
+
...(obs.source !== undefined ? { source: obs.source } : {}),
|
|
261
|
+
passed: obs.passed,
|
|
262
|
+
exitCode: obs.exitCode,
|
|
263
|
+
durationMs: obs.durationMs,
|
|
264
|
+
...(obs.total !== undefined ? { total: obs.total } : {}),
|
|
265
|
+
failureCount: obs.failureCount,
|
|
266
|
+
failureNames: obs.failureNames.slice(0, MAX_FAILURE_NAMES),
|
|
267
|
+
outputTruncated: obs.outputTruncated,
|
|
268
|
+
substrate: obs.substrate,
|
|
269
|
+
});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.write({
|
|
273
|
+
kind: "external-change",
|
|
274
|
+
at,
|
|
275
|
+
...this.runId(),
|
|
276
|
+
path: obs.path,
|
|
277
|
+
what: obs.what,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
174
280
|
/**
|
|
175
281
|
* Enforce retention for this project, once, as this session opens.
|
|
176
282
|
*
|
|
@@ -232,9 +338,46 @@ export class SessionLog {
|
|
|
232
338
|
this.pendingMeta = null;
|
|
233
339
|
if (!this.writeLine(meta))
|
|
234
340
|
return false;
|
|
341
|
+
this.claim(); // the file now exists — so can a second `--resume` of it
|
|
235
342
|
}
|
|
236
343
|
return this.writeLine(event);
|
|
237
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Stamp this process as the file's owner (P1 — see `owner.ts`). Idempotent.
|
|
347
|
+
* The stamp is released on process exit; a crash leaves it behind, and that
|
|
348
|
+
* is fine — a stamp is pid + start-time, so the next reader sees a dead
|
|
349
|
+
* owner and ignores it. Never fatal: an unwritable stamp is the pre-P1
|
|
350
|
+
* behaviour (no ownership), and a session must still run without one.
|
|
351
|
+
*/
|
|
352
|
+
claim() {
|
|
353
|
+
if (this.claimed)
|
|
354
|
+
return;
|
|
355
|
+
// Never overwrite a LIVE owner's stamp. `loadResume` refuses that file
|
|
356
|
+
// before we get here, so this is the guard for any other writer that
|
|
357
|
+
// opens a log — and for the race two resumes in the same instant would
|
|
358
|
+
// be. Recording continues (the format tolerates it); ownership does not
|
|
359
|
+
// move, so the third process to come along still sees the real holder.
|
|
360
|
+
const holder = sessionHeldBy(this.file);
|
|
361
|
+
if (holder) {
|
|
362
|
+
this.logger?.warn(`session is open in another cruxy (${describeHolder(holder)}) — this process is not taking it over`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.claimed = claimSession(this.file);
|
|
366
|
+
if (this.claimed)
|
|
367
|
+
releaseOnExit(this.file);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Give the file up: remove our stamp. The exit hook does this for the
|
|
371
|
+
* normal case; this is for a caller that ends a session while the process
|
|
372
|
+
* lives on (tests, and any future in-process session switch).
|
|
373
|
+
*/
|
|
374
|
+
close() {
|
|
375
|
+
if (!this.claimed)
|
|
376
|
+
return;
|
|
377
|
+
this.claimed = false;
|
|
378
|
+
releaseSession(this.file);
|
|
379
|
+
claimedFiles.delete(this.file);
|
|
380
|
+
}
|
|
238
381
|
/**
|
|
239
382
|
* Append one event as a single line. Returns whether it landed. The first
|
|
240
383
|
* failure warns and latches `broken`, so a persistent problem (a full disk)
|
|
@@ -255,6 +398,24 @@ export class SessionLog {
|
|
|
255
398
|
}
|
|
256
399
|
}
|
|
257
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Every session file this process has stamped, released together on exit.
|
|
403
|
+
* ONE listener for the process rather than one per log: a test opens dozens
|
|
404
|
+
* of logs, and `process.on` warns past ten listeners.
|
|
405
|
+
*/
|
|
406
|
+
const claimedFiles = new Set();
|
|
407
|
+
let exitHookInstalled = false;
|
|
408
|
+
function releaseOnExit(file) {
|
|
409
|
+
claimedFiles.add(file);
|
|
410
|
+
if (exitHookInstalled)
|
|
411
|
+
return;
|
|
412
|
+
exitHookInstalled = true;
|
|
413
|
+
process.on("exit", () => {
|
|
414
|
+
for (const f of claimedFiles)
|
|
415
|
+
releaseSession(f);
|
|
416
|
+
claimedFiles.clear();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
258
419
|
/**
|
|
259
420
|
* Whether `file` is an existing log with content — i.e. this is a reopen.
|
|
260
421
|
*
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { sessionFilesByRecency } from "./list.js";
|
|
3
|
+
import { readEvents, readMeta } from "./replay.js";
|
|
4
|
+
/**
|
|
5
|
+
* The verification record of a session that has ENDED, read back from its log
|
|
6
|
+
* (P2 verification). `cruxy pr` runs outside any session, so there is no
|
|
7
|
+
* ledger in the process; the log is the durable side of the same record, and
|
|
8
|
+
* the runs it holds are the evidence there is for a PR opened from here.
|
|
9
|
+
*
|
|
10
|
+
* Every `verification` event is returned, in the order it was written — the
|
|
11
|
+
* fold in `replay.ts` keeps only the LAST run (what `/status` shows after a
|
|
12
|
+
* resume); a PR body lists them all. Nothing is filtered by content or age:
|
|
13
|
+
* each run carries its timestamp, and the reader judges whether a run from
|
|
14
|
+
* before the last edit still counts.
|
|
15
|
+
*/
|
|
16
|
+
export function recordedRuns(file) {
|
|
17
|
+
const runs = [];
|
|
18
|
+
for (const event of readEvents(file).events) {
|
|
19
|
+
if (event.kind !== "verification")
|
|
20
|
+
continue;
|
|
21
|
+
runs.push({
|
|
22
|
+
at: event.at,
|
|
23
|
+
tool: event.tool,
|
|
24
|
+
command: event.command,
|
|
25
|
+
...(event.source !== undefined ? { source: event.source } : {}),
|
|
26
|
+
passed: event.passed,
|
|
27
|
+
exitCode: event.exitCode,
|
|
28
|
+
durationMs: event.durationMs,
|
|
29
|
+
...(event.total !== undefined ? { total: event.total } : {}),
|
|
30
|
+
failureCount: event.failureCount,
|
|
31
|
+
failureNames: event.failureNames,
|
|
32
|
+
outputTruncated: event.outputTruncated,
|
|
33
|
+
substrate: event.substrate,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return runs;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The NEWEST session for `cwd` and the runs it recorded, or null when the
|
|
40
|
+
* project has no session whose meta names this directory (`projectKey` can
|
|
41
|
+
* collide — `a-b` and `a/b` — so meta.cwd is compared, as resume does).
|
|
42
|
+
*
|
|
43
|
+
* The newest session, not the newest session that ran something: if the last
|
|
44
|
+
* thing done here recorded no runs, the honest answer is no section, not the
|
|
45
|
+
* runs of an older session cherry-picked because it has some.
|
|
46
|
+
*/
|
|
47
|
+
export function latestRecordedRuns(cwd) {
|
|
48
|
+
const here = path.resolve(cwd);
|
|
49
|
+
for (const ref of sessionFilesByRecency(cwd)) {
|
|
50
|
+
const meta = readMeta(ref.file);
|
|
51
|
+
if (!meta || path.resolve(meta.cwd) !== here)
|
|
52
|
+
continue;
|
|
53
|
+
return { sessionId: meta.sessionId, runs: recordedRuns(ref.file) };
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
package/dist/session/replay.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { modeFromFlags } from "../agent/mode.js";
|
|
3
3
|
import { redactMessages } from "./redact.js";
|
|
4
|
-
import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, } from "./types.js";
|
|
4
|
+
import { KNOWN_EVENT_KINDS, SessionEventSchema, SessionMetaSchema, emptyCompactionTally, } from "./types.js";
|
|
5
5
|
/**
|
|
6
6
|
* Replay: fold an append-only event log back into the state a session needs to
|
|
7
7
|
* resume (P2).
|
|
@@ -109,6 +109,10 @@ export function foldEvents(events, counts = {}) {
|
|
|
109
109
|
let planMode = false;
|
|
110
110
|
let mode = null;
|
|
111
111
|
let redactions = 0;
|
|
112
|
+
let lastVerification;
|
|
113
|
+
let externalChanges = 0;
|
|
114
|
+
let plan;
|
|
115
|
+
const compactions = emptyCompactionTally();
|
|
112
116
|
const resumes = [];
|
|
113
117
|
const usage = { input_tokens: 0, output_tokens: 0 };
|
|
114
118
|
for (const event of events) {
|
|
@@ -123,6 +127,22 @@ export function foldEvents(events, counts = {}) {
|
|
|
123
127
|
...asMessages(event.summary),
|
|
124
128
|
...messages.slice(event.replaced),
|
|
125
129
|
];
|
|
130
|
+
// And tallied (P3): the count is the events; the sums cover only the
|
|
131
|
+
// events that carry a cost, so a pre-P3 compaction counts as one that
|
|
132
|
+
// happened and not as one that freed nothing.
|
|
133
|
+
compactions.count++;
|
|
134
|
+
if (event.estimatedBefore !== undefined &&
|
|
135
|
+
event.estimatedAfter !== undefined) {
|
|
136
|
+
compactions.measured++;
|
|
137
|
+
compactions.freedTokens += Math.max(0, event.estimatedBefore - event.estimatedAfter);
|
|
138
|
+
compactions.summaryInputTokens += event.summaryInputTokens ?? 0;
|
|
139
|
+
compactions.summaryOutputTokens += event.summaryOutputTokens ?? 0;
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
case "instruction-loss":
|
|
143
|
+
// Counted, not folded: the synopsis is already in the history as the
|
|
144
|
+
// model sees it. This is the fact that it may be missing something.
|
|
145
|
+
compactions.instructionLosses++;
|
|
126
146
|
break;
|
|
127
147
|
case "clear":
|
|
128
148
|
messages = [];
|
|
@@ -156,6 +176,56 @@ export function foldEvents(events, counts = {}) {
|
|
|
156
176
|
// could be added without touching `meta` or the message array.
|
|
157
177
|
resumes.push({ at: event.at, cwd: event.cwd });
|
|
158
178
|
break;
|
|
179
|
+
case "verification":
|
|
180
|
+
// Last-writer-wins, and never folded into the messages: a run is a
|
|
181
|
+
// fact ABOUT the turn, not a turn in it. The tool_result the model saw
|
|
182
|
+
// is already in the `append` events; this is the structured claim.
|
|
183
|
+
lastVerification = {
|
|
184
|
+
at: event.at,
|
|
185
|
+
tool: event.tool,
|
|
186
|
+
command: event.command,
|
|
187
|
+
...(event.source !== undefined ? { source: event.source } : {}),
|
|
188
|
+
passed: event.passed,
|
|
189
|
+
exitCode: event.exitCode,
|
|
190
|
+
durationMs: event.durationMs,
|
|
191
|
+
...(event.total !== undefined ? { total: event.total } : {}),
|
|
192
|
+
failureCount: event.failureCount,
|
|
193
|
+
failureNames: event.failureNames,
|
|
194
|
+
outputTruncated: event.outputTruncated,
|
|
195
|
+
substrate: event.substrate,
|
|
196
|
+
};
|
|
197
|
+
break;
|
|
198
|
+
case "external-change":
|
|
199
|
+
// Counted, not folded: the refusal wrote nothing, so there is nothing
|
|
200
|
+
// in the history to adjust — only a fact for the resume notice.
|
|
201
|
+
externalChanges++;
|
|
202
|
+
break;
|
|
203
|
+
case "plan-approved":
|
|
204
|
+
// A new approval replaces the last: every step starts `pending`, as
|
|
205
|
+
// the executor had it, and the `plan-step` events that follow move
|
|
206
|
+
// them. Never folded into the messages — the plan's text is already
|
|
207
|
+
// there as the model's own `submit_plan` call.
|
|
208
|
+
plan = {
|
|
209
|
+
approvedAt: event.at,
|
|
210
|
+
decision: event.decision,
|
|
211
|
+
steps: event.steps.map((s) => ({
|
|
212
|
+
id: s.id,
|
|
213
|
+
title: s.title,
|
|
214
|
+
kind: s.kind,
|
|
215
|
+
status: "pending",
|
|
216
|
+
})),
|
|
217
|
+
};
|
|
218
|
+
break;
|
|
219
|
+
case "plan-step": {
|
|
220
|
+
// A transition for a step the last approval did not name is skipped,
|
|
221
|
+
// not an error: a torn `plan-approved` line, or a newer build's
|
|
222
|
+
// vocabulary. Position matters — a step left `running` is one whose
|
|
223
|
+
// outcome the log never received.
|
|
224
|
+
const step = plan?.steps.find((s) => s.id === event.stepId);
|
|
225
|
+
if (step)
|
|
226
|
+
step.status = event.status;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
159
229
|
case "meta":
|
|
160
230
|
break;
|
|
161
231
|
}
|
|
@@ -172,6 +242,10 @@ export function foldEvents(events, counts = {}) {
|
|
|
172
242
|
unknownEvents: counts.unknownEvents ?? 0,
|
|
173
243
|
resumes,
|
|
174
244
|
redactions,
|
|
245
|
+
...(lastVerification ? { lastVerification } : {}),
|
|
246
|
+
externalChanges,
|
|
247
|
+
compactions,
|
|
248
|
+
...(plan ? { plan } : {}),
|
|
175
249
|
};
|
|
176
250
|
}
|
|
177
251
|
/** Read and parse a session file into its events, counting unusable lines. */
|