From 46b8bce03acebd579cdcb4daf91c02e51eddf78e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:47:47 +0800 Subject: [PATCH] fix: join exact session startup on teardown --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- packages/core/agent-loop/src/index.ts | 19 +++++++++++--- .../tests/config-session-id.spec.ts | 25 +++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 024d3b766b..d22bcca6f7 100644 --- a/docs/config-catalog.md +++ b/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` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6b456ef942..e79422b94f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -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` diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 33e8c3c443..d2d8648595 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -45,6 +45,7 @@ const INACTIVE_STATES: ReadonlySet = new Set([ class FactoryOwnership { private accepting = true private transactions = new Set() + private startupTasks = new Set>() 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 { + this.startupTasks.add(task) + const forget = () => { this.startupTasks.delete(task) } + void task.then(forget, forget) + } + async dispose(): Promise { 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, ): Promise { 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 diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index dcde82efe2..c1a04fadd0 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/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>>() + 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)