fix(subagent): close continuation lifecycle gaps
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
|
||||
README.md: 1b38d493efa1dbe86464ad376649ff37914067da
|
||||
README.zh.md: ec907f466779fc5c8a503f003a50f4aaf41c8b49
|
||||
README.md: 0e59a1ad5f256de4d6505d3d00d3790d7738a457
|
||||
README.zh.md: 073b4903520544e1b5b9209f792aa5e05d9334b0
|
||||
|
||||
@@ -80,7 +80,7 @@ A continuation-managed parent Activation records each child Session id in an `ow
|
||||
|
||||
## Lifecycle events
|
||||
|
||||
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names.
|
||||
The service emits a `subagent/start`/`subagent/end` pair for each one-shot run and each resident continuable Activation epoch, so continuable children are observable with the same vocabulary as one-shot runs without exposing whether the manager materialized, woke, or cold-resumed them. For a one-shot start it attaches the result observer before the synchronous `subagent/start`, so even an already-settled child still produces `subagent/start` before `subagent/end`; a continuable epoch that fails before residency emits neither edge. The pair shares a service-minted `runId`; the `local` flag is snapshotted from the provider's exact `localAgent` (always true for a continuable child), so observers never infer run identity or locality from reusable provider/session names. The `provider` field is lifecycle provenance rather than a live-registry claim: an accepted one-shot run may become ready after provider removal, and a cold-resumed epoch retains its descriptor's initial provider name without requiring that provider to be registered.
|
||||
|
||||
Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run.
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
## 生命周期事件
|
||||
|
||||
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。
|
||||
服务会为每次一次性运行以及每个已驻留的可继续 Activation 时段发出一对 `subagent/start`/`subagent/end`,因此可继续子 agent 可用与一次性运行相同的词汇观察,且不会暴露管理器是物化、唤醒还是冷恢复了它们。对于一次性启动,它会在同步的 `subagent/start` 之前附加结果观察器,因此即使子 agent 已经结算,也仍会先产生 `subagent/start`,再产生 `subagent/end`;在驻留前失败的可继续时段不发出任何事件。这对事件共享服务生成的 `runId`;`local` 标志取自提供方准确 `localAgent` 的快照(可继续子 agent 恒为 true),因此观察器绝不会从可复用的提供方/会话名称推断运行身份或本地性。`provider` 字段是生命周期来源信息,而非提供方仍在注册的声明:已接受的一次性 run 可在提供方移除后才进入就绪状态,冷恢复时段也会保留描述符中的初始提供方名称,而不要求该提供方仍处于注册状态。
|
||||
|
||||
运行事件受执行委派的父级作用域约束。每个监听器都独立隔离:同步抛出或返回的 promise 被拒绝时,只会记录日志,不会阻塞同级监听器或改变运行。
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
resolveChildAgentOptions,
|
||||
resolveChildDepth,
|
||||
} from './child-agent.ts'
|
||||
import { assertSubagentMaxDepth } from './depth.ts'
|
||||
import { seedDescriptorTurn } from './descriptor-seed.ts'
|
||||
import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts'
|
||||
import type { ActivationObserver } from './lifecycle.ts'
|
||||
@@ -248,6 +249,7 @@ export class SubagentContinuationManager {
|
||||
this.requirePersistence()
|
||||
const request = spec.request
|
||||
const parent = request.parent
|
||||
assertSubagentMaxDepth(request.maxDepth)
|
||||
const childId = SessionId(randomUUID())
|
||||
const childDepth = resolveChildDepth(parent, request.maxDepth)
|
||||
// Snapshot before any await: invalid descriptor JSON rejects the call
|
||||
@@ -282,11 +284,13 @@ export class SubagentContinuationManager {
|
||||
composition: { persona: request.persona, toolFilter: request.toolFilter },
|
||||
signal: spec.signal,
|
||||
})
|
||||
// Materialization published the Activation; an abort landing in that
|
||||
// window — a `subagent/start` listener can cancel synchronously — must
|
||||
// roll the child back instead of opening its first turn.
|
||||
await this.rollbackIfAborted(activation, spec.signal)
|
||||
return this.submit(activation, request.prompt, { kind: 'user' }, parent)
|
||||
return this.submitMaterialized(
|
||||
activation,
|
||||
request.prompt,
|
||||
{ kind: 'user' },
|
||||
parent,
|
||||
spec.signal,
|
||||
)
|
||||
})
|
||||
return { childId, messageId }
|
||||
}
|
||||
@@ -328,13 +332,8 @@ export class SubagentContinuationManager {
|
||||
if (activation.disposal !== undefined) {
|
||||
return activation.disposal.then(() => undefined, () => undefined)
|
||||
}
|
||||
await this.authorizeLive(parent, activation)
|
||||
// The caller signal owns admission until acceptance, so re-check it
|
||||
// here: the outer check cannot cover an abort that landed while
|
||||
// authorization yielded, and enqueueing afterwards would return a
|
||||
// message id for a delivery the caller already cancelled.
|
||||
options.signal.throwIfAborted()
|
||||
return this.submit(activation, content, options.source, parent)
|
||||
this.authorizeLive(parent, activation)
|
||||
return this.submitAdmitted(activation, content, options.source, parent, options.signal)
|
||||
})
|
||||
/* v8 ignore start -- only the lost-cutoff arm above returns undefined, so only that
|
||||
* race reaches the retry below, which then cold-resumes a new Activation. */
|
||||
@@ -455,23 +454,33 @@ export class SubagentContinuationManager {
|
||||
composition: { persona: descriptor.persona, toolFilter: descriptor.toolFilter },
|
||||
signal: options.signal,
|
||||
})
|
||||
await this.rollbackIfAborted(activation, options.signal)
|
||||
return this.submit(activation, content, options.source, parent)
|
||||
return this.submitMaterialized(activation, content, options.source, parent, options.signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose a freshly materialized Activation when the caller signal won the
|
||||
* handoff between publication and inbox acceptance, so an aborted operation
|
||||
* never leaves a resident child.
|
||||
* @param activation - the just-published Activation.
|
||||
* @param signal - the caller signal owning admission until acceptance.
|
||||
* Submit to a freshly materialized Activation or roll it back completely.
|
||||
* @param activation - the just-published Activation to admit or release.
|
||||
* @param content - the initial or resumed message content.
|
||||
* @param source - durable provenance for the accepted message.
|
||||
* @param parent - the live direct parent authorizing admission.
|
||||
* @param signal - caller cancellation owning admission until acceptance.
|
||||
* @returns the accepted inbox message id.
|
||||
*/
|
||||
private async rollbackIfAborted(activation: Activation, signal: AbortSignal): Promise<void> {
|
||||
if (!signal.aborted) return
|
||||
/* v8 ignore next -- the swallow only covers a disposal fault during rollback, which
|
||||
* must not mask the caller's abort as the operation's failure. */
|
||||
await this.dispose(activation).catch(() => undefined)
|
||||
signal.throwIfAborted()
|
||||
private async submitMaterialized(
|
||||
activation: Activation,
|
||||
content: ContentBlock[],
|
||||
source: MessageSource,
|
||||
parent: Agent,
|
||||
signal: AbortSignal,
|
||||
): Promise<MessageId> {
|
||||
try {
|
||||
return this.submitAdmitted(activation, content, source, parent, signal)
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- rollback disposal failures must not mask the
|
||||
* pre-acceptance signal, drain, or lifecycle failure. */
|
||||
await this.dispose(activation).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,30 +507,24 @@ export class SubagentContinuationManager {
|
||||
inputs.signal.throwIfAborted()
|
||||
const setup = (childCtx: Context): void => { applyChildComposition(childCtx, inputs.composition) }
|
||||
const observer = this.host.observeActivation(provider, childId, parent)
|
||||
let handle: AgentHandle
|
||||
try {
|
||||
const { create } = inputs
|
||||
handle = create === undefined
|
||||
? await this.ownerCtx.agents.resume({
|
||||
resumeSessionId: childId,
|
||||
agentOptions: inputs.agentOptions,
|
||||
signal: inputs.signal,
|
||||
setup,
|
||||
})
|
||||
: await this.ownerCtx.agents.create({
|
||||
sessionId: childId,
|
||||
meta: create.meta,
|
||||
seed: create.seed,
|
||||
agentOptions: inputs.agentOptions,
|
||||
signal: inputs.signal,
|
||||
setup,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
// Agent creation provides rollback before handle transfer, so nothing
|
||||
// outlives this rejection; report the epoch that never became resident.
|
||||
// No start edge was published, so this epoch has no lifecycle to close.
|
||||
throw error
|
||||
}
|
||||
const { create } = inputs
|
||||
// Agent creation owns rollback before handle transfer. A rejection leaves
|
||||
// no resident Activation and therefore publishes no lifecycle edge.
|
||||
const handle: AgentHandle = create === undefined
|
||||
? await this.ownerCtx.agents.resume({
|
||||
resumeSessionId: childId,
|
||||
agentOptions: inputs.agentOptions,
|
||||
signal: inputs.signal,
|
||||
setup,
|
||||
})
|
||||
: await this.ownerCtx.agents.create({
|
||||
sessionId: childId,
|
||||
meta: create.meta,
|
||||
seed: create.seed,
|
||||
agentOptions: inputs.agentOptions,
|
||||
signal: inputs.signal,
|
||||
setup,
|
||||
})
|
||||
|
||||
const activation: Activation = {
|
||||
childId,
|
||||
@@ -540,42 +543,53 @@ export class SubagentContinuationManager {
|
||||
inputs.signal.throwIfAborted()
|
||||
this.assertAdmitting()
|
||||
this.acquireOwnership(parent, childId)
|
||||
// Every accepted id leaves the inbox exactly once, through dequeue or
|
||||
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
|
||||
// quiet Agent from one whose accepted turn has not been admitted yet.
|
||||
// Registered through the child's own scoped context, so scope filtering
|
||||
// already restricts both listeners to this exact agent.
|
||||
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
|
||||
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
|
||||
* another sender on the same child, which no current path allows. */
|
||||
if (activation.accepted.delete(item.message.id)) this.wake(activation)
|
||||
})
|
||||
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
|
||||
// Deleting every id in the batch is unconditional; waking once afterwards
|
||||
// costs nothing and avoids branching on which ids this manager admitted.
|
||||
for (const item of items) activation.accepted.delete(item.message.id)
|
||||
this.wake(activation)
|
||||
})
|
||||
// Resident: publish the start edge before any turn can run, so observers
|
||||
// see this epoch before its first request.
|
||||
observer.start(handle.agent)
|
||||
} catch (error: unknown) {
|
||||
// Roll the transfer back completely: the Activation leaves the map, the
|
||||
// parent's ownership membership is released, and the created handle is
|
||||
// disposed before this rejection surfaces. No lifecycle edge is published,
|
||||
// because `observer.start()` below has not run for this epoch.
|
||||
this.activations.delete(childId)
|
||||
this.releaseOwnership(childId)
|
||||
activation.disposal = handle.dispose()
|
||||
/* v8 ignore next -- the created handle disposes cleanly on every rollback this
|
||||
* transaction can reach; the catch only keeps a disposal fault from masking `error`. */
|
||||
await activation.disposal.catch(() => undefined)
|
||||
// Listener exceptions are contained by the lifecycle emitter; a start
|
||||
// publication throw therefore leaves no residency edge to pair.
|
||||
/* v8 ignore next -- rollback failure must not mask the admission failure
|
||||
* that prevented this operation from returning an accepted message id. */
|
||||
await this.rollbackUnpublished(activation).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
// Every accepted id leaves the inbox exactly once, through dequeue or
|
||||
// discard. Clearing it there is what lets `stateOf()` distinguish a truly
|
||||
// quiet Agent from one whose accepted turn has not been admitted yet.
|
||||
// Registered through the child's own scoped context, so scope filtering
|
||||
// already restricts both listeners to this exact agent.
|
||||
handle.agent.ctx.on('agent/inbox/dequeue', (_agent, item) => {
|
||||
/* v8 ignore next -- a dequeue of an id this manager never admitted needs
|
||||
* another sender on the same child, which no current path allows. */
|
||||
if (activation.accepted.delete(item.message.id)) this.wake(activation)
|
||||
})
|
||||
handle.agent.ctx.on('agent/inbox/discard', (_agent, items) => {
|
||||
// Deleting every id in the batch is unconditional; waking once afterwards
|
||||
// costs nothing and avoids branching on which ids this manager admitted.
|
||||
for (const item of items) activation.accepted.delete(item.message.id)
|
||||
this.wake(activation)
|
||||
})
|
||||
// Resident: publish the start edge before any turn can run, so observers
|
||||
// see this epoch before its first request.
|
||||
observer.start(handle.agent)
|
||||
this.watchSettlement(activation)
|
||||
return activation
|
||||
}
|
||||
|
||||
/**
|
||||
* Release an Activation whose start edge was not published. The memoized
|
||||
* transaction remains in the live map until handle disposal settles, so a
|
||||
* concurrent drain or delivery observes the same closing boundary.
|
||||
*/
|
||||
private rollbackUnpublished(activation: Activation): Promise<void> {
|
||||
return (activation.disposal ??= (async () => {
|
||||
try {
|
||||
await activation.handle.dispose()
|
||||
} finally {
|
||||
this.activations.delete(activation.childId)
|
||||
this.releaseOwnership(activation.childId)
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the child in a continuation-managed parent's owned set before the
|
||||
* child can run, so that parent cannot settle while the child is live. A
|
||||
@@ -637,12 +651,36 @@ export class SubagentContinuationManager {
|
||||
return message.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross the final admission cutoff and submit without yielding. Signal abort,
|
||||
* manager drain, or Activation disposal that wins before this synchronous
|
||||
* span rejects without inbox acceptance.
|
||||
*/
|
||||
private submitAdmitted(
|
||||
activation: Activation,
|
||||
content: ContentBlock[],
|
||||
source: MessageSource,
|
||||
parent: Agent,
|
||||
signal: AbortSignal,
|
||||
): MessageId {
|
||||
signal.throwIfAborted()
|
||||
this.assertAdmitting()
|
||||
/* v8 ignore next 6 -- only a synchronous re-entrant disposer can change
|
||||
* this field between the caller's live check and this no-await boundary. */
|
||||
if (disposalOf(activation) !== undefined) {
|
||||
throw new SubagentError(
|
||||
`subagent "${activation.childId}" activation is being disposed; the message was not accepted`,
|
||||
'ACTIVATION_CLOSING',
|
||||
)
|
||||
}
|
||||
return this.submit(activation, content, source, parent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize delivery to a live Activation. A parent must be the exact live
|
||||
* direct parent recorded in the child's durable header.
|
||||
*/
|
||||
private async authorizeLive(parent: Agent, activation: Activation): Promise<void> {
|
||||
await Promise.resolve()
|
||||
private authorizeLive(parent: Agent, activation: Activation): void {
|
||||
this.authorizeLineage(
|
||||
parent,
|
||||
activation.childId,
|
||||
@@ -762,6 +800,12 @@ export class SubagentContinuationManager {
|
||||
// Capture the child-dependent edge data while the child is still live:
|
||||
// handle disposal unregisters it, and consumers read its log and scope.
|
||||
activation.observer.capture(activation.handle.agent)
|
||||
} catch (error: unknown) {
|
||||
failure ??= new SubagentError(
|
||||
`subagent "${childId}" activation teardown failed: ${errorChain(error)}`,
|
||||
'ACTIVATION_TEARDOWN_FAILED',
|
||||
{ cause: error },
|
||||
)
|
||||
} finally {
|
||||
try {
|
||||
await activation.handle.dispose()
|
||||
|
||||
@@ -303,7 +303,7 @@ export class SubagentService extends Service {
|
||||
return provider
|
||||
}
|
||||
|
||||
/** Resolve the optional Task-backed continuation runtime or fail loud. */
|
||||
/** Resolve the optional continuable-subagent manager or fail loud. */
|
||||
private requireContinuations(): SubagentContinuationManager {
|
||||
if (this.continuations === undefined) {
|
||||
throw new SubagentError(
|
||||
|
||||
@@ -43,9 +43,11 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
}
|
||||
if (eventName === 'subagent/start') {
|
||||
const info = args[0] as SubagentRunInfo
|
||||
if (!providers.has(info.provider)) fail(`subagent/start names inactive provider ${JSON.stringify(info.provider)}`)
|
||||
if (String(info.runId).length === 0 || String(info.id).length === 0) {
|
||||
fail('subagent/start runId and child id must be non-empty')
|
||||
// Provider availability is an admission-time relationship. A ready
|
||||
// one-shot run may outlive provider removal, and a cold-resumed Activation
|
||||
// carries durable provider provenance without dispatching through it.
|
||||
if (info.provider.length === 0 || String(info.runId).length === 0 || String(info.id).length === 0) {
|
||||
fail('subagent/start provider, runId, and child id must be non-empty')
|
||||
}
|
||||
if (runs.has(info.runId)) fail(`subagent/start repeated run id ${JSON.stringify(info.runId)}`)
|
||||
stagedStarts.add(info)
|
||||
|
||||
@@ -35,7 +35,11 @@ export function SubagentRunId(id: string): SubagentRunId {
|
||||
export interface SubagentRunInfo {
|
||||
/** Unique identity shared with the paired terminal event. */
|
||||
readonly runId: SubagentRunId
|
||||
/** The provider that established the run. */
|
||||
/**
|
||||
* Provider provenance for this run or Activation epoch. The named provider
|
||||
* may be absent when an accepted run becomes ready or a persisted Activation
|
||||
* cold-resumes, because neither lifecycle depends on continued registration.
|
||||
*/
|
||||
readonly provider: string
|
||||
/** The child agent's id. */
|
||||
readonly id: SessionId
|
||||
@@ -50,7 +54,7 @@ export interface SubagentRunInfo {
|
||||
export interface SubagentRunEndInfo {
|
||||
/** Unique identity shared with the paired start event. */
|
||||
readonly runId: SubagentRunId
|
||||
/** The provider that ran it. */
|
||||
/** The same provider provenance carried by the paired start event. */
|
||||
readonly provider: string
|
||||
/** The child agent's id. */
|
||||
readonly id: SessionId
|
||||
|
||||
@@ -14,12 +14,14 @@ import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
import SubagentService, {
|
||||
SubagentError,
|
||||
SUBAGENT_DESCRIPTOR_VERSION,
|
||||
} from '../src/index.ts'
|
||||
import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
|
||||
import * as SubagentInvariant from '../src/invariant.ts'
|
||||
|
||||
type Script = ConstructorParameters<typeof MockAdapter>[0]
|
||||
|
||||
@@ -228,6 +230,24 @@ describe('SubagentService.startContinuable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls an unpublished Activation back when lifecycle publication fails', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('unused')])
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', info => void ends.push(info))
|
||||
ctx.on('internal/dispatch', (_mode, eventName) => {
|
||||
if (eventName === 'subagent/start') throw new Error('start publication failed')
|
||||
}, { global: true })
|
||||
|
||||
await expect(ctx.subagents.startContinuable(startSpec(parent)))
|
||||
.rejects.toThrow(/start publication failed/)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
|
||||
})
|
||||
expect(ends).toEqual([])
|
||||
await expect(ctx.subagents.drainContinuable()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a continuable child that would exceed the configured depth cap', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await expect(ctx.subagents.startContinuable({
|
||||
@@ -237,6 +257,15 @@ describe('SubagentService.startContinuable', () => {
|
||||
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
|
||||
})
|
||||
|
||||
it('rejects an invalid continuable depth cap before provider preparation', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
await expect(ctx.subagents.startContinuable({
|
||||
...startSpec(parent),
|
||||
request: { prompt: message('deep'), parent, maxDepth: Number.NaN },
|
||||
})).rejects.toThrow(/non-negative safe integer/)
|
||||
expect(ctx.agents.list().map(agent => agent.id)).toEqual([SessionId('parent')])
|
||||
})
|
||||
|
||||
it('omits undeclared composition fields from the descriptor', async () => {
|
||||
const { ctx } = await setup([])
|
||||
// A routeless parent declares no provider/model, and this start declares no
|
||||
@@ -402,6 +431,38 @@ describe('SubagentService.followup residency routing', () => {
|
||||
expect(loaded.events.filter(event => event.type === 'subagent/descriptor')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('cold-resumes after the initial provider unregisters', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')])
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SubagentInvariant)
|
||||
const disposeProvider = ctx.subagents.registerProvider({
|
||||
name: 'retired',
|
||||
capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
|
||||
inheritsParentContext: false,
|
||||
start: async () => { throw new Error('one-shot start is not used') },
|
||||
prepareContinuable: () => Promise.resolve({}),
|
||||
})
|
||||
const starts: SubagentRunInfo[] = []
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/start', info => void starts.push(info))
|
||||
ctx.on('subagent/end', info => void ends.push(info))
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent, 'retired'))
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
disposeProvider()
|
||||
expect(ctx.subagents.getProvider('retired')).toBeUndefined()
|
||||
|
||||
await expect(followup(ctx, parent, started.childId, message('continue without provider')))
|
||||
.resolves.toBeTypeOf('string')
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(2) })
|
||||
|
||||
expect(starts.map(info => info.provider)).toEqual(['retired', 'retired'])
|
||||
expect(ends.map(info => info.runId)).toEqual(starts.map(info => info.runId))
|
||||
const loaded = await ctx.sessionPersistence.load(started.childId)
|
||||
expect(userTexts(loaded.events)).toEqual(['child task', 'continue without provider'])
|
||||
})
|
||||
|
||||
it('wakes a waiting Activation instead of cold-resuming it', async () => {
|
||||
const releaseGrandchild = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
@@ -615,6 +676,48 @@ describe('continuable durability and teardown', () => {
|
||||
.rejects.toMatchObject({ code: 'DRAINING' })
|
||||
})
|
||||
|
||||
it('rejects an initial prompt when drain starts after materialization', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const drains: Promise<void>[] = []
|
||||
const accepted: MessageId[] = []
|
||||
ctx.on('subagent/start', () => { drains.push(ctx.subagents.drainContinuable()) })
|
||||
ctx.on('agent/inbox/enqueue', (_agent, item) => { accepted.push(item.message.id) })
|
||||
|
||||
await expect(ctx.subagents.startContinuable(startSpec(parent)))
|
||||
.rejects.toMatchObject({ code: 'DRAINING' })
|
||||
await Promise.all(drains)
|
||||
|
||||
expect(accepted).toEqual([])
|
||||
expect(ctx.agents.list()).toEqual([parent])
|
||||
})
|
||||
|
||||
it('admits a live follow-up before a later drain can begin disposal', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const child = ctx.agents.get(started.childId)!
|
||||
const order: string[] = []
|
||||
child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
|
||||
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) {
|
||||
order.push('enqueue')
|
||||
}
|
||||
})
|
||||
child.ctx.on('agent/cancel-requested', () => { order.push('cancel') })
|
||||
|
||||
const delivery = followup(ctx, parent, started.childId, message('before drain'))
|
||||
// Let the child-lock operation reach the live admission cutoff. Admission
|
||||
// and inbox submission must then complete in one synchronous span.
|
||||
await Promise.resolve()
|
||||
const drained = ctx.subagents.drainContinuable()
|
||||
hold.resolve(undefined)
|
||||
|
||||
await expect(delivery).resolves.toBeTypeOf('string')
|
||||
await drained
|
||||
expect(order).toEqual(['enqueue', 'cancel'])
|
||||
})
|
||||
|
||||
it('has no automatic replay for an accepted but unlogged message', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('first'), gate: hold.promise }])
|
||||
@@ -744,6 +847,29 @@ describe('continuable review regressions', () => {
|
||||
expect(ends[0]!.stopReason).toBe('error')
|
||||
})
|
||||
|
||||
it('reports a pre-disposal teardown failure on the terminal edge', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('answer'), gate: hold.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
ctx.on('subagent/end', info => void ends.push(info))
|
||||
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: {
|
||||
activations: Map<SessionId, { observer: { capture: (child: Agent) => void } }>
|
||||
}
|
||||
}).continuations
|
||||
const activation = manager.activations.get(started.childId)!
|
||||
activation.observer.capture = () => { throw new Error('capture failed') }
|
||||
|
||||
const drained = ctx.subagents.drainContinuable()
|
||||
hold.resolve(undefined)
|
||||
await expect(drained).rejects.toMatchObject({ code: 'ACTIVATION_TEARDOWN_FAILED' })
|
||||
await vi.waitFor(() => { expect(ends).toHaveLength(1) })
|
||||
expect(ends[0]!.stopReason).toBe('error')
|
||||
})
|
||||
|
||||
it('cancels a running turn before the final durability checkpoint', async () => {
|
||||
const hold = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('slow'), gate: hold.promise }])
|
||||
@@ -797,8 +923,8 @@ describe('continuable review regressions', () => {
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const child = ctx.agents.get(started.childId)!
|
||||
|
||||
// Cancel from the synchronous enqueue observer: the discard fires before
|
||||
// `followup()` returns, so the id is discarded before it can be recorded.
|
||||
// Cancel from the synchronous enqueue observer: the discard fires after the
|
||||
// id is recorded but before `followup()` returns.
|
||||
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
|
||||
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
|
||||
child.cancel({ kind: 'user' })
|
||||
@@ -815,6 +941,35 @@ describe('continuable review regressions', () => {
|
||||
expect(hasUserText(loaded.events, 'doomed')).toBe(false)
|
||||
})
|
||||
|
||||
it('releases older ids discarded during a later admission window', async () => {
|
||||
const releaseFirst = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: releaseFirst.promise }])
|
||||
const { ctx, parent } = await setupWith(adapter)
|
||||
const started = await ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
const child = ctx.agents.get(started.childId)!
|
||||
const manager = (ctx.subagents as unknown as {
|
||||
continuations: {
|
||||
activations: Map<SessionId, { accepted: Set<MessageId> }>
|
||||
}
|
||||
}).continuations
|
||||
const activation = manager.activations.get(started.childId)!
|
||||
|
||||
await followup(ctx, parent, started.childId, message('queued'))
|
||||
expect(activation.accepted.size).toBe(1)
|
||||
const off = child.ctx.on('agent/inbox/enqueue', (_agent, accepted) => {
|
||||
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
|
||||
child.cancel({ kind: 'user' })
|
||||
}
|
||||
})
|
||||
await followup(ctx, parent, started.childId, message('doomed'))
|
||||
off()
|
||||
|
||||
expect(activation.accepted.size).toBe(0)
|
||||
releaseFirst.resolve(undefined)
|
||||
await waitNoActivation(ctx, started.childId)
|
||||
})
|
||||
|
||||
it('reports completed when no ordinary turn closed', async () => {
|
||||
const { ctx, parent } = await setup([])
|
||||
const ends: SubagentRunEndInfo[] = []
|
||||
|
||||
@@ -68,10 +68,10 @@ describe('subagent invariants', () => {
|
||||
|
||||
it('rejects malformed and unpaired run transitions', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/inactive provider/)
|
||||
ctx.emit('subagent/provider-added', provider('mock'))
|
||||
expect(() => { emitRun(ctx, 'subagent/start', start({ provider: '' })) })
|
||||
.toThrow(/provider, runId, and child id must be non-empty/)
|
||||
expect(() => { emitRun(ctx, 'subagent/start', start({ runId: SubagentRunId('') })) })
|
||||
.toThrow(/runId and child id must be non-empty/)
|
||||
.toThrow(/provider, runId, and child id must be non-empty/)
|
||||
emitRun(ctx, 'subagent/start', start())
|
||||
expect(() => { emitRun(ctx, 'subagent/start', start()) }).toThrow(/repeated run id/)
|
||||
expect(() => { emitRun(ctx, 'subagent/end', end({ runId: SubagentRunId('missing') })) })
|
||||
@@ -79,4 +79,14 @@ describe('subagent invariants', () => {
|
||||
expect(() => { emitRun(ctx, 'subagent/end', end({ id: SessionId('other') })) })
|
||||
.toThrow(/identity diverges/)
|
||||
})
|
||||
|
||||
it('accepts historical provider provenance after registration ends', async () => {
|
||||
const ctx = await setup()
|
||||
const historical = provider('historical')
|
||||
ctx.emit('subagent/provider-added', historical)
|
||||
ctx.emit('subagent/provider-removed', historical.name)
|
||||
|
||||
emitRun(ctx, 'subagent/start', start({ provider: historical.name }))
|
||||
emitRun(ctx, 'subagent/end', end({ provider: historical.name }))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user