fix(subagent): preserve cancellation during durability

This commit is contained in:
Dudu-0223
2026-07-24 14:57:54 +08:00
committed by imccyu
parent e1f7eeeb95
commit 43151ed9c0
9 changed files with 78 additions and 18 deletions

View File

@@ -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-inprocess/README.md
README.md: 6225b84f1274b61cae1d4ca567155dcc6e6a0888
README.zh.md: 5c3ab3baa3ab86f33fe34026ddbdf97449cb4f92
README.md: 1bbbfd282fe98f73b1828b22a95efd34e5ddc0ab
README.zh.md: d6dc91415beb3986ad226a8467ce2abbabce8591

View File

@@ -14,7 +14,7 @@ The driver follows this sequence:
2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. A continuable request publishes exactly `request.continuation.sessionId` instead of an internally minted id.
3. During that transaction's unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and — for a continuable request — the one-shot `agent/step` contribution that appends the `subagent/descriptor` event after the initial `turn/start` and before the first request, so the descriptor reaches persistence with that turn's flush.
4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`.
5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Foreground runs keep the loop's best-effort checkpoint behavior.
5. For a continuable start or resume, call `child.ctx.sessions.flush(child.session)` again before returning the result. This final confirmation retries events retained after a failed turn checkpoint; if it still fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the backend failure as `cause`, and names the resumability risk in its message. Activation cancellation during this await owns the unpublished result even when the completed turn was already recorded or the checkpoint subsequently fails. Foreground runs keep the loop's best-effort checkpoint behavior.
6. Read the child's own last assistant message and latest message-triggered turn reason, excluding any fork seed and later plugin-owned between-turn records.
The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset.

View File

@@ -14,7 +14,7 @@
2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。可继续请求会精确发布 `request.continuation.sessionId`,而不是内部生成的 ID。
3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时对于可继续请求还会安装一次性的 `agent/step` 贡献,在初始 `turn/start` 之后、首次请求之前追加 `subagent/descriptor` 事件,使描述符随该轮次的 flush 到达持久化层。
4. 发布子 agent保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。
5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。前台运行仍采用循环的尽力而为检查点行为。
5. 对于可继续的启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flush(child.session)`。这次最终确认会重试轮次检查点失败后保留的事件;若仍然失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,保留后端失败作为 `cause`,并在消息中指出可恢复性风险。在这次等待期间取消 activation 时,即使已记录完成的轮次,或检查点随后失败,取消仍决定尚未发布的结果。前台运行仍采用循环的尽力而为检查点行为。
6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。

View File

@@ -272,11 +272,13 @@ function driveTurn(
try {
await child.ctx.sessions.flush(child.session)
} catch (error: unknown) {
throw new SubagentError(
`subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`,
'DURABILITY_FAILED',
{ cause: error },
)
if (!signal.aborted) {
throw new SubagentError(
`subagent "${childId}" durability checkpoint failed; the latest child state was not confirmed persisted and may be unavailable or stale on resume: ${errorChain(error)}`,
'DURABILITY_FAILED',
{ cause: error },
)
}
}
}
return readResult(
@@ -284,6 +286,7 @@ function driveTurn(
boundary,
flags.cancelled,
structured ? { captured: structured.captured() } : undefined,
durability === 'required' && signal.aborted,
)
} finally {
signal.removeEventListener('abort', onAbort)
@@ -325,6 +328,7 @@ function readResult(
boundary: number,
cancelled: boolean,
structured?: { captured?: { value: unknown } | undefined },
cancellationOwnsCompleted = false,
): SubagentResult {
const own = child.session.events.slice(boundary)
const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message')
@@ -332,9 +336,11 @@ function readResult(
const output: ContentBlock[] = lastMessage?.data.message.content ?? []
const recorded = toStopReason(lastEnd?.data.reason)
// Disposal can tear the owner down before the loop records its ordinary
// `aborted` end, yielding `disposed` instead. A requested cancellation owns
// every non-completed in-flight outcome; a turn already completed stays so.
const stopReason: SubagentStopReason = cancelled && recorded !== 'completed'
// `aborted` end, yielding `disposed` instead. Activation cancellation during
// its final durability checkpoint also owns a recorded completed turn because
// the provider has not published that result yet.
const stopReason: SubagentStopReason = cancelled
&& (recorded !== 'completed' || cancellationOwnsCompleted)
? 'aborted'
: recorded
if (structured !== undefined) {

View File

@@ -111,6 +111,37 @@ describe('startInProcessRun', () => {
await run.dispose()
})
it.each([
{ checkpoint: 'succeeds', failure: undefined },
{ checkpoint: 'fails', failure: new Error('disk full') },
])('lets cancellation own the result when the final durability checkpoint $checkpoint', async ({ failure }) => {
const { ctx, parent } = await setup([textResponse('driver answer')])
const checkpointStarted = Promise.withResolvers<undefined>()
const releaseCheckpoint = Promise.withResolvers<undefined>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session.header.parentSession === undefined) return
flushes++
if (flushes !== 2) return
checkpointStarted.resolve(undefined)
await releaseCheckpoint.promise
if (failure !== undefined) throw failure
})
const controller = new AbortController()
const run = await startInProcessRun({
...continuableRequest(parent),
signal: controller.signal,
}, {})
await checkpointStarted.promise
controller.abort()
releaseCheckpoint.resolve(undefined)
await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' })
expect(flushes).toBe(2)
await run.dispose()
})
it('keeps foreground runs best-effort when their turn checkpoint fails', async () => {
const { ctx, parent } = await setup([textResponse('driver answer')])
let flushes = 0