Browse Source

fix(agent): preserve initialization failure during teardown

_Kerman 1 month ago
parent
commit
0fb509f544

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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 .agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.md
+2026-09-09-awaited-agent-creation.md: 7098f367f0eff4fdf05bafe6e0dd6de9f558f559
+2026-09-09-awaited-agent-creation.zh.md: 5e15a69898c29e506709c8ee078c7480f6e26791

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.md

@@ -0,0 +1,31 @@
+# Agent Note: Awaited per-agent initialization
+
+Status: implemented
+
+English | [中文](2026-09-09-awaited-agent-creation.zh.md)
+
+## Problem
+
+Shared presets install tools and prompt sections separately for each Agent. That installation can await plugin activation, and external SessionStart hooks can produce context asynchronously. A creator must know that these contributions have finished before the Agent's first model request.
+
+## Decision
+
+`agent/created` is the serial initialization event after factory setup and registry entry. Each listener finishes before the next starts; a throw or rejection fails creation and skips later listeners. The payload retains `SessionStartSource` and accepts the factory's cancellation signal. `register()` and `announce()` are awaited by their callers.
+
+AgentLoop holds its existing maintenance activity through setup and creation dispatch. Input may enter the inbox during initialization, but the driver starts only after successful completion. Failure cancels that activity without waking queued input; ordered teardown owns inbox cleanup. Keeping these operations separate preserves the initialization error when another teardown has already removed the inbox projection.
+
+Creation dispatch retains the scope and Session while listeners await. Disposal cancels initialization and joins the dispatch before releasing those resources. A listener must not await its own Agent's idle state or its owner's disposal, because each waits for that listener to finish. Background work on another Agent follows that Agent's own initialization lifecycle.
+
+This decision owns asynchronous creation timing. The [scope runtime decision](2026-07-12-agent-scope-runtime-design.md) retains registry identity and teardown ownership, while the [interception decision](../feature/2026-06-30-interception-extension-points.md) retains policy and tool-event semantics.
+
+## Alternatives considered
+
+**A separate setup event.** Existing creation listeners already install per-agent contributions. A second initialization event splits that responsibility without a distinct consumer need.
+
+**Detached initialization.** Returning before plugin activation or hook context settles lets the first request omit required tools or context and disconnects initialization failure from the creator.
+
+## Consequences
+
+Creation latency includes asynchronous plugin and SessionStart work. Initializer failures become caller-visible creation failures, and cancellation relies on listeners settling cooperatively. Notifications already delivered cannot be undone; rollback pairs them with disposal notifications.
+
+Scoped installer tests verify rollback, lifecycle tests verify ordered completion and cancellation, and the SDK serial-created scenario verifies that asynchronous prompt context reaches the first request. Hook tests cover awaited context injection and subprocess disposal.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-09-awaited-agent-creation.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 可等待的单个 agent 初始化
+
+Status: implemented
+
+[English](2026-09-09-awaited-agent-creation.md) | 中文
+
+## 问题
+
+共享 preset 为每个 Agent(智能体)分别安装工具与提示词段。安装可能要等待插件激活,外部 SessionStart 钩子也可能异步产生上下文。创建方必须知道这些贡献已完成,才能允许 Agent 发起首次模型请求。
+
+## 决策
+
+`agent/created` 是工厂 setup 完成且进入注册表后的串行初始化事件。每个监听器完成后才启动下一个;抛出或拒绝会使创建失败,并跳过后续监听器。载荷保留 `SessionStartSource`,并接受工厂的取消信号。调用方等待 `register()` 与 `announce()` 完成。
+
+AgentLoop 在 setup 与创建分发期间保留现有的维护活动。初始化期间输入可以进入收件箱,但只有成功完成后驱动器才会启动。失败会取消该活动而不唤醒已排队输入;有序 teardown 拥有收件箱清理。区分这两项操作,能够在另一条 teardown 已移除收件箱投影时保留初始化错误。
+
+创建分发在监听器等待期间保留作用域与 Session。Dispose(资源释放)会取消初始化,并在释放这些资源前等待分发结束。监听器不得等待自身 Agent 的空闲状态或自身所有者的 dispose,因为两者都要等待该监听器完成。另一个 Agent 上的后台工作遵循该 Agent 自己的初始化生命周期。
+
+本决策拥有异步创建时序。[作用域运行时决策](2026-07-12-agent-scope-runtime-design.zh.md)继续拥有注册表身份与 teardown 归属,[拦截决策](../feature/2026-06-30-interception-extension-points.zh.md)继续拥有策略与工具事件语义。
+
+## 考虑过的替代方案
+
+**单独的 setup 事件。** 现有创建监听器已经安装针对单个 agent 的贡献。第二个初始化事件会拆分这一职责,却没有独立的消费方需求。
+
+**分离运行的初始化。** 在插件激活或钩子上下文结算前返回,会让首次请求缺少所需工具或上下文,也会使初始化失败脱离创建方。
+
+## 后果
+
+创建延迟包括异步插件与 SessionStart 工作。初始化器失败成为调用方可见的创建失败,取消依赖监听器协作结算。已经送达的通知无法撤回;回滚会为其配对销毁通知。
+
+作用域安装器测试验证回滚,生命周期测试验证有序完成与取消,SDK serial-created 场景验证异步提示词上下文进入首次请求。钩子测试覆盖需等待的上下文注入与子进程 dispose。

+ 2 - 1
packages/core/agent-loop/src/agent.ts

@@ -170,7 +170,8 @@ export class ReactLoopAgent implements Agent {
         return await job(maintenance.abort.signal)
       } finally {
         this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn })
-        if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver()
+        const cause = maintenance.abort.signal.reason as AgentCancelCause | undefined
+        if (cause?.kind !== 'disposed' && maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver()
         done.resolve()
       }
     })()

+ 3 - 2
packages/core/agent-loop/src/index.ts

@@ -213,7 +213,7 @@ interface PreparedAgent {
   agent: ReactLoopAgent
   /** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
   signal: AbortSignal
-  /** Enter registries, announce, notify session-start, and start the machine. */
+  /** Enter both registries and await creation listeners. */
   publish(source: SessionStartSource): Promise<AgentHandle>
   /** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
   dispose(): Promise<void>
@@ -834,7 +834,8 @@ export class AgentLoop extends Service implements AgentFactory {
         try {
           return await initialize()
         } catch (error: unknown) {
-          prepared.agent.cancel({ kind: 'disposed' })
+          // Teardown owns inbox cleanup and may already have removed its projection.
+          prepared.agent.cancel({ kind: 'disposed' }, { keepInbox: true })
           throw error
         }
       })

+ 33 - 37
packages/core/agent-loop/tests/scope-lifecycle.spec.ts

@@ -61,52 +61,50 @@ function disposeCurrentLifecycle(ownerCtx: Context): void {
 }
 
 describe('agent scope lifecycle', () => {
-  it('awaits serial creation before session-start and caller readiness', async () => {
+  it('awaits serial creation before caller readiness', async () => {
     const ctx = await harness()
-    const entered = Promise.withResolvers<void>()
-    const release = Promise.withResolvers<void>()
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
     const order: string[] = []
     ctx.on('agent/created', async () => {
       order.push('created:start')
-      entered.resolve()
+      entered.resolve(undefined)
       await release.promise
       order.push('created:end')
     })
     ctx.on('agent/created', () => { order.push('created:second') })
-    ctx.on('agent/session-start', () => { order.push('session-start') })
-    const creating = ctx.agentLoop.create(SessionId('serial-start')).then(agent => {
+    const creating = ctx.agentLoop.create(SessionId('serial-start')).then((agent) => {
       order.push('ready')
       return agent
     })
     try {
       await entered.promise
       expect(order).toEqual(['created:start'])
-      release.resolve()
+      release.resolve(undefined)
       await creating
-      expect(order).toEqual(['created:start', 'created:end', 'created:second', 'session-start', 'ready'])
+      expect(order).toEqual(['created:start', 'created:end', 'created:second', 'ready'])
     } finally {
-      release.resolve()
+      release.resolve(undefined)
       await ctx.fiber.dispose()
     }
   })
 
   it('retains both entries and scoped effects until an asynchronous creation listener settles on owner unload', async () => {
     const ctx = await harness()
-    const entered = Promise.withResolvers<void>()
-    const release = Promise.withResolvers<void>()
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
     let ownerCtx!: Context
     const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['agents'] }))
     const order: string[] = []
     const id = SessionId('serial-owner-dispose')
     ctx.on('agent/created', async ({ agent }) => {
-      entered.resolve()
+      entered.resolve(undefined)
       await release.promise
       expect(ctx.agents.get(id)).toBe(agent)
       expect(ctx.sessions.get(id)).toBe(agent.session)
       expect(order).toEqual([])
       order.push('created:end')
     })
-    ctx.on('agent/session-start', () => { order.push('session-start') })
     ctx.on('agent/disposed', () => { order.push('agent-disposed') })
     ctx.on('session/disposed', () => { order.push('session-disposed') })
     const creating = ownerCtx.agents.create({
@@ -118,28 +116,29 @@ describe('agent scope lifecycle', () => {
       await entered.promise
       const disposing = owner.dispose()
       expect(ctx.agents.get(id)).toBeDefined()
-      release.resolve()
+      release.resolve(undefined)
       await rejected
       await disposing
       expect(order).toEqual(['created:end', 'scope-disposed', 'agent-disposed', 'session-disposed'])
       expect(ctx.agents.get(id)).toBeUndefined()
       expect(ctx.sessions.get(id)).toBeUndefined()
     } finally {
-      release.resolve()
+      release.resolve(undefined)
       await ctx.fiber.dispose()
     }
   })
 
   it('rolls back both registries and the scope when asynchronous creation fails', async () => {
-    const ctx = await harness()
+    const adapter = new MockAdapter([textResponse('unexpected')])
+    const ctx = await harness(adapter)
     const reason = new Error('async creation veto')
     const order: string[] = []
-    ctx.on('agent/created', async () => {
+    ctx.on('agent/created', async ({ agent }) => {
+      agent.followup(createUserMessage({ content: text('queued during creation'), source: { kind: 'user' } }))
       await Promise.resolve()
       throw reason
     })
     ctx.on('agent/created', () => { order.push('later-listener') })
-    ctx.on('agent/session-start', () => { order.push('session-start') })
     ctx.on('agent/disposed', () => { order.push('agent-disposed') })
     ctx.on('session/disposed', () => { order.push('session-disposed') })
     await expect(ctx.agents.create({
@@ -149,6 +148,7 @@ describe('agent scope lifecycle', () => {
     expect(order).toEqual(['scope-disposed', 'agent-disposed', 'session-disposed'])
     expect(ctx.agents.list()).toEqual([])
     expect(ctx.sessions.list()).toEqual([])
+    expect(adapter.requests).toHaveLength(0)
     await ctx.fiber.dispose()
   })
 
@@ -332,12 +332,12 @@ describe('agent scope lifecycle', () => {
     expect(heard).toContain('a-sees:user-message')
   })
 
-  it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
+  it('runs setup in the guaranteed slot: scoped world complete before agent/created and the first assembly', async () => {
     const ctx = await harness()
     const order: string[] = []
-    ctx.on('agent/session-start', ({ agent }) => {
-      order.push('session-start')
-      // The scoped section is already registered by the time session-start fires.
+    ctx.on('agent/created', ({ agent }) => {
+      order.push('agent/created')
+      // The scoped section is already registered by the time agent/created fires.
       void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
         order.push(`persona:${assembly.sections.find(s => s.name === 'deployment:persona-prefix')?.text}`)
       })
@@ -352,8 +352,7 @@ describe('agent scope lifecycle', () => {
         agentCtx.systemPrompt.section({ name: 'deployment:persona-prefix', order: 0, text: 'You are the child.' })
       },
     })
-    await new Promise(resolve => setTimeout(resolve, 0))
-    expect(order).toEqual(['setup', 'session-start', 'persona:You are the child.'])
+    expect(order).toEqual(['setup', 'agent/created', 'persona:You are the child.'])
     await handle.dispose()
   })
 
@@ -368,7 +367,6 @@ describe('agent scope lifecycle', () => {
       order.push('session/created')
     })
     ctx.on('agent/created', () => void order.push('agent/created'))
-    ctx.on('agent/session-start', () => void order.push('agent/session-start'))
     const acceptedOptions = { provider: 'mock', model: 'mock' }
 
     const creating = ctx.agents.create({
@@ -406,7 +404,6 @@ describe('agent scope lifecycle', () => {
       'setup-listener:session/created',
       'agent/created',
       'setup-listener:agent/created',
-      'agent/session-start',
     ])
     await handle.dispose()
   })
@@ -841,7 +838,7 @@ describe('agent scope lifecycle', () => {
     const starts: string[] = []
     let ownerCtx!: Context
     let creating!: ReturnType<typeof ctx.agents.create>
-    ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id))
+    ctx.on('agent/status', ({ agent, status }) => { if (status === 'running') starts.push(agent.id) })
     ctx.on('agent/created', ({ agent }) => {
       if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
     })
@@ -862,7 +859,7 @@ describe('agent scope lifecycle', () => {
     await ctx.fiber.dispose()
   })
 
-  it('rechecks caller liveness after session-start before starting the driver', async () => {
+  it('rechecks caller liveness after agent/created before starting the driver', async () => {
     const ctx = await harness()
     let ownerCtx!: Context
     let creating!: ReturnType<typeof ctx.agents.create>
@@ -871,15 +868,15 @@ describe('agent scope lifecycle', () => {
     let scopeDisposed = false
     let observerSawLive = false
     ctx.on('agent/status', ({ agent, status }) => {
-      if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
+      if (agent.id === SessionId('agent/created-dispose-s')) statuses.push(status)
     })
-    ctx.on('agent/session-start', ({ agent }) => {
-      if (agent.id !== SessionId('session-start-dispose-s')) return
+    ctx.on('agent/created', ({ agent }) => {
+      if (agent.id !== SessionId('agent/created-dispose-s')) return
       announced = agent
       disposeCurrentLifecycle(ownerCtx)
     })
-    ctx.on('agent/session-start', ({ agent }) => {
-      if (agent.id !== SessionId('session-start-dispose-s')) return
+    ctx.on('agent/created', ({ agent }) => {
+      if (agent.id !== SessionId('agent/created-dispose-s')) return
       expect(ctx.agents.get(agent.id)).toBe(agent)
       expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
       agent.ctx.effect(() => () => { scopeDisposed = true })
@@ -889,7 +886,7 @@ describe('agent scope lifecycle', () => {
     const owner = await ctx.plugin(Object.assign((inner: Context) => {
       ownerCtx = inner
       creating = inner.agents.create({
-        sessionId: SessionId('session-start-dispose-s'),
+        sessionId: SessionId('agent/created-dispose-s'),
         agentOptions: { provider: 'mock', model: 'mock' },
       })
     }, { inject: ['agents'] }))
@@ -901,8 +898,8 @@ describe('agent scope lifecycle', () => {
     expect(observerSawLive).toBe(true)
     expect(scopeDisposed).toBe(true)
     expect(announced.session.snapshotEvents()).toEqual([])
-    expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined()
-    expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
+    expect(ctx.agents.get(SessionId('agent/created-dispose-s'))).toBeUndefined()
+    expect(ctx.sessions.get(SessionId('agent/created-dispose-s'))).toBeUndefined()
     await ctx.fiber.dispose()
   })
 
@@ -911,7 +908,6 @@ describe('agent scope lifecycle', () => {
     const published: string[] = []
     ctx.on('session/created', () => void published.push('session/created'))
     ctx.on('agent/created', () => void published.push('agent/created'))
-    ctx.on('agent/session-start', () => void published.push('agent/session-start'))
     await expect(ctx.agents.create({
       sessionId: SessionId('bad-s'),
       agentOptions: { provider: 'mock', model: 'mock' },

+ 18 - 14
packages/core/agent-loop/tests/serial-listener-review.spec.ts

@@ -18,10 +18,15 @@ import Selection from '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
 import * as Schedule from '@deepseek-ai/dsh-schedule'
 
 const roots: string[] = []
-afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
+const contexts: Context[] = []
+afterEach(async () => {
+  for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
 
 async function core(persistenceRoot?: string) {
   const ctx = new Context()
+  contexts.push(ctx)
   await mountAgentLoopTestDependencies(ctx)
   if (persistenceRoot !== undefined) await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' })
   await ctx.plugin(AgentLoop, { agents: [] })
@@ -62,8 +67,7 @@ describe('serial creation listener integrations', () => {
       await expect(ctx.agents.create({
         sessionId: SessionId('review-tool'),
         setup(agentCtx) {
-          const binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!)
-          agentCtx.effect(() => () => { binding.dispose() })
+          bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!)
           agentCtx.tools.register(defineContentToolFixture({ name: 'subagent', description: 'occupied', parameters: {}, execute: () => Promise.resolve([]) }))
         },
       })).rejects.toThrow('subagent')
@@ -80,15 +84,15 @@ describe('serial creation listener integrations', () => {
     roots.push(root)
     const ctx = await core(root)
     await ctx.plugin(Schedule)
-    const entered = Promise.withResolvers<void>()
-    const release = Promise.withResolvers<void>()
-    const turn = Promise.withResolvers<void>()
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const turn = Promise.withResolvers<undefined>()
     let turns = 0
     let created = false
-    let sessionStarted = false
-    ctx.on('agent/created', async () => { entered.resolve(); await release.promise; created = true })
-    ctx.on('agent/session-start', () => { sessionStarted = true })
-    ctx.on('session/event', (_session, event) => { if (event.type === 'turn/start') { turns += 1; turn.resolve() } })
+    let laterCreated = false
+    ctx.on('agent/created', async () => { entered.resolve(undefined); await release.promise; created = true })
+    ctx.on('agent/created', () => { laterCreated = true })
+    ctx.on('session/event', (_session, event) => { if (event.type === 'turn/start') { turns += 1; turn.resolve(undefined) } })
     const creating = ctx.agents.create({
       sessionId: SessionId('review-schedule'),
       setup(_agentCtx, agent) {
@@ -104,14 +108,14 @@ describe('serial creation listener integrations', () => {
       // A full event-loop turn lets the due runtime run if it starts during creation.
       await setImmediate()
       expect(turns).toBe(0)
-      expect(sessionStarted).toBe(false)
-      release.resolve()
+      expect(laterCreated).toBe(false)
+      release.resolve(undefined)
       await creating
       await turn.promise
       expect(created).toBe(true)
-      expect(sessionStarted).toBe(true)
+      expect(laterCreated).toBe(true)
     } finally {
-      release.resolve()
+      release.resolve(undefined)
       await creating
       await ctx.fiber.dispose()
     }