apex-code 0.0.4 → 0.0.6

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 (48) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/cli/agent-lifecycle.d.ts +24 -0
  3. package/dist/cli/agent-lifecycle.d.ts.map +1 -0
  4. package/dist/cli/agent-lifecycle.js +126 -0
  5. package/dist/cli/agent-lifecycle.js.map +1 -0
  6. package/dist/cli/args.d.ts +13 -0
  7. package/dist/cli/args.d.ts.map +1 -1
  8. package/dist/cli/args.js +80 -0
  9. package/dist/cli/args.js.map +1 -1
  10. package/dist/core/agent-session.d.ts +160 -0
  11. package/dist/core/agent-session.d.ts.map +1 -1
  12. package/dist/core/agent-session.js +265 -0
  13. package/dist/core/agent-session.js.map +1 -1
  14. package/dist/core/delegation/runtime.d.ts +682 -1
  15. package/dist/core/delegation/runtime.d.ts.map +1 -1
  16. package/dist/core/delegation/runtime.js +1339 -66
  17. package/dist/core/delegation/runtime.js.map +1 -1
  18. package/dist/core/sdk.d.ts +61 -3
  19. package/dist/core/sdk.d.ts.map +1 -1
  20. package/dist/core/sdk.js +316 -22
  21. package/dist/core/sdk.js.map +1 -1
  22. package/dist/core/tools/delegate.d.ts +8 -0
  23. package/dist/core/tools/delegate.d.ts.map +1 -1
  24. package/dist/core/tools/delegate.js +33 -3
  25. package/dist/core/tools/delegate.js.map +1 -1
  26. package/dist/core/workspace/git-observer.d.ts +16 -0
  27. package/dist/core/workspace/git-observer.d.ts.map +1 -1
  28. package/dist/core/workspace/git-observer.js +8 -1
  29. package/dist/core/workspace/git-observer.js.map +1 -1
  30. package/dist/core/workspace/git-worktree-owner.d.ts +89 -0
  31. package/dist/core/workspace/git-worktree-owner.d.ts.map +1 -0
  32. package/dist/core/workspace/git-worktree-owner.js +240 -0
  33. package/dist/core/workspace/git-worktree-owner.js.map +1 -0
  34. package/dist/main.d.ts.map +1 -1
  35. package/dist/main.js +20 -0
  36. package/dist/main.js.map +1 -1
  37. package/dist/modes/acp/server.d.ts +43 -1
  38. package/dist/modes/acp/server.d.ts.map +1 -1
  39. package/dist/modes/acp/server.js +78 -0
  40. package/dist/modes/acp/server.js.map +1 -1
  41. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  42. package/dist/modes/rpc/rpc-mode.js +38 -0
  43. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  44. package/dist/modes/rpc/rpc-types.d.ts +110 -0
  45. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  46. package/dist/modes/rpc/rpc-types.js.map +1 -1
  47. package/npm-shrinkwrap.json +5 -5
  48. package/package.json +2 -2
@@ -13,19 +13,1220 @@
13
13
  * would create (`sdk.ts` already imports `agent-session.ts`).
14
14
  */
15
15
  import { randomUUID } from "node:crypto";
16
- import { mkdirSync } from "node:fs";
17
- import { join, resolve } from "node:path";
16
+ import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
17
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
18
+ import { loadEntriesFromFile } from "../session-manager.js";
18
19
  import { computeCapabilityCeiling } from "./ceiling.js";
20
+ /** Default follow-up prompt for resuming an interrupted child run. */
21
+ export const RESUME_CHILD_PROMPT = "Resume the interrupted task and continue from the existing session.";
22
+ /** The record status's terminal outcome, for closing an attempt at resume time. */
23
+ function terminalOutcomeOfStatus(status, cancelled) {
24
+ if (status === "completed" || status === "failed")
25
+ return status;
26
+ if (status === "interrupted")
27
+ return cancelled ? "cancelled" : "interrupted";
28
+ return undefined;
29
+ }
30
+ /**
31
+ * Attempts for a record, synthesizing one from legacy fields when the record
32
+ * predates attempt tracking: one attempt, started (and, for a terminal status,
33
+ * ended) at the record's `updatedAt`, with the status mapped to an outcome.
34
+ * Older records therefore load unchanged and still report a single attempt.
35
+ */
36
+ function childRunAttemptsOf(record) {
37
+ if (record.attempts && record.attempts.length > 0)
38
+ return record.attempts;
39
+ const attempt = { id: "attempt-1", startedAt: record.updatedAt };
40
+ const outcome = terminalOutcomeOfStatus(record.status, record.cancelled);
41
+ if (outcome !== undefined) {
42
+ attempt.endedAt = record.updatedAt;
43
+ attempt.outcome = outcome;
44
+ }
45
+ return [attempt];
46
+ }
47
+ /** The entry's active attempt, falling back to the most recent one. */
48
+ function activeAttemptOf(entry) {
49
+ const attempts = entry.attempts ?? [];
50
+ return attempts.find((attempt) => attempt.id === entry.activeAttemptId) ?? attempts[attempts.length - 1];
51
+ }
52
+ /**
53
+ * Shared delegation admission: definition resolution, recursion-depth bound, and
54
+ * the capability ceiling. One projection for both a fresh delegation and a
55
+ * historical reattachment, so a resumed child can never hold authority the
56
+ * current parent cannot cover (no second classification, ADR 0010). The
57
+ * admitted capability set rides along on the result: it is what the build
58
+ * request carries so the child's policy snapshot describes the same projection
59
+ * instead of recomputing it.
60
+ */
61
+ function resolveAdmittedDefinition(options, agentType) {
62
+ const definition = options.resolveAgent(agentType);
63
+ if (!definition) {
64
+ throw new Error(`Unknown agent type "${agentType}".`);
65
+ }
66
+ const depth = options.getDelegationDepth();
67
+ if (depth >= options.maxDelegationDepth) {
68
+ throw new Error(`Delegation depth limit (${options.maxDelegationDepth}) reached at depth ${depth}; cannot delegate to agent "${agentType}" further.`);
69
+ }
70
+ const requestedCapabilities = new Set();
71
+ for (const toolName of definition.tools) {
72
+ const capabilities = options.getToolCapabilities(toolName);
73
+ if (!capabilities) {
74
+ throw new Error(`Agent "${agentType}" requests unknown tool "${toolName}".`);
75
+ }
76
+ for (const capability of capabilities)
77
+ requestedCapabilities.add(capability);
78
+ }
79
+ const ceiling = computeCapabilityCeiling(options.getParentCapabilities(), requestedCapabilities);
80
+ if (!ceiling.allowed) {
81
+ throw new Error(`Delegating to agent "${agentType}" requires capability "${ceiling.deniedCapability}", which exceeds the parent's authority.`);
82
+ }
83
+ return { definition, capabilities: ceiling.capabilities };
84
+ }
85
+ /**
86
+ * Lazily resolve a child's transcript path under its artifact directory, per
87
+ * SessionManager's `<timestamp>_<sessionId>.jsonl` naming (the timestamp prefix
88
+ * is not recorded, so the directory is scanned). Never throws: an absent
89
+ * directory or transcript -- an in-memory child never persisted one -- simply
90
+ * yields `undefined`, so status payloads can resolve it lazily without
91
+ * becoming fallible. When several transcripts match, the newest wins (resume
92
+ * may have grown the file set).
93
+ */
94
+ function resolveSessionFile(artifactDir, sessionId) {
95
+ if (!artifactDir || !sessionId)
96
+ return undefined;
97
+ if (!existsSync(artifactDir))
98
+ return undefined;
99
+ try {
100
+ const matches = readdirSync(artifactDir).filter((name) => name.endsWith(`_${sessionId}.jsonl`));
101
+ if (matches.length === 0)
102
+ return undefined;
103
+ if (matches.length === 1)
104
+ return join(artifactDir, matches[0]);
105
+ const newest = matches
106
+ .map((name) => ({ name, mtime: statSync(join(artifactDir, name)).mtimeMs }))
107
+ .sort((a, b) => b.mtime - a.mtime)[0];
108
+ return join(artifactDir, newest.name);
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ /**
115
+ * Roll provider-reported usage up over session entries (the child's OWN
116
+ * transcript is the source; SessionManager's reader supplies the entries).
117
+ * Every surface that reports child-run usage goes through here, so the numbers
118
+ * are summed exactly once, exactly as the provider reported them: cost is
119
+ * provider-reported, so cache reads are NOT re-priced or re-counted, and an
120
+ * entry without usage contributes zero. Assistant messages carry per-turn
121
+ * usage; compaction and branch-summary entries carry their summarization
122
+ * call's usage, which is additional real spend and therefore included.
123
+ * Returns `undefined` when the entries hold no usage-bearing record at all
124
+ * (nothing to report) -- callers omit rather than zero-fill.
125
+ */
126
+ function computeUsageTotals(entries) {
127
+ let inputTokens = 0;
128
+ let outputTokens = 0;
129
+ let cacheReadTokens = 0;
130
+ let cacheWriteTokens = 0;
131
+ let totalTokens = 0;
132
+ const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
133
+ let entriesCounted = 0;
134
+ const add = (usage) => {
135
+ if (!usage)
136
+ return; // missing/absent usage counts as zero
137
+ inputTokens += usage.input;
138
+ outputTokens += usage.output;
139
+ cacheReadTokens += usage.cacheRead;
140
+ cacheWriteTokens += usage.cacheWrite;
141
+ totalTokens += usage.totalTokens;
142
+ cost.input += usage.cost.input;
143
+ cost.output += usage.cost.output;
144
+ cost.cacheRead += usage.cost.cacheRead;
145
+ cost.cacheWrite += usage.cost.cacheWrite;
146
+ cost.total += usage.cost.total;
147
+ };
148
+ for (const entry of entries) {
149
+ if (entry.type === "message") {
150
+ if (entry.message.role !== "assistant")
151
+ continue;
152
+ entriesCounted++;
153
+ add(entry.message.usage);
154
+ }
155
+ else if (entry.type === "compaction" || entry.type === "branch_summary") {
156
+ entriesCounted++;
157
+ add(entry.usage);
158
+ }
159
+ }
160
+ if (entriesCounted === 0)
161
+ return undefined;
162
+ return {
163
+ inputTokens,
164
+ outputTokens,
165
+ cacheReadTokens,
166
+ cacheWriteTokens,
167
+ totalTokens,
168
+ cost,
169
+ asOf: Date.now(),
170
+ entriesCounted,
171
+ };
172
+ }
19
173
  // Results deliberately stay available for the lifetime of their parent runtime.
20
174
  // Phase 5 promises in-process retrieval only; restart durability belongs to Phase 6.
21
- const backgroundByRuntime = new WeakMap();
22
- function background(options) {
23
- let value = backgroundByRuntime.get(options);
24
- if (!value) {
25
- value = new Map();
26
- backgroundByRuntime.set(options, value);
27
- }
28
- return value;
175
+ export class ChildRunRegistry {
176
+ entries = new Map();
177
+ workspaceClaims = new Map();
178
+ children = new Set();
179
+ /**
180
+ * Session ids whose workspace this registry must release on close/dispose
181
+ * (spec 2026-09-09, "Parallel work and ownership"): populated by
182
+ * `trackWorkspace()` when a worktree-isolated delegation is prepared, and
183
+ * drained exactly once per session id by `releaseWorkspaceOnce()`.
184
+ */
185
+ trackedWorkspaces = new Set();
186
+ /** Session ids whose workspace release has already started -- release runs once, never twice. */
187
+ releasedWorkspaces = new Set();
188
+ /** Last release outcome per session id, surfaced through `workspaceReleaseOutcome()`. */
189
+ workspaceReleaseOutcomes = new Map();
190
+ workspaceOwner;
191
+ disposed = false;
192
+ persist;
193
+ records = new Map();
194
+ /**
195
+ * Spawn dedupe keys -> handle ids (spec 2026-09-09, idempotent spawn). A
196
+ * second spawn with a known key returns the existing handle and never builds
197
+ * a second child. Populated at launch and rebuilt from persisted records on
198
+ * `restore()`, so a restarted parent dedupes too.
199
+ */
200
+ idempotencyKeys = new Map();
201
+ /**
202
+ * Handle ids currently holding a concurrency slot (spec 2026-09-09,
203
+ * "Shared budgets"). Admission takes one slot per child run BEFORE any child
204
+ * session is built; the slot is released on terminal settlement
205
+ * (completed/failed/interrupted), close, registry disposal, or launch
206
+ * failure. Keyed by handle id, so release is idempotent.
207
+ */
208
+ heldRunSlots = new Set();
209
+ /**
210
+ * The delegation runtime this registry runs under, attached by the session
211
+ * that owns it (sdk wiring) or by the first `runDelegation` call. Historical
212
+ * resume reattaches through the SAME `buildChildSession` seam a live
213
+ * delegation uses; without an attached runtime, historical records are still
214
+ * listed and their persisted output retrievable, but they cannot reattach.
215
+ */
216
+ runtimeOptions;
217
+ /**
218
+ * Last-computed usage totals per handle id, keyed by what makes them stale
219
+ * (the transcript file's size+mtime, or the in-memory entry count+last id).
220
+ * The transcript file itself stays the one source: on every read the key is
221
+ * re-derived and a mismatch recomputes from the file -- this cache never
222
+ * becomes a second transcript.
223
+ */
224
+ usageCache = new Map();
225
+ /** First attach wins: a registry is owned by one session's runtime. */
226
+ setRuntimeOptions(options) {
227
+ if (!this.runtimeOptions)
228
+ this.runtimeOptions = options;
229
+ }
230
+ setPersistence(persist) {
231
+ this.persist = persist;
232
+ }
233
+ /** Load validated historical records. This never constructs or starts a child. */
234
+ restore(records) {
235
+ for (const record of records) {
236
+ if (!record ||
237
+ typeof record.handleId !== "string" ||
238
+ typeof record.agentType !== "string" ||
239
+ !Number.isFinite(record.updatedAt) ||
240
+ !["created", "running", "completed", "failed", "interrupted", "closed"].includes(record.status))
241
+ continue;
242
+ // Legacy records without attempts load with one synthesized attempt
243
+ // derived from their status/updatedAt, so every in-memory record carries
244
+ // at least one attempt.
245
+ const normalized = { ...record, attempts: childRunAttemptsOf(record) };
246
+ if (normalized.activeAttemptId === undefined) {
247
+ const openStatus = normalized.status === "running" || normalized.status === "created";
248
+ normalized.activeAttemptId = openStatus
249
+ ? normalized.attempts[normalized.attempts.length - 1].id
250
+ : undefined;
251
+ }
252
+ // Stale-running reconciliation (restart semantics): a persisted
253
+ // "running" record cannot be live in this fresh process, so its
254
+ // in-memory status becomes "interrupted" and its active attempt closes
255
+ // as interrupted. The historical JSONL is never rewritten here -- the
256
+ // corrected status persists with the record's NEXT save (e.g. a resume).
257
+ // This is also the honest statement of restart semantics: a resumed run
258
+ // continues from the last persisted transcript boundary, never
259
+ // exactly-once -- turns that settled after the last save are re-run.
260
+ if (normalized.status === "running") {
261
+ normalized.status = "interrupted";
262
+ normalized.attempts = normalized.attempts.map((attempt) => ({ ...attempt }));
263
+ const attempt = normalized.attempts[normalized.attempts.length - 1];
264
+ if (attempt) {
265
+ attempt.endedAt ??= Date.now();
266
+ attempt.outcome ??= "interrupted";
267
+ }
268
+ }
269
+ this.records.set(normalized.handleId, normalized);
270
+ if (typeof normalized.idempotencyKey === "string") {
271
+ this.idempotencyKeys.set(normalized.idempotencyKey, normalized.handleId);
272
+ }
273
+ }
274
+ }
275
+ /** The handle a spawn dedupe key already maps to, if any. */
276
+ handleForIdempotencyKey(key) {
277
+ return this.idempotencyKeys.get(key);
278
+ }
279
+ /** True when `id` is known only as a persisted record -- no live entry in this process. */
280
+ isHistorical(id) {
281
+ return !this.entries.has(id) && this.records.has(id);
282
+ }
283
+ /**
284
+ * Resolve a historical record's child session file. Verified against
285
+ * SessionManager's naming (`<timestamp>_<sessionId>.jsonl` inside the
286
+ * per-child artifact directory, which IS the child's session dir): the
287
+ * timestamp prefix is not recorded, so the directory is scanned for the file
288
+ * whose name ends in `_<sessionId>.jsonl`. Returns the validated triple so
289
+ * the reattachment request carries checked values, not optional record fields.
290
+ */
291
+ resolveHistoricalSession(record) {
292
+ const artifactDir = record.artifactDir ? resolve(record.artifactDir) : undefined;
293
+ const sessionId = typeof record.sessionId === "string" ? record.sessionId : undefined;
294
+ if (!sessionId || !artifactDir) {
295
+ throw new Error(`Child run "${record.handleId}" was never file-backed (no persisted child session directory; in-memory child), so it cannot be resumed after restart.`);
296
+ }
297
+ if (!existsSync(artifactDir)) {
298
+ throw new Error(`Child run "${record.handleId}" cannot be resumed: its artifact directory is missing: ${artifactDir}.`);
299
+ }
300
+ const path = resolveSessionFile(artifactDir, sessionId);
301
+ if (!path) {
302
+ throw new Error(`Child run "${record.handleId}" cannot be resumed: no child session file for session "${sessionId}" exists under ${artifactDir} (the child never persisted a transcript).`);
303
+ }
304
+ return { path, sessionId, artifactDir };
305
+ }
306
+ /**
307
+ * Reattach a persisted child run to its existing child session and continue
308
+ * it with one turn (restart reconstruction). The child session file recorded
309
+ * under the run's artifact directory is reopened through the same
310
+ * `buildChildSession` seam a live delegation uses (reattachment marker), and
311
+ * the run then behaves like any live entry: settlement is observed and
312
+ * persisted, and wait/retrieve/sendInput work on the same handle id.
313
+ *
314
+ * Never-file-backed (in-memory) children, records whose artifact directory or
315
+ * session file is gone, closed runs, and runs whose recorded worktree was
316
+ * released all refuse with an actionable error; nothing is reconstructed
317
+ * from nothing.
318
+ */
319
+ async resumeHistorical(id, input) {
320
+ this.assertOpen();
321
+ if (this.entries.has(id)) {
322
+ throw new Error(`Child run "${id}" is already live in this process; use sendInput instead of historical resume.`);
323
+ }
324
+ const record = this.records.get(id);
325
+ if (!record)
326
+ throw new Error(`Unknown delegation handle "${id}".`);
327
+ // The worktree gate runs BEFORE the closed check: a retained (or missing)
328
+ // workspace is the more actionable refusal for a released child, and it
329
+ // names the persisted state and the explicit-recovery way out.
330
+ if (record.workspace?.isolation === "worktree")
331
+ this.rejectUnresumableWorktree(id, record);
332
+ if (record.status === "closed")
333
+ throw new Error(`Child run "${id}" was closed and cannot be resumed.`);
334
+ if (typeof record.agentType !== "string" || record.agentType === "") {
335
+ throw new Error(`Child run "${id}" has a malformed record (no agent type) and cannot be resumed.`);
336
+ }
337
+ const { path: sessionPath, sessionId, artifactDir } = this.resolveHistoricalSession(record);
338
+ const runtime = this.runtimeOptions;
339
+ if (!runtime?.buildChildSession) {
340
+ throw new Error(`Child run "${id}" cannot be resumed: no delegation runtime is attached to this registry.`);
341
+ }
342
+ const { definition, capabilities } = resolveAdmittedDefinition(runtime, record.agentType);
343
+ const workspace = record.workspace ?? { isolation: "shared-read", ownedPaths: [] };
344
+ const claims = [...workspace.ownedPaths].map((p) => resolve(p));
345
+ for (const claim of this.activeWorkspaceClaims()) {
346
+ if (claims.some((path) => claimPathsOverlap(path, claim))) {
347
+ throw new Error(`Resuming child run "${id}" overlaps an active write ownership claim.`);
348
+ }
349
+ }
350
+ this.claimWorkspace(id, claims);
351
+ try {
352
+ // A resumed run counts against the concurrency limit exactly like a
353
+ // live delegation, from before the child is built (spec 2026-09-09,
354
+ // "Shared budgets").
355
+ this.admitChildRun(id);
356
+ const child = await runtime.buildChildSession({
357
+ agentType: record.agentType,
358
+ definition,
359
+ toolNames: definition.tools,
360
+ capabilities,
361
+ depth: typeof record.depth === "number" ? record.depth : 1,
362
+ sessionId,
363
+ artifactDir,
364
+ workspace,
365
+ reattachSessionPath: sessionPath,
366
+ });
367
+ this.own(child);
368
+ // Resume closes the previous attempt with its terminal outcome and opens
369
+ // a NEW attempt (spec 2026-09-09, "Child lifecycle"): the resumed turn
370
+ // is a fresh epoch of the same child session, never a continuation of
371
+ // the interrupted attempt.
372
+ const priorAttempts = childRunAttemptsOf(record).map((attempt) => ({ ...attempt }));
373
+ const priorOutcome = terminalOutcomeOfStatus(record.status, record.cancelled);
374
+ const prior = record.activeAttemptId
375
+ ? priorAttempts.find((attempt) => attempt.id === record.activeAttemptId)
376
+ : undefined;
377
+ const closing = prior ?? priorAttempts[priorAttempts.length - 1];
378
+ if (closing && closing.endedAt === undefined) {
379
+ closing.endedAt = Date.now();
380
+ closing.outcome ??= priorOutcome;
381
+ }
382
+ // The resumed epoch's predecessor gets its cumulative-at-end rollup
383
+ // here when the settlement before the restart never produced one: the
384
+ // persisted transcript is all that survived, so its current totals are
385
+ // the honest snapshot (see ChildRunAttempt.tokensAtEnd).
386
+ if (closing && closing.tokensAtEnd === undefined) {
387
+ const totals = this.usageTotalsFor(id, record.artifactDir);
388
+ if (totals)
389
+ closing.tokensAtEnd = totals;
390
+ }
391
+ const attempt = { id: `attempt-${priorAttempts.length + 1}`, startedAt: Date.now() };
392
+ const entry = {
393
+ agentType: record.agentType,
394
+ task: record.task ?? "",
395
+ // No initial-turn promise: each resumed turn is tracked through
396
+ // sendInput's settlement observation, like any live entry.
397
+ promise: new Promise(() => { }),
398
+ child,
399
+ artifactDir: record.artifactDir,
400
+ workspace,
401
+ depth: record.depth,
402
+ // Record linkage: the reattached construction re-derives the policy
403
+ // through the same admission projection; the record's persisted
404
+ // values stand in when a fixture handle reports none.
405
+ policy: child.policy ?? record.policy,
406
+ sandboxEnforced: child.sandboxEnforced ?? record.sandboxEnforced,
407
+ parentSessionId: record.parentSessionId,
408
+ attempts: [...priorAttempts, attempt],
409
+ activeAttemptId: attempt.id,
410
+ idempotencyKey: typeof record.idempotencyKey === "string" ? record.idempotencyKey : undefined,
411
+ };
412
+ this.entries.set(id, entry);
413
+ this.save(id, entry, "running");
414
+ await this.sendInput(id, input ?? RESUME_CHILD_PROMPT);
415
+ return child.status;
416
+ }
417
+ catch (error) {
418
+ this.releaseChildRunSlot(id);
419
+ this.releaseWorkspace(id);
420
+ throw error;
421
+ }
422
+ }
423
+ /**
424
+ * Gate a worktree-isolated historical resume on the workspace's persisted
425
+ * state (spec 2026-09-09, "Workspace states and explicit recovery"). A
426
+ * vanished root classifies the record "missing" (persisted) and refuses; a
427
+ * retained (dirty/failed) root refuses, naming the persisted state and
428
+ * pointing at explicit recovery. Legacy records and recovered ("active")
429
+ * workspaces pass. Read-only: nothing is recreated, checked out, or reset.
430
+ */
431
+ rejectUnresumableWorktree(id, record) {
432
+ const workspace = record.workspace;
433
+ const root = workspace.root;
434
+ if (!root)
435
+ return; // never prepared: the launch failed before the owner ran
436
+ if (!existsSync(root)) {
437
+ if (record.workspaceState !== "released") {
438
+ record.workspaceState = "missing";
439
+ record.updatedAt = Date.now();
440
+ try {
441
+ this.persist?.(record);
442
+ }
443
+ catch {
444
+ // The in-memory classification stands even if persistence cannot.
445
+ }
446
+ }
447
+ throw new Error(`Child run "${id}" cannot be resumed: its worktree workspace "${root}" no longer exists ` +
448
+ `(workspace state: "${record.workspaceState ?? "missing"}"). Worktrees are released or retained when the ` +
449
+ `owning session closes; a missing workspace cannot be recovered -- start a new delegation instead.`);
450
+ }
451
+ if (record.workspaceState === "retained-dirty" || record.workspaceState === "retained-failed") {
452
+ throw new Error(`Child run "${id}" cannot be resumed: its worktree workspace "${root}" was retained at release ` +
453
+ `(workspace state: "${record.workspaceState}"; uncommitted work is preserved there). Recover it explicitly first -- ` +
454
+ `recoverChildWorkspace("${id}") verifies the worktree and reactivates it -- then resume.`);
455
+ }
456
+ }
457
+ /**
458
+ * Explicitly verify and reactivate a retained child worktree (spec
459
+ * 2026-09-09, "Workspace states and explicit recovery"). Read-only
460
+ * inspection only -- the workspace owner's verification reads the
461
+ * administrative entry and the checked-out branch and never creates,
462
+ * checks out, resets, or force-removes anything -- and on success the
463
+ * record's workspace state becomes "active" (persisted) so the child can be
464
+ * resumed in the SAME worktree. Automatic recreation stays out of scope by
465
+ * design: every failed check refuses with an actionable error naming the
466
+ * failed check, and the workspace state stays unverified.
467
+ */
468
+ async recoverWorkspace(id) {
469
+ this.assertOpen();
470
+ const record = this.records.get(id);
471
+ if (!record)
472
+ throw new Error(`Unknown delegation handle "${id}".`);
473
+ const workspace = record.workspace;
474
+ if (!workspace || workspace.isolation !== "worktree" || !workspace.root) {
475
+ throw new Error(`Child workspace recovery refused for run "${id}": it has no worktree workspace to recover ` +
476
+ `(isolation: ${workspace?.isolation ?? "none"}). Only worktree-isolated children hold a recoverable workspace; ` +
477
+ `nothing was verified and the record is unchanged.`);
478
+ }
479
+ const root = workspace.root;
480
+ if (!existsSync(root)) {
481
+ if (record.workspaceState !== "released") {
482
+ record.workspaceState = "missing";
483
+ record.updatedAt = Date.now();
484
+ try {
485
+ this.persist?.(record);
486
+ }
487
+ catch {
488
+ // The in-memory classification stands even if persistence cannot.
489
+ }
490
+ }
491
+ throw new Error(`Child workspace recovery refused for run "${id}": its recorded worktree root "${root}" does not exist ` +
492
+ `(workspace state: "${record.workspaceState ?? "missing"}"). A missing workspace cannot be recovered; ` +
493
+ `nothing was created.`);
494
+ }
495
+ const owner = this.workspaceOwner ?? this.runtimeOptions?.workspaceOwner;
496
+ if (!owner || typeof owner.verify !== "function") {
497
+ throw new Error(`Child workspace recovery refused for run "${id}": no workspace owner with verification is available for ` +
498
+ `this session. The workspace at "${root}" was not inspected and stays unverified.`);
499
+ }
500
+ let dirty;
501
+ try {
502
+ ({ dirty } = await owner.verify(id, root));
503
+ }
504
+ catch (error) {
505
+ const detail = error instanceof Error ? error.message : String(error);
506
+ throw new Error(`Child workspace recovery refused for run "${id}": ${detail} No recovery was performed and the ` +
507
+ `workspace stays unverified.`);
508
+ }
509
+ record.workspaceState = "active";
510
+ record.updatedAt = Date.now();
511
+ try {
512
+ this.persist?.(record);
513
+ }
514
+ catch {
515
+ // The in-memory record is reactivated regardless; the persisted state
516
+ // catches up with the next save.
517
+ }
518
+ return { workspaceState: "active", dirty };
519
+ }
520
+ /**
521
+ * The owner consulted when a tracked child's lifecycle ends. Optional:
522
+ * without one, close/dispose still clean claims but release nothing.
523
+ */
524
+ setWorkspaceOwner(owner) {
525
+ this.workspaceOwner = owner;
526
+ }
527
+ /** Record that a child session's workspace (worktree) must be released when its lifecycle ends. */
528
+ trackWorkspace(sessionId) {
529
+ this.trackedWorkspaces.add(sessionId);
530
+ }
531
+ /**
532
+ * Release a tracked child's workspace through the owner, once per session
533
+ * id. Best effort: a missing owner is tolerated and an owner failure never
534
+ * propagates -- cleanup must never break close or dispose. The outcome is
535
+ * stored per session id (`workspaceReleaseOutcome()`), and a kept tree is
536
+ * warned about loudly so silent data loss cannot hide behind best-effort
537
+ * cleanup.
538
+ */
539
+ releaseWorkspaceOnce(sessionId) {
540
+ if (this.releasedWorkspaces.has(sessionId))
541
+ return Promise.resolve();
542
+ this.releasedWorkspaces.add(sessionId);
543
+ this.trackedWorkspaces.delete(sessionId);
544
+ const owner = this.workspaceOwner;
545
+ if (!owner)
546
+ return Promise.resolve();
547
+ return Promise.resolve()
548
+ .then(() => owner.release(sessionId))
549
+ .catch((error) => ({
550
+ removed: false,
551
+ kept: "failed",
552
+ dir: "",
553
+ error: error instanceof Error ? error.message : String(error),
554
+ }))
555
+ .then((outcome) => {
556
+ this.workspaceReleaseOutcomes.set(sessionId, outcome);
557
+ this.recordWorkspaceState(sessionId, outcome);
558
+ if (!outcome.removed) {
559
+ if (outcome.kept === "dirty") {
560
+ console.warn(`[apex-code] Child worktree for session "${sessionId}" kept at ${outcome.dir}; uncommitted child work preserved. ` +
561
+ `Resume after reattach is refused for worktree children. Manual removal: git worktree remove --force ${outcome.dir}`);
562
+ }
563
+ else {
564
+ console.warn(`[apex-code] Child worktree release failed for session "${sessionId}" (tree kept at ${outcome.dir}): ${outcome.error ?? "unknown error"}`);
565
+ }
566
+ }
567
+ })
568
+ .catch(() => undefined);
569
+ }
570
+ /**
571
+ * Classify a finished release on the record (spec 2026-09-09, "Workspace
572
+ * states and explicit recovery"): removed -> "released", kept dirty ->
573
+ * "retained-dirty", kept failed -> "retained-failed". Only worktree-isolated
574
+ * records carry the state. The classified record persists immediately -- even
575
+ * though release is fire-and-forget -- so a restarted parent sees the
576
+ * classification and can refuse or recover accordingly.
577
+ */
578
+ recordWorkspaceState(sessionId, outcome) {
579
+ const record = this.records.get(sessionId);
580
+ if (!record || record.workspace?.isolation !== "worktree")
581
+ return;
582
+ record.workspaceState = outcome.removed
583
+ ? "released"
584
+ : outcome.kept === "dirty"
585
+ ? "retained-dirty"
586
+ : "retained-failed";
587
+ record.updatedAt = Date.now();
588
+ try {
589
+ this.persist?.(record);
590
+ }
591
+ catch {
592
+ // The in-memory record still carries the classification even when
593
+ // persistence cannot run (best effort, like the release itself).
594
+ }
595
+ }
596
+ /** The stored outcome of this session id's workspace release, if it has run. */
597
+ workspaceReleaseOutcome(sessionId) {
598
+ return this.workspaceReleaseOutcomes.get(sessionId);
599
+ }
600
+ /** Awaitable variant for the delegation failure path, which must release before the throw surfaces. */
601
+ async releaseWorkspaceNow(sessionId) {
602
+ await this.releaseWorkspaceOnce(sessionId);
603
+ this.workspaceClaims.delete(sessionId);
604
+ }
605
+ releaseWorkspace(sessionId) {
606
+ this.workspaceClaims.delete(sessionId);
607
+ }
608
+ activeWorkspaceClaims() {
609
+ return [...this.workspaceClaims.values()].flat();
610
+ }
611
+ claimWorkspace(sessionId, paths) {
612
+ if (paths.length)
613
+ this.workspaceClaims.set(sessionId, paths);
614
+ }
615
+ save(handleId, entry, status) {
616
+ const latest = entry.latest;
617
+ const record = {
618
+ handleId,
619
+ agentType: entry.agentType,
620
+ sessionId: handleId,
621
+ task: entry.task,
622
+ artifactDir: entry.artifactDir,
623
+ workspace: entry.workspace,
624
+ depth: entry.depth,
625
+ status,
626
+ updatedAt: Date.now(),
627
+ // Attempts, the active attempt, and the spawn dedupe key persist with
628
+ // every save: a restarted parent rebuilds attempts and the key->handle
629
+ // map from these records alone.
630
+ attempts: (entry.attempts ?? []).map((attempt) => ({ ...attempt })),
631
+ activeAttemptId: entry.activeAttemptId,
632
+ // Record linkage (policy snapshot, sandbox flag, parent session id)
633
+ // persists with every save too, so session readers describe the child
634
+ // without re-deriving its policy (spec 2026-09-09, "Derive, do not
635
+ // reconstruct").
636
+ ...(entry.parentSessionId !== undefined ? { parentSessionId: entry.parentSessionId } : {}),
637
+ ...(entry.policy
638
+ ? {
639
+ policy: {
640
+ ...entry.policy,
641
+ tools: [...entry.policy.tools],
642
+ capabilities: [...entry.policy.capabilities],
643
+ },
644
+ }
645
+ : {}),
646
+ ...(entry.sandboxEnforced !== undefined ? { sandboxEnforced: entry.sandboxEnforced } : {}),
647
+ ...(entry.idempotencyKey !== undefined ? { idempotencyKey: entry.idempotencyKey } : {}),
648
+ ...(entry.deadlineMs !== undefined ? { deadlineMs: entry.deadlineMs } : {}),
649
+ ...(entry.cancelled ? { cancelled: { ...entry.cancelled } } : {}),
650
+ };
651
+ // The settlement's output persists with the record so a historical run's
652
+ // result stays retrievable after restart without reattaching the child.
653
+ if (latest) {
654
+ record.latestResult = {
655
+ output: latest.outcome === "failed"
656
+ ? latest.error instanceof Error
657
+ ? latest.error.message
658
+ : String(latest.error)
659
+ : latest.output,
660
+ outcome: latest.outcome,
661
+ };
662
+ }
663
+ this.records.set(handleId, record);
664
+ this.persist?.(record);
665
+ }
666
+ /**
667
+ * Record a turn settlement from the child handle's own report, falling back to
668
+ * the settlement value for handles that did not report. The latest settlement
669
+ * wins; retrieval serves it instead of the stored first-turn promise.
670
+ */
671
+ recordCompletion(id, entry, fallbackOutput) {
672
+ const reported = entry.child?.latestResult?.();
673
+ entry.latest =
674
+ !reported || reported.outcome === "completed"
675
+ ? { outcome: "completed", output: reported?.output ?? fallbackOutput }
676
+ : reported.outcome === "interrupted"
677
+ ? { outcome: "interrupted", output: reported.output }
678
+ : { outcome: "failed", error: new Error(reported.output || "Child run failed.") };
679
+ this.persistSettlement(id, entry);
680
+ }
681
+ recordFailure(id, entry, error) {
682
+ const reported = entry.child?.latestResult?.();
683
+ entry.latest =
684
+ reported?.outcome === "interrupted" || (!reported && entry.child?.status === "interrupted")
685
+ ? { outcome: "interrupted", output: reported?.output ?? "" }
686
+ : { outcome: "failed", error };
687
+ this.persistSettlement(id, entry);
688
+ }
689
+ /**
690
+ * Stamp the active attempt with the settled turn's outcome. A cancellation
691
+ * recorded on the entry wins for an interrupted settlement: the attempt
692
+ * stays `cancelled` instead of downgrading to plain `interrupted`. Attempt
693
+ * usage snapshots from the child's own controller where its handle exposes
694
+ * one; handles without a controller leave usage unset. `tokensAtEnd`
695
+ * snapshots the run-level token/cost rollup at settlement time (a
696
+ * cumulative-at-end snapshot of the whole transcript -- see
697
+ * `ChildRunAttempt`); when nothing is reachable it stays unset.
698
+ */
699
+ stampAttemptSettlement(id, entry) {
700
+ const attempt = activeAttemptOf(entry);
701
+ const latest = entry.latest;
702
+ if (!attempt || !latest)
703
+ return;
704
+ attempt.outcome = latest.outcome === "interrupted" && entry.cancelled ? "cancelled" : latest.outcome;
705
+ if (latest.outcome === "failed") {
706
+ attempt.error = latest.error instanceof Error ? latest.error.message : String(latest.error);
707
+ }
708
+ const usage = entry.child?.usage?.();
709
+ if (usage)
710
+ attempt.usage = usage;
711
+ const totals = this.usageTotalsFor(id, entry.artifactDir, entry.child);
712
+ if (totals)
713
+ attempt.tokensAtEnd = totals;
714
+ }
715
+ /** Persist the settlement's status. A closed run's terminal status is never overwritten by late settlement. */
716
+ persistSettlement(id, entry) {
717
+ if (entry.closed)
718
+ return;
719
+ const latest = entry.latest;
720
+ if (!latest)
721
+ return;
722
+ this.stampAttemptSettlement(id, entry);
723
+ this.save(id, entry, latest.outcome === "completed" ? "completed" : latest.outcome === "interrupted" ? "interrupted" : "failed");
724
+ // Terminal settlement (completed/failed/interrupted) releases the run's
725
+ // concurrency slot (spec 2026-09-09, "Shared budgets").
726
+ this.releaseChildRunSlot(id);
727
+ }
728
+ assertOpen() {
729
+ if (this.disposed)
730
+ throw new Error("Child run registry is disposed.");
731
+ }
732
+ /** The configured child-run concurrency limit, if any. */
733
+ concurrencyLimit() {
734
+ const limit = this.runtimeOptions?.maxConcurrentChildren;
735
+ return typeof limit === "number" && Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : undefined;
736
+ }
737
+ /**
738
+ * Concurrency admission (spec 2026-09-09, "Shared budgets"): refuse with an
739
+ * actionable error naming the limit BEFORE any child session is built when
740
+ * every slot is occupied. An admitted run holds one slot until terminal
741
+ * settlement, close, dispose, or a launch failure releases it.
742
+ */
743
+ admitChildRun(handleId) {
744
+ const limit = this.concurrencyLimit();
745
+ if (limit === undefined)
746
+ return;
747
+ if (this.heldRunSlots.size >= limit) {
748
+ throw new Error(`Delegation refused: maxConcurrentChildren=${limit} and ${this.heldRunSlots.size} child run(s) are still active. Wait for a child run to finish or close it, or raise maxConcurrentChildren.`);
749
+ }
750
+ this.heldRunSlots.add(handleId);
751
+ }
752
+ /** Release a run's concurrency slot. Idempotent. */
753
+ releaseChildRunSlot(handleId) {
754
+ this.heldRunSlots.delete(handleId);
755
+ }
756
+ own(child) {
757
+ if (this.disposed) {
758
+ child.dispose();
759
+ this.assertOpen();
760
+ }
761
+ this.children.add(child);
762
+ }
763
+ release(child) {
764
+ if (this.children.delete(child))
765
+ child.dispose();
766
+ }
767
+ register(id, entry) {
768
+ this.assertOpen();
769
+ // A launch is attempt 1: an entry without attempts starts its first epoch
770
+ // here (resumeHistorical supplies its own carried-over attempts).
771
+ if (entry.attempts === undefined || entry.attempts.length === 0) {
772
+ entry.attempts = [{ id: "attempt-1", startedAt: Date.now() }];
773
+ entry.activeAttemptId ??= entry.attempts[0].id;
774
+ }
775
+ if (entry.idempotencyKey !== undefined) {
776
+ this.idempotencyKeys.set(entry.idempotencyKey, id);
777
+ }
778
+ if (!this.entries.has(id)) {
779
+ this.entries.set(id, entry);
780
+ // Observe the initial turn's settlement here so the latest result is
781
+ // recorded even when retrieval never happens; this derived branch always
782
+ // resolves, so it can never become an unhandled rejection itself.
783
+ const settled = entry.promise.then((result) => {
784
+ this.recordCompletion(id, entry, result.output);
785
+ return result;
786
+ }, (error) => {
787
+ this.recordFailure(id, entry, error);
788
+ return undefined;
789
+ });
790
+ void settled.catch(() => undefined);
791
+ entry.pending = settled;
792
+ }
793
+ this.save(id, entry, "created");
794
+ }
795
+ retrieve(id, expected) {
796
+ const entry = this.entries.get(id);
797
+ if (!entry) {
798
+ const record = this.records.get(id);
799
+ if (!record)
800
+ throw new Error(`Unknown delegation handle "${id}".`);
801
+ if (expected !== undefined && record.agentType !== expected)
802
+ throw new Error(`Delegation handle "${id}" belongs to agent "${record.agentType}", not "${expected}".`);
803
+ const latest = record.latestResult;
804
+ if (!latest) {
805
+ throw new Error(`Child run "${id}" has no persisted output, so its result cannot be recovered after restart. Resume it with resumeChildRun("${id}") to reattach its child session.`);
806
+ }
807
+ return Promise.resolve({
808
+ agentType: record.agentType,
809
+ task: record.task ?? "",
810
+ output: latest.output,
811
+ outcome: latest.outcome,
812
+ });
813
+ }
814
+ if (expected !== undefined && entry.agentType !== expected)
815
+ throw new Error(`Delegation handle "${id}" belongs to agent "${entry.agentType}", not "${expected}".`);
816
+ return this.latestDelegationResult(entry);
817
+ }
818
+ /** Await any in-flight turn, then serve its settled outcome -- not the stored first-turn promise. */
819
+ async latestDelegationResult(entry) {
820
+ if (entry.pending)
821
+ await entry.pending.catch(() => undefined);
822
+ const latest = entry.latest;
823
+ if (!latest)
824
+ return entry.promise;
825
+ if (latest.outcome === "failed")
826
+ throw latest.error;
827
+ return { agentType: entry.agentType, task: entry.task, output: latest.output, outcome: latest.outcome };
828
+ }
829
+ /**
830
+ * One entry per child run -- live entries first, then historical records --
831
+ * each as `{handleId, agentType, task, status, attemptCount}`. Backward
832
+ * compatible: fields were only ever added. Observing the list lazily
833
+ * interrupts a live running child whose wall-clock deadline has passed.
834
+ */
835
+ list() {
836
+ this.observeDeadlines();
837
+ const historical = [...this.records]
838
+ .filter(([id]) => !this.entries.has(id))
839
+ .map(([handleId, record]) => ({
840
+ handleId,
841
+ agentType: record.agentType,
842
+ task: record.task ?? "",
843
+ status: record.status === "closed"
844
+ ? "closed"
845
+ : record.status === "interrupted"
846
+ ? "interrupted"
847
+ : record.status === "running"
848
+ ? "running"
849
+ : "idle",
850
+ attemptCount: childRunAttemptsOf(record).length,
851
+ }));
852
+ return [...this.entries]
853
+ .map(([handleId, entry]) => ({
854
+ handleId,
855
+ agentType: entry.agentType,
856
+ task: entry.task,
857
+ status: entry.closed ? "closed" : (entry.child?.status ?? "idle"),
858
+ attemptCount: (entry.attempts ?? []).length,
859
+ }))
860
+ .concat(historical);
861
+ }
862
+ /**
863
+ * Non-blocking status snapshot for one handle (spec 2026-09-09, pollable
864
+ * status): built from the live entry or the persisted record alone, never by
865
+ * awaiting a turn. Unknown ids still error. Observing the status lazily
866
+ * interrupts a live running child whose wall-clock deadline has passed, so a
867
+ * timed-out run is reported (and stopped) at observation time.
868
+ */
869
+ status(id) {
870
+ this.assertOpen();
871
+ this.observeDeadlines();
872
+ const entry = this.entries.get(id);
873
+ if (entry)
874
+ return this.statusFromEntry(id, entry);
875
+ const record = this.records.get(id);
876
+ if (!record)
877
+ throw new Error(`Unknown delegation handle "${id}".`);
878
+ return this.statusFromRecord(record);
879
+ }
880
+ /**
881
+ * The run's token/cost totals, rolled up on demand from the child's OWN
882
+ * session transcript (the session-file reading seam) -- or from the handle's
883
+ * in-memory session entries when no transcript file exists. Reads lazily and
884
+ * caches only the last-computed totals, keyed by transcript size/mtime or
885
+ * entry count, so the cache can never become a second transcript.
886
+ * `undefined` -- never zero-filled, never a throw -- when nothing is
887
+ * reachable: an in-memory child without a reachable transcript, or a
888
+ * historical record whose transcript is gone. Unknown ids error like
889
+ * `status`.
890
+ */
891
+ usageTotals(id) {
892
+ this.assertOpen();
893
+ const entry = this.entries.get(id);
894
+ if (entry)
895
+ return this.usageTotalsFor(id, entry.artifactDir, entry.child);
896
+ const record = this.records.get(id);
897
+ if (!record)
898
+ throw new Error(`Unknown delegation handle "${id}".`);
899
+ return this.usageTotalsFor(id, record.artifactDir);
900
+ }
901
+ /**
902
+ * One rollup read for both live entries and historical records: a
903
+ * resolvable transcript file wins (the persisted transcript is the source of
904
+ * truth, including after restart); the handle's in-memory session entries
905
+ * cover an in-memory child whose transcript was never written.
906
+ */
907
+ usageTotalsFor(id, artifactDir, child) {
908
+ const sessionFile = resolveSessionFile(artifactDir, id);
909
+ if (sessionFile) {
910
+ try {
911
+ const stats = statSync(sessionFile);
912
+ const key = `file:${stats.size}:${stats.mtimeMs}`;
913
+ const cached = this.usageCache.get(id);
914
+ if (cached && cached.key === key)
915
+ return cached.totals;
916
+ const totals = computeUsageTotals(loadEntriesFromFile(sessionFile));
917
+ if (totals)
918
+ this.usageCache.set(id, { key, totals });
919
+ return totals;
920
+ }
921
+ catch {
922
+ // An unreadable transcript falls through to the in-memory seam below;
923
+ // reporting usage must never turn a status read into a failure.
924
+ }
925
+ }
926
+ const entries = child?.sessionEntries?.();
927
+ if (entries) {
928
+ const last = entries[entries.length - 1];
929
+ const key = `mem:${entries.length}:${last?.id ?? ""}`;
930
+ const cached = this.usageCache.get(id);
931
+ if (cached && cached.key === key)
932
+ return cached.totals;
933
+ const totals = computeUsageTotals(entries);
934
+ if (totals)
935
+ this.usageCache.set(id, { key, totals });
936
+ return totals;
937
+ }
938
+ return undefined;
939
+ }
940
+ /** The status payload's flattened view of the rollup, omitted when unreachable. */
941
+ static usageSummaryOf(totals) {
942
+ if (!totals)
943
+ return {};
944
+ return {
945
+ tokens: {
946
+ inputTokens: totals.inputTokens,
947
+ outputTokens: totals.outputTokens,
948
+ cacheReadTokens: totals.cacheReadTokens,
949
+ cacheWriteTokens: totals.cacheWriteTokens,
950
+ totalTokens: totals.totalTokens,
951
+ },
952
+ cost: { ...totals.cost },
953
+ };
954
+ }
955
+ statusFromEntry(id, entry) {
956
+ const attempt = activeAttemptOf(entry);
957
+ const latest = entry.latest;
958
+ const usage = entry.child?.usage?.();
959
+ const workspace = entry.workspace;
960
+ // The persisted record is the authority for a worktree's lifecycle state
961
+ // (a closed-but-live entry's release may already have classified it);
962
+ // while the record has none the live workspace is simply active.
963
+ const workspaceState = this.records.get(id)?.workspaceState;
964
+ // Entries never carry their own session id: save() always records the
965
+ // handle id as the child session id, so transcript resolution uses it.
966
+ const sessionFile = resolveSessionFile(entry.artifactDir, id);
967
+ return {
968
+ handleId: id,
969
+ agentType: entry.agentType,
970
+ task: entry.task,
971
+ status: entry.closed ? "closed" : (entry.child?.status ?? "idle"),
972
+ attempt: {
973
+ id: attempt?.id ?? "",
974
+ ...(attempt?.outcome !== undefined ? { outcome: attempt.outcome } : {}),
975
+ },
976
+ attempts: (entry.attempts ?? []).length,
977
+ ...(latest
978
+ ? {
979
+ lastResult: {
980
+ output: latest.outcome === "failed"
981
+ ? latest.error instanceof Error
982
+ ? latest.error.message
983
+ : String(latest.error)
984
+ : latest.output,
985
+ outcome: latest.outcome,
986
+ },
987
+ }
988
+ : {}),
989
+ ...(entry.cancelled ? { cancelled: { ...entry.cancelled } } : {}),
990
+ ...(entry.deadlineMs !== undefined ? { deadlineMs: entry.deadlineMs } : {}),
991
+ ...(usage ? { usage } : {}),
992
+ // Token/cost rollup from the child's own transcript, on the same lazy,
993
+ // cached seam `usageTotals` serves; omitted when nothing is reachable.
994
+ ...ChildRunRegistry.usageSummaryOf(this.usageTotalsFor(id, entry.artifactDir, entry.child)),
995
+ ...(workspace
996
+ ? {
997
+ workspace: {
998
+ isolation: workspace.isolation,
999
+ ...(workspace.root ? { root: workspace.root } : {}),
1000
+ },
1001
+ ...(workspace.isolation === "worktree" ? { workspaceState: workspaceState ?? "active" } : {}),
1002
+ }
1003
+ : {}),
1004
+ // Record linkage (additive): artifact/session-file/parent identity,
1005
+ // the derived policy, and the sandbox flag ride along when known and
1006
+ // are simply omitted otherwise.
1007
+ ...(entry.artifactDir !== undefined ? { artifactDir: entry.artifactDir } : {}),
1008
+ ...(sessionFile !== undefined ? { sessionFile } : {}),
1009
+ ...(entry.parentSessionId !== undefined ? { parentSessionId: entry.parentSessionId } : {}),
1010
+ ...(entry.policy ? { policy: entry.policy } : {}),
1011
+ ...(entry.sandboxEnforced !== undefined ? { sandboxEnforced: entry.sandboxEnforced } : {}),
1012
+ };
1013
+ }
1014
+ statusFromRecord(record) {
1015
+ const attempts = childRunAttemptsOf(record);
1016
+ // The active attempt when one is open; otherwise (terminal or legacy
1017
+ // record) the most recent attempt carries the reported outcome.
1018
+ const attempt = record.activeAttemptId
1019
+ ? (attempts.find((candidate) => candidate.id === record.activeAttemptId) ?? attempts[attempts.length - 1])
1020
+ : attempts[attempts.length - 1];
1021
+ const workspace = record.workspace;
1022
+ const sessionFile = resolveSessionFile(record.artifactDir, record.sessionId);
1023
+ return {
1024
+ handleId: record.handleId,
1025
+ agentType: record.agentType,
1026
+ task: record.task ?? "",
1027
+ status: record.status === "closed"
1028
+ ? "closed"
1029
+ : record.status === "interrupted"
1030
+ ? "interrupted"
1031
+ : record.status === "running"
1032
+ ? "running"
1033
+ : "idle",
1034
+ attempt: {
1035
+ id: attempt?.id ?? attempts[attempts.length - 1]?.id ?? "",
1036
+ ...(attempt?.outcome !== undefined ? { outcome: attempt.outcome } : {}),
1037
+ },
1038
+ attempts: attempts.length,
1039
+ ...(record.latestResult ? { lastResult: { ...record.latestResult } } : {}),
1040
+ ...(record.cancelled ? { cancelled: { ...record.cancelled } } : {}),
1041
+ ...(record.deadlineMs !== undefined ? { deadlineMs: record.deadlineMs } : {}),
1042
+ // Token/cost rollup from the persisted transcript, so a restarted
1043
+ // parent reports the same totals without a live child.
1044
+ ...ChildRunRegistry.usageSummaryOf(this.usageTotalsFor(record.handleId, record.artifactDir)),
1045
+ ...(workspace
1046
+ ? {
1047
+ workspace: {
1048
+ isolation: workspace.isolation,
1049
+ ...(workspace.root ? { root: workspace.root } : {}),
1050
+ },
1051
+ // Absent means active (legacy records predate the field).
1052
+ ...(workspace.isolation === "worktree" ? { workspaceState: record.workspaceState ?? "active" } : {}),
1053
+ }
1054
+ : {}),
1055
+ // Record linkage (additive): legacy records without the fields simply
1056
+ // omit them (spec 2026-09-09, policy/sandbox/artifact linkage).
1057
+ ...(record.artifactDir !== undefined ? { artifactDir: record.artifactDir } : {}),
1058
+ ...(sessionFile !== undefined ? { sessionFile } : {}),
1059
+ ...(record.parentSessionId !== undefined ? { parentSessionId: record.parentSessionId } : {}),
1060
+ ...(record.policy ? { policy: record.policy } : {}),
1061
+ ...(record.sandboxEnforced !== undefined ? { sandboxEnforced: record.sandboxEnforced } : {}),
1062
+ };
1063
+ }
1064
+ /**
1065
+ * Lazy deadline observation (spec 2026-09-09, timeouts): a live RUNNING child
1066
+ * past its recorded wall-clock deadline is interrupted at observation time.
1067
+ * The interrupt carries no reason -- a wall-time timeout is not a user
1068
+ * cancellation -- so the attempt settles `interrupted`, and the child's own
1069
+ * budget gate names "wall-time" in the settlement error path when the run's
1070
+ * turn was refused for time. Historical records have no live child to
1071
+ * interrupt and are left untouched.
1072
+ */
1073
+ observeDeadlines() {
1074
+ const now = Date.now();
1075
+ for (const [id, entry] of this.entries) {
1076
+ if (entry.closed || entry.deadlineMs === undefined || now < entry.deadlineMs)
1077
+ continue;
1078
+ if (entry.child?.status !== "running")
1079
+ continue;
1080
+ try {
1081
+ this.interrupt(id);
1082
+ }
1083
+ catch {
1084
+ // The child vanished between the check and the interrupt; nothing left to observe.
1085
+ }
1086
+ }
1087
+ }
1088
+ child(id) {
1089
+ this.assertOpen();
1090
+ const entry = this.entries.get(id);
1091
+ if (!entry)
1092
+ throw new Error(`Unknown delegation handle "${id}".`);
1093
+ if (entry.closed)
1094
+ throw new Error(`Child session "${id}" is closed.`);
1095
+ if (!entry.child)
1096
+ throw new Error(`Delegation handle "${id}" has no child session.`);
1097
+ return entry.child;
1098
+ }
1099
+ async wait(id) {
1100
+ const entry = this.entries.get(id);
1101
+ // A historical record has nothing to await: its persisted settlement (if
1102
+ // any) is served through retrieve, which throws the actionable error when
1103
+ // no output was persisted.
1104
+ if (!entry)
1105
+ return this.retrieve(id);
1106
+ if (!entry.closed) {
1107
+ const child = this.child(id);
1108
+ if (entry.pending || child.status === "running")
1109
+ this.save(id, entry, "running");
1110
+ // A turn that ends interrupted or failed rejects here; retrieval below owns
1111
+ // propagation, surfacing interrupted as an outcome and failed as a throw.
1112
+ await child.wait().catch(() => undefined);
1113
+ }
1114
+ return this.retrieve(id);
1115
+ }
1116
+ sendInput(id, input) {
1117
+ // Unknown and closed handles throw synchronously, matching close()'s contract.
1118
+ const child = this.child(id);
1119
+ const entry = this.entries.get(id);
1120
+ const turn = child.sendInput(input).then(() => {
1121
+ this.recordCompletion(id, entry, "");
1122
+ }, (error) => {
1123
+ this.recordFailure(id, entry, error);
1124
+ throw error;
1125
+ });
1126
+ void turn.catch(() => undefined);
1127
+ entry.pending = turn;
1128
+ return turn;
1129
+ }
1130
+ /**
1131
+ * Interrupt a live child. An optional reason turns the interrupt into an
1132
+ * explicit cancellation: `{reason, at}` is persisted on the record
1133
+ * immediately and the active attempt is marked `cancelled`; without a
1134
+ * reason the attempt settles plain `interrupted` at the turn's settlement.
1135
+ */
1136
+ interrupt(id, reason) {
1137
+ this.child(id).interrupt();
1138
+ if (reason === undefined)
1139
+ return;
1140
+ const entry = this.entries.get(id);
1141
+ if (!entry)
1142
+ return; // Unreachable: child(id) already validated the handle.
1143
+ entry.cancelled = { reason, at: Date.now() };
1144
+ const attempt = activeAttemptOf(entry);
1145
+ if (attempt)
1146
+ attempt.outcome = "cancelled";
1147
+ // The child's post-interrupt handle status maps onto the record's status
1148
+ // vocabulary ("idle" is a handle state, not a record state).
1149
+ const childStatus = entry.child?.status;
1150
+ const recordStatus = childStatus === "running" ? "running" : "interrupted";
1151
+ this.save(id, entry, recordStatus);
1152
+ }
1153
+ /**
1154
+ * Close a live run's active attempt with its latest settled outcome and open
1155
+ * a new one for the resuming turn (spec 2026-09-09, "Child lifecycle"):
1156
+ * `AgentSession.resumeChildRun` calls this on the live path before
1157
+ * `sendInput` so a resume is a distinct attempt epoch, while ordinary
1158
+ * follow-ups on a live run stay inside the active attempt.
1159
+ */
1160
+ beginResumeAttempt(id) {
1161
+ const entry = this.entries.get(id);
1162
+ if (!entry)
1163
+ throw new Error(`Unknown delegation handle "${id}".`);
1164
+ const prior = activeAttemptOf(entry);
1165
+ if (prior && prior.endedAt === undefined) {
1166
+ prior.endedAt = Date.now();
1167
+ prior.outcome ??= entry.latest?.outcome;
1168
+ }
1169
+ // A resume close stamps the closed attempt's cumulative-at-end rollup
1170
+ // when its settlement never produced one (a settlement-stamped snapshot
1171
+ // is never overwritten with the later, larger transcript).
1172
+ if (prior && prior.tokensAtEnd === undefined) {
1173
+ const totals = this.usageTotalsFor(id, entry.artifactDir, entry.child);
1174
+ if (totals)
1175
+ prior.tokensAtEnd = totals;
1176
+ }
1177
+ const attempts = entry.attempts ?? [];
1178
+ const attempt = { id: `attempt-${attempts.length + 1}`, startedAt: Date.now() };
1179
+ attempts.push(attempt);
1180
+ entry.attempts = attempts;
1181
+ entry.activeAttemptId = attempt.id;
1182
+ }
1183
+ close(id) {
1184
+ this.assertOpen();
1185
+ const entry = this.entries.get(id);
1186
+ if (!entry)
1187
+ throw new Error(`Unknown delegation handle "${id}".`);
1188
+ if (entry.closed)
1189
+ return;
1190
+ entry.closed = true;
1191
+ this.workspaceClaims.delete(id);
1192
+ // A closed run's slot is released with it (spec 2026-09-09, "Shared
1193
+ // budgets"), even when its turn never settles.
1194
+ this.releaseChildRunSlot(id);
1195
+ // Release the child's workspace (worktree) with its lifecycle; best effort,
1196
+ // fire-and-forget: close() is synchronous and must never throw on cleanup.
1197
+ void this.releaseWorkspaceOnce(id);
1198
+ this.save(id, entry, "closed");
1199
+ if (entry.child) {
1200
+ this.children.delete(entry.child);
1201
+ entry.child.close();
1202
+ }
1203
+ }
1204
+ /** Stop tracking handles and release any children and workspaces still owned by this registry. */
1205
+ dispose() {
1206
+ if (this.disposed)
1207
+ return;
1208
+ this.disposed = true;
1209
+ for (const child of this.children) {
1210
+ try {
1211
+ child.interrupt();
1212
+ }
1213
+ catch { }
1214
+ try {
1215
+ this.release(child);
1216
+ }
1217
+ catch { }
1218
+ }
1219
+ // Release every tracked child workspace (worktree) with the registry.
1220
+ // Best effort and fire-and-forget: dispose() is synchronous.
1221
+ for (const sessionId of this.trackedWorkspaces) {
1222
+ void this.releaseWorkspaceOnce(sessionId);
1223
+ }
1224
+ this.entries.clear();
1225
+ this.workspaceClaims.clear();
1226
+ this.usageCache.clear();
1227
+ // No run holds a concurrency slot past the registry's disposal.
1228
+ this.heldRunSlots.clear();
1229
+ }
29
1230
  }
30
1231
  /**
31
1232
  * Run one delegation to completion. Throws rather than returning a failure value,
@@ -47,72 +1248,144 @@ function background(options) {
47
1248
  * or `buildChildSession`. `buildChildSession` receives the child's depth (parent + 1)
48
1249
  * so the caller can record it on the child's own session header.
49
1250
  */
1251
+ /**
1252
+ * Whether two resolved paths denote the same directory or either contains the
1253
+ * other. Comparison is separator-correct: forward-slash prefix matching
1254
+ * silently never matches on platforms whose resolve() produces backslashes.
1255
+ */
1256
+ export function claimPathsOverlap(a, b) {
1257
+ const ab = relative(a, b);
1258
+ if (ab !== ".." && !ab.startsWith(`..${sep}`) && !isAbsolute(ab))
1259
+ return true;
1260
+ const ba = relative(b, a);
1261
+ return ba !== ".." && !ba.startsWith(`..${sep}`) && !isAbsolute(ba);
1262
+ }
50
1263
  export async function runDelegation(options, agentType, task, request = {}) {
51
- const definition = options.resolveAgent(agentType);
52
- if (!definition) {
53
- throw new Error(`Unknown agent type "${agentType}".`);
54
- }
55
- const depth = options.getDelegationDepth();
56
- if (depth >= options.maxDelegationDepth) {
57
- throw new Error(`Delegation depth limit (${options.maxDelegationDepth}) reached at depth ${depth}; cannot delegate to agent "${agentType}" further.`);
1264
+ let registry = options.childRunRegistry;
1265
+ if (!registry) {
1266
+ registry = new ChildRunRegistry();
1267
+ options.childRunRegistry = registry;
58
1268
  }
59
- const requestedCapabilities = new Set();
60
- for (const toolName of definition.tools) {
61
- const capabilities = options.getToolCapabilities(toolName);
62
- if (!capabilities) {
63
- throw new Error(`Agent "${agentType}" requests unknown tool "${toolName}".`);
1269
+ registry.assertOpen();
1270
+ registry.setRuntimeOptions(options);
1271
+ // Idempotent spawn: a key that already maps to a handle returns that handle
1272
+ // BEFORE any admission or child construction, so a duplicate spawn consumes
1273
+ // no second concurrency slot. The map is rebuilt from persisted records on
1274
+ // restore, so a restarted parent dedupes too.
1275
+ if (request.idempotencyKey !== undefined) {
1276
+ const existing = registry.handleForIdempotencyKey(request.idempotencyKey);
1277
+ if (existing !== undefined) {
1278
+ return {
1279
+ agentType,
1280
+ task,
1281
+ output: `Delegation already started with handle "${existing}" (idempotency key "${request.idempotencyKey}"); retrieve it with that handle.`,
1282
+ handleId: existing,
1283
+ };
64
1284
  }
65
- for (const capability of capabilities)
66
- requestedCapabilities.add(capability);
67
1285
  }
68
- const ceiling = computeCapabilityCeiling(options.getParentCapabilities(), requestedCapabilities);
69
- if (!ceiling.allowed) {
70
- throw new Error(`Delegating to agent "${agentType}" requires capability "${ceiling.deniedCapability}", which exceeds the parent's authority.`);
1286
+ const timeoutMs = typeof request.timeoutMs === "number" && Number.isFinite(request.timeoutMs) ? request.timeoutMs : undefined;
1287
+ const { definition, capabilities } = resolveAdmittedDefinition(options, agentType);
1288
+ const workspace = request.workspace ?? { isolation: "shared-read", ownedPaths: [] };
1289
+ // The owner is read only for worktree isolation, so production callers can
1290
+ // construct it lazily: a shared-read delegation never builds an owner and
1291
+ // never invokes git.
1292
+ const workspaceOwner = workspace.isolation === "worktree" ? options.workspaceOwner : undefined;
1293
+ if (workspace.isolation === "worktree" && !workspaceOwner) {
1294
+ throw new Error(`Delegation to agent "${agentType}" requested worktree isolation, but no workspace owner is available.`);
71
1295
  }
72
- const sessionId = randomUUID();
73
- let artifactDir;
74
- const parentSessionDir = options.getParentSessionDir?.();
75
- if (parentSessionDir) {
76
- const parentRoot = resolve(parentSessionDir);
77
- artifactDir = join(parentRoot, "delegations", sessionId);
78
- mkdirSync(artifactDir, { recursive: true });
79
- }
80
- const child = await options.buildChildSession({
81
- agentType,
82
- definition,
83
- toolNames: definition.tools,
84
- depth: depth + 1,
85
- sessionId,
86
- artifactDir,
87
- });
88
- const execute = async () => {
89
- try {
90
- const { output } = await child.run(task);
91
- return { agentType, task, output };
1296
+ const claims = [...workspace.ownedPaths].map((p) => resolve(p));
1297
+ for (const entry of registry.activeWorkspaceClaims()) {
1298
+ if (claims.some((path) => claimPathsOverlap(path, entry))) {
1299
+ throw new Error(`Delegation to agent "${agentType}" overlaps an active write ownership claim.`);
92
1300
  }
93
- finally {
94
- child.dispose();
1301
+ }
1302
+ // The depth bound and capability ceiling are enforced inside
1303
+ // resolveAdmittedDefinition above, shared with historical reattachment.
1304
+ // The caller may provide the public handle so the registry identity and
1305
+ // child session identity cannot diverge. Existing callers retain generated IDs.
1306
+ const sessionId = request.handleId ?? randomUUID();
1307
+ const depth = options.getDelegationDepth();
1308
+ registry.claimWorkspace(sessionId, claims);
1309
+ if (workspaceOwner) {
1310
+ // Worktree isolation: the registry releases this workspace when the run
1311
+ // closes or the registry disposes, through the same owner.
1312
+ registry.setWorkspaceOwner(workspaceOwner);
1313
+ registry.trackWorkspace(sessionId);
1314
+ }
1315
+ try {
1316
+ // Concurrency admission (spec 2026-09-09, "Shared budgets"): over the
1317
+ // limit this throws BEFORE any child session is built, naming the limit.
1318
+ registry.admitChildRun(sessionId);
1319
+ const preparedWorkspace = workspaceOwner
1320
+ ? await workspaceOwner.prepare({ ...workspace, ownedPaths: claims, sessionId })
1321
+ : workspace;
1322
+ let artifactDir;
1323
+ const parentSessionDir = options.getParentSessionDir?.();
1324
+ if (parentSessionDir) {
1325
+ const parentRoot = resolve(parentSessionDir);
1326
+ artifactDir = join(parentRoot, "delegations", sessionId);
1327
+ mkdirSync(artifactDir, { recursive: true });
95
1328
  }
96
- };
97
- if (!request.background)
98
- return execute();
99
- const handleId = sessionId;
100
- const promise = execute();
101
- // Retrieval owns propagation of a child failure; mark the background promise
102
- // observed now so a child that fails before retrieval does not become an
103
- // unhandled rejection.
104
- void promise.catch(() => { });
105
- background(options).set(handleId, { agentType, promise });
106
- return { agentType, task, output: `Delegation started. Retrieve result with handle "${handleId}".`, handleId };
1329
+ const child = await options.buildChildSession({
1330
+ agentType,
1331
+ definition,
1332
+ toolNames: definition.tools,
1333
+ capabilities,
1334
+ depth: depth + 1,
1335
+ sessionId,
1336
+ artifactDir,
1337
+ workspace: preparedWorkspace,
1338
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
1339
+ });
1340
+ registry.own(child);
1341
+ const handleId = sessionId;
1342
+ const promise = child.run(task).then(({ output }) => ({ agentType, task, output }));
1343
+ // register() observes the turn's settlement: it records the handle's latest
1344
+ // result (retrieval follows that, not this promise) and persists the run's
1345
+ // terminal status, so no separate settlement hook is needed here. The
1346
+ // durable fields ride along so every save() can persist them for restart.
1347
+ // A launch is attempt 1; timeoutMs also records the wall-clock deadline the
1348
+ // pollable status observes lazily. The record linkage (policy snapshot,
1349
+ // sandbox flag, parent session id) comes from the handle's own
1350
+ // construction report -- the values buildChildSession actually used --
1351
+ // and the runtime's parent-session seam; fixture handles that omit them
1352
+ // simply leave the record fields absent.
1353
+ registry.register(handleId, {
1354
+ agentType,
1355
+ task,
1356
+ promise,
1357
+ child,
1358
+ artifactDir,
1359
+ workspace: preparedWorkspace,
1360
+ depth: depth + 1,
1361
+ attempts: [{ id: "attempt-1", startedAt: Date.now() }],
1362
+ activeAttemptId: "attempt-1",
1363
+ ...(child.policy ? { policy: child.policy } : {}),
1364
+ ...(child.sandboxEnforced !== undefined ? { sandboxEnforced: child.sandboxEnforced } : {}),
1365
+ ...(options.getParentSessionId ? { parentSessionId: options.getParentSessionId() } : {}),
1366
+ ...(request.idempotencyKey !== undefined ? { idempotencyKey: request.idempotencyKey } : {}),
1367
+ ...(timeoutMs !== undefined ? { deadlineMs: Date.now() + timeoutMs } : {}),
1368
+ });
1369
+ if (!request.background)
1370
+ return promise;
1371
+ return { agentType, task, output: `Delegation started. Retrieve result with handle "${handleId}".`, handleId };
1372
+ }
1373
+ catch (error) {
1374
+ // The delegation never started: release the workspace immediately (also
1375
+ // marks it released, so a later close/dispose cannot double-release) and
1376
+ // give back the concurrency slot admitted above.
1377
+ registry.releaseChildRunSlot(sessionId);
1378
+ await registry.releaseWorkspaceNow(sessionId);
1379
+ throw error;
1380
+ }
107
1381
  }
108
1382
  /** Retrieve a background delegation. Running children are awaited; unknown handles fail explicitly. */
109
1383
  export async function retrieveDelegationResult(options, handleId, expectedAgentType) {
110
- const entry = background(options).get(handleId);
111
- if (!entry)
112
- throw new Error(`Unknown delegation handle "${handleId}".`);
113
- if (expectedAgentType !== undefined && entry.agentType !== expectedAgentType) {
114
- throw new Error(`Delegation handle "${handleId}" belongs to agent "${entry.agentType}", not "${expectedAgentType}".`);
1384
+ let registry = options.childRunRegistry;
1385
+ if (!registry) {
1386
+ registry = new ChildRunRegistry();
1387
+ options.childRunRegistry = registry;
115
1388
  }
116
- return entry.promise;
1389
+ return registry.retrieve(handleId, expectedAgentType);
117
1390
  }
118
1391
  //# sourceMappingURL=runtime.js.map