@cruxy/cli 1.9.0 → 1.11.0
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 +2 -2
- package/dist/agent/loop.js +16 -1
- package/dist/agent/session.js +73 -9
- package/dist/approval/classify.js +170 -40
- package/dist/approval/prompt.js +52 -6
- package/dist/approval/service.js +1 -11
- package/dist/budget/session-budget.js +10 -1
- package/dist/checkpoint/coverage.js +147 -4
- package/dist/cli/command-catalog.js +5 -1
- package/dist/cli/commands/config.js +18 -3
- package/dist/cli/commands/limits.js +76 -0
- package/dist/cli/commands/logs.js +149 -0
- package/dist/cli/commands/pr.js +11 -11
- package/dist/cli/commands/rollback.js +10 -2
- package/dist/cli/commands/run.js +25 -6
- package/dist/cli/commands/sessions.js +181 -0
- package/dist/cli/onboard.js +0 -9
- package/dist/cli/program.js +19 -1
- package/dist/cli/repl.js +25 -0
- package/dist/cli/session-commands.js +45 -2
- package/dist/cli/session-factory.js +31 -10
- package/dist/config/manager.js +91 -11
- package/dist/config/schema.js +194 -20
- package/dist/constants.js +12 -2
- package/dist/errors/constructors.js +84 -46
- package/dist/errors/types.js +6 -0
- package/dist/jobs/index.js +1 -0
- package/dist/jobs/log-renderer.js +10 -5
- package/dist/jobs/log-store.js +505 -0
- package/dist/jobs/manager.js +338 -18
- package/dist/mcp/client.js +16 -0
- package/dist/render/limits-report.js +213 -0
- package/dist/render/limits-view.js +125 -0
- package/dist/routing/index.js +1 -1
- package/dist/routing/router.js +34 -14
- package/dist/routing/types.js +0 -2
- package/dist/sandbox/service.js +9 -0
- package/dist/sandbox/types.js +15 -0
- package/dist/session/index.js +3 -1
- package/dist/session/list.js +20 -6
- package/dist/session/log.js +120 -21
- package/dist/session/prune.js +166 -0
- package/dist/session/resume.js +5 -0
- package/dist/subagent/orchestrator.js +87 -34
- package/dist/subagent/spawn-tool.js +11 -4
- package/dist/tools/schema-depth.js +18 -0
- package/dist/tui/limits-panel.js +53 -30
- package/dist/usage/collect.js +20 -1
- package/dist/usage/summary.js +48 -1
- package/dist/usage/types.js +52 -0
- package/package.json +2 -2
package/dist/session/log.js
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { APP_VERSION } from "../constants.js";
|
|
4
|
+
import { formatBytes } from "../utils/disk.js";
|
|
4
5
|
import { sessionFile } from "./paths.js";
|
|
6
|
+
import { pruneSessions } from "./prune.js";
|
|
5
7
|
import { SESSION_FILE_VERSION, } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* How many pruned sessions is worth telling the user about unprompted.
|
|
10
|
+
*
|
|
11
|
+
* Retention on an existing install is a one-off cliff: every build so far had
|
|
12
|
+
* none, so the first prune after upgrading can remove months at once. That
|
|
13
|
+
* should not happen silently. Steady-state prunes remove one or two files and
|
|
14
|
+
* stay at `debug`, where they belong.
|
|
15
|
+
*/
|
|
16
|
+
const PRUNE_NOTICE_THRESHOLD = 10;
|
|
6
17
|
export class SessionLog {
|
|
7
18
|
file;
|
|
8
19
|
logger;
|
|
9
20
|
currentRunId;
|
|
10
21
|
/** Set once a write fails: the log goes inert rather than warning per turn. */
|
|
11
22
|
broken = false;
|
|
23
|
+
/**
|
|
24
|
+
* A NEW session's `meta` line, held until the session records something.
|
|
25
|
+
* Null on a reopen (the file already has its meta) and null again the moment
|
|
26
|
+
* it is flushed — see {@link write}.
|
|
27
|
+
*/
|
|
28
|
+
pendingMeta = null;
|
|
12
29
|
constructor(file, opts) {
|
|
13
30
|
this.file = file;
|
|
14
31
|
this.logger = opts.logger;
|
|
15
32
|
this.currentRunId = opts.currentRunId;
|
|
16
33
|
}
|
|
17
34
|
/**
|
|
18
|
-
* Open
|
|
35
|
+
* Open a session log. WRITES NOTHING for a new session — see the note on the
|
|
36
|
+
* class about why the file no longer exists before the conversation does.
|
|
19
37
|
*
|
|
20
|
-
* A NEW
|
|
21
|
-
* still does NOT get a second `meta` — that ruling is unchanged
|
|
22
|
-
* reason this method has always branched: `meta` describes where
|
|
23
|
-
* conversation began, and a second copy written from wherever it
|
|
24
|
-
* would make "the session's directory" ambiguous. Replay takes
|
|
25
|
-
* `meta`, so a duplicate could only ever mislead a later reader.
|
|
38
|
+
* A NEW session gets its `meta` line BUFFERED. Reopening an existing one (a
|
|
39
|
+
* `--resume`) still does NOT get a second `meta` — that ruling is unchanged
|
|
40
|
+
* and is the reason this method has always branched: `meta` describes where
|
|
41
|
+
* and when the conversation began, and a second copy written from wherever it
|
|
42
|
+
* was resumed would make "the session's directory" ambiguous. Replay takes
|
|
43
|
+
* the first `meta`, so a duplicate could only ever mislead a later reader.
|
|
26
44
|
*
|
|
27
45
|
* What a reopen gets instead is a `resumed` event (#172 item 2). It carries
|
|
28
46
|
* the directory this run is in, which is the fact that used to vanish: a
|
|
@@ -31,16 +49,33 @@ export class SessionLog {
|
|
|
31
49
|
* file forgot immediately. `meta` stays singular and authoritative; the
|
|
32
50
|
* reopen is a separate kind, and no reader can confuse the two.
|
|
33
51
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
52
|
+
* The reopen write stays EAGER, and deliberately: a reopen is a fact about a
|
|
53
|
+
* conversation that already exists on disk, and `tree.test.ts` pins that it
|
|
54
|
+
* lifts a resumed session to the top of the picker with nothing said to it.
|
|
55
|
+
* Deferring it would quietly undo that.
|
|
56
|
+
*
|
|
57
|
+
* WHAT A FRESH OPEN NO LONGER TELLS YOU. It used to return `null` when the
|
|
58
|
+
* meta write failed, which is how the caller learned up front that
|
|
59
|
+
* persistence was dead. Nothing is written now, so there is nothing to fail:
|
|
60
|
+
* an unwritable home surfaces on the FIRST append instead, as one warning,
|
|
61
|
+
* and the session continues in memory. That is not a new tolerance — it is
|
|
62
|
+
* exactly how the reopen path has always behaved ("a session that cannot
|
|
63
|
+
* record its own reopen is not a session worth refusing to continue"), now
|
|
64
|
+
* applied to both branches rather than one.
|
|
65
|
+
*
|
|
66
|
+
* The `| null` in the signature is kept for the next thing to land here — a
|
|
67
|
+
* `sessions.enabled: false` that declines to record at all (#257) — rather
|
|
68
|
+
* than churning every call site twice.
|
|
40
69
|
*/
|
|
41
70
|
static open(opts) {
|
|
71
|
+
// `sessions.enabled: false` — record nothing. The session runs in memory,
|
|
72
|
+
// which is the same degraded mode an unwritable home produces, reached on
|
|
73
|
+
// purpose. Every consumer already treats a missing recorder as normal.
|
|
74
|
+
if (opts.sessions?.enabled === false)
|
|
75
|
+
return null;
|
|
42
76
|
const file = opts.file ?? sessionFile(opts.cwd, opts.sessionId);
|
|
43
77
|
const log = new SessionLog(file, opts);
|
|
78
|
+
log.pruneOnce(opts);
|
|
44
79
|
if (hasContent(file)) {
|
|
45
80
|
log.write({
|
|
46
81
|
kind: "resumed",
|
|
@@ -51,7 +86,10 @@ export class SessionLog {
|
|
|
51
86
|
});
|
|
52
87
|
return log;
|
|
53
88
|
}
|
|
54
|
-
|
|
89
|
+
// Buffered, not written. `startedAt` is stamped HERE rather than at flush:
|
|
90
|
+
// it means "when this conversation began", and the session began when it
|
|
91
|
+
// was opened, not when the user got around to saying something.
|
|
92
|
+
log.pendingMeta = {
|
|
55
93
|
kind: "meta",
|
|
56
94
|
version: SESSION_FILE_VERSION,
|
|
57
95
|
sessionId: opts.sessionId,
|
|
@@ -61,8 +99,8 @@ export class SessionLog {
|
|
|
61
99
|
cliVersion: APP_VERSION,
|
|
62
100
|
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
63
101
|
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
64
|
-
}
|
|
65
|
-
return
|
|
102
|
+
};
|
|
103
|
+
return log;
|
|
66
104
|
}
|
|
67
105
|
/** Messages appended to the history since the last event. */
|
|
68
106
|
append(messages) {
|
|
@@ -99,8 +137,11 @@ export class SessionLog {
|
|
|
99
137
|
}
|
|
100
138
|
/**
|
|
101
139
|
* One turn's token usage. Copied here rather than referenced, because the
|
|
102
|
-
* usage store keeps only its newest 50 runs while
|
|
103
|
-
*
|
|
140
|
+
* usage store keeps only its newest 50 runs while a session keeps its own
|
|
141
|
+
* for as long as the session survives `sessions.retention` — see the note on
|
|
142
|
+
* `UsageEventSchema`. (That used to say "indefinitely", which was true until
|
|
143
|
+
* #257 gave sessions a bound; the two stores still expire independently, and
|
|
144
|
+
* the copy is still what makes a restored session's numbers whole.)
|
|
104
145
|
*/
|
|
105
146
|
usage(inputTokens, outputTokens) {
|
|
106
147
|
this.write({
|
|
@@ -130,18 +171,76 @@ export class SessionLog {
|
|
|
130
171
|
count,
|
|
131
172
|
});
|
|
132
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Enforce retention for this project, once, as this session opens.
|
|
176
|
+
*
|
|
177
|
+
* ORDERING: before the `resumed` write and before any meta flush, so the
|
|
178
|
+
* count cap is applied to the sessions that were there BEFORE this one and a
|
|
179
|
+
* fresh session never has to compete with itself for a slot. On a resume the
|
|
180
|
+
* file already exists and is explicitly excluded by id.
|
|
181
|
+
*
|
|
182
|
+
* Skipped entirely when the `file` seam is in use — see the note on
|
|
183
|
+
* {@link SessionLogOptions.sessions}.
|
|
184
|
+
*/
|
|
185
|
+
pruneOnce(opts) {
|
|
186
|
+
if (!opts.sessions || opts.file !== undefined)
|
|
187
|
+
return;
|
|
188
|
+
let result;
|
|
189
|
+
try {
|
|
190
|
+
result = pruneSessions(opts.cwd, {
|
|
191
|
+
sessions: opts.sessions,
|
|
192
|
+
activeSessionId: opts.sessionId,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
// Retention is housekeeping. It does not get to fail a session.
|
|
197
|
+
this.logger?.debug(`session retention skipped: ${err.message}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (result.removed.length === 0)
|
|
201
|
+
return;
|
|
202
|
+
const line = `pruned ${result.removed.length} session${result.removed.length === 1 ? "" : "s"} ` +
|
|
203
|
+
`(${formatBytes(result.bytesFreed)}) past sessions.retention/maxAgeDays`;
|
|
204
|
+
if (result.removed.length >= PRUNE_NOTICE_THRESHOLD) {
|
|
205
|
+
this.logger?.info(`${line} — see \`cruxy sessions\``);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
this.logger?.debug(line);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
133
211
|
runId() {
|
|
134
212
|
const id = this.currentRunId?.();
|
|
135
213
|
return id === undefined ? {} : { runId: id };
|
|
136
214
|
}
|
|
137
215
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
216
|
+
* Record one event, flushing a buffered `meta` ahead of it.
|
|
217
|
+
*
|
|
218
|
+
* This is where a new session's file comes into existence: the first event to
|
|
219
|
+
* reach here brings the `meta` line with it, so the on-disk order is exactly
|
|
220
|
+
* what it always was and no reader can observe the difference.
|
|
221
|
+
*
|
|
222
|
+
* `pendingMeta` is cleared BEFORE the write is attempted. If the meta write
|
|
223
|
+
* fails, `broken` latches and every later event is refused — leaving the meta
|
|
224
|
+
* queued for a retry would let it land AFTER events it is supposed to
|
|
225
|
+
* precede, which is the one ordering the format depends on.
|
|
141
226
|
*/
|
|
142
227
|
write(event) {
|
|
143
228
|
if (this.broken)
|
|
144
229
|
return false;
|
|
230
|
+
const meta = this.pendingMeta;
|
|
231
|
+
if (meta !== null) {
|
|
232
|
+
this.pendingMeta = null;
|
|
233
|
+
if (!this.writeLine(meta))
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return this.writeLine(event);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Append one event as a single line. Returns whether it landed. The first
|
|
240
|
+
* failure warns and latches `broken`, so a persistent problem (a full disk)
|
|
241
|
+
* produces one diagnostic rather than one per turn.
|
|
242
|
+
*/
|
|
243
|
+
writeLine(event) {
|
|
145
244
|
try {
|
|
146
245
|
mkdirSync(path.dirname(this.file), { recursive: true });
|
|
147
246
|
// `mode` applies only when the file is created — 0600 from the first
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { INTERRUPTED, idKey, jobLogFilesByRecency, readJobLogTerminal, sessionKeysPresent, } from "../jobs/log-store.js";
|
|
4
|
+
import { SESSION_RETENTION_FLOOR, } from "../config/index.js";
|
|
5
|
+
import { sessionFilesByRecency } from "./list.js";
|
|
6
|
+
import { SESSION_FILE_EXT } from "./paths.js";
|
|
7
|
+
/** `<sessionId>.jsonl` → `<sessionId>`. */
|
|
8
|
+
function idOf(file) {
|
|
9
|
+
return path.basename(file, SESSION_FILE_EXT);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Enforce `sessions.retention` and `sessions.maxAgeDays` on one project.
|
|
13
|
+
*
|
|
14
|
+
* Age is the primary bound and is measured on MTIME, never on
|
|
15
|
+
* `meta.startedAt`: a conversation begun forty days ago and resumed this
|
|
16
|
+
* morning is live, and an age check on when it BEGAN would delete it out from
|
|
17
|
+
* under the user. mtime is also the field the scan already carries, so the
|
|
18
|
+
* correct rule is the cheap one.
|
|
19
|
+
*
|
|
20
|
+
* The count cap is the backstop, applied by position in the same
|
|
21
|
+
* newest-first ordering. It exists because age alone does not protect a
|
|
22
|
+
* directory where someone runs forty sessions a day.
|
|
23
|
+
*
|
|
24
|
+
* Never throws. A file that cannot be unlinked is counted in `failed` and the
|
|
25
|
+
* sweep continues — retention failing is not a reason to take a session down,
|
|
26
|
+
* exactly as a failed session write is not.
|
|
27
|
+
*/
|
|
28
|
+
export function pruneSessions(cwd, opts) {
|
|
29
|
+
const result = {
|
|
30
|
+
removed: [],
|
|
31
|
+
kept: 0,
|
|
32
|
+
bytesFreed: 0,
|
|
33
|
+
failed: 0,
|
|
34
|
+
jobLogsRemoved: 0,
|
|
35
|
+
jobLogBytesFreed: 0,
|
|
36
|
+
};
|
|
37
|
+
if (!opts.sessions.enabled)
|
|
38
|
+
return result;
|
|
39
|
+
// Defensive, mirroring `usage/store.ts`: the schema rejects anything below
|
|
40
|
+
// the floor, but this is also reachable with a hand-built config.
|
|
41
|
+
const retention = Math.max(SESSION_RETENTION_FLOOR, Math.floor(opts.sessions.retention));
|
|
42
|
+
const now = opts.now ?? Date.now();
|
|
43
|
+
const cutoff = now - opts.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
44
|
+
// Newest first. Every file here ends in `.jsonl` and is a regular file — see
|
|
45
|
+
// the note on the scan about why `subagents/` depends on that.
|
|
46
|
+
const files = sessionFilesByRecency(cwd);
|
|
47
|
+
for (const [index, ref] of files.entries()) {
|
|
48
|
+
const sessionId = idOf(ref.file);
|
|
49
|
+
// WHY THE FILENAME IS TRUSTED HERE, when `listSessionRefs` refuses to.
|
|
50
|
+
// That refusal is about what may be OFFERED: a session resolvable by
|
|
51
|
+
// filename but unloadable for want of a meta line is a `--resume` that
|
|
52
|
+
// fails after the user picked it. This is the opposite question. Being
|
|
53
|
+
// conservative about what to DELETE and permissive about what to offer are
|
|
54
|
+
// opposite failure modes, and the cheap answer is the safe one on this
|
|
55
|
+
// side — at worst a filename that does not match the meta spares a file
|
|
56
|
+
// that could have gone. Do not "fix" this to `readMeta`; that puts a full
|
|
57
|
+
// read of every session back on the startup path.
|
|
58
|
+
if (sessionId === opts.activeSessionId) {
|
|
59
|
+
result.kept++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const tooOld = ref.mtimeMs < cutoff;
|
|
63
|
+
const beyondCap = index >= retention;
|
|
64
|
+
if (!tooOld && !beyondCap) {
|
|
65
|
+
result.kept++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
unlinkSync(ref.file);
|
|
70
|
+
result.removed.push({ ...ref, sessionId });
|
|
71
|
+
result.bytesFreed += ref.size;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
result.failed++;
|
|
75
|
+
result.kept++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
sweepOrphanedJobLogs(cwd, result, opts);
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Drop the job logs this project no longer has a reason to keep (#172 item 1).
|
|
83
|
+
*
|
|
84
|
+
* This is the second phase of the same mark-and-sweep `CheckpointService.prune`
|
|
85
|
+
* runs: the doomed sessions are already gone above, and what follows is the
|
|
86
|
+
* content that belonged to them. A job log IS content owned by its session,
|
|
87
|
+
* keyed on the session, exactly as a checkpoint's shadow objects are content
|
|
88
|
+
* owned by its manifest — which is the answer #172 asked for in passing and
|
|
89
|
+
* #257 was filed to settle: pruned WITH the session, not independently.
|
|
90
|
+
*
|
|
91
|
+
* ## The three rules, and why each one is where the line falls
|
|
92
|
+
*
|
|
93
|
+
* **A finished job's log goes at the next start.** A `done` job's OUTCOME is
|
|
94
|
+
* already durable somewhere better: the dispatch tool's result is a
|
|
95
|
+
* `tool_result` in the session's own transcript, its file mutations are a
|
|
96
|
+
* checkpoint set `cruxy rollback <id>` still finds, and its spend is a usage
|
|
97
|
+
* record under the same id. What only this file holds is the intermediate
|
|
98
|
+
* chatter of a run that went fine — the least interesting artefact in the set.
|
|
99
|
+
* Dropping it eagerly is what keeps the steady-state cost of this subtree near
|
|
100
|
+
* zero, so the logs that survive are the ones somebody would actually open: the
|
|
101
|
+
* failed and the interrupted.
|
|
102
|
+
*
|
|
103
|
+
* **An orphan goes when its session does.** Not only the sessions THIS prune
|
|
104
|
+
* removed: the rule is "no session file with this key is present", which also
|
|
105
|
+
* covers `cruxy sessions rm`, a prune from another process, and a file the user
|
|
106
|
+
* deleted by hand. Keying it on what the directory holds rather than on
|
|
107
|
+
* `result.removed` means there is no path by which a log outlives its session
|
|
108
|
+
* unnoticed.
|
|
109
|
+
*
|
|
110
|
+
* **Nothing without a terminal record is deleted while it is young.** A log
|
|
111
|
+
* with no `end` record is one of two things, and this sweep cannot tell them
|
|
112
|
+
* apart: a job that was interrupted (the whole reason to keep logs at all) or a
|
|
113
|
+
* job that is RUNNING RIGHT NOW in another terminal, whose session may not have
|
|
114
|
+
* flushed its own file yet. Treating it as garbage would delete a live job's
|
|
115
|
+
* output from under it. Both cases are handled by leaving it alone until it is
|
|
116
|
+
* older than `maxAgeDays`, at which point it is certainly not live and its
|
|
117
|
+
* session is certainly gone.
|
|
118
|
+
*
|
|
119
|
+
* The active session is skipped outright, for the reason `pruneSessions` spares
|
|
120
|
+
* its own file: "almost always survives" is not a policy to hand a user's
|
|
121
|
+
* running work.
|
|
122
|
+
*
|
|
123
|
+
* ## What it must not do
|
|
124
|
+
*
|
|
125
|
+
* No `rm -r`, and no assumption that every entry here is a session's — the
|
|
126
|
+
* scan behind {@link jobLogFilesByRecency} filters to regular `.jsonl` files
|
|
127
|
+
* whose name splits into exactly two ids, so anything else in `subagents/`
|
|
128
|
+
* (including the subtree's own subdirectories, should P3 add any) is never even
|
|
129
|
+
* considered. Never throws: a log that cannot be unlinked is left, exactly as a
|
|
130
|
+
* session that cannot be is.
|
|
131
|
+
*/
|
|
132
|
+
function sweepOrphanedJobLogs(cwd, result, opts) {
|
|
133
|
+
const refs = jobLogFilesByRecency(cwd);
|
|
134
|
+
if (refs.length === 0)
|
|
135
|
+
return;
|
|
136
|
+
// Derived from the directory as it stands NOW — after the deletes above — so
|
|
137
|
+
// the sessions this prune just removed are absent from it by construction and
|
|
138
|
+
// need no separate bookkeeping.
|
|
139
|
+
const present = sessionKeysPresent(sessionFilesByRecency(cwd));
|
|
140
|
+
const activeKey = opts.activeSessionId === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: idKey(opts.activeSessionId);
|
|
143
|
+
const now = opts.now ?? Date.now();
|
|
144
|
+
const cutoff = now - opts.sessions.maxAgeDays * 24 * 60 * 60 * 1000;
|
|
145
|
+
for (const ref of refs) {
|
|
146
|
+
if (activeKey !== undefined && ref.sessionKey === activeKey)
|
|
147
|
+
continue;
|
|
148
|
+
// One bounded tail read per file, never a full one: see `TAIL_BYTES`.
|
|
149
|
+
const status = readJobLogTerminal(ref.file);
|
|
150
|
+
const orphaned = !present.has(ref.sessionKey);
|
|
151
|
+
const doomed = status === "done" ||
|
|
152
|
+
(orphaned && (status !== INTERRUPTED || ref.mtimeMs < cutoff));
|
|
153
|
+
if (!doomed)
|
|
154
|
+
continue;
|
|
155
|
+
try {
|
|
156
|
+
unlinkSync(ref.file);
|
|
157
|
+
result.jobLogsRemoved++;
|
|
158
|
+
result.jobLogBytesFreed += ref.size;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Left in place, and deliberately NOT counted in `failed` — that field
|
|
162
|
+
// counts sessions, and inflating it here would report a conversation that
|
|
163
|
+
// could not be deleted when none was even tried.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
package/dist/session/resume.js
CHANGED
|
@@ -152,6 +152,11 @@ export function resolveSessionId(cwd, id) {
|
|
|
152
152
|
throw usageError(`no session \`${id}\` in this project`, [
|
|
153
153
|
"run `cruxy --resume` to pick from recent sessions",
|
|
154
154
|
"sessions are per-directory; check you are in the right one",
|
|
155
|
+
// #257: retention can now delete a session the user still remembers the
|
|
156
|
+
// id of, and a pruned session must not read as a bug. The checkpoint
|
|
157
|
+
// equivalent (`errors/constructors.ts`, CRUXY_E_CHECKPOINT_NOT_FOUND)
|
|
158
|
+
// says the same thing for the same reason.
|
|
159
|
+
"old sessions are pruned by retention — adjust `sessions.retention` / `sessions.maxAgeDays` in config to keep more",
|
|
155
160
|
]);
|
|
156
161
|
}
|
|
157
162
|
return summary;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { runAgent } from "../agent/loop.js";
|
|
4
|
-
import {
|
|
5
|
-
import { UsageCollector } from "../usage/index.js";
|
|
4
|
+
import { CruxyError, ErrorCode, messageOf, poolDenial, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
5
|
+
import { UsageCollector, } from "../usage/index.js";
|
|
6
6
|
import { UNRESOLVED_TIER, } from "../budget/index.js";
|
|
7
7
|
import { resolveTaskModel } from "../routing/index.js";
|
|
8
8
|
import { Workspace } from "../workspace/index.js";
|
|
@@ -76,7 +76,30 @@ export class SubagentOrchestrator {
|
|
|
76
76
|
if (childDepth < maxDepth) {
|
|
77
77
|
registry.register(makeSpawnSubagentTool(this, childDepth));
|
|
78
78
|
}
|
|
79
|
-
|
|
79
|
+
// Admission control for the SEQUENTIAL seam (cli#245). `spawnMany` asks for
|
|
80
|
+
// its whole batch and passes the verdict down; everything else — the
|
|
81
|
+
// `spawn_subagent` tool, a plan step, a nested child — arrives here having
|
|
82
|
+
// asked nobody, which is the hole cli#243 left behind. A refusal throws the
|
|
83
|
+
// same coded error the fan-out throws, and the spawn tool hands it to the
|
|
84
|
+
// model as a tool error it can act on (see `PROPAGATE` in `spawn-tool.ts`).
|
|
85
|
+
const admission = opts.admission ?? this.admitRuns(1, [spec]);
|
|
86
|
+
if (admission.kind === "refused") {
|
|
87
|
+
throw sessionBudgetExhausted(admission.reason);
|
|
88
|
+
}
|
|
89
|
+
if (admission.kind === "narrowed" && !opts.admission) {
|
|
90
|
+
deps.logger.warn(admission.reason);
|
|
91
|
+
}
|
|
92
|
+
// `admit` only ever NARROWS, and `Math.min` is what makes that true here: a
|
|
93
|
+
// batch verdict carries the heaviest child's ceiling, so applying it raw to
|
|
94
|
+
// a cheaper sibling would RAISE that child's cap on the strength of an
|
|
95
|
+
// admission check. `0` means the budget bounded nothing.
|
|
96
|
+
const resolved = resolveBudget(defaultBudget, spec.budget);
|
|
97
|
+
const budget = new Budget(admission.maxTokens > 0
|
|
98
|
+
? {
|
|
99
|
+
...resolved,
|
|
100
|
+
maxTokens: Math.min(resolved.maxTokens, admission.maxTokens),
|
|
101
|
+
}
|
|
102
|
+
: resolved);
|
|
80
103
|
// Root scoping (C.33): a `spec.root` narrows the child's cwd + confinement to
|
|
81
104
|
// that ONE root (its writes land there and nowhere else). Omitted → the full
|
|
82
105
|
// session workspace, unchanged from C.14. An unknown name fails loud here
|
|
@@ -124,7 +147,6 @@ export class SubagentOrchestrator {
|
|
|
124
147
|
const startedAt = new Date().toISOString();
|
|
125
148
|
try {
|
|
126
149
|
return await this.runChild({
|
|
127
|
-
spec,
|
|
128
150
|
opts,
|
|
129
151
|
messages,
|
|
130
152
|
registry,
|
|
@@ -150,12 +172,19 @@ export class SubagentOrchestrator {
|
|
|
150
172
|
// sibling's pool denial cancelled, still drew whatever it drew before it
|
|
151
173
|
// stopped, and the record is what it reported.
|
|
152
174
|
//
|
|
153
|
-
// NOT
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
175
|
+
// STILL NOT WRITTEN TO THE USAGE STORE AS A RUN OF ITS OWN, and now for a
|
|
176
|
+
// different reason than when this was written (cli#244 closed the gap the
|
|
177
|
+
// old note described). The child's requests DO reach `/usage` — forwarded
|
|
178
|
+
// above into the spawning turn's collector, so they land as entries of the
|
|
179
|
+
// turn's record. What this record is for is the budget, and only the
|
|
180
|
+
// budget: it exists so the fold can happen the MOMENT the child finishes
|
|
181
|
+
// rather than at end of turn, because the next admission check inside the
|
|
182
|
+
// same turn has to see the spend.
|
|
183
|
+
//
|
|
184
|
+
// Which is exactly why the parent must NOT hand these same entries to the
|
|
185
|
+
// budget again when its turn closes — see the `origin` filter at
|
|
186
|
+
// `agent/session.ts`. Two folds of one child's tokens would halve the
|
|
187
|
+
// effective session cap on any turn that fans out.
|
|
159
188
|
this.deps.budget?.record(usage.toRecord(randomUUID(), undefined, startedAt));
|
|
160
189
|
}
|
|
161
190
|
}
|
|
@@ -163,7 +192,7 @@ export class SubagentOrchestrator {
|
|
|
163
192
|
* usage fold above can be a `finally` over every path this can leave by. */
|
|
164
193
|
async runChild(args) {
|
|
165
194
|
const { deps } = this;
|
|
166
|
-
const {
|
|
195
|
+
const { opts, messages, registry, budget, ctx, artifacts } = args;
|
|
167
196
|
const { label, noun, tag, usage } = args;
|
|
168
197
|
let run;
|
|
169
198
|
try {
|
|
@@ -181,9 +210,24 @@ export class SubagentOrchestrator {
|
|
|
181
210
|
subagent: true,
|
|
182
211
|
budget,
|
|
183
212
|
router: deps.router,
|
|
184
|
-
|
|
213
|
+
// A LITERAL, not a per-spawn override (cli#281). Every child is a
|
|
214
|
+
// `subagent` by construction; letting the spec name its own class would
|
|
215
|
+
// hand the model a lever to route itself out of `routing.map.subagent`
|
|
216
|
+
// by relabelling, and would put the difficulty guess back in a call site
|
|
217
|
+
// whose whole contract (`routing/types.ts`) is that callers declare and
|
|
218
|
+
// nobody sniffs.
|
|
219
|
+
taskClass: "subagent",
|
|
185
220
|
signal: opts.signal,
|
|
186
|
-
|
|
221
|
+
// Both, and in this order (cli#244). The child's OWN collector is what
|
|
222
|
+
// the budget fold below reads — it needs a per-child record — while the
|
|
223
|
+
// parent's sink is what puts this request in `/usage` at all. The
|
|
224
|
+
// explicit origin is load-bearing: the parent's collector defaults to
|
|
225
|
+
// `"turn"`, and a child's request inheriting that would be a lie about
|
|
226
|
+
// what the user did.
|
|
227
|
+
onRequestUsage: (req) => {
|
|
228
|
+
usage.record(req);
|
|
229
|
+
opts.onRequestUsage?.({ ...req, origin: "subagent" });
|
|
230
|
+
},
|
|
187
231
|
});
|
|
188
232
|
}
|
|
189
233
|
catch (err) {
|
|
@@ -277,7 +321,7 @@ export class SubagentOrchestrator {
|
|
|
277
321
|
// AFTER the scope check on purpose: an overlapping fan-out is malformed and
|
|
278
322
|
// must be refused whatever the budget says, and narrowing a malformed batch
|
|
279
323
|
// to two children would hide the overlap rather than report it.
|
|
280
|
-
const admitted = this.
|
|
324
|
+
const admitted = this.admitRuns(specs.length, specs);
|
|
281
325
|
if (admitted.kind === "refused") {
|
|
282
326
|
throw sessionBudgetExhausted(admitted.reason);
|
|
283
327
|
}
|
|
@@ -317,6 +361,17 @@ export class SubagentOrchestrator {
|
|
|
317
361
|
results[i] = await this.spawn(spec, parentDepth, {
|
|
318
362
|
signal: controller.signal,
|
|
319
363
|
slot: `${i + 1}/${total}`,
|
|
364
|
+
// The batch verdict, so the child does not ask a second time —
|
|
365
|
+
// and so a cut token cap actually reaches the run it was cut
|
|
366
|
+
// for. `admit` returns one for a single run it can only
|
|
367
|
+
// partially afford, and until cli#245 `spawnMany` read the
|
|
368
|
+
// count off that verdict and dropped the ceiling with it.
|
|
369
|
+
admission: admitted,
|
|
370
|
+
// Every child of the batch reports into the one turn that
|
|
371
|
+
// spawned them, exactly as a sequential child does.
|
|
372
|
+
...(opts.onRequestUsage
|
|
373
|
+
? { onRequestUsage: opts.onRequestUsage }
|
|
374
|
+
: {}),
|
|
320
375
|
});
|
|
321
376
|
}
|
|
322
377
|
catch (err) {
|
|
@@ -347,7 +402,13 @@ export class SubagentOrchestrator {
|
|
|
347
402
|
}
|
|
348
403
|
}
|
|
349
404
|
/**
|
|
350
|
-
* Ask the session budget whether
|
|
405
|
+
* Ask the session budget whether these runs fit (P10 track 3 / cli#212).
|
|
406
|
+
*
|
|
407
|
+
* ONE method for both seams (cli#245): a batch of N from `spawnMany`, and the
|
|
408
|
+
* single run `spawn` asks for when nobody admitted it. They differ only in
|
|
409
|
+
* `count` — the arithmetic, the tier resolution and the per-run ceiling are
|
|
410
|
+
* the same question, and a second copy for the singular case is how the two
|
|
411
|
+
* would come to disagree.
|
|
351
412
|
*
|
|
352
413
|
* The estimate is the batch's CEILING, not a guess at its actual draw:
|
|
353
414
|
* `count × perChildTokens × multiplier`, where `perChildTokens` is the local
|
|
@@ -361,17 +422,17 @@ export class SubagentOrchestrator {
|
|
|
361
422
|
* the pool, so it is weighed at the worst case rather than skipped — see
|
|
362
423
|
* `UNRESOLVED_TIER`.
|
|
363
424
|
*/
|
|
364
|
-
|
|
425
|
+
admitRuns(count, specs) {
|
|
365
426
|
const budget = this.deps.budget;
|
|
366
427
|
if (!budget)
|
|
367
|
-
return { kind: "allow", count
|
|
428
|
+
return { kind: "allow", count, maxTokens: 0 };
|
|
368
429
|
const { defaultBudget } = this.deps.config.subagent;
|
|
369
430
|
// The heaviest child in the batch sets the per-run figure. The bound has to
|
|
370
431
|
// hold for the batch as dispatched, and averaging would let one 64k child
|
|
371
432
|
// hide behind four 4k ones.
|
|
372
433
|
const perRunTokens = specs.reduce((max, spec) => Math.max(max, resolveBudget(defaultBudget, spec.budget).maxTokens), 0);
|
|
373
434
|
return budget.admit({
|
|
374
|
-
count
|
|
435
|
+
count,
|
|
375
436
|
perRunTokens,
|
|
376
437
|
tier: this.fanOutTier(),
|
|
377
438
|
});
|
|
@@ -383,6 +444,14 @@ export class SubagentOrchestrator {
|
|
|
383
444
|
return undefined; // no cruxy routing — not a weighted-pool request
|
|
384
445
|
// No tier means the router chose `auto` and the gateway decides — a request
|
|
385
446
|
// that still draws on the pool, so it is weighed at the worst case.
|
|
447
|
+
//
|
|
448
|
+
// A routing table that maps some classes and declares no `default` now
|
|
449
|
+
// lands here for `subagent` (it used to be filled with vaani), so such a
|
|
450
|
+
// fan-out is weighed at MAX_TIER_MULTIPLIER 3.8 rather than vaani's 3.25 —
|
|
451
|
+
// about 17% heavier against the pool. That is the intended answer, not
|
|
452
|
+
// drift: the tier genuinely is not known before the request, and a
|
|
453
|
+
// fan-out the gateway sends to kavi must not have been admitted on
|
|
454
|
+
// vaani's arithmetic.
|
|
386
455
|
return resolveTaskModel(router, "subagent").tier ?? UNRESOLVED_TIER;
|
|
387
456
|
}
|
|
388
457
|
/**
|
|
@@ -489,22 +558,6 @@ function taskLabel(task) {
|
|
|
489
558
|
function isWriter(spec) {
|
|
490
559
|
return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
|
|
491
560
|
}
|
|
492
|
-
/**
|
|
493
|
-
* The typed pool denial behind an error, or `null` if it is not one.
|
|
494
|
-
*
|
|
495
|
-
* Two shapes reach here and both are the same fact: the raw SDK
|
|
496
|
-
* `BudgetExhaustedError` from this child's own request, and — when a child that
|
|
497
|
-
* itself spawned re-throws — the `CruxyError` a nested `spawn` already
|
|
498
|
-
* converted. Mapping goes through `classifyProviderError` so there is still ONE
|
|
499
|
-
* place that knows which SDK class means what.
|
|
500
|
-
*/
|
|
501
|
-
function poolDenial(err) {
|
|
502
|
-
if (CruxyError.is(err)) {
|
|
503
|
-
return err.code === ErrorCode.BudgetExhausted ? err : null;
|
|
504
|
-
}
|
|
505
|
-
const typed = classifyProviderError(err);
|
|
506
|
-
return typed?.code === ErrorCode.BudgetExhausted ? typed : null;
|
|
507
|
-
}
|
|
508
561
|
/**
|
|
509
562
|
* The tokens a child is KNOWN to have spent, or `undefined` when no request
|
|
510
563
|
* reported any.
|
|
@@ -91,7 +91,7 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
|
|
|
91
91
|
"transcript is discarded. Use for independent subtasks whose details you don't " +
|
|
92
92
|
'need in your own context (e.g. "find where X is configured and report the paths").',
|
|
93
93
|
parameters,
|
|
94
|
-
async execute(input) {
|
|
94
|
+
async execute(input, ctx) {
|
|
95
95
|
const budget = {
|
|
96
96
|
...(input.maxIterations !== undefined
|
|
97
97
|
? { maxIterations: input.maxIterations }
|
|
@@ -102,7 +102,12 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
|
|
|
102
102
|
};
|
|
103
103
|
let result;
|
|
104
104
|
try {
|
|
105
|
-
result = await orchestrator.spawn({ task: input.task, tools: input.tools, budget }, depth
|
|
105
|
+
result = await orchestrator.spawn({ task: input.task, tools: input.tools, budget }, depth,
|
|
106
|
+
// The calling turn's usage collector (cli#244), the one thing on `ctx`
|
|
107
|
+
// that is per-turn rather than per-session. Absent outside a turn, in
|
|
108
|
+
// which case the child's spend still reaches the budget — it just has
|
|
109
|
+
// no record to join.
|
|
110
|
+
ctx.onRequestUsage ? { onRequestUsage: ctx.onRequestUsage } : {});
|
|
106
111
|
}
|
|
107
112
|
catch (err) {
|
|
108
113
|
// Non-interactive default-deny propagates to the boundary (U.3) —
|
|
@@ -186,10 +191,12 @@ export function makeSpawnSubagentsTool(orchestrator, depth) {
|
|
|
186
191
|
"For children that WRITE, give each a distinct `root`; overlapping write scope is " +
|
|
187
192
|
"refused. For a single subtask, use spawn_subagent instead.",
|
|
188
193
|
parameters: batchParameters,
|
|
189
|
-
async execute(input) {
|
|
194
|
+
async execute(input, ctx) {
|
|
190
195
|
let results;
|
|
191
196
|
try {
|
|
192
|
-
results = await orchestrator.spawnMany(input.tasks.map(toSpec), depth
|
|
197
|
+
results = await orchestrator.spawnMany(input.tasks.map(toSpec), depth,
|
|
198
|
+
// Every child of the batch reports into the one turn that spawned it.
|
|
199
|
+
ctx.onRequestUsage ? { onRequestUsage: ctx.onRequestUsage } : {});
|
|
193
200
|
}
|
|
194
201
|
catch (err) {
|
|
195
202
|
// Non-interactive default-deny propagates to the boundary (U.3).
|