Просмотр исходного кода

fix: join exact session startup on teardown

Tianyi Cui 2 месяцев назад
Родитель
Сommit
46b8bce03a

+ 1 - 1
docs/config-catalog.md

@@ -131,7 +131,7 @@ export interface Config {
 
 Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
 
-Source: [`packages/core/agent-loop/src/index.ts:324`](../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:333`](../packages/core/agent-loop/src/index.ts)
 
 ## `@deepseek-ai/dsh-bash-local`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<Agent
 async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
 ```
 
-Source: [`packages/core/agent-loop/src/index.ts:339`](../../packages/core/agent-loop/src/index.ts)
+Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts)
 
 ## `ctx.agents` — `AgentRegistry`
 

+ 15 - 4
packages/core/agent-loop/src/index.ts

@@ -45,6 +45,7 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
 class FactoryOwnership {
   private accepting = true
   private transactions = new Set<AgentCreationTransaction>()
+  private startupTasks = new Set<Promise<void>>()
 
   constructor(private readonly fiber: Context['fiber']) {}
 
@@ -57,12 +58,20 @@ class FactoryOwnership {
     return () => { this.transactions.delete(transaction) }
   }
 
+  /** Join config startup work that begins before an agent transaction exists. */
+  trackStartup(task: Promise<void>): void {
+    this.startupTasks.add(task)
+    const forget = () => { this.startupTasks.delete(task) }
+    void task.then(forget, forget)
+  }
+
   async dispose(): Promise<void> {
     this.accepting = false
     const reason = new Error('agent loop is not active')
-    await Promise.all(
-      [...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
-    )
+    await Promise.all([
+      ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
+      ...this.startupTasks,
+    ])
   }
 }
 
@@ -371,9 +380,10 @@ export class AgentLoop extends Service implements AgentFactory {
         if (persistence === undefined) {
           this.create(configuredId, options, meta)
         } else {
-          void this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
+          const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
             ctx.logger.warn(`agent "${id}": config-driven restore of "${configuredId}" failed: ${String(error)}`)
           })
+          this.ownership.trackStartup(startup)
         }
         continue
       }
@@ -403,6 +413,7 @@ export class AgentLoop extends Service implements AgentFactory {
     meta: Pick<SessionHeader, 'cwd'>,
   ): Promise<void> {
     const exists = (await persistence.list()).some(header => header.id === sessionId)
+    if (!this.ownership.isActive()) return
     if (exists) {
       await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
       return

+ 25 - 0
packages/core/agent-loop/tests/config-session-id.spec.ts

@@ -113,6 +113,31 @@ describe('config-driven session id', () => {
     await ctx.fiber.dispose()
   })
 
+  it('joins an exact-id persistence lookup before AgentLoop disposal completes', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
+    dirs.push(root)
+    const ctx = await makeCoreContext()
+    await ctx.plugin(SessionPersistenceJsonl, { root })
+    const listing = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.list>>>()
+    vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise)
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
+
+    const loop = await ctx.plugin(AgentLoop, {
+      agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }],
+    })
+    let disposed = false
+    const disposal = loop.dispose().then(() => { disposed = true })
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+
+    listing.resolve([])
+    await disposal
+    expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined()
+    expect(warn).not.toHaveBeenCalled()
+    warn.mockRestore()
+    await ctx.fiber.dispose()
+  })
+
   it('identity-nests the deferred resume fiber under its labeled owner effect', async () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)