@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.
Files changed (40) hide show
  1. package/dist/agent/session.d.ts +13 -0
  2. package/dist/agent/session.js +6 -0
  3. package/dist/checkpoint/gate-hook.d.ts +28 -0
  4. package/dist/checkpoint/gate-hook.js +98 -0
  5. package/dist/checkpoint/gate.d.ts +7 -1
  6. package/dist/checkpoint/gate.js +8 -2
  7. package/dist/checkpoint/index.d.ts +1 -0
  8. package/dist/checkpoint/index.js +1 -0
  9. package/dist/cli/commands/rollback.d.ts +4 -1
  10. package/dist/cli/commands/rollback.js +16 -9
  11. package/dist/cli/commands/run.js +12 -0
  12. package/dist/cli/repl.d.ts +1 -1
  13. package/dist/cli/repl.js +106 -0
  14. package/dist/cli/session-factory.d.ts +4 -12
  15. package/dist/cli/session-factory.js +50 -96
  16. package/dist/config/schema.d.ts +86 -0
  17. package/dist/config/schema.js +41 -0
  18. package/dist/errors/constructors.d.ts +18 -0
  19. package/dist/errors/constructors.js +49 -0
  20. package/dist/errors/types.d.ts +13 -0
  21. package/dist/errors/types.js +21 -0
  22. package/dist/jobs/approval-queue.d.ts +85 -0
  23. package/dist/jobs/approval-queue.js +96 -0
  24. package/dist/jobs/dispatch-tool.d.ts +34 -0
  25. package/dist/jobs/dispatch-tool.js +96 -0
  26. package/dist/jobs/index.d.ts +6 -0
  27. package/dist/jobs/index.js +6 -0
  28. package/dist/jobs/log-buffer.d.ts +31 -0
  29. package/dist/jobs/log-buffer.js +30 -0
  30. package/dist/jobs/log-renderer.d.ts +32 -0
  31. package/dist/jobs/log-renderer.js +70 -0
  32. package/dist/jobs/manager.d.ts +139 -0
  33. package/dist/jobs/manager.js +397 -0
  34. package/dist/jobs/types.d.ts +81 -0
  35. package/dist/jobs/types.js +10 -0
  36. package/dist/subagent/orchestrator.d.ts +9 -0
  37. package/dist/subagent/orchestrator.js +6 -1
  38. package/dist/subagent/semaphore.d.ts +40 -11
  39. package/dist/subagent/semaphore.js +23 -26
  40. package/package.json +1 -1
@@ -0,0 +1,397 @@
1
+ import { runAgent } from "../agent/loop.js";
2
+ import { ApprovalService, classify, serializeGate, } from "../approval/index.js";
3
+ import { CheckpointGate, withCheckpointGate } from "../checkpoint/index.js";
4
+ import { CruxyError, ErrorCode, approvalRequired, jobLimitExceeded, jobNotFound, jobsDisabled, messageOf, } from "../errors/index.js";
5
+ import { Budget, resolveBudget, scopeRegistry, } from "../subagent/index.js";
6
+ import { Workspace } from "../workspace/index.js";
7
+ import { LogBuffer } from "./log-buffer.js";
8
+ import { JobLogRenderer } from "./log-renderer.js";
9
+ import { isTerminal } from "./types.js";
10
+ /** Longest task excerpt kept as a job label (display, not record). */
11
+ const LABEL_MAX = 60;
12
+ /**
13
+ * Session-scoped background jobs (C.28). The main agent dispatches a job with
14
+ * `run_in_background`; it runs the SAME agent loop as a subagent but CONCURRENTLY
15
+ * with the foreground session, off screen (its output goes to a log buffer). A
16
+ * gated action pauses the job, releases its execution slot, and enqueues an
17
+ * approval request the foreground human services between turns; on approval the
18
+ * job re-acquires a slot (with priority) and runs on — its pre- AND post-pause
19
+ * mutations coalescing under ONE checkpoint. Cancel and session exit kill-tree a
20
+ * job's process tree (via its cancellation signal), leaving no orphan.
21
+ *
22
+ * The manager is session-scoped and dies with the session (NOT a daemon): nothing
23
+ * here persists a live registry across processes. What survives on disk is a
24
+ * job's checkpoint set, so `cruxy rollback <id>` still works afterwards.
25
+ */
26
+ export class JobManager {
27
+ deps;
28
+ jobs = new Map();
29
+ /** In-flight executions, awaited on {@link cancelAll} so teardown completes. */
30
+ running = new Set();
31
+ idFactory;
32
+ now;
33
+ runAgentFn;
34
+ seq = 0;
35
+ constructor(deps) {
36
+ this.deps = deps;
37
+ this.now = deps.now ?? Date.now;
38
+ this.runAgentFn = deps.runAgentFn ?? runAgent;
39
+ this.idFactory =
40
+ deps.idFactory ??
41
+ (() => `job-${++this.seq}-${Math.random().toString(36).slice(2, 6)}`);
42
+ }
43
+ /**
44
+ * Dispatch a background job (the `run_in_background` seam). Fails loud when the
45
+ * feature is disabled, when the live-job ceiling is reached, or when a named
46
+ * root is unknown — all BEFORE the job is registered, so a refused dispatch
47
+ * leaves no ghost. Returns a view immediately; the job runs asynchronously.
48
+ */
49
+ dispatch(spec) {
50
+ const { config } = this.deps;
51
+ if (!config.jobs.enabled)
52
+ throw jobsDisabled();
53
+ const live = this.liveCount();
54
+ if (live >= config.jobs.maxJobs) {
55
+ throw jobLimitExceeded(config.jobs.maxJobs, live);
56
+ }
57
+ // Resolve (and validate) the job's scope up front — an unknown root name
58
+ // throws CRUXY_E_ROOT_UNKNOWN here, which the dispatch tool surfaces to the
59
+ // model, rather than failing silently inside the background run.
60
+ const scope = this.jobScope(spec.root);
61
+ const id = this.idFactory();
62
+ const checkpoints = config.checkpoint.enabled
63
+ ? new CheckpointGate({
64
+ config,
65
+ primaryRoot: scope.workspace.primary().absPath,
66
+ })
67
+ : undefined;
68
+ const job = {
69
+ id,
70
+ spec,
71
+ label: taskLabel(spec.task),
72
+ status: "queued",
73
+ iterations: 0,
74
+ usage: { input_tokens: 0, output_tokens: 0 },
75
+ summary: "",
76
+ logs: new LogBuffer(config.jobs.logBufferLines),
77
+ controller: new AbortController(),
78
+ checkpoints,
79
+ scope,
80
+ holdsSlot: false,
81
+ };
82
+ this.jobs.set(id, job);
83
+ this.log(job, "out", `dispatched: ${job.label}`);
84
+ // Fire-and-forget, but TRACKED: cancelAll awaits these so no execution is
85
+ // left detached at session exit. `execute` never rejects (it maps every
86
+ // failure onto the job's status), so the catch is purely defensive.
87
+ const p = this.execute(job)
88
+ .catch((err) => {
89
+ job.status = "failed";
90
+ job.error = `${ErrorCode.Internal}: ${messageOf(err) ?? "job crashed"}`;
91
+ })
92
+ .finally(() => this.running.delete(p));
93
+ this.running.add(p);
94
+ return this.view(job);
95
+ }
96
+ /** Live view of every job this session, dispatch order. */
97
+ list() {
98
+ return [...this.jobs.values()].map((j) => this.view(j));
99
+ }
100
+ /** One job's view, or undefined if the id is unknown. */
101
+ get(id) {
102
+ const job = this.jobs.get(id);
103
+ return job ? this.view(job) : undefined;
104
+ }
105
+ /** One job's full log (for `/logs <id>`). Fails loud on an unknown id. */
106
+ logs(id) {
107
+ const job = this.requireJob(id);
108
+ return {
109
+ id: job.id,
110
+ status: job.status,
111
+ lines: job.logs.snapshot(),
112
+ dropped: job.logs.dropped,
113
+ };
114
+ }
115
+ /**
116
+ * Cancel one job: abort its run (kill-tree'ing any in-flight process via the
117
+ * signal reaching `ctx.signal`) and, if it is paused, withdraw its queued
118
+ * approval so it stops waiting. A checkpoint it already took SURVIVES for
119
+ * review/rollback. Fails loud on an unknown id; a no-op on an already-terminal
120
+ * job (returns false).
121
+ */
122
+ cancel(id) {
123
+ const job = this.requireJob(id);
124
+ if (isTerminal(job.status))
125
+ return false;
126
+ this.abort(job);
127
+ return true;
128
+ }
129
+ /**
130
+ * Cancel EVERY live job (session exit / teardown) and AWAIT their teardown, so
131
+ * nothing is left running or orphaned. Returns how many were cancelled — the
132
+ * caller prints the honest "N job(s) cancelled". Idempotent.
133
+ */
134
+ async cancelAll(reason) {
135
+ let cancelled = 0;
136
+ for (const job of this.jobs.values()) {
137
+ if (!isTerminal(job.status))
138
+ this.log(job, "out", `cancelling: ${reason}`);
139
+ }
140
+ for (const job of this.jobs.values()) {
141
+ if (!isTerminal(job.status)) {
142
+ this.abort(job);
143
+ cancelled++;
144
+ }
145
+ }
146
+ // Await every in-flight execution so a caller (session exit) does not race
147
+ // ahead of the kill-tree teardown.
148
+ await Promise.allSettled([...this.running]);
149
+ return cancelled;
150
+ }
151
+ /** Jobs that are not yet terminal (queued + running + paused). */
152
+ liveCount() {
153
+ let n = 0;
154
+ for (const job of this.jobs.values())
155
+ if (!isTerminal(job.status))
156
+ n++;
157
+ return n;
158
+ }
159
+ /** Whether any job is paused awaiting a foreground approval (drive the drain). */
160
+ hasPendingApprovals() {
161
+ return this.deps.approvalQueue.hasPending();
162
+ }
163
+ /** Service every pending job approval on the foreground (called between turns). */
164
+ serviceApprovals() {
165
+ return this.deps.approvalQueue.serviceAll();
166
+ }
167
+ // ── internals ────────────────────────────────────────────────────────────
168
+ /** Abort a job's run and unblock it if paused (shared by cancel/cancelAll). */
169
+ abort(job) {
170
+ job.controller.abort();
171
+ // If paused on the queue, withdraw with a deny so its `submit` resolves and
172
+ // the run tears down at its next turn boundary instead of hanging.
173
+ this.deps.approvalQueue.withdraw(job.id, { allow: false });
174
+ this.log(job, "out", "cancel requested");
175
+ }
176
+ /** Drive one job to completion, mapping every outcome onto its status. */
177
+ async execute(job) {
178
+ const signal = job.controller.signal;
179
+ try {
180
+ // Wait for an execution permit — a queued job holds none until a slot frees
181
+ // (the shared cap, contended with subagents). This is the queued→running
182
+ // gate.
183
+ await this.deps.semaphore.acquire();
184
+ job.holdsSlot = true;
185
+ if (signal.aborted) {
186
+ job.status = "cancelled";
187
+ return;
188
+ }
189
+ job.status = "running";
190
+ // ONE undo unit for the WHOLE job, keyed by the job id — begun here and
191
+ // NEVER again (a pause is not a checkpoint boundary), so pre- and post-pause
192
+ // mutations coalesce and `cruxy rollback <id>` reverts the whole job.
193
+ job.checkpoints?.beginRun(job.spec.task, job.id);
194
+ const result = await this.runAgentFn(this.runArgs(job, signal));
195
+ job.iterations = result.iterations;
196
+ job.usage = result.usage;
197
+ job.summary = lastAssistantText(result.messages);
198
+ job.status = this.statusFor(result, job);
199
+ if (job.status === "failed")
200
+ job.error = this.errorFor(result);
201
+ }
202
+ catch (err) {
203
+ if (signal.aborted) {
204
+ job.status = "cancelled";
205
+ }
206
+ else if (CruxyError.is(err) &&
207
+ err.code === ErrorCode.ApprovalRequired) {
208
+ // A gated action with no interactive foreground to service it — the
209
+ // pinned fail-loud path. The job FAILS with the coded reason; it never
210
+ // auto-approves.
211
+ job.status = "failed";
212
+ job.error = `${err.code}: ${err.title}`;
213
+ }
214
+ else {
215
+ job.status = "failed";
216
+ job.error = `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`;
217
+ }
218
+ }
219
+ finally {
220
+ if (job.holdsSlot) {
221
+ this.deps.semaphore.release();
222
+ job.holdsSlot = false;
223
+ }
224
+ this.log(job, "out", `job ${job.status}`);
225
+ }
226
+ }
227
+ /** Build the {@link runAgent} args for a job: scoped registry, budget, ctx. */
228
+ runArgs(job, signal) {
229
+ const { deps } = this;
230
+ const registry = scopeRegistry(deps.parentRegistry, job.spec.tools);
231
+ const budget = new Budget(resolveBudget(deps.config.subagent.defaultBudget, {
232
+ ...(job.spec.budget?.maxIterations !== undefined
233
+ ? { maxIterations: job.spec.budget.maxIterations }
234
+ : {}),
235
+ ...(job.spec.budget?.maxTokens !== undefined
236
+ ? { maxTokens: job.spec.budget.maxTokens }
237
+ : {}),
238
+ }));
239
+ const ctx = {
240
+ cwd: job.scope.cwd,
241
+ workspace: job.scope.workspace,
242
+ config: deps.config,
243
+ logger: deps.logger,
244
+ requestApproval: this.makeJobApproval(job),
245
+ checkpointsActive: Boolean(job.checkpoints),
246
+ sandbox: deps.sandbox,
247
+ signal,
248
+ };
249
+ const messages = [{ role: "user", content: job.spec.task }];
250
+ return {
251
+ messages,
252
+ provider: deps.provider,
253
+ registry,
254
+ config: deps.config,
255
+ ctx,
256
+ renderer: new JobLogRenderer((stream, text) => this.log(job, stream, text)),
257
+ git: deps.git,
258
+ projectInstructions: deps.projectInstructions,
259
+ subagent: true,
260
+ budget,
261
+ router: deps.router,
262
+ taskClass: "subagent",
263
+ signal,
264
+ };
265
+ }
266
+ /**
267
+ * The job's approval gate — the producer side of the pending-approval queue.
268
+ * A read-tier action passes freely. A mutate/destructive action with NO
269
+ * interactive foreground fails loud (never auto-approve, never hang). Otherwise
270
+ * the job PAUSES: it releases its execution slot (JC-D), enqueues the request
271
+ * for the foreground human, and blocks. On resolution it re-acquires a slot
272
+ * with PRIORITY (so it is not starved by work dispatched while it waited) and
273
+ * returns the decision. The real prompt + this job's checkpoint snapshot happen
274
+ * inside `finalize` on the foreground, serialized on the shared mutex.
275
+ */
276
+ makeJobApproval(job) {
277
+ const { approvalQueue, semaphore, approvalMutex, foregroundInteractive } = this.deps;
278
+ const cwd = job.scope.cwd;
279
+ const interactive = new ApprovalService({
280
+ cwd,
281
+ interactive: foregroundInteractive,
282
+ io: this.deps.promptIO,
283
+ });
284
+ // The foreground-serviced decision: the interactive U.3 prompt, wrapped in
285
+ // THIS job's checkpoint hook, serialized on the SHARED approval mutex — so a
286
+ // job's prompt/checkpoint serialize against a foreground action's on the same
287
+ // lock, and the snapshot lands under the job's own gate.
288
+ const foregroundGate = serializeGate(withCheckpointGate((a) => interactive.requestApproval(a), job.checkpoints, job.scope.workspace), approvalMutex, cwd);
289
+ return async (action) => {
290
+ const req = classify(action, cwd);
291
+ if (req.tier === "read")
292
+ return { allow: true };
293
+ if (!foregroundInteractive)
294
+ throw approvalRequired(req.summary);
295
+ // Pause: release the slot and enqueue for the foreground human.
296
+ if (job.holdsSlot) {
297
+ semaphore.release();
298
+ job.holdsSlot = false;
299
+ }
300
+ job.status = "paused-needs-approval";
301
+ this.log(job, "out", `paused — needs approval: ${req.summary}`);
302
+ try {
303
+ return await approvalQueue.submit({
304
+ jobId: job.id,
305
+ summary: req.summary,
306
+ tier: req.tier,
307
+ finalize: () => foregroundGate(action),
308
+ });
309
+ }
310
+ finally {
311
+ // Resume: re-take a slot with PRIORITY unless the job was cancelled while
312
+ // paused (in which case the run aborts at its next turn boundary and the
313
+ // outer finally, seeing holdsSlot=false, does not double-release).
314
+ if (!job.controller.signal.aborted) {
315
+ await semaphore.acquire({ priority: true });
316
+ job.holdsSlot = true;
317
+ job.status = "running";
318
+ this.log(job, "out", "resumed after approval");
319
+ }
320
+ }
321
+ };
322
+ }
323
+ /** Resolve a job's scope from an optional root name (fail-loud on unknown). */
324
+ jobScope(rootName) {
325
+ if (rootName === undefined) {
326
+ return { cwd: this.deps.cwd, workspace: this.deps.workspace };
327
+ }
328
+ const root = this.deps.workspace.rootByName(rootName); // throws ROOT_UNKNOWN
329
+ return {
330
+ cwd: root.absPath,
331
+ workspace: new Workspace([
332
+ { name: root.name, absPath: root.absPath, primary: true },
333
+ ]),
334
+ };
335
+ }
336
+ statusFor(result, job) {
337
+ if (result.stop === "completed")
338
+ return "done";
339
+ if (result.stop === "aborted" || job.controller.signal.aborted) {
340
+ return "cancelled";
341
+ }
342
+ return "failed"; // budget / max_iterations — partial, honestly not "done"
343
+ }
344
+ errorFor(result) {
345
+ const reason = result.stop === "budget"
346
+ ? (result.stopReason ?? "budget cap reached")
347
+ : `agent.maxIterations ceiling reached (${this.deps.config.agent.maxIterations})`;
348
+ return `${ErrorCode.SubagentBudget}: ${reason}`;
349
+ }
350
+ requireJob(id) {
351
+ const job = this.jobs.get(id);
352
+ if (!job)
353
+ throw jobNotFound(id);
354
+ return job;
355
+ }
356
+ log(job, stream, text) {
357
+ job.logs.append({ atMs: this.now(), stream, text });
358
+ }
359
+ view(job) {
360
+ const pending = this.deps.approvalQueue.pendingFor(job.id);
361
+ return {
362
+ id: job.id,
363
+ status: job.status,
364
+ label: job.label,
365
+ ...(job.error ? { error: job.error } : {}),
366
+ iterations: job.iterations,
367
+ usage: job.usage,
368
+ ...(pending ? { pendingApproval: pending.summary } : {}),
369
+ };
370
+ }
371
+ }
372
+ /** One-line task excerpt for a job label. */
373
+ function taskLabel(task) {
374
+ const flat = task.replace(/\s+/g, " ").trim();
375
+ return flat.length > LABEL_MAX ? flat.slice(0, LABEL_MAX - 1) + "…" : flat;
376
+ }
377
+ /** The final assistant text of a run — the job's summary. */
378
+ function lastAssistantText(messages) {
379
+ for (let i = messages.length - 1; i >= 0; i--) {
380
+ const msg = messages[i];
381
+ if (msg.role !== "assistant")
382
+ continue;
383
+ if (typeof msg.content === "string") {
384
+ if (msg.content.trim())
385
+ return msg.content.trim();
386
+ continue;
387
+ }
388
+ const text = msg.content
389
+ .filter((block) => block.type === "text")
390
+ .map((block) => block.text)
391
+ .join("\n")
392
+ .trim();
393
+ if (text)
394
+ return text;
395
+ }
396
+ return "";
397
+ }
@@ -0,0 +1,81 @@
1
+ import type { Usage } from "@cruxy/sdk";
2
+ import type { LogLine } from "./log-buffer.js";
3
+ /**
4
+ * Types for session-scoped background jobs (C.28): non-interactive orchestration
5
+ * the main agent dispatches with `run_in_background`. A job runs the SAME agent
6
+ * loop as a subagent, but CONCURRENTLY with (and outliving the turn of) the
7
+ * foreground session — bound to the session's lifetime, nothing survives exit
8
+ * (NOT a daemon). Its gated actions enqueue approval requests into the one
9
+ * foreground queue; a paused job releases its execution slot.
10
+ */
11
+ /**
12
+ * A job's lifecycle state. Every state is HONEST — a job is `done` only when its
13
+ * run actually completed, never a fabricated success. Terminal states are `done`,
14
+ * `failed`, `cancelled`; the rest are live.
15
+ * - `queued` — dispatched, waiting for an execution slot (the shared cap is full).
16
+ * - `running` — holding a slot, executing its agent loop.
17
+ * - `paused-needs-approval` — hit a gated action; its approval is enqueued for a
18
+ * foreground human and it has RELEASED its slot until the human decides (JC-D).
19
+ * - `done` — the run completed.
20
+ * - `failed` — the run errored, exceeded budget, or hit a no-foreground approval
21
+ * wall (`CRUXY_E_APPROVAL_REQUIRED`); `error` carries the coded reason.
22
+ * - `cancelled` — `cruxy cancel <id>` or session exit stopped it; its process tree
23
+ * was kill-tree'd and any checkpoint it took survives for review/rollback.
24
+ */
25
+ export type JobStatus = "queued" | "running" | "paused-needs-approval" | "done" | "failed" | "cancelled";
26
+ /** The terminal states — a job in one of these will never run again. */
27
+ export declare const TERMINAL_JOB_STATUSES: readonly JobStatus[];
28
+ /** Whether a status is terminal (no further execution). */
29
+ export declare function isTerminal(status: JobStatus): boolean;
30
+ /**
31
+ * What a background job should do — mirrors the self-contained subtask shape of a
32
+ * subagent spawn (C.14/C.33). A job starts with NO context beyond `task`.
33
+ */
34
+ export interface JobSpec {
35
+ /** The complete, self-contained task the job should perform. */
36
+ task: string;
37
+ /**
38
+ * Tool names to grant, resolved against the parent registry — a job can only
39
+ * scope DOWN. Omitted → the default read-only set. A job granted write/shell
40
+ * tools takes real, gated actions in the background.
41
+ */
42
+ tools?: readonly string[];
43
+ /**
44
+ * The workspace root (by exact name, C.26) this job is confined to. Omitted →
45
+ * the full session workspace (read-only, or a single-root session).
46
+ */
47
+ root?: string;
48
+ /** Budget overrides, clamped to `subagent.defaultBudget` (never raised). */
49
+ budget?: {
50
+ maxIterations?: number;
51
+ maxTokens?: number;
52
+ };
53
+ }
54
+ /**
55
+ * A read-only view of one job for `cruxy jobs` / the dispatch tool's result /
56
+ * tests. Never exposes the live control handles (abort controller, checkpoint
57
+ * gate) — those stay inside the manager.
58
+ */
59
+ export interface JobView {
60
+ /** Stable, session-unique id (also the checkpoint run id for `cruxy rollback`). */
61
+ id: string;
62
+ status: JobStatus;
63
+ /** One-line task excerpt (for listings). */
64
+ label: string;
65
+ /** Present on `failed`: the coded, actionable reason. */
66
+ error?: string;
67
+ /** Model turns consumed so far. */
68
+ iterations: number;
69
+ /** Token usage accumulated so far. */
70
+ usage: Usage;
71
+ /** A one-line summary of what's pending, when `paused-needs-approval`. */
72
+ pendingApproval?: string;
73
+ }
74
+ /** The full log of one job, for `cruxy logs <id>`. */
75
+ export interface JobLog {
76
+ id: string;
77
+ status: JobStatus;
78
+ lines: LogLine[];
79
+ /** How many earlier lines rolled off the ring buffer (0 when none). */
80
+ dropped: number;
81
+ }
@@ -0,0 +1,10 @@
1
+ /** The terminal states — a job in one of these will never run again. */
2
+ export const TERMINAL_JOB_STATUSES = [
3
+ "done",
4
+ "failed",
5
+ "cancelled",
6
+ ];
7
+ /** Whether a status is terminal (no further execution). */
8
+ export function isTerminal(status) {
9
+ return TERMINAL_JOB_STATUSES.includes(status);
10
+ }
@@ -6,6 +6,7 @@ import type { Router } from "../routing/index.js";
6
6
  import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
7
7
  import type { SandboxService } from "../sandbox/index.js";
8
8
  import { Workspace } from "../workspace/index.js";
9
+ import { Semaphore } from "./semaphore.js";
9
10
  import type { SubagentResult, SubagentSpec } from "./types.js";
10
11
  /**
11
12
  * Everything a spawn needs from the surrounding session, injected by the
@@ -60,6 +61,14 @@ export interface SubagentOrchestratorDeps {
60
61
  * same shared checkpoint gate, those writes join the run's one set (⚖︎JC-δ).
61
62
  */
62
63
  checkpointsActive?: boolean;
64
+ /**
65
+ * The ONE shared execution semaphore (C.28). When set, parallel fan-out draws
66
+ * permits from THIS instance — the same one background jobs use — so the
67
+ * `subagent.maxConcurrency` cap bounds subagents AND jobs COMBINED, never one
68
+ * cap each. Omitted → the orchestrator constructs its own (C.33 behaviour,
69
+ * used by tests that exercise the orchestrator in isolation).
70
+ */
71
+ executionSemaphore?: Semaphore;
63
72
  }
64
73
  /**
65
74
  * Spawns subagents (C.14): the existing agent loop re-driven over isolated
@@ -32,7 +32,12 @@ export class SubagentOrchestrator {
32
32
  sem;
33
33
  constructor(deps) {
34
34
  this.deps = deps;
35
- this.sem = new Semaphore(deps.config.subagent.maxConcurrency);
35
+ // Share the session's ONE execution semaphore when injected (C.28) so
36
+ // subagents and background jobs contend for the same permits; fall back to a
37
+ // private one at the same cap when running the orchestrator in isolation.
38
+ this.sem =
39
+ deps.executionSemaphore ??
40
+ new Semaphore(deps.config.subagent.maxConcurrency);
36
41
  }
37
42
  /** Live/queued fan-out slots (inspection/tests): proves the global cap holds. */
38
43
  get concurrency() {
@@ -1,27 +1,56 @@
1
1
  /**
2
- * A counting semaphore (C.33): bounds how many subagent runs execute at once.
2
+ * A counting semaphore (C.33): bounds how many agent runs execute at once.
3
3
  * FIFO — waiters are served in arrival order, so a fan-out's results stay
4
4
  * dispatch-order-fair — and the permit is handed directly from a releaser to
5
5
  * the next waiter, so the live count never transiently exceeds the cap.
6
6
  *
7
- * Used as the ONE shared bound on parallel fan-out. Parallel dispatch happens at
8
- * depth 0 only (the `spawn_subagents` tool is never granted to a child), and a
9
- * permit is held for a child's whole lifetime including any *sequential*
10
- * nested spawn beneath it, which is deliberately un-permitted. Because no permit
11
- * holder ever blocks trying to acquire a second permit, the semaphore cannot be
12
- * part of a wait cycle: it is deadlock-free by construction (see the C.33 design
13
- * doc's deadlock argument).
7
+ * Used as the ONE shared bound on all concurrent execution: parallel subagent
8
+ * fan-out (C.33) AND background jobs (C.28) draw permits from the SAME instance,
9
+ * so `subagent.maxConcurrency` caps their COMBINED live countnever one cap
10
+ * per subsystem. Parallel dispatch happens at depth 0 only (the `spawn_subagents`
11
+ * tool is never granted to a child), and a permit is held for a child's whole
12
+ * lifetime including any *sequential* nested spawn beneath it, which is
13
+ * deliberately un-permitted. Because no permit holder ever blocks trying to
14
+ * acquire a second permit, the semaphore cannot be part of a wait cycle: it is
15
+ * deadlock-free by construction (see the C.33 design doc's deadlock argument).
16
+ *
17
+ * C.28 adds a PRIORITY lane to {@link acquire}. A background job that pauses to
18
+ * await a human approval RELEASES its permit (JC-D: a job blocked on a human is
19
+ * not using compute, and holding the slot would deadlock the cap if N jobs all
20
+ * paused). When the human approves, the job re-acquires with `priority: true` so
21
+ * it jumps ahead of newly-dispatched work — a resumed job never starves behind a
22
+ * fresh fan-out that filled the queue while it waited. Normal (non-priority)
23
+ * waiters keep strict FIFO among themselves, so C.33's dispatch-order fairness is
24
+ * unchanged for everything that does not pause.
14
25
  */
26
+ export interface AcquireOptions {
27
+ /**
28
+ * Serve this waiter ahead of all non-priority waiters (still FIFO among
29
+ * priority waiters). Used by a resumed background job re-acquiring its slot
30
+ * after an approval, so it is not starved by work dispatched while it paused.
31
+ */
32
+ priority?: boolean;
33
+ }
15
34
  export declare class Semaphore {
16
35
  private permits;
17
- private readonly queue;
36
+ /** Waiters that take the next permit ahead of {@link normal} (FIFO within). */
37
+ private readonly priority;
38
+ /** Ordinary waiters, served strictly FIFO after any priority waiters. */
39
+ private readonly normal;
18
40
  constructor(permits: number);
19
41
  /** Run `fn` while holding one permit; the permit is released even if it throws. */
20
42
  run<T>(fn: () => Promise<T>): Promise<T>;
43
+ /**
44
+ * Take one permit, waiting if none is free. Resolves immediately when a permit
45
+ * is available; otherwise enqueues onto the priority or normal lane and resolves
46
+ * when {@link release} hands it a permit. Callers that acquire directly (a
47
+ * paused/resumed job) MUST call {@link release} exactly once per acquire.
48
+ */
49
+ acquire(opts?: AcquireOptions): Promise<void>;
50
+ /** Return one permit — handed straight to the next waiter (priority first). */
51
+ release(): void;
21
52
  /** Permits currently available (inspection/tests). */
22
53
  get available(): number;
23
54
  /** Callers currently blocked waiting for a permit (inspection/tests). */
24
55
  get waiting(): number;
25
- private acquire;
26
- private release;
27
56
  }
@@ -1,20 +1,9 @@
1
- /**
2
- * A counting semaphore (C.33): bounds how many subagent runs execute at once.
3
- * FIFO — waiters are served in arrival order, so a fan-out's results stay
4
- * dispatch-order-fair — and the permit is handed directly from a releaser to
5
- * the next waiter, so the live count never transiently exceeds the cap.
6
- *
7
- * Used as the ONE shared bound on parallel fan-out. Parallel dispatch happens at
8
- * depth 0 only (the `spawn_subagents` tool is never granted to a child), and a
9
- * permit is held for a child's whole lifetime — including any *sequential*
10
- * nested spawn beneath it, which is deliberately un-permitted. Because no permit
11
- * holder ever blocks trying to acquire a second permit, the semaphore cannot be
12
- * part of a wait cycle: it is deadlock-free by construction (see the C.33 design
13
- * doc's deadlock argument).
14
- */
15
1
  export class Semaphore {
16
2
  permits;
17
- queue = [];
3
+ /** Waiters that take the next permit ahead of {@link normal} (FIFO within). */
4
+ priority = [];
5
+ /** Ordinary waiters, served strictly FIFO after any priority waiters. */
6
+ normal = [];
18
7
  constructor(permits) {
19
8
  // A non-positive cap would wedge every run; clamp to at least 1.
20
9
  this.permits = Math.max(1, Math.floor(permits));
@@ -29,23 +18,23 @@ export class Semaphore {
29
18
  this.release();
30
19
  }
31
20
  }
32
- /** Permits currently available (inspection/tests). */
33
- get available() {
34
- return this.permits;
35
- }
36
- /** Callers currently blocked waiting for a permit (inspection/tests). */
37
- get waiting() {
38
- return this.queue.length;
39
- }
40
- acquire() {
21
+ /**
22
+ * Take one permit, waiting if none is free. Resolves immediately when a permit
23
+ * is available; otherwise enqueues onto the priority or normal lane and resolves
24
+ * when {@link release} hands it a permit. Callers that acquire directly (a
25
+ * paused/resumed job) MUST call {@link release} exactly once per acquire.
26
+ */
27
+ acquire(opts = {}) {
41
28
  if (this.permits > 0) {
42
29
  this.permits--;
43
30
  return Promise.resolve();
44
31
  }
45
- return new Promise((resolve) => this.queue.push(resolve));
32
+ const lane = opts.priority ? this.priority : this.normal;
33
+ return new Promise((resolve) => lane.push(resolve));
46
34
  }
35
+ /** Return one permit — handed straight to the next waiter (priority first). */
47
36
  release() {
48
- const next = this.queue.shift();
37
+ const next = this.priority.shift() ?? this.normal.shift();
49
38
  // Hand the permit straight to the next waiter (never bump the count above
50
39
  // the cap); only when nobody waits does the count grow back.
51
40
  if (next)
@@ -53,4 +42,12 @@ export class Semaphore {
53
42
  else
54
43
  this.permits++;
55
44
  }
45
+ /** Permits currently available (inspection/tests). */
46
+ get available() {
47
+ return this.permits;
48
+ }
49
+ /** Callers currently blocked waiting for a permit (inspection/tests). */
50
+ get waiting() {
51
+ return this.priority.length + this.normal.length;
52
+ }
56
53
  }