@deepseek-ai/dsh-subagent 0.0.1-rc.1

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