@cruxy/cli 0.24.0 → 0.25.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/dist/agent/session.d.ts +13 -0
- package/dist/agent/session.js +6 -0
- package/dist/checkpoint/gate-hook.d.ts +28 -0
- package/dist/checkpoint/gate-hook.js +98 -0
- package/dist/checkpoint/gate.d.ts +7 -1
- package/dist/checkpoint/gate.js +8 -2
- package/dist/checkpoint/index.d.ts +1 -0
- package/dist/checkpoint/index.js +1 -0
- package/dist/cli/commands/rollback.d.ts +4 -1
- package/dist/cli/commands/rollback.js +16 -9
- package/dist/cli/commands/run.js +12 -0
- package/dist/cli/repl.d.ts +1 -1
- package/dist/cli/repl.js +106 -0
- package/dist/cli/session-factory.d.ts +4 -12
- package/dist/cli/session-factory.js +50 -96
- package/dist/config/schema.d.ts +86 -0
- package/dist/config/schema.js +41 -0
- package/dist/errors/constructors.d.ts +18 -0
- package/dist/errors/constructors.js +49 -0
- package/dist/errors/types.d.ts +13 -0
- package/dist/errors/types.js +21 -0
- package/dist/jobs/approval-queue.d.ts +85 -0
- package/dist/jobs/approval-queue.js +96 -0
- package/dist/jobs/dispatch-tool.d.ts +34 -0
- package/dist/jobs/dispatch-tool.js +96 -0
- package/dist/jobs/index.d.ts +6 -0
- package/dist/jobs/index.js +6 -0
- package/dist/jobs/log-buffer.d.ts +31 -0
- package/dist/jobs/log-buffer.js +30 -0
- package/dist/jobs/log-renderer.d.ts +32 -0
- package/dist/jobs/log-renderer.js +70 -0
- package/dist/jobs/manager.d.ts +139 -0
- package/dist/jobs/manager.js +397 -0
- package/dist/jobs/types.d.ts +81 -0
- package/dist/jobs/types.js +10 -0
- package/dist/subagent/orchestrator.d.ts +9 -0
- package/dist/subagent/orchestrator.js +6 -1
- package/dist/subagent/semaphore.d.ts +40 -11
- package/dist/subagent/semaphore.js +23 -26
- package/package.json +1 -1
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { createProvider } from "@cruxy/sdk";
|
|
3
2
|
import { loadProjectInstructions } from "../config/index.js";
|
|
4
3
|
import { logger } from "../utils/logger.js";
|
|
5
4
|
import { getGitInfo } from "../utils/git.js";
|
|
6
|
-
import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist,
|
|
5
|
+
import { ApprovalMutex, ApprovalService, InteractivePolicy, SessionAllowlist, defaultPromptIO, serializeGate, } from "../approval/index.js";
|
|
6
|
+
import { withCheckpointGate } from "../checkpoint/index.js";
|
|
7
|
+
// Re-exported for back-compat: the checkpoint hook moved to the checkpoint
|
|
8
|
+
// package (so the subagent orchestrator and C.28 jobs can compose it without
|
|
9
|
+
// importing the CLI layer). Existing imports of `withCheckpointGate` from the
|
|
10
|
+
// session factory keep working.
|
|
11
|
+
export { withCheckpointGate };
|
|
7
12
|
import { shouldUseColor } from "../errors/index.js";
|
|
8
13
|
import { buildDefaultRegistry, } from "../tools/index.js";
|
|
9
14
|
import { Session, } from "../agent/index.js";
|
|
@@ -13,7 +18,8 @@ import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memo
|
|
|
13
18
|
import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
|
|
14
19
|
import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
|
|
15
20
|
import { appendRun } from "../usage/index.js";
|
|
16
|
-
import { SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
|
|
21
|
+
import { Semaphore, SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
|
|
22
|
+
import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/index.js";
|
|
17
23
|
/**
|
|
18
24
|
* Wrap a PromptIO so the live region yields before any prompt text lands
|
|
19
25
|
* (U.2/U.4): the prompt writes to stderr while the status line owns the last
|
|
@@ -53,99 +59,6 @@ function resumeLineAfterApproval(requestApproval, renderer) {
|
|
|
53
59
|
}
|
|
54
60
|
};
|
|
55
61
|
}
|
|
56
|
-
/**
|
|
57
|
-
* Wrap the approval gate with the C.32 auto-checkpoint hook. Ordering is the
|
|
58
|
-
* whole point: a tool mutates only *after* `requestApproval` resolves, so
|
|
59
|
-
* snapshotting after an `allow` decision but before returning it means the
|
|
60
|
-
* checkpoint always lands before the run's first mutation — and a denied
|
|
61
|
-
* action never creates one. The same seam records which paths the run touched
|
|
62
|
-
* (file actions) or that attribution is lost (shell), for rollback's
|
|
63
|
-
* external-change detection.
|
|
64
|
-
*/
|
|
65
|
-
export function withCheckpointGate(requestApproval, gate, ws) {
|
|
66
|
-
if (!gate)
|
|
67
|
-
return requestApproval;
|
|
68
|
-
return async (action) => {
|
|
69
|
-
const decision = await requestApproval(action);
|
|
70
|
-
if (!decision.allow)
|
|
71
|
-
return decision;
|
|
72
|
-
const request = classify(action, ws.primary().absPath);
|
|
73
|
-
if (request.tier === "read")
|
|
74
|
-
return decision;
|
|
75
|
-
if (action.kind === "shell" || action.kind === "test") {
|
|
76
|
-
// JC-β residual: non-primary shell/test are Step 5, so they are still
|
|
77
|
-
// hard-attributed to the primary root regardless of `action.root` (which
|
|
78
|
-
// those tools populate as the seam). They can mutate files we cannot
|
|
79
|
-
// attribute (scripts, snapshot writers) — record the lost attribution.
|
|
80
|
-
const root = ws.primary();
|
|
81
|
-
const svc = gate.serviceFor(root.name, root.absPath);
|
|
82
|
-
const checkpoint = await svc.ensureCheckpoint();
|
|
83
|
-
await svc.recordShellMutation();
|
|
84
|
-
if (checkpoint) {
|
|
85
|
-
await gate.recordMember(root.name, root.absPath, checkpoint.id);
|
|
86
|
-
}
|
|
87
|
-
return decision;
|
|
88
|
-
}
|
|
89
|
-
if (action.kind === "vcs") {
|
|
90
|
-
// C.26 Step 4: a PR now names its root (⚖︎#11), so the checkpoint is
|
|
91
|
-
// attributed to THAT selected root — its git commit stages/lands in that
|
|
92
|
-
// root's working tree, never the primary's. `recordShellMutation` because a
|
|
93
|
-
// `git add -A` + commit mutates the tree opaquely (no per-file attribution).
|
|
94
|
-
// Fall back to the primary only if a root name is somehow absent (defensive).
|
|
95
|
-
const root = (action.root ? ws.tryRootByName(action.root) : undefined) ??
|
|
96
|
-
ws.primary();
|
|
97
|
-
const svc = gate.serviceFor(root.name, root.absPath);
|
|
98
|
-
const checkpoint = await svc.ensureCheckpoint();
|
|
99
|
-
await svc.recordShellMutation();
|
|
100
|
-
if (checkpoint) {
|
|
101
|
-
await gate.recordMember(root.name, root.absPath, checkpoint.id);
|
|
102
|
-
}
|
|
103
|
-
return decision;
|
|
104
|
-
}
|
|
105
|
-
// File actions (write/edit/patch): attribute each RESOLVED target to its root
|
|
106
|
-
// (JC-G — post-confinement truth) and checkpoint every touched root. A patch
|
|
107
|
-
// may span roots; each root gets its own checkpoint + set member.
|
|
108
|
-
for (const [rootName, group] of attributeFileTargets(action, ws)) {
|
|
109
|
-
const svc = gate.serviceFor(rootName, group.rootAbsPath);
|
|
110
|
-
const checkpoint = await svc.ensureCheckpoint();
|
|
111
|
-
await svc.recordTouched(group.paths);
|
|
112
|
-
if (checkpoint) {
|
|
113
|
-
await gate.recordMember(rootName, group.rootAbsPath, checkpoint.id);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return decision;
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Group a file action's resolved absolute targets by the root that contains each
|
|
121
|
-
* (JC-G). write/edit carry an already-absolute `path`; patch preview paths are
|
|
122
|
-
* relative to the PRIMARY cwd (`path.relative(ctx.cwd, abs)` in apply_patch), so
|
|
123
|
-
* we reconstruct the absolute path from the primary root rather than trusting
|
|
124
|
-
* classify's `targets` — which also correctly handles a patch spanning roots.
|
|
125
|
-
*/
|
|
126
|
-
function attributeFileTargets(action, ws) {
|
|
127
|
-
const abs = [];
|
|
128
|
-
if (action.kind === "write" || action.kind === "edit") {
|
|
129
|
-
if (action.path)
|
|
130
|
-
abs.push(action.path);
|
|
131
|
-
}
|
|
132
|
-
else if (action.kind === "patch" && action.preview?.type === "patch") {
|
|
133
|
-
for (const file of action.preview.files) {
|
|
134
|
-
abs.push(path.resolve(ws.primary().absPath, file.path));
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
const byRoot = new Map();
|
|
138
|
-
for (const target of abs) {
|
|
139
|
-
const root = ws.rootContaining(target);
|
|
140
|
-
const group = byRoot.get(root.name) ?? {
|
|
141
|
-
rootAbsPath: root.absPath,
|
|
142
|
-
paths: [],
|
|
143
|
-
};
|
|
144
|
-
group.paths.push(target);
|
|
145
|
-
byRoot.set(root.name, group);
|
|
146
|
-
}
|
|
147
|
-
return byRoot;
|
|
148
|
-
}
|
|
149
62
|
/**
|
|
150
63
|
* Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
|
|
151
64
|
* order the model sees them: `remember` (memory), the four LSP tools, the two web
|
|
@@ -198,6 +111,11 @@ export function registerRuntimeTools(registry, config, opts = {}) {
|
|
|
198
111
|
if (opts.spawnManyTool)
|
|
199
112
|
registry.register(opts.spawnManyTool);
|
|
200
113
|
}
|
|
114
|
+
// Background jobs (C.28): the non-blocking `run_in_background` dispatch tool,
|
|
115
|
+
// registered only when the feature is enabled (off by default). Bound to the
|
|
116
|
+
// session's job manager by the caller.
|
|
117
|
+
if (config.jobs.enabled && opts.jobTool)
|
|
118
|
+
registry.register(opts.jobTool);
|
|
201
119
|
}
|
|
202
120
|
/**
|
|
203
121
|
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
@@ -326,6 +244,13 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
326
244
|
// Read-tier actions bypass it (see serializeGate), so a parallel read fan-out is
|
|
327
245
|
// never stalled behind an unrelated pending prompt.
|
|
328
246
|
const approvalMutex = new ApprovalMutex();
|
|
247
|
+
// The ONE execution semaphore for the whole session (C.28 + C.33): subagent
|
|
248
|
+
// fan-out AND background jobs draw permits from THIS instance, so
|
|
249
|
+
// `subagent.maxConcurrency` bounds their COMBINED concurrency — not one cap
|
|
250
|
+
// each. The ONE pending-approval queue background jobs produce onto is created
|
|
251
|
+
// here too, so foreground servicing and job production share it.
|
|
252
|
+
const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
|
|
253
|
+
const approvalQueue = new ApprovalQueue();
|
|
329
254
|
const gate = (approval) => serializeGate(withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace), approvalMutex, cwd);
|
|
330
255
|
// Subagent orchestration (C.14): spawn_subagent goes on the main registry
|
|
331
256
|
// only when depth allows (maxDepth 0 disables the feature structurally).
|
|
@@ -344,8 +269,34 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
344
269
|
renderer,
|
|
345
270
|
sandbox,
|
|
346
271
|
checkpointsActive,
|
|
272
|
+
executionSemaphore,
|
|
347
273
|
makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
|
|
348
274
|
});
|
|
275
|
+
// Background jobs (C.28): the session-scoped manager the `run_in_background`
|
|
276
|
+
// tool dispatches onto. Built only when enabled. It shares the SAME execution
|
|
277
|
+
// semaphore, approval queue, and approval mutex as the foreground/subagents —
|
|
278
|
+
// one system, one cap, one queue — and each job gets its OWN checkpoint gate
|
|
279
|
+
// (keyed by job id) so `cruxy rollback <id>` isolates a job.
|
|
280
|
+
const jobManager = config.jobs.enabled
|
|
281
|
+
? new JobManager({
|
|
282
|
+
config,
|
|
283
|
+
provider,
|
|
284
|
+
router,
|
|
285
|
+
parentRegistry: execRegistry,
|
|
286
|
+
cwd,
|
|
287
|
+
workspace,
|
|
288
|
+
logger,
|
|
289
|
+
git,
|
|
290
|
+
projectInstructions,
|
|
291
|
+
sandbox,
|
|
292
|
+
semaphore: executionSemaphore,
|
|
293
|
+
approvalQueue,
|
|
294
|
+
approvalMutex,
|
|
295
|
+
foregroundInteractive: ttyInteractive,
|
|
296
|
+
promptIO: io,
|
|
297
|
+
})
|
|
298
|
+
: undefined;
|
|
299
|
+
const jobTool = jobManager ? makeRunInBackgroundTool(jobManager) : undefined;
|
|
349
300
|
// Now that the orchestrator exists, register every conditionally-enabled tool
|
|
350
301
|
// (remember / LSP / web / MCP / spawn_subagent) through the one seam the JC-B
|
|
351
302
|
// allowlist test also uses — order preserved, behaviour byte-identical.
|
|
@@ -359,6 +310,7 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
359
310
|
mcpTools,
|
|
360
311
|
spawnTool,
|
|
361
312
|
spawnManyTool,
|
|
313
|
+
jobTool,
|
|
362
314
|
});
|
|
363
315
|
if (planMode) {
|
|
364
316
|
// One allowlist shared by the plan-approval prompt and the per-action
|
|
@@ -409,6 +361,7 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
409
361
|
hooks,
|
|
410
362
|
router,
|
|
411
363
|
onRunUsage,
|
|
364
|
+
jobs: jobManager,
|
|
412
365
|
});
|
|
413
366
|
}
|
|
414
367
|
const approval = new ApprovalService({
|
|
@@ -436,5 +389,6 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
|
|
|
436
389
|
hooks,
|
|
437
390
|
router,
|
|
438
391
|
onRunUsage,
|
|
392
|
+
jobs: jobManager,
|
|
439
393
|
});
|
|
440
394
|
}
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -291,6 +291,53 @@ export declare const SubagentConfigSchema: z.ZodObject<{
|
|
|
291
291
|
maxIterations?: number | undefined;
|
|
292
292
|
} | undefined;
|
|
293
293
|
}>;
|
|
294
|
+
/**
|
|
295
|
+
* Session-scoped background jobs (C.28): non-interactive orchestration the main
|
|
296
|
+
* agent dispatches with `run_in_background`, running CONCURRENTLY with the
|
|
297
|
+
* foreground session but bound to it — nothing survives session exit (NOT a
|
|
298
|
+
* daemon). A job hitting a gated action enqueues an approval request into the one
|
|
299
|
+
* foreground queue and pauses until a human services it; a paused job releases
|
|
300
|
+
* its execution slot.
|
|
301
|
+
*
|
|
302
|
+
* Two distinct ceilings, stated explicitly because they bound different things:
|
|
303
|
+
* • {@link maxJobs} — how many background JOBS may exist at once (queued +
|
|
304
|
+
* running + paused). A dispatch past it is refused (`CRUXY_E_JOB_LIMIT`).
|
|
305
|
+
* • the shared execution cap is `subagent.maxConcurrency` (default 3) — how many
|
|
306
|
+
* runs (subagents AND jobs, combined) may EXECUTE at once. It is NOT
|
|
307
|
+
* duplicated here: jobs and subagents draw from the one semaphore. So up to
|
|
308
|
+
* `maxJobs` jobs can be alive while only `maxConcurrency` execute; the rest
|
|
309
|
+
* wait for a slot (or are paused on a human).
|
|
310
|
+
*/
|
|
311
|
+
export declare const JobsConfigSchema: z.ZodObject<{
|
|
312
|
+
/**
|
|
313
|
+
* Master switch. When true, the `run_in_background` tool is registered so the
|
|
314
|
+
* agent can dispatch background jobs. OFF by default — background work is an
|
|
315
|
+
* opt-in capability, and a session that never enables it behaves exactly as
|
|
316
|
+
* before (no tool, no manager, no queue).
|
|
317
|
+
*/
|
|
318
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
319
|
+
/**
|
|
320
|
+
* Ceiling on live background jobs (queued + running + paused). Distinct from
|
|
321
|
+
* the shared execution cap (`subagent.maxConcurrency`): this bounds how many
|
|
322
|
+
* jobs can be OUTSTANDING, not how many run at once. Default 5.
|
|
323
|
+
*/
|
|
324
|
+
maxJobs: z.ZodDefault<z.ZodNumber>;
|
|
325
|
+
/**
|
|
326
|
+
* How many of a job's most-recent log lines are retained in its ring buffer
|
|
327
|
+
* for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
|
|
328
|
+
* limit; older lines roll off oldest-first. Default 1000.
|
|
329
|
+
*/
|
|
330
|
+
logBufferLines: z.ZodDefault<z.ZodNumber>;
|
|
331
|
+
}, "strict", z.ZodTypeAny, {
|
|
332
|
+
maxJobs: number;
|
|
333
|
+
enabled: boolean;
|
|
334
|
+
logBufferLines: number;
|
|
335
|
+
}, {
|
|
336
|
+
maxJobs?: number | undefined;
|
|
337
|
+
enabled?: boolean | undefined;
|
|
338
|
+
logBufferLines?: number | undefined;
|
|
339
|
+
}>;
|
|
340
|
+
export type JobsConfig = z.infer<typeof JobsConfigSchema>;
|
|
294
341
|
/**
|
|
295
342
|
* Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
|
|
296
343
|
* When enabled, `run_command` and `run_tests` execute inside an isolated,
|
|
@@ -1081,6 +1128,35 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1081
1128
|
maxIterations?: number | undefined;
|
|
1082
1129
|
} | undefined;
|
|
1083
1130
|
}>>;
|
|
1131
|
+
jobs: z.ZodDefault<z.ZodObject<{
|
|
1132
|
+
/**
|
|
1133
|
+
* Master switch. When true, the `run_in_background` tool is registered so the
|
|
1134
|
+
* agent can dispatch background jobs. OFF by default — background work is an
|
|
1135
|
+
* opt-in capability, and a session that never enables it behaves exactly as
|
|
1136
|
+
* before (no tool, no manager, no queue).
|
|
1137
|
+
*/
|
|
1138
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
1139
|
+
/**
|
|
1140
|
+
* Ceiling on live background jobs (queued + running + paused). Distinct from
|
|
1141
|
+
* the shared execution cap (`subagent.maxConcurrency`): this bounds how many
|
|
1142
|
+
* jobs can be OUTSTANDING, not how many run at once. Default 5.
|
|
1143
|
+
*/
|
|
1144
|
+
maxJobs: z.ZodDefault<z.ZodNumber>;
|
|
1145
|
+
/**
|
|
1146
|
+
* How many of a job's most-recent log lines are retained in its ring buffer
|
|
1147
|
+
* for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
|
|
1148
|
+
* limit; older lines roll off oldest-first. Default 1000.
|
|
1149
|
+
*/
|
|
1150
|
+
logBufferLines: z.ZodDefault<z.ZodNumber>;
|
|
1151
|
+
}, "strict", z.ZodTypeAny, {
|
|
1152
|
+
maxJobs: number;
|
|
1153
|
+
enabled: boolean;
|
|
1154
|
+
logBufferLines: number;
|
|
1155
|
+
}, {
|
|
1156
|
+
maxJobs?: number | undefined;
|
|
1157
|
+
enabled?: boolean | undefined;
|
|
1158
|
+
logBufferLines?: number | undefined;
|
|
1159
|
+
}>>;
|
|
1084
1160
|
test: z.ZodDefault<z.ZodObject<{
|
|
1085
1161
|
/** Explicit test command (overrides package.json detection). */
|
|
1086
1162
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -1532,6 +1608,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1532
1608
|
idleTimeout: number;
|
|
1533
1609
|
maxResults: number;
|
|
1534
1610
|
};
|
|
1611
|
+
jobs: {
|
|
1612
|
+
maxJobs: number;
|
|
1613
|
+
enabled: boolean;
|
|
1614
|
+
logBufferLines: number;
|
|
1615
|
+
};
|
|
1535
1616
|
test: {
|
|
1536
1617
|
maxIterations: number;
|
|
1537
1618
|
captureBytes: number;
|
|
@@ -1677,6 +1758,11 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1677
1758
|
idleTimeout?: number | undefined;
|
|
1678
1759
|
maxResults?: number | undefined;
|
|
1679
1760
|
} | undefined;
|
|
1761
|
+
jobs?: {
|
|
1762
|
+
maxJobs?: number | undefined;
|
|
1763
|
+
enabled?: boolean | undefined;
|
|
1764
|
+
logBufferLines?: number | undefined;
|
|
1765
|
+
} | undefined;
|
|
1680
1766
|
test?: {
|
|
1681
1767
|
command?: string | undefined;
|
|
1682
1768
|
maxIterations?: number | undefined;
|
package/dist/config/schema.js
CHANGED
|
@@ -204,6 +204,46 @@ export const SubagentConfigSchema = z
|
|
|
204
204
|
.default({}),
|
|
205
205
|
})
|
|
206
206
|
.strict();
|
|
207
|
+
/**
|
|
208
|
+
* Session-scoped background jobs (C.28): non-interactive orchestration the main
|
|
209
|
+
* agent dispatches with `run_in_background`, running CONCURRENTLY with the
|
|
210
|
+
* foreground session but bound to it — nothing survives session exit (NOT a
|
|
211
|
+
* daemon). A job hitting a gated action enqueues an approval request into the one
|
|
212
|
+
* foreground queue and pauses until a human services it; a paused job releases
|
|
213
|
+
* its execution slot.
|
|
214
|
+
*
|
|
215
|
+
* Two distinct ceilings, stated explicitly because they bound different things:
|
|
216
|
+
* • {@link maxJobs} — how many background JOBS may exist at once (queued +
|
|
217
|
+
* running + paused). A dispatch past it is refused (`CRUXY_E_JOB_LIMIT`).
|
|
218
|
+
* • the shared execution cap is `subagent.maxConcurrency` (default 3) — how many
|
|
219
|
+
* runs (subagents AND jobs, combined) may EXECUTE at once. It is NOT
|
|
220
|
+
* duplicated here: jobs and subagents draw from the one semaphore. So up to
|
|
221
|
+
* `maxJobs` jobs can be alive while only `maxConcurrency` execute; the rest
|
|
222
|
+
* wait for a slot (or are paused on a human).
|
|
223
|
+
*/
|
|
224
|
+
export const JobsConfigSchema = z
|
|
225
|
+
.object({
|
|
226
|
+
/**
|
|
227
|
+
* Master switch. When true, the `run_in_background` tool is registered so the
|
|
228
|
+
* agent can dispatch background jobs. OFF by default — background work is an
|
|
229
|
+
* opt-in capability, and a session that never enables it behaves exactly as
|
|
230
|
+
* before (no tool, no manager, no queue).
|
|
231
|
+
*/
|
|
232
|
+
enabled: z.boolean().default(false),
|
|
233
|
+
/**
|
|
234
|
+
* Ceiling on live background jobs (queued + running + paused). Distinct from
|
|
235
|
+
* the shared execution cap (`subagent.maxConcurrency`): this bounds how many
|
|
236
|
+
* jobs can be OUTSTANDING, not how many run at once. Default 5.
|
|
237
|
+
*/
|
|
238
|
+
maxJobs: z.number().int().positive().default(5),
|
|
239
|
+
/**
|
|
240
|
+
* How many of a job's most-recent log lines are retained in its ring buffer
|
|
241
|
+
* for `cruxy logs <id>`. Bounded so a chatty job can't grow memory without
|
|
242
|
+
* limit; older lines roll off oldest-first. Default 1000.
|
|
243
|
+
*/
|
|
244
|
+
logBufferLines: z.number().int().positive().default(1000),
|
|
245
|
+
})
|
|
246
|
+
.strict();
|
|
207
247
|
/**
|
|
208
248
|
* Sandbox / container execution (C.16): defense-in-depth beneath the U.3 gate.
|
|
209
249
|
* When enabled, `run_command` and `run_tests` execute inside an isolated,
|
|
@@ -490,6 +530,7 @@ export const CruxyConfigSchema = z
|
|
|
490
530
|
lsp: LspConfigSchema.default({}),
|
|
491
531
|
checkpoint: CheckpointConfigSchema.default({}),
|
|
492
532
|
subagent: SubagentConfigSchema.default({}),
|
|
533
|
+
jobs: JobsConfigSchema.default({}),
|
|
493
534
|
test: TestConfigSchema.default({}),
|
|
494
535
|
sandbox: SandboxConfigSchema.default({}),
|
|
495
536
|
hooks: HooksConfigSchema.default({}),
|
|
@@ -219,6 +219,24 @@ export declare function subagentScopeOverlap(conflicts: readonly ScopeConflict[]
|
|
|
219
219
|
* reasons over; thrown only when the orchestrator itself cannot proceed.
|
|
220
220
|
*/
|
|
221
221
|
export declare function subagentFailed(underlying?: unknown): CruxyError;
|
|
222
|
+
/**
|
|
223
|
+
* A `run_in_background` dispatch was refused because the session already holds
|
|
224
|
+
* `jobs.maxJobs` live jobs (queued + running + paused). The MODEL corrects it, so
|
|
225
|
+
* this is a coded tool error, not a silent drop: wait for a job to finish (or
|
|
226
|
+
* cancel one) and retry, or run the work in the foreground.
|
|
227
|
+
*/
|
|
228
|
+
export declare function jobLimitExceeded(maxJobs: number, live: number): CruxyError;
|
|
229
|
+
/**
|
|
230
|
+
* `cruxy cancel/logs/rollback <id>` named a job that does not exist in this
|
|
231
|
+
* session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
|
|
232
|
+
* is legitimately gone — fail loud with the id rather than a silent no-op.
|
|
233
|
+
*/
|
|
234
|
+
export declare function jobNotFound(id: string): CruxyError;
|
|
235
|
+
/**
|
|
236
|
+
* A background-jobs command was used while the feature is disabled. Surfaced with
|
|
237
|
+
* how to enable it rather than pretending there are simply no jobs.
|
|
238
|
+
*/
|
|
239
|
+
export declare function jobsDisabled(): CruxyError;
|
|
222
240
|
/**
|
|
223
241
|
* No test command could be detected and none is configured (C.13). cruxy never
|
|
224
242
|
* invents a test command — the fix is always to declare one.
|
|
@@ -792,6 +792,55 @@ export function subagentFailed(underlying) {
|
|
|
792
792
|
underlying,
|
|
793
793
|
});
|
|
794
794
|
}
|
|
795
|
+
// ── background jobs (exit 2 / 19) — C.28 ──────────────────────────────────────
|
|
796
|
+
/**
|
|
797
|
+
* A `run_in_background` dispatch was refused because the session already holds
|
|
798
|
+
* `jobs.maxJobs` live jobs (queued + running + paused). The MODEL corrects it, so
|
|
799
|
+
* this is a coded tool error, not a silent drop: wait for a job to finish (or
|
|
800
|
+
* cancel one) and retry, or run the work in the foreground.
|
|
801
|
+
*/
|
|
802
|
+
export function jobLimitExceeded(maxJobs, live) {
|
|
803
|
+
return new CruxyError({
|
|
804
|
+
code: ErrorCode.JobLimit,
|
|
805
|
+
title: `too many background jobs: ${live} live, limit ${maxJobs}`,
|
|
806
|
+
cause: "the count of queued + running + paused jobs is at `jobs.maxJobs`; a new " +
|
|
807
|
+
"dispatch would exceed the ceiling on OUTSTANDING jobs (distinct from the " +
|
|
808
|
+
"shared execution cap `subagent.maxConcurrency`)",
|
|
809
|
+
nextSteps: [
|
|
810
|
+
"wait for a running job to finish, or `cruxy cancel <id>` one you no longer need",
|
|
811
|
+
"raise `jobs.maxJobs` in config if more concurrent jobs are intended",
|
|
812
|
+
"or run this task in the foreground instead of the background",
|
|
813
|
+
],
|
|
814
|
+
meta: { maxJobs, live },
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* `cruxy cancel/logs/rollback <id>` named a job that does not exist in this
|
|
819
|
+
* session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
|
|
820
|
+
* is legitimately gone — fail loud with the id rather than a silent no-op.
|
|
821
|
+
*/
|
|
822
|
+
export function jobNotFound(id) {
|
|
823
|
+
return new CruxyError({
|
|
824
|
+
code: ErrorCode.JobNotFound,
|
|
825
|
+
title: `no background job with id "${id}" in this session`,
|
|
826
|
+
cause: "background jobs live only for the session that dispatched them; an id from " +
|
|
827
|
+
"a previous session, or a mistyped one, has no live job",
|
|
828
|
+
nextSteps: ["run `cruxy jobs` to list the live jobs and their ids"],
|
|
829
|
+
meta: { id },
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* A background-jobs command was used while the feature is disabled. Surfaced with
|
|
834
|
+
* how to enable it rather than pretending there are simply no jobs.
|
|
835
|
+
*/
|
|
836
|
+
export function jobsDisabled() {
|
|
837
|
+
return new CruxyError({
|
|
838
|
+
code: ErrorCode.JobsDisabled,
|
|
839
|
+
title: "background jobs are disabled",
|
|
840
|
+
cause: "`jobs.enabled` is false, so no jobs can be dispatched or listed",
|
|
841
|
+
nextSteps: ["enable it: `cruxy config set jobs.enabled true`"],
|
|
842
|
+
});
|
|
843
|
+
}
|
|
795
844
|
// ── testing (exit 2) ──────────────────────────────────────────────────────────
|
|
796
845
|
/**
|
|
797
846
|
* No test command could be detected and none is configured (C.13). cruxy never
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -173,6 +173,19 @@ export declare const ErrorCode: {
|
|
|
173
173
|
* is a single-repo artifact, so it is refused (naming both) rather than silently
|
|
174
174
|
* PR one half. */
|
|
175
175
|
readonly VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT";
|
|
176
|
+
/** A `run_in_background` dispatch was refused because the live job count
|
|
177
|
+
* (queued + running + paused) already sits at `jobs.maxJobs`. The MODEL corrects
|
|
178
|
+
* it — wait for a job to finish (or cancel one) and retry — so it is a usage-tier
|
|
179
|
+
* coded error, never a silently-dropped dispatch. */
|
|
180
|
+
readonly JobLimit: "CRUXY_E_JOB_LIMIT";
|
|
181
|
+
/** `cruxy cancel/logs/rollback <id>` named a job id that does not exist in this
|
|
182
|
+
* session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
|
|
183
|
+
* is legitimately unknown — fail loud with the id rather than a silent no-op. */
|
|
184
|
+
readonly JobNotFound: "CRUXY_E_JOB_NOT_FOUND";
|
|
185
|
+
/** A background-jobs command (`cruxy jobs/logs/cancel`) was used while
|
|
186
|
+
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
187
|
+
* rather than pretending there are simply no jobs. */
|
|
188
|
+
readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
|
|
176
189
|
};
|
|
177
190
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
178
191
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -193,6 +193,20 @@ export const ErrorCode = {
|
|
|
193
193
|
* is a single-repo artifact, so it is refused (naming both) rather than silently
|
|
194
194
|
* PR one half. */
|
|
195
195
|
VcsCrossRoot: "CRUXY_E_VCS_CROSS_ROOT",
|
|
196
|
+
// session-scoped background jobs (exit 2 / 19) — C.28
|
|
197
|
+
/** A `run_in_background` dispatch was refused because the live job count
|
|
198
|
+
* (queued + running + paused) already sits at `jobs.maxJobs`. The MODEL corrects
|
|
199
|
+
* it — wait for a job to finish (or cancel one) and retry — so it is a usage-tier
|
|
200
|
+
* coded error, never a silently-dropped dispatch. */
|
|
201
|
+
JobLimit: "CRUXY_E_JOB_LIMIT",
|
|
202
|
+
/** `cruxy cancel/logs/rollback <id>` named a job id that does not exist in this
|
|
203
|
+
* session. Jobs are session-scoped (NOT a daemon), so an id from a prior session
|
|
204
|
+
* is legitimately unknown — fail loud with the id rather than a silent no-op. */
|
|
205
|
+
JobNotFound: "CRUXY_E_JOB_NOT_FOUND",
|
|
206
|
+
/** A background-jobs command (`cruxy jobs/logs/cancel`) was used while
|
|
207
|
+
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
208
|
+
* rather than pretending there are simply no jobs. */
|
|
209
|
+
JobsDisabled: "CRUXY_E_JOBS_DISABLED",
|
|
196
210
|
};
|
|
197
211
|
/**
|
|
198
212
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -304,6 +318,13 @@ const EXIT_CODES = {
|
|
|
304
318
|
// refusals kin to the other cross-root guards — they share the greppable code.
|
|
305
319
|
[ErrorCode.VcsRemoteChanged]: 18,
|
|
306
320
|
[ErrorCode.VcsCrossRoot]: 18,
|
|
321
|
+
// Background jobs (C.28). The dispatch-limit refusal is model-correctable, so
|
|
322
|
+
// it shares the usage exit code (2) with the other model-facing coded errors
|
|
323
|
+
// (subagent depth/scope). The CLI-facing ones — an unknown job id, the feature
|
|
324
|
+
// disabled — get a distinct greppable category (19).
|
|
325
|
+
[ErrorCode.JobLimit]: 2,
|
|
326
|
+
[ErrorCode.JobNotFound]: 19,
|
|
327
|
+
[ErrorCode.JobsDisabled]: 19,
|
|
307
328
|
};
|
|
308
329
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
309
330
|
export function exitCodeFor(code) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ApprovalDecision, RiskTier } from "../approval/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* The single foreground pending-approval queue (C.28) — the seam that lets a
|
|
4
|
+
* NON-interactive background job get a gated action approved by the ONE
|
|
5
|
+
* interactive foreground human, without becoming a second interactive context.
|
|
6
|
+
*
|
|
7
|
+
* The §0 model: a background job is not a competing consumer of the terminal —
|
|
8
|
+
* it is a PRODUCER on this queue. When a job hits a gated action it `submit`s the
|
|
9
|
+
* request here and blocks on the returned promise; it holds no terminal, no
|
|
10
|
+
* mutex, and (having released its execution slot) no compute while it waits. The
|
|
11
|
+
* foreground drains the queue when it is idle (between turns): {@link serviceAll}
|
|
12
|
+
* runs each request's `finalize` — the REAL U.3 prompt + checkpoint, serialized
|
|
13
|
+
* through the shared approval mutex INSIDE `finalize` — and settles the producer
|
|
14
|
+
* with the human's decision. Because the prompt happens on the foreground under
|
|
15
|
+
* the shared mutex, a background job can never paint a second prompt over a
|
|
16
|
+
* foreground one, and the action never executes until a human has decided.
|
|
17
|
+
*
|
|
18
|
+
* This queue is pure transport: it holds pending entries and settles them. It
|
|
19
|
+
* knows nothing about checkpoints, the mutex, or how a decision is reached — the
|
|
20
|
+
* manager composes that into each entry's `finalize`.
|
|
21
|
+
*/
|
|
22
|
+
/** A read-only view of one pending request (for `/jobs` display + tests). */
|
|
23
|
+
export interface PendingApproval {
|
|
24
|
+
/** Queue-unique id. */
|
|
25
|
+
readonly id: string;
|
|
26
|
+
/** The job that is blocked on this decision. */
|
|
27
|
+
readonly jobId: string;
|
|
28
|
+
/** One plain line describing the action (e.g. "run: rm -rf build"). */
|
|
29
|
+
readonly summary: string;
|
|
30
|
+
/** Risk tier (mutate | destructive — reads never reach the queue). */
|
|
31
|
+
readonly tier: RiskTier;
|
|
32
|
+
}
|
|
33
|
+
/** What a producing job submits: how to describe it, and how to actually decide. */
|
|
34
|
+
export interface ApprovalSubmission {
|
|
35
|
+
jobId: string;
|
|
36
|
+
summary: string;
|
|
37
|
+
tier: RiskTier;
|
|
38
|
+
/**
|
|
39
|
+
* Run the REAL decision: the interactive U.3 prompt + the job's checkpoint
|
|
40
|
+
* snapshot, serialized on the shared approval mutex. Composed by the manager so
|
|
41
|
+
* this queue stays decoupled. Called by {@link serviceAll} on the foreground;
|
|
42
|
+
* its resolved decision settles the producer's `submit` promise. A throw
|
|
43
|
+
* (e.g. a checkpoint failure) rejects the producer's promise so the job fails
|
|
44
|
+
* loud rather than hanging.
|
|
45
|
+
*/
|
|
46
|
+
finalize: () => Promise<ApprovalDecision>;
|
|
47
|
+
}
|
|
48
|
+
export declare class ApprovalQueue {
|
|
49
|
+
private readonly queue;
|
|
50
|
+
private seq;
|
|
51
|
+
/**
|
|
52
|
+
* A job submits a gated action and awaits the decision. Resolves when the
|
|
53
|
+
* foreground services it ({@link serviceAll}) or a caller {@link withdraw}s it;
|
|
54
|
+
* rejects if the finalize throws. The action does NOT execute here — the caller
|
|
55
|
+
* only proceeds after this resolves `{allow:true}`.
|
|
56
|
+
*/
|
|
57
|
+
submit(sub: ApprovalSubmission): Promise<ApprovalDecision>;
|
|
58
|
+
/** Whether any request is waiting to be serviced. */
|
|
59
|
+
hasPending(): boolean;
|
|
60
|
+
/** Count of requests waiting. */
|
|
61
|
+
get size(): number;
|
|
62
|
+
/** Read-only snapshot of every pending request, FIFO order. */
|
|
63
|
+
pending(): PendingApproval[];
|
|
64
|
+
/** The pending request for one job (a job has at most one at a time), if any. */
|
|
65
|
+
pendingFor(jobId: string): PendingApproval | undefined;
|
|
66
|
+
/**
|
|
67
|
+
* Foreground drain: service every currently-pending request, FIFO, ONE AT A
|
|
68
|
+
* TIME — each `finalize` fully settles (including the human's keypress) before
|
|
69
|
+
* the next begins, so two background prompts never overlap and the shared mutex
|
|
70
|
+
* inside `finalize` also serializes them against any foreground action. Returns
|
|
71
|
+
* the number serviced. Requests that arrive AFTER draining starts wait for the
|
|
72
|
+
* next drain (bounded work per idle window). A finalize throw rejects that one
|
|
73
|
+
* producer and drains on — one job's checkpoint failure never wedges the queue.
|
|
74
|
+
*/
|
|
75
|
+
serviceAll(): Promise<number>;
|
|
76
|
+
/**
|
|
77
|
+
* Withdraw a job's pending request WITHOUT prompting — used when the job is
|
|
78
|
+
* cancelled (or the session exits) while it is paused: settle the producer with
|
|
79
|
+
* `decision` (a deny) so its `submit` promise resolves and the job can finish
|
|
80
|
+
* tearing down instead of blocking forever. Returns true if one was withdrawn.
|
|
81
|
+
*/
|
|
82
|
+
withdraw(jobId: string, decision: ApprovalDecision): boolean;
|
|
83
|
+
/** Settle an entry exactly once (double-settle is a defensive no-op). */
|
|
84
|
+
private settle;
|
|
85
|
+
}
|