fix(subagent): require durability participant
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-inprocess/README.md
|
||||
README.md: 1bbbfd282fe98f73b1828b22a95efd34e5ddc0ab
|
||||
README.zh.md: d6dc91415beb3986ad226a8467ce2abbabce8591
|
||||
README.md: afc92cf4f38830c22a2de401620e0223e7bf62d1
|
||||
README.zh.md: dcf7d343901145f63758bfce0f85fa70691cb14e
|
||||
|
||||
@@ -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. 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.
|
||||
5. For a continuable start or resume, call `child.ctx.sessions.flushRequired(child.session)` again before returning the result. This final confirmation requires an installed durability listener and retries events retained after a failed turn checkpoint; if no listener participates or any listener fails, `result` rejects with `SubagentError.code === 'DURABILITY_FAILED'`, retains the checkpoint 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.
|
||||
|
||||
@@ -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`,并在消息中指出可恢复性风险。在这次等待期间取消 activation 时,即使已记录完成的轮次,或检查点随后失败,取消仍决定尚未发布的结果。前台运行仍采用循环的尽力而为检查点行为。
|
||||
5. 对于可继续启动或恢复,在返回结果前再次调用 `child.ctx.sessions.flushRequired(child.session)`。这次最终确认要求有已安装的持久性监听器参与,并会重试轮次检查点失败后保留的事件;如果没有监听器参与或任一监听器失败,`result` 会以 `SubagentError.code === 'DURABILITY_FAILED'` 拒绝,将检查点失败保留为 `cause`,并在消息中说明恢复风险。即使已记录完成的轮次,或随后检查点失败,等待期间发生的激活取消仍决定尚未发布的结果。前台运行保留循环的尽力检查点行为。
|
||||
6. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除任何 fork 初始内容和后续由插件拥有的轮次间记录。
|
||||
|
||||
子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。
|
||||
|
||||
@@ -270,7 +270,7 @@ function driveTurn(
|
||||
await child.whenIdle()
|
||||
if (durability === 'required') {
|
||||
try {
|
||||
await child.ctx.sessions.flush(child.session)
|
||||
await child.ctx.sessions.flushRequired(child.session)
|
||||
} catch (error: unknown) {
|
||||
if (!signal.aborted) {
|
||||
throw new SubagentError(
|
||||
|
||||
@@ -73,6 +73,40 @@ describe('startInProcessRun', () => {
|
||||
expect(ctx.agents.get(run.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a continuable child when no durability listener is registered', async () => {
|
||||
const { parent } = await setup([textResponse('driver answer')])
|
||||
|
||||
const run = await startInProcessRun(continuableRequest(parent), {})
|
||||
const caught: unknown = await run.result.catch((error: unknown) => error)
|
||||
|
||||
expect(caught).toBeInstanceOf(SubagentError)
|
||||
const durabilityError = caught as SubagentError
|
||||
expect(durabilityError.code).toBe('DURABILITY_FAILED')
|
||||
expect(durabilityError.message).toContain('required durability checkpoint has no registered listener')
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('rejects when the durability listener disappears before final confirmation', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
let flushes = 0
|
||||
let detach = (): void => {}
|
||||
detach = ctx.on('session/flush', (session) => {
|
||||
if (session.header.parentSession === undefined) return
|
||||
flushes++
|
||||
if (flushes === 1) detach()
|
||||
})
|
||||
|
||||
const run = await startInProcessRun(continuableRequest(parent), {})
|
||||
const caught: unknown = await run.result.catch((error: unknown) => error)
|
||||
|
||||
expect(caught).toBeInstanceOf(SubagentError)
|
||||
const durabilityError = caught as SubagentError
|
||||
expect(durabilityError.code).toBe('DURABILITY_FAILED')
|
||||
expect(durabilityError.message).toContain('required durability checkpoint has no registered listener')
|
||||
expect(flushes).toBe(1)
|
||||
await run.dispose()
|
||||
})
|
||||
|
||||
it('requires a final durability checkpoint for a continuable child', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('driver answer')])
|
||||
const failure = new Error('disk full')
|
||||
@@ -312,7 +346,7 @@ describe('startInProcessRun', () => {
|
||||
acceptsNextStep: false,
|
||||
ctx: {
|
||||
sessions: {
|
||||
flush: () => {
|
||||
flushRequired: () => {
|
||||
flushes++
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -61,10 +61,12 @@ afterEach(() => {
|
||||
async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) {
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
let disposePersistence: (() => Promise<void>) | undefined
|
||||
if (options.persistence !== false) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-'))
|
||||
roots.push(root)
|
||||
await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
const persistenceFiber = await ctx.plugin(JsonlSessionPersistence, { root })
|
||||
disposePersistence = () => persistenceFiber.dispose()
|
||||
}
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SubagentService)
|
||||
@@ -74,7 +76,7 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean }
|
||||
await ctx.plugin(ToolTasks, {})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
|
||||
return { ctx, parent }
|
||||
return { ctx, parent, disposePersistence }
|
||||
}
|
||||
|
||||
async function setup(script: Script, options: { persistence?: boolean } = {}) {
|
||||
@@ -141,6 +143,25 @@ describe('SubagentService.startContinuable', () => {
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails the Task when persistence detaches before the activation completes', async () => {
|
||||
const releaseResponse = Promise.withResolvers<undefined>()
|
||||
const adapter = new GatedAdapter([
|
||||
{ chunks: textResponse('unconfirmed answer'), gate: releaseResponse.promise },
|
||||
])
|
||||
const { ctx, parent, disposePersistence } = await setupWith(adapter)
|
||||
const started = ctx.subagents.startContinuable(startSpec(parent))
|
||||
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
|
||||
|
||||
await disposePersistence!()
|
||||
releaseResponse.resolve(undefined)
|
||||
|
||||
const snapshot = await waitTerminal(ctx, started.taskId, parent)
|
||||
expect(snapshot.status).toBe('failed')
|
||||
expect(snapshot.detail).toContain('durability checkpoint failed')
|
||||
expect(snapshot.detail).toContain('required durability checkpoint has no registered listener')
|
||||
expect(ctx.agents.get(started.childId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('publishes the service-allocated child id and appends the turn-enclosed descriptor', async () => {
|
||||
const { ctx, parent } = await setup([textResponse('answer')])
|
||||
const seen: SessionEvent[] = []
|
||||
|
||||
Reference in New Issue
Block a user