@hydraharness/harness-subagent 0.1.1-rc.5

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +155 -0
  3. package/lib/index.js +2743 -0
  4. package/lib/invariant.js +76 -0
  5. package/lib/types/activation-setup-registry.d.ts +57 -0
  6. package/lib/types/activation-setup-registry.js +148 -0
  7. package/lib/types/assistant-output.d.ts +48 -0
  8. package/lib/types/assistant-output.js +72 -0
  9. package/lib/types/budget.d.ts +34 -0
  10. package/lib/types/budget.js +54 -0
  11. package/lib/types/child-agent.d.ts +139 -0
  12. package/lib/types/child-agent.js +169 -0
  13. package/lib/types/client.d.ts +7 -0
  14. package/lib/types/client.js +7 -0
  15. package/lib/types/continuation.d.ts +457 -0
  16. package/lib/types/continuation.js +1157 -0
  17. package/lib/types/depth.d.ts +31 -0
  18. package/lib/types/depth.js +39 -0
  19. package/lib/types/descriptor-seed.d.ts +21 -0
  20. package/lib/types/descriptor-seed.js +24 -0
  21. package/lib/types/descriptor.d.ts +139 -0
  22. package/lib/types/descriptor.js +189 -0
  23. package/lib/types/error.d.ts +11 -0
  24. package/lib/types/error.js +14 -0
  25. package/lib/types/index.d.ts +296 -0
  26. package/lib/types/index.js +374 -0
  27. package/lib/types/invariant.d.ts +13 -0
  28. package/lib/types/invariant.js +91 -0
  29. package/lib/types/lifecycle.d.ts +113 -0
  30. package/lib/types/lifecycle.js +177 -0
  31. package/lib/types/list-children.d.ts +112 -0
  32. package/lib/types/list-children.js +316 -0
  33. package/lib/types/out-of-process.d.ts +117 -0
  34. package/lib/types/out-of-process.js +211 -0
  35. package/lib/types/projection-types.d.ts +60 -0
  36. package/lib/types/projection-types.js +7 -0
  37. package/lib/types/projection.d.ts +95 -0
  38. package/lib/types/projection.js +150 -0
  39. package/lib/types/run-settlement.d.ts +17 -0
  40. package/lib/types/run-settlement.js +66 -0
  41. package/lib/types/types.d.ts +315 -0
  42. package/lib/types/types.js +19 -0
  43. package/package.json +112 -0
@@ -0,0 +1,1157 @@
1
+ /**
2
+ * Internal continuable-subagent manager: stable child ids, descriptor
3
+ * persistence, activation admission, the live ownership graph, cold resume,
4
+ * child-first disposal, and settlement delivery to the parent, behind
5
+ * `ctx.subagents`.
6
+ *
7
+ * A continuable child has one durable Session and at most one process-local
8
+ * {@link Activation} — one residency epoch for a reconstructed child Agent. An
9
+ * Activation is not a request, result, cancellation, or Task boundary: it may
10
+ * execute many FIFO turns and stays resident while descendants it created are
11
+ * still running. The Agent inbox is the only turn queue, so this manager owns
12
+ * residency while the Agent loop owns all turn ordering and execution. No
13
+ * continuable path creates a Task or an intermediate result-bearing wrapper.
14
+ *
15
+ * Because residency is this manager's alone to end, telling the parent that a
16
+ * child settled is its job too. An external `subagent/end` listener cannot do
17
+ * it correctly: that payload names no parent, the child handle is already
18
+ * disposed by then, and the release that wakes the parent's own settlement
19
+ * watcher has already run. See {@link SubagentContinuationManager.notifySettlement}.
20
+ *
21
+ * @module @hydraharness/harness-subagent
22
+ */
23
+ import { randomUUID } from 'node:crypto';
24
+ import { boundContextSummary, createUserMessage, errorChain } from '@hydraharness/harness-llm';
25
+ import { SessionId } from '@hydraharness/harness-session';
26
+ import { foldSubagentDescriptor, snapshotSubagentDescriptor } from "./descriptor.js";
27
+ import { appendDelegatedPolicyOverrides, applyChildComposition, captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, } from "./child-agent.js";
28
+ import { assertSubagentMaxDepth } from "./depth.js";
29
+ import { seedDescriptorTurn } from "./descriptor-seed.js";
30
+ import { SubagentError } from "./error.js";
31
+ /**
32
+ * Read one Activation's current disposal transaction. This indirection exists
33
+ * because TypeScript would otherwise narrow repeated reads of the mutable field
34
+ * inside a long-lived closure to constants instead of re-reading runtime state.
35
+ * @param activation - the Activation to inspect.
36
+ * @returns the in-flight or settled disposal, or `undefined` while resident.
37
+ */
38
+ function disposalOf(activation) {
39
+ return activation.disposal;
40
+ }
41
+ /**
42
+ * One line telling a parent that a background child is finished and why, in
43
+ * the parent's own task vocabulary.
44
+ * @param childId - the durable child the parent knows by id.
45
+ * @param stopReason - how the child's last ordinary turn ended.
46
+ * @returns the model-facing opening line of the settlement notice.
47
+ */
48
+ function settlementSummary(childId, stopReason) {
49
+ const subject = `Background subagent ${childId}`;
50
+ switch (stopReason) {
51
+ case 'completed':
52
+ return `${subject} finished and will do no further work unless you send it more.`;
53
+ case 'aborted':
54
+ return `${subject} was stopped before it finished.`;
55
+ case 'max-tokens':
56
+ return `${subject} ran out of room before it finished.`;
57
+ // A pre-step rejection — a hook deny, a policy plugin — discarded input
58
+ // the child had claimed, so the parent must not treat the task as done.
59
+ case 'refusal':
60
+ return `${subject} declined the task.`;
61
+ case 'error':
62
+ return `${subject} failed before it finished.`;
63
+ /* v8 ignore next 4 -- `SubagentResult['stopReason']` is merge-extensible, so this arm
64
+ * needs a backend that adds a variant; an unnameable ending is reported as unfinished
65
+ * rather than silently as success. */
66
+ default:
67
+ return `${subject} ended abnormally (${String(stopReason)}) before it finished.`;
68
+ }
69
+ }
70
+ /** Serialize each durable child's delivery, release, and disposal. */
71
+ class ChildLock {
72
+ tails = new Map();
73
+ /**
74
+ * Run `operation` after every previously queued operation for `childId`.
75
+ * @param childId - the durable child whose operations are linearized.
76
+ * @param operation - the critical section to run in order.
77
+ * @returns the operation's own settlement.
78
+ */
79
+ run(childId, operation) {
80
+ const previous = this.tails.get(childId) ?? Promise.resolve();
81
+ const result = previous.then(operation, operation);
82
+ // Absorb rejections in the chaining tail so one failed critical section
83
+ // cannot reject an unrelated later caller.
84
+ const tail = result.then(() => undefined, () => undefined);
85
+ this.tails.set(childId, tail);
86
+ void tail.then(() => {
87
+ if (this.tails.get(childId) === tail)
88
+ this.tails.delete(childId);
89
+ });
90
+ return result;
91
+ }
92
+ }
93
+ /**
94
+ * The continuable-subagent orchestration service behind `ctx.subagents`. Tool
95
+ * schema and host adapters are consumers of this one contract; foreground
96
+ * one-shot delegation keeps calling `ctx.subagents.start()` and never enters
97
+ * this lifecycle.
98
+ */
99
+ export class SubagentContinuationManager {
100
+ ctx;
101
+ host;
102
+ setupRegistry;
103
+ /** Child session id → its live Activation. Process-local, never durable. */
104
+ activations = new Map();
105
+ /** Materializations admitted before drain, tracked through publication or rollback. */
106
+ materializations = new Set();
107
+ locks = new ChildLock();
108
+ /** Structural Cordis owner of every Activation handle. */
109
+ ownerCtx;
110
+ /**
111
+ * Exact roots whose host teardown has begun, with the live lineage members
112
+ * observed under each root. Entries remain until that exact root leaves the
113
+ * Agent registry, closing admission throughout its host's teardown without
114
+ * poisoning a later same-id replacement.
115
+ */
116
+ closingScopes = new Map();
117
+ draining = false;
118
+ constructor(ctx, host, setupRegistry) {
119
+ this.ctx = ctx;
120
+ this.host = host;
121
+ this.setupRegistry = setupRegistry;
122
+ // Ordinary Cordis owner effects unwind in reverse registration order, which
123
+ // cannot express the dynamic child graph. Register the private scope's
124
+ // structural disposer FIRST and the drain SECOND, so reverse unwind invokes
125
+ // the drain before releasing the scope; a cleanup effect on the same scope
126
+ // as the Agent handles would let structural handle disposal bypass
127
+ // child-first ordering.
128
+ const scope = ctx.plugin(function activationOwner() { });
129
+ this.ownerCtx = scope.ctx;
130
+ ctx.on('agent/disposed', ({ agent }) => {
131
+ this.closingScopes.delete(agent);
132
+ });
133
+ ctx.effect(function* () {
134
+ yield scope.dispose;
135
+ yield () => this.drain();
136
+ }.bind(this), 'subagents.continuations()');
137
+ }
138
+ /**
139
+ * Start one continuable background child: reserve its durable identity,
140
+ * resolve the provider's detached creation spec, create the child Agent
141
+ * through the private activation-owner scope, establish any continuable-parent
142
+ * ownership, and submit the initial prompt. Resolves when inbox acceptance
143
+ * yields the message id — without waiting for the turn to start or for the
144
+ * message to reach the Session log.
145
+ *
146
+ * Every failure before that acceptance rejects without either id, disposing
147
+ * any created handle and rolling back the Activation and parent ownership.
148
+ * The caller signal owns lookup, materialization, and admission only until
149
+ * acceptance; afterwards the manager owns the Activation independently.
150
+ * @param spec - provider, delegation request, and caller cancellation.
151
+ * @returns the durable child id and the accepted initial prompt's message id.
152
+ */
153
+ async startContinuable(spec) {
154
+ const request = spec.request;
155
+ const parent = request.parent;
156
+ this.assertAdmitting(parent);
157
+ const persistence = this.requirePersistence();
158
+ assertSubagentMaxDepth(request.maxDepth);
159
+ const childId = spec.childId ?? SessionId(randomUUID());
160
+ this.assertChildIdAvailable(childId);
161
+ const childDepth = resolveChildDepth(parent, request.maxDepth);
162
+ // Snapshot before any await: invalid descriptor JSON rejects the call
163
+ // before a child exists, and the detached value is what reaches the log.
164
+ const agentProvider = request.agentOptions?.provider ?? parent.options.provider;
165
+ const agentModel = request.agentOptions?.model ?? parent.options.model;
166
+ const descriptor = snapshotSubagentDescriptor({
167
+ mode: 'continuable',
168
+ provider: spec.provider,
169
+ label: spec.label,
170
+ ...agentProvider !== undefined ? { agentProvider } : {},
171
+ ...agentModel !== undefined ? { agentModel } : {},
172
+ ...request.persona !== undefined ? { persona: request.persona } : {},
173
+ ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {},
174
+ });
175
+ // Capture before the first await: a later parent switch belongs to the
176
+ // parent's future, not to this child.
177
+ const delegatedPolicies = captureDelegatedPolicyOverrides(parent);
178
+ const prepared = await this.host.prepareContinuable(spec.provider, {
179
+ sessionId: childId,
180
+ parent,
181
+ signal: spec.signal,
182
+ });
183
+ spec.signal.throwIfAborted();
184
+ this.assertAdmitting(parent);
185
+ const lineageSeedLength = prepared.seed?.length ?? 0;
186
+ const seed = seedDescriptorTurn(childId, prepared.seed, descriptor);
187
+ const messageId = await this.locks.run(childId, async () => {
188
+ spec.signal.throwIfAborted();
189
+ this.assertAdmitting(parent);
190
+ this.assertChildIdAvailable(childId);
191
+ if (spec.childId !== undefined) {
192
+ const persisted = await persistence.listSnapshots(spec.signal);
193
+ spec.signal.throwIfAborted();
194
+ this.assertAdmitting(parent);
195
+ this.assertChildIdAvailable(childId);
196
+ if (persisted.some(snapshot => snapshot.header.id === childId)) {
197
+ throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD');
198
+ }
199
+ }
200
+ const activation = await this.materialize({
201
+ childId,
202
+ provider: spec.provider,
203
+ parent,
204
+ create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies },
205
+ agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth),
206
+ composition: { persona: request.persona, toolFilter: request.toolFilter },
207
+ signal: spec.signal,
208
+ });
209
+ return this.submitMaterialized(activation, request.prompt, { kind: 'user' }, parent, spec.signal);
210
+ });
211
+ return { childId, messageId };
212
+ }
213
+ /** Reject one child identity already owned by a live Agent or Session. */
214
+ assertChildIdAvailable(childId) {
215
+ if (this.ctx.agents.get(childId) !== undefined || this.ctx.get('sessions')?.get(childId) !== undefined) {
216
+ throw new SubagentError(`subagent "${childId}" already exists`, 'DUPLICATE_CHILD');
217
+ }
218
+ }
219
+ /**
220
+ * Deliver one later message to a known continuable child as its next FIFO
221
+ * turn. Routing depends only on Activation residency: a `running` Activation
222
+ * enqueues, a `waiting` one wakes the same Agent, and an absent one
223
+ * cold-resumes a new Activation from the persisted Session. The Agent inbox
224
+ * is the only queue, so every accepted message has one observable order.
225
+ *
226
+ * The caller signal owns lookup, materialization, and admission only until
227
+ * inbox acceptance; afterwards the accepted turn cannot be cancelled through
228
+ * this service.
229
+ * @param parent - the exact live direct parent authorizing this delivery.
230
+ * @param childId - the durable child session id.
231
+ * @param content - the user-role content to deliver.
232
+ * @param options - the message source fields and caller cancellation.
233
+ * @returns the accepted message's inbox id.
234
+ * @throws when parent authority, availability, or admission rejects the delivery.
235
+ */
236
+ async followup(parent, childId, content, options) {
237
+ this.assertAdmitting(parent);
238
+ while (true) {
239
+ const live = await this.locks.run(childId, async () => {
240
+ const activation = this.activations.get(childId);
241
+ if (activation === undefined)
242
+ return this.coldResume(parent, childId, content, options);
243
+ // A delivery that arrives after the disposal transaction began must not
244
+ // reach a handle being torn down; wait for release, then cold-resume.
245
+ /* v8 ignore next 3 -- the send-versus-dispose cutoff: reaching this arm needs a
246
+ * delivery to observe the transaction inside the same critical section that opened it,
247
+ * which no test can schedule deterministically. The behavior is covered end-to-end by
248
+ * "cold-resumes a delivery that lost the race with final disposal". */
249
+ if (activation.disposal !== undefined) {
250
+ return activation.disposal.then(() => undefined, () => undefined);
251
+ }
252
+ return this.submitAdmitted(activation, content, options.source, parent, options.signal);
253
+ });
254
+ /* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that
255
+ * race reaches the retry below, which then cold-resumes a new Activation. */
256
+ if (live !== undefined)
257
+ return live;
258
+ this.assertAdmitting(parent);
259
+ options.signal.throwIfAborted();
260
+ /* v8 ignore stop */
261
+ }
262
+ }
263
+ /**
264
+ * Interrupt one live continuable child's current turn. Admission is
265
+ * synchronous and the effect is asynchronous: this authorizes the caller,
266
+ * requests `Agent.cancel(cause, { keepInbox: true })` on the target, and
267
+ * returns without waiting for the target to observe the signal or reach
268
+ * quiescence. The Activation, its handle, accepted unclaimed inbox work, and
269
+ * already-published descendants are untouched; work already claimed into the
270
+ * interrupted turn is not requeued. Once the interrupted driver is idle, a
271
+ * waking send resumes the parked queue.
272
+ *
273
+ * An absent target is an accepted no-op, which uniformly covers natural
274
+ * completion races, repeated requests, one-shot ids, and unknown ids without
275
+ * consulting the durable catalog. A target whose disposal transaction is
276
+ * already open is likewise an accepted no-op after authorization.
277
+ * @param targetSessionId - the durable child session id to interrupt.
278
+ * @param authority - the human parent address or exact live ancestor Agent.
279
+ * @throws {SubagentError} `UNAUTHORIZED` when the presented authority does
280
+ * not own the live target: a stale or self-targeting ancestor caller, a
281
+ * parent address that is not the live target's durable direct parent, or
282
+ * an ancestor outside the target's recorded live lineage.
283
+ */
284
+ interrupt(targetSessionId, authority) {
285
+ if (authority.kind === 'ancestor') {
286
+ const caller = authority.agent;
287
+ // A stale caller is rejected even when the target is absent, so a
288
+ // replaced same-id Agent can never probe this manager's state.
289
+ if (this.ctx.agents.get(caller.id) !== caller) {
290
+ throw new SubagentError(`interrupting "${targetSessionId}" requires the exact live ancestor agent`, 'UNAUTHORIZED');
291
+ }
292
+ if (caller.id === targetSessionId) {
293
+ throw new SubagentError(`agent "${caller.id}" cannot interrupt itself`, 'UNAUTHORIZED');
294
+ }
295
+ }
296
+ const activation = this.activations.get(targetSessionId);
297
+ if (activation === undefined)
298
+ return;
299
+ if (authority.kind === 'user') {
300
+ if (activation.handle.agent.session.header.parentSession !== authority.parentSessionId) {
301
+ throw new SubagentError(`subagent "${targetSessionId}" belongs to another parent session`, 'UNAUTHORIZED');
302
+ }
303
+ }
304
+ else if (!activation.ancestry.has(authority.agent)) {
305
+ throw new SubagentError(`subagent "${targetSessionId}" is not a live descendant of agent "${authority.agent.id}"`, 'UNAUTHORIZED');
306
+ }
307
+ // Disposal already stopped the target with a whole-Activation teardown;
308
+ // a second cancel would be a redundant signal on a closing handle.
309
+ if (activation.disposal !== undefined)
310
+ return;
311
+ activation.handle.agent.cancel(authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' }, { keepInbox: true });
312
+ }
313
+ /**
314
+ * Deliver explicitly selected content from one resident continuable child to
315
+ * its durable direct parent. Sender authorization, parent resolution, and
316
+ * send acceptance share one no-await span. Reporting neither concludes the
317
+ * child's turn nor changes its Activation lifetime.
318
+ * @param child - exact live reporting child; this is the authority credential.
319
+ * @param content - selected model-facing content.
320
+ * @param options - scheduling policy and pre-acceptance cancellation.
321
+ * @returns the stable identity of the message accepted by the parent.
322
+ * @throws {SubagentError} when the sender is unauthorized, the parent is not
323
+ * live, or continuation admission is closing.
324
+ */
325
+ // oxlint-disable-next-line typescript/require-await -- keep rejection semantics without yielding during admission
326
+ async reportFrom(child, content, options) {
327
+ options.signal.throwIfAborted();
328
+ this.assertAdmitting(child);
329
+ const activation = this.authorizeReporter(child);
330
+ const parent = this.resolveReportParent(child);
331
+ return this.deliverReport(activation, parent, content, options.delivery);
332
+ }
333
+ /** Authorize only the exact Agent of one resident Activation. */
334
+ authorizeReporter(child) {
335
+ const activation = this.activations.get(child.id);
336
+ if (activation === undefined || activation.handle.agent !== child) {
337
+ throw new SubagentError(`agent "${child.id}" is not a live continuable subagent and cannot report`, 'UNAUTHORIZED');
338
+ }
339
+ /* v8 ignore next 6 -- only a synchronous re-entrant disposer can open this
340
+ * transaction between exact-agent authorization and this no-await cutoff. */
341
+ if (activation.disposal !== undefined) {
342
+ throw new SubagentError(`subagent "${child.id}" activation is being disposed; the report was not delivered`, 'ACTIVATION_CLOSING');
343
+ }
344
+ return activation;
345
+ }
346
+ /** Resolve the reporting child's live direct parent from durable lineage. */
347
+ resolveReportParent(child) {
348
+ const parentId = child.session.header.parentSession;
349
+ /* v8 ignore next -- every continuation-managed child has direct-parent metadata. */
350
+ const parent = parentId === undefined ? undefined : this.ctx.agents.get(parentId);
351
+ if (parent === undefined) {
352
+ throw new SubagentError('direct parent is not live; report was not delivered', 'PARENT_UNAVAILABLE');
353
+ }
354
+ return parent;
355
+ }
356
+ /** Deliver one framed report through the selected parent scheduling preset. */
357
+ deliverReport(activation, parent, content, delivery) {
358
+ const message = createUserMessage({
359
+ content: [
360
+ { type: 'text', text: `Background subagent ${activation.childId} reported:` },
361
+ ...content,
362
+ ],
363
+ source: {
364
+ kind: 'subagent-report',
365
+ form: 'relay',
366
+ senderSessionId: activation.childId,
367
+ },
368
+ });
369
+ if (delivery === 'next-step') {
370
+ this.sendWaking(parent, message, () => { this.sendReport(parent, message, delivery); });
371
+ }
372
+ else {
373
+ this.sendReport(parent, message, delivery);
374
+ }
375
+ return message.id;
376
+ }
377
+ /**
378
+ * Perform one waking send to a parent, accounted against that parent's own
379
+ * Activation when it has one. Registering the id before the send is what
380
+ * keeps a continuation-managed parent from being judged quiescent in the
381
+ * window between a waking send and the microtask that admits it.
382
+ * @param parent - the exact live parent receiving the waking message.
383
+ * @param message - the message whose id is accounted.
384
+ * @param send - the synchronous waking send to perform.
385
+ */
386
+ sendWaking(parent, message, send) {
387
+ const parentActivation = this.activations.get(parent.id);
388
+ if (parentActivation !== undefined && parentActivation.handle.agent === parent) {
389
+ this.admitWaking(parentActivation, message.id, send);
390
+ }
391
+ else {
392
+ send();
393
+ }
394
+ }
395
+ /** Send one report while translating only the parent's own rejection. */
396
+ sendReport(parent, message, delivery) {
397
+ try {
398
+ if (delivery === 'next-step')
399
+ parent.steer(message);
400
+ else
401
+ parent.inject(message);
402
+ }
403
+ catch (error) {
404
+ throw new SubagentError('direct parent is not live; report was not delivered', 'PARENT_UNAVAILABLE', { cause: error });
405
+ }
406
+ }
407
+ /**
408
+ * Close admission, await every already-admitted materialization through
409
+ * publication or rollback, then dispose the stable live Activation forest
410
+ * child-first. Sibling branches drain independently: one failure is recorded
411
+ * but never prevents the remaining handles from being attempted, and the
412
+ * aggregate rejects only after every branch settles.
413
+ * @returns once materialization is quiescent and every live Activation released its handle.
414
+ * @throws an aggregate error when any branch failed to release.
415
+ */
416
+ async drain() {
417
+ // Close admission synchronously before the first await. Materializations
418
+ // already past that cutoff remain tracked until their handle is installed
419
+ // or rollback completes, producing a stable forest for the later snapshot.
420
+ this.draining = true;
421
+ await Promise.all([...this.materializations].map(materialization => materialization.settled));
422
+ // Snapshot roots after closing admission: a root is an Activation no live
423
+ // Activation owns, so disposing roots recurses child-first into the forest.
424
+ const owned = new Set();
425
+ for (const activation of this.activations.values()) {
426
+ for (const child of activation.ownedChildren)
427
+ owned.add(child);
428
+ }
429
+ const roots = [...this.activations.values()].filter(activation => !owned.has(activation.childId));
430
+ await this.disposeRoots(roots, 'activation(s)');
431
+ }
432
+ /**
433
+ * Stop only the continuable descendants of exact live host-owned parents.
434
+ * Admission stays closed for those parent trees until each exact parent
435
+ * leaves the Agent registry; unrelated trees and manager-wide admission stay
436
+ * live.
437
+ * @param parents - exact live roots whose continuable descendants must stop.
438
+ * @returns once every retained descendant Activation released its handle.
439
+ * @throws an aggregate error after all scoped branches settle when any failed.
440
+ */
441
+ async drainDescendants(parents) {
442
+ const roots = new Set(parents.filter(parent => this.ctx.agents.get(parent.id) === parent));
443
+ if (roots.size === 0)
444
+ return;
445
+ // Publish the scoped admission cutoff before the first await. Merge with an
446
+ // earlier call for the same exact root so a converging drain cannot forget
447
+ // descendants whose release is already in flight.
448
+ for (const root of roots) {
449
+ this.closingMembers(root).add(root);
450
+ }
451
+ const targets = [];
452
+ for (const activation of this.activations.values()) {
453
+ const lineage = this.liveLineage(activation.handle.agent);
454
+ // Strict descendants only: a continuable Agent may itself be a
455
+ // host-owned root, and its host remains responsible for that root handle.
456
+ const owners = [...roots].filter(root => activation.handle.agent !== root
457
+ && activation.ancestry.has(root));
458
+ if (owners.length === 0)
459
+ continue;
460
+ targets.push(activation);
461
+ for (const owner of owners) {
462
+ const members = this.closingMembers(owner);
463
+ members.add(activation.handle.agent);
464
+ for (const agent of lineage)
465
+ members.add(agent);
466
+ }
467
+ }
468
+ const materializations = [...this.materializations].filter((materialization) => {
469
+ const owners = [...roots].filter(root => materialization.lineage.includes(root));
470
+ for (const owner of owners) {
471
+ const members = this.closingMembers(owner);
472
+ for (const agent of materialization.lineage)
473
+ members.add(agent);
474
+ }
475
+ return owners.length > 0;
476
+ });
477
+ const ownedTargets = new Set();
478
+ for (const activation of targets) {
479
+ for (const child of activation.ownedChildren)
480
+ ownedTargets.add(child);
481
+ }
482
+ const targetRoots = targets.filter(activation => !ownedTargets.has(activation.childId));
483
+ // Open every selected transaction before the materialization barrier.
484
+ // Disposal propagates cancellation top-down in the same synchronous span;
485
+ // handle release remains child-first.
486
+ for (const activation of targets) {
487
+ const disposal = this.dispose(activation);
488
+ void disposal.catch(() => undefined);
489
+ }
490
+ await Promise.all(materializations.map(materialization => materialization.settled));
491
+ await this.disposeRoots(targetRoots, 'scoped activation(s)');
492
+ }
493
+ /**
494
+ * Release selected resident direct children of one exact live parent without
495
+ * closing admission for the parent's other continuable children. Owned
496
+ * descendants are released recursively through the same lifecycle.
497
+ * @param parent - exact live direct parent authorizing the selected release.
498
+ * @param childIds - durable direct-child ids to release when resident.
499
+ * @returns once every selected Activation released its handle.
500
+ * @throws {SubagentError} `UNAUTHORIZED` when a resident target is not the
501
+ * parent's direct continuable child or the parent identity is stale.
502
+ */
503
+ async drainChildren(parent, childIds) {
504
+ if (this.ctx.agents.get(parent.id) !== parent) {
505
+ throw new SubagentError('selected child teardown requires the exact live parent agent', 'UNAUTHORIZED');
506
+ }
507
+ const targets = [];
508
+ for (const childId of new Set(childIds)) {
509
+ const activation = this.activations.get(childId);
510
+ if (activation === undefined)
511
+ continue;
512
+ if (activation.parentSession !== parent.id || !activation.ancestry.has(parent)) {
513
+ throw new SubagentError(`subagent "${childId}" is not a direct child of agent "${parent.id}"`, 'UNAUTHORIZED');
514
+ }
515
+ targets.push(activation);
516
+ }
517
+ // Open every transaction before the first await so cancellation propagates
518
+ // across the selected roots in one synchronous span.
519
+ for (const activation of targets) {
520
+ const disposal = this.dispose(activation);
521
+ void disposal.catch(() => undefined);
522
+ }
523
+ await this.disposeRoots(targets, 'selected activation(s)');
524
+ }
525
+ /** Dispose independent roots and report every branch failure after all settle. */
526
+ async disposeRoots(roots, failureSubject) {
527
+ const failures = await Promise.all(roots.map(async (activation) => {
528
+ try {
529
+ await this.dispose(activation);
530
+ return undefined;
531
+ }
532
+ catch (error) {
533
+ return error;
534
+ }
535
+ }));
536
+ const reasons = failures.filter(failure => failure !== undefined);
537
+ if (reasons.length > 0) {
538
+ throw new SubagentError(`continuable subagent teardown failed for ${reasons.length} ${failureSubject}: `
539
+ + reasons.map(reason => errorChain(reason)).join('; '), 'ACTIVATION_TEARDOWN_FAILED');
540
+ }
541
+ }
542
+ /** Return the retained member set for one exact scoped-teardown root. */
543
+ closingMembers(root) {
544
+ const existing = this.closingScopes.get(root);
545
+ if (existing !== undefined)
546
+ return existing;
547
+ const members = new Set();
548
+ this.closingScopes.set(root, members);
549
+ return members;
550
+ }
551
+ /**
552
+ * Return the exact currently resolvable ancestry from `agent` upward. The
553
+ * first element is always the supplied identity, even when it is already
554
+ * stale; each ancestor after it must be the registry's current exact entry.
555
+ */
556
+ liveLineage(agent) {
557
+ const lineage = [agent];
558
+ const seen = new Set([agent.id]);
559
+ let parentSession = agent.session.header.parentSession;
560
+ while (parentSession !== undefined) {
561
+ const parent = this.ctx.agents.get(parentSession);
562
+ if (parent === undefined || seen.has(parent.id))
563
+ break;
564
+ lineage.push(parent);
565
+ seen.add(parent.id);
566
+ parentSession = parent.session.header.parentSession;
567
+ }
568
+ return lineage;
569
+ }
570
+ /**
571
+ * The teardown that closed continuable admission for this agent's lineage.
572
+ * `'manager'` is the whole manager draining; an Agent is the exact scoped root
573
+ * whose forest is closing.
574
+ * @param agent - the agent whose lineage is tested.
575
+ * @returns the closing teardown, or `undefined` while admission is open.
576
+ */
577
+ closingTeardownFor(agent) {
578
+ if (this.draining)
579
+ return 'manager';
580
+ const lineage = this.liveLineage(agent);
581
+ for (const [root, members] of this.closingScopes) {
582
+ if (members.has(agent) || lineage.includes(root))
583
+ return root;
584
+ }
585
+ return undefined;
586
+ }
587
+ /** Reject new admission once the manager or this exact parent tree began draining. */
588
+ assertAdmitting(agent) {
589
+ const closing = this.closingTeardownFor(agent);
590
+ if (closing === undefined)
591
+ return;
592
+ throw new SubagentError(closing === 'manager'
593
+ ? 'continuable subagents are draining; the operation was not admitted'
594
+ : `continuable subagents below parent "${closing.id}" are draining; the operation was not admitted`, 'DRAINING');
595
+ }
596
+ /**
597
+ * Derive residency from Agent quiescence and the owned-child set. `running`
598
+ * covers an active admission, an open turn, or accepted waking inbox work.
599
+ *
600
+ * `Agent.status` alone is insufficient: it stays `idle` between an accepted
601
+ * waking send and the microtask that admits it, so a synchronous inbox
602
+ * observer would see `settled` while a turn is already queued. `accepted`
603
+ * holds the ids this manager admitted but has not yet seen drained.
604
+ */
605
+ stateOf(activation) {
606
+ if (activation.handle.agent.status === 'running' || activation.accepted.size > 0)
607
+ return 'running';
608
+ if (activation.ownedChildren.size > 0)
609
+ return 'waiting';
610
+ return 'settled';
611
+ }
612
+ /**
613
+ * Cold-resume a persisted child: inspect and authorize its Session, fold the
614
+ * generic descriptor, create the Activation through `ctx.agents.resume()`,
615
+ * and submit the waiting turn. This never dispatches through a subagent
616
+ * provider — the persisted Session already holds the initial prefix and the
617
+ * descriptor is the whole reconstruction input.
618
+ */
619
+ async coldResume(parent, childId, content, options) {
620
+ const persistence = this.requirePersistence();
621
+ let loaded;
622
+ try {
623
+ loaded = await persistence.inspect(childId, options.signal);
624
+ }
625
+ catch (error) {
626
+ options.signal.throwIfAborted();
627
+ throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error });
628
+ }
629
+ options.signal.throwIfAborted();
630
+ this.assertAdmitting(parent);
631
+ // Authorize the persisted header before folding: only the durable child's
632
+ // exact live direct parent may continue it.
633
+ this.authorizeLineage(parent, childId, loaded.meta.parentSession);
634
+ // Fold only the child's own suffix: a fork seed replays the parent's log,
635
+ // which may carry an ANCESTOR's descriptor when the parent is itself a
636
+ // continuable child.
637
+ const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0));
638
+ if (descriptor === undefined || descriptor.mode !== 'continuable') {
639
+ throw new SubagentError(`subagent "${childId}" has no supported continuation state and cannot be resumed; `
640
+ + 'do not retry send_message with this id', 'NOT_RESUMABLE');
641
+ }
642
+ let activation;
643
+ try {
644
+ activation = await this.materialize({
645
+ childId,
646
+ provider: descriptor.provider,
647
+ parent,
648
+ agentOptions: {
649
+ ...descriptor.agentProvider !== undefined ? { provider: descriptor.agentProvider } : {},
650
+ ...descriptor.agentModel !== undefined ? { model: descriptor.agentModel } : {},
651
+ },
652
+ composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter },
653
+ signal: options.signal,
654
+ });
655
+ }
656
+ catch (error) {
657
+ options.signal.throwIfAborted();
658
+ if (error instanceof SubagentError)
659
+ throw error;
660
+ throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error });
661
+ }
662
+ return this.submitMaterialized(activation, content, options.source, parent, options.signal);
663
+ }
664
+ /**
665
+ * Submit to a freshly materialized Activation or roll it back completely.
666
+ * @param activation - the just-published Activation to admit or release.
667
+ * @param content - the initial or resumed message content.
668
+ * @param source - durable fields naming who supplied the accepted message.
669
+ * @param parent - the live direct parent authorizing admission.
670
+ * @param signal - caller cancellation owning admission until acceptance.
671
+ * @returns the accepted inbox message id.
672
+ */
673
+ async submitMaterialized(activation, content, source, parent, signal) {
674
+ try {
675
+ return this.submitAdmitted(activation, content, source, parent, signal);
676
+ }
677
+ catch (error) {
678
+ /* v8 ignore next -- rollback disposal failures must not mask the
679
+ * pre-acceptance signal, drain, or lifecycle failure. */
680
+ await this.dispose(activation).catch(() => undefined);
681
+ throw error;
682
+ }
683
+ }
684
+ /**
685
+ * Create or resume the child Agent through the private activation-owner
686
+ * scope, install the handle in a fresh Activation, and register ownership on
687
+ * a continuation-managed parent. Rejection leaves no Activation, no handle,
688
+ * and no ownership membership.
689
+ */
690
+ materialize(inputs) {
691
+ this.assertAdmitting(inputs.parent);
692
+ const settled = Promise.withResolvers();
693
+ const lineage = this.liveLineage(inputs.parent);
694
+ const releaseBudget = this.host.admit(inputs.parent, inputs.create !== undefined);
695
+ const materialization = {
696
+ lineage,
697
+ settled: settled.promise,
698
+ };
699
+ this.materializations.add(materialization);
700
+ return this.materializeTracked(inputs, lineage, releaseBudget).catch((error) => {
701
+ releaseBudget();
702
+ throw error;
703
+ }).finally(() => {
704
+ this.materializations.delete(materialization);
705
+ settled.resolve();
706
+ });
707
+ }
708
+ /**
709
+ * Perform one tracked materialization. The caller keeps the drain barrier
710
+ * registered until this either returns a resident Activation or finishes
711
+ * rollback.
712
+ */
713
+ async materializeTracked(inputs, parentLineage, releaseBudget) {
714
+ const { childId, provider, parent, create } = inputs;
715
+ // No id pre-check here: the child lock serializes each durable child, both
716
+ // callers reach this only after confirming no Activation exists, and
717
+ // `AgentRegistry.enter()` is the authoritative collision boundary for an id
718
+ // some other owner holds — a duplicate would reject there with rollback.
719
+ inputs.signal.throwIfAborted();
720
+ const setup = (childCtx) => {
721
+ // Only fresh creation seeds the delegation policy onto the child's own
722
+ // log (after any fork seed, so fresh policy wins stale seed state); a
723
+ // cold resume replays those persisted events instead.
724
+ if (create !== undefined) {
725
+ appendDelegatedPolicyOverrides(childCtx.agent.session, create.delegatedPolicies);
726
+ }
727
+ applyChildComposition(childCtx, parent, inputs.composition);
728
+ return this.setupRegistry.apply(childCtx);
729
+ };
730
+ const observer = this.host.observeActivation(provider, childId, parent);
731
+ // Agent creation owns rollback before handle transfer. A rejection leaves
732
+ // no resident Activation and therefore publishes no lifecycle edge.
733
+ const handle = create === undefined
734
+ ? await this.ownerCtx.agents.resume({
735
+ resumeSessionId: childId,
736
+ agentOptions: inputs.agentOptions,
737
+ signal: inputs.signal,
738
+ setup,
739
+ })
740
+ : await this.ownerCtx.agents.create({
741
+ sessionId: childId,
742
+ meta: create.meta,
743
+ seed: create.seed,
744
+ agentOptions: inputs.agentOptions,
745
+ signal: inputs.signal,
746
+ setup,
747
+ });
748
+ const activation = {
749
+ releaseBudget,
750
+ childId,
751
+ // The durable lineage, not merely the caller: creation stamps this same
752
+ // agent into the child's header, and cold resume authorized it against
753
+ // the persisted header before materializing.
754
+ parentSession: parent.id,
755
+ provider,
756
+ handle,
757
+ ancestry: new WeakSet([handle.agent, ...parentLineage]),
758
+ ownedChildren: new Set(),
759
+ observer,
760
+ disposal: undefined,
761
+ accepted: new Set(),
762
+ announced: false,
763
+ poke: Promise.withResolvers(),
764
+ };
765
+ // After transfer, any failure must dispose the created handle, remove the
766
+ // Activation, and roll back parent ownership before rejecting.
767
+ this.activations.set(childId, activation);
768
+ try {
769
+ inputs.signal.throwIfAborted();
770
+ this.assertAdmitting(parent);
771
+ this.acquireOwnership(parent, childId);
772
+ // Every accepted id leaves the inbox exactly once, through dequeue or
773
+ // discard. Clearing it there is what lets `stateOf()` distinguish a truly
774
+ // quiet Agent from one whose accepted turn has not been admitted yet.
775
+ // Registered through the child's own scoped context, so scope filtering
776
+ // already restricts both listeners to this exact agent.
777
+ handle.agent.ctx.on('agent/inbox/claimed', ({ message }) => {
778
+ /* v8 ignore next -- a claim of an id this manager never admitted needs
779
+ * another sender on the same child, which no current path allows. */
780
+ if (activation.accepted.delete(message.id))
781
+ this.wake(activation);
782
+ });
783
+ handle.agent.ctx.on('agent/inbox/discarded', ({ message }) => {
784
+ if (activation.accepted.delete(message.id))
785
+ this.wake(activation);
786
+ });
787
+ // Agent creation committed setup at its publication boundary;
788
+ // revocations from here on are immediate live revocation.
789
+ // Publish the start edge before any turn can run, so observers see this
790
+ // epoch before its first request.
791
+ observer.start(handle.agent);
792
+ }
793
+ catch (error) {
794
+ // Listener exceptions are contained by the lifecycle emitter; a start
795
+ // publication throw therefore leaves no residency edge to pair.
796
+ /* v8 ignore next -- rollback failure must not mask the admission failure
797
+ * that prevented this operation from returning an accepted message id. */
798
+ await this.rollbackUnpublished(activation).catch(() => undefined);
799
+ throw error;
800
+ }
801
+ this.watchSettlement(activation);
802
+ return activation;
803
+ }
804
+ /**
805
+ * Release an Activation whose start edge was not published. The memoized
806
+ * transaction remains in the live map until handle disposal settles, so a
807
+ * concurrent drain or delivery observes the same closing boundary.
808
+ */
809
+ rollbackUnpublished(activation) {
810
+ return (activation.disposal ??= (async () => {
811
+ try {
812
+ await activation.handle.dispose();
813
+ }
814
+ finally {
815
+ this.activations.delete(activation.childId);
816
+ this.releaseOwnership(activation.childId);
817
+ }
818
+ })());
819
+ }
820
+ /**
821
+ * Register the child in a continuation-managed parent's owned set before the
822
+ * child can run, so that parent cannot settle while the child is live. A
823
+ * top-level or other non-continuation Agent has no Activation and stays
824
+ * outside the waiting graph.
825
+ */
826
+ acquireOwnership(parent, childId) {
827
+ const parentActivation = this.activations.get(parent.id);
828
+ if (parentActivation === undefined)
829
+ return;
830
+ if (parentActivation.disposal !== undefined) {
831
+ throw new SubagentError(`subagent parent "${parent.id}" is being disposed; the child was not established`, 'ACTIVATION_CLOSING');
832
+ }
833
+ parentActivation.ownedChildren.add(childId);
834
+ }
835
+ /** Remove one child from its live owner's set and let that owner re-check settlement. */
836
+ releaseOwnership(childId) {
837
+ for (const candidate of this.activations.values()) {
838
+ if (candidate.ownedChildren.delete(childId))
839
+ this.wake(candidate);
840
+ }
841
+ }
842
+ /** Let a settlement watcher re-observe quiescence after ownership or inbox changes. */
843
+ wake(activation) {
844
+ activation.poke.resolve();
845
+ activation.poke = Promise.withResolvers();
846
+ }
847
+ /**
848
+ * Submit one message as the child's next FIFO turn and return its accepted
849
+ * inbox id. Acceptance is the operation's success boundary; the manager owns
850
+ * the Activation independently afterwards.
851
+ */
852
+ submit(activation, content, source, parent) {
853
+ // Parent-originated delivery keeps the parent live through ownership, so
854
+ // establish it before the message can enter the child's inbox.
855
+ this.acquireOwnership(parent, activation.childId);
856
+ const message = createUserMessage({ content, source });
857
+ const accepted = this.admitWaking(activation, message.id, () => {
858
+ activation.handle.agent.followup(message);
859
+ });
860
+ // Past this point the caller has an id for this child, so its eventual
861
+ // settlement is something the parent is owed an account of.
862
+ activation.announced = true;
863
+ return accepted;
864
+ }
865
+ /**
866
+ * Account one waking send across a resident Activation's settlement window.
867
+ * @param activation - Activation receiving waking inbox work.
868
+ * @param messageId - stable identity of the message about to be sent.
869
+ * @param send - synchronous send that publishes one enqueue occurrence.
870
+ * @returns the accepted message id.
871
+ */
872
+ admitWaking(activation, messageId, send) {
873
+ // Waking Agent sends publish inbox events synchronously, so observers must
874
+ // see this Activation as busy before the call begins.
875
+ activation.accepted.add(messageId);
876
+ try {
877
+ send();
878
+ }
879
+ catch (error) {
880
+ activation.accepted.delete(messageId);
881
+ throw error;
882
+ }
883
+ // Accepted waking work keeps this Activation live until whenIdle() observes
884
+ // the complete waking suffix.
885
+ this.wake(activation);
886
+ return messageId;
887
+ }
888
+ /**
889
+ * Cross the final admission cutoff and submit without yielding. Signal abort,
890
+ * manager drain, or Activation disposal that wins before this synchronous
891
+ * span rejects without inbox acceptance.
892
+ */
893
+ submitAdmitted(activation, content, source, parent, signal) {
894
+ signal.throwIfAborted();
895
+ this.assertAdmitting(parent);
896
+ /* v8 ignore next 6 -- only a synchronous re-entrant disposer can change
897
+ * this field between the caller's live check and this no-await boundary. */
898
+ if (disposalOf(activation) !== undefined) {
899
+ throw new SubagentError(`subagent "${activation.childId}" activation is being disposed; the message was not accepted`, 'ACTIVATION_CLOSING');
900
+ }
901
+ this.authorizeLineage(parent, activation.childId, activation.handle.agent.session.header.parentSession);
902
+ return this.submit(activation, content, source, parent);
903
+ }
904
+ /**
905
+ * Authorize one operation against the durable direct-parent lineage. Other
906
+ * agents, ancestors, teams, workflows, and hosts remain rejected until an
907
+ * explicit authority protocol has a production consumer.
908
+ */
909
+ authorizeLineage(parent, childId, parentSession) {
910
+ if (this.ctx.agents.get(parent.id) !== parent) {
911
+ throw new SubagentError(`subagent "${childId}" delivery requires the exact live parent agent`, 'UNAUTHORIZED');
912
+ }
913
+ if (parentSession !== parent.id) {
914
+ throw new SubagentError(`subagent "${childId}" belongs to another parent session`, 'UNAUTHORIZED');
915
+ }
916
+ }
917
+ /**
918
+ * Follow one Activation to settlement: wait for Agent quiescence, then for
919
+ * every owned child to complete disposal, and dispose the handle once both
920
+ * hold. A `next-turn` delivered while `waiting` wakes the same Agent and
921
+ * returns it to `running`, so this re-observes rather than settling early.
922
+ */
923
+ watchSettlement(activation) {
924
+ void (async () => {
925
+ while (disposalOf(activation) === undefined) {
926
+ const poked = activation.poke.promise;
927
+ await Promise.race([activation.handle.agent.whenIdle(), poked]);
928
+ if (disposalOf(activation) !== undefined)
929
+ return;
930
+ // Re-check settlement INSIDE the child lock and begin disposal in the
931
+ // same critical section, so a concurrent delivery either wins admission
932
+ // before the transaction opens or waits for release and cold-resumes.
933
+ // Deciding outside the lock would let a delivery observe a not-yet
934
+ // resident handle that this watcher is already about to tear down.
935
+ const settling = await this.locks.run(activation.childId, () => {
936
+ if (disposalOf(activation) !== undefined || this.stateOf(activation) !== 'settled') {
937
+ return Promise.resolve({ settling: false });
938
+ }
939
+ // `dispose()` assigns its memoized transaction synchronously, so
940
+ // admission is closed before this critical section releases.
941
+ return Promise.resolve({ settling: true, done: this.dispose(activation) });
942
+ });
943
+ if (!settling.settling) {
944
+ // Still running, or waiting on descendants: re-observe after the next
945
+ // accepted message or ownership release.
946
+ if (activation.handle.agent.status !== 'running')
947
+ await poked;
948
+ continue;
949
+ }
950
+ try {
951
+ await settling.done;
952
+ }
953
+ catch (error) {
954
+ this.ctx.logger.warn(`subagent "${activation.childId}" activation teardown failed: ${errorChain(error)}`);
955
+ }
956
+ return;
957
+ }
958
+ })();
959
+ }
960
+ /**
961
+ * Stop one Activation immediately, then release it child-first. The memoized
962
+ * transaction is installed before cancellation or recursive callbacks, so
963
+ * admission and reentrant teardown converge on the same owner.
964
+ *
965
+ * The final session flush is best effort and never prevents handle disposal
966
+ * or ownership release, because retaining a child would permanently pin its
967
+ * ancestors in `waiting`.
968
+ * @param activation - the residency epoch to stop and release.
969
+ * @returns the one disposal transaction owned by this Activation.
970
+ */
971
+ dispose(activation) {
972
+ const existing = activation.disposal;
973
+ if (existing !== undefined)
974
+ return existing;
975
+ const completion = Promise.withResolvers();
976
+ // Presence is the admission cutoff. Assign it before the async helper starts
977
+ // because that helper cancels Agents and may synchronously re-enter callers.
978
+ activation.disposal = completion.promise;
979
+ void this.finishDisposal(activation).then(completion.resolve, completion.reject);
980
+ return completion.promise;
981
+ }
982
+ /**
983
+ * Propagate stop synchronously, then finish the child-first release.
984
+ * @param activation - the Activation whose disposal transaction is installed.
985
+ * @returns once the handle and ownership edge are released.
986
+ */
987
+ async finishDisposal(activation) {
988
+ this.wake(activation);
989
+ const { childId } = activation;
990
+ // Stop top-down before the first await. Slow descendant cleanup may delay
991
+ // release, but it cannot let this ancestor continue model or tool work.
992
+ activation.handle.agent.cancel({ kind: 'parent' });
993
+ const idle = activation.handle.agent.whenIdle();
994
+ const children = [...activation.ownedChildren]
995
+ .map(child => this.activations.get(child))
996
+ .filter((child) => child !== undefined);
997
+ const childDisposals = children.map(child => this.dispose(child));
998
+ const failures = [];
999
+ try {
1000
+ // Release remains child-first even though cancellation propagated
1001
+ // top-down: every owned child completes before this handle is removed.
1002
+ const childFailures = await Promise.all(childDisposals.map(async (disposal) => {
1003
+ try {
1004
+ await disposal;
1005
+ return undefined;
1006
+ }
1007
+ catch (error) {
1008
+ return error;
1009
+ }
1010
+ }));
1011
+ const reasons = childFailures.filter(reason => reason !== undefined);
1012
+ if (reasons.length > 0) {
1013
+ failures.push(new SubagentError(`subagent "${childId}" child teardown failed: ${reasons.map(reason => errorChain(reason)).join('; ')}`, 'ACTIVATION_TEARDOWN_FAILED'));
1014
+ }
1015
+ // Quiesce before the flush: a turn still running would keep
1016
+ // appending events the flush cannot cover.
1017
+ await idle;
1018
+ await this.flushFinalState(activation);
1019
+ // Capture the child-dependent edge data while the child is still live:
1020
+ // handle disposal unregisters it, and consumers read its log and scope.
1021
+ activation.observer.capture(activation.handle.agent);
1022
+ }
1023
+ catch (error) {
1024
+ failures.push(new SubagentError(`subagent "${childId}" activation teardown failed: ${errorChain(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }));
1025
+ }
1026
+ try {
1027
+ await activation.handle.dispose();
1028
+ }
1029
+ catch (error) {
1030
+ failures.push(new SubagentError(`subagent "${childId}" activation handle disposal failed: ${errorChain(error)}`, 'ACTIVATION_TEARDOWN_FAILED', { cause: error }));
1031
+ }
1032
+ let failure;
1033
+ if (failures.length === 1) {
1034
+ failure = failures[0];
1035
+ }
1036
+ else if (failures.length > 1) {
1037
+ failure = new SubagentError(`subagent "${childId}" activation teardown failed at ${failures.length} boundaries: `
1038
+ + failures.map(item => errorChain(item)).join('; '), 'ACTIVATION_TEARDOWN_FAILED', { cause: new AggregateError(failures) });
1039
+ }
1040
+ // Only now is the Activation gone: keeping the entry until disposal settles
1041
+ // makes a racing delivery wait for release rather than cold-resume into the
1042
+ // still-registered agent.
1043
+ this.activations.delete(childId);
1044
+ activation.releaseBudget();
1045
+ // BEFORE releasing ownership, while the parent still counts this child and
1046
+ // therefore cannot be judged settled. Delivering after the release would
1047
+ // race a parent watcher that resumes one microtask later, finds itself
1048
+ // childless and quiet, and disposes an Agent whose `cancel()` clears the
1049
+ // inbox this notice is sitting in.
1050
+ this.notifySettlement(activation, activation.observer.terminal(failure));
1051
+ // Release ownership even on failure: a retained failed child would pin its
1052
+ // ancestors in `waiting` forever.
1053
+ this.releaseOwnership(childId);
1054
+ // Emit once the disposal outcome is known, so a rejecting scoped cleanup
1055
+ // cannot be reported as a successful epoch.
1056
+ activation.observer.settle(failure);
1057
+ if (failure !== undefined)
1058
+ throw failure;
1059
+ }
1060
+ /**
1061
+ * Tell the durable direct parent that this child produced everything it is
1062
+ * going to. Unconditional for every child the caller received an id for: it
1063
+ * does not consider whether the child reported, because the cases that most
1064
+ * need it — a token ceiling, a model failure, cancellation, teardown — are
1065
+ * exactly the ones where the child never got to choose. A materialization
1066
+ * rolled back before its first acceptance stays silent, since the caller was
1067
+ * told that child was not established. A parent that is no longer live is not
1068
+ * an error; the child's own Session remains the durable record either way.
1069
+ * A parent whose own lineage is already closing receives the notice without a
1070
+ * wake, because teardown is not a reason to start a turn.
1071
+ *
1072
+ * Never blocks disposal. A delivery failure is logged and dropped, because
1073
+ * retaining a child to retry a notice would pin its whole ancestry in
1074
+ * `waiting` forever.
1075
+ * @param activation - the settling Activation, still owned by its parent.
1076
+ * @param terminal - how this epoch ended, as the terminal edge will report it.
1077
+ */
1078
+ notifySettlement(activation, terminal) {
1079
+ if (!activation.announced)
1080
+ return;
1081
+ try {
1082
+ const parent = this.ctx.agents.get(activation.parentSession);
1083
+ if (parent === undefined)
1084
+ return;
1085
+ const summary = settlementSummary(activation.childId, terminal.stopReason);
1086
+ const message = createUserMessage({
1087
+ content: [
1088
+ { type: 'text', text: summary },
1089
+ ...terminal.output === undefined
1090
+ ? [{ type: 'text', text: 'It left no closing message.' }]
1091
+ : [{ type: 'text', text: 'Its closing message:' }, ...terminal.output],
1092
+ ],
1093
+ source: {
1094
+ kind: 'subagent-settled',
1095
+ form: 'notice',
1096
+ summary: boundContextSummary(summary),
1097
+ senderSessionId: activation.childId,
1098
+ },
1099
+ });
1100
+ // A parent whose own teardown already began must not be woken. Waking is
1101
+ // not a queue operation: `followup()` on a quiescent Agent starts a turn,
1102
+ // and `cancel()` does not arm against a later one, so a notice arriving
1103
+ // during teardown would spend a model request on an Agent its host is
1104
+ // about to dispose — once per tree layer, since each layer's own notice
1105
+ // then wakes the layer above it. Injecting delivers to a parent still
1106
+ // reading its inbox and records the account in the log either way; it
1107
+ // does NOT survive that parent's own disposal, whose `keepInbox: false`
1108
+ // cancel durably clears whatever it never claimed.
1109
+ if (this.closingTeardownFor(parent) !== undefined) {
1110
+ parent.inject(message);
1111
+ return;
1112
+ }
1113
+ // An idle parent has nothing else to look at, so it gets one ordinary
1114
+ // turn. A busy parent is steered instead of woken: `Inbox.claim()` takes
1115
+ // the whole next-step batch at one boundary, so several children settling
1116
+ // together cost one step rather than one turn each. Steering rather than
1117
+ // injecting closes the window where a driver retires between this status
1118
+ // read and the send, which would strand the notice unclaimed.
1119
+ this.sendWaking(parent, message, () => {
1120
+ if (parent.status === 'idle')
1121
+ parent.followup(message);
1122
+ else
1123
+ parent.steer(message);
1124
+ });
1125
+ }
1126
+ catch (error) {
1127
+ this.ctx.logger.warn(`subagent "${activation.childId}" settlement notice was not delivered to its parent: `
1128
+ + errorChain(error));
1129
+ }
1130
+ }
1131
+ /**
1132
+ * Request a best-effort final session flush after the child is quiescent.
1133
+ * Listener failure is logged because flush participation cannot identify a
1134
+ * particular persistence backend, and teardown must still release ownership.
1135
+ * @param activation - the Activation whose final events should be flushed.
1136
+ */
1137
+ async flushFinalState(activation) {
1138
+ const child = activation.handle.agent;
1139
+ try {
1140
+ await child.ctx.sessions.flush(child.session);
1141
+ }
1142
+ catch (error) {
1143
+ this.ctx.logger.warn(`subagent "${activation.childId}" best-effort final session flush failed; `
1144
+ + `the persisted state may be unavailable or stale on resume: ${errorChain(error)}`);
1145
+ }
1146
+ }
1147
+ /** Resolve the persistence service continuable children require, or fail loud. */
1148
+ requirePersistence() {
1149
+ const persistence = this.ctx.get('sessionPersistence');
1150
+ if (persistence === undefined) {
1151
+ throw new SubagentError('continuable subagents require session persistence (load a hydra-session-persistence backend)', 'PERSISTENCE_UNAVAILABLE');
1152
+ }
1153
+ return persistence;
1154
+ }
1155
+ }
1156
+ export default SubagentContinuationManager;
1157
+ //# sourceMappingURL=continuation.js.map