feat(agent): return an AgentHandle with an async per-agent disposer

The agent factory (`ctx.agents.create`/`resume`, the `AgentFactory` seam)
now returns `AgentHandle = { agent; dispose(): Promise<void> }` instead of a
bare `Agent`. The disposer is a capability: only the holder can tear down
exactly this agent — stop its loop, await the loop's exit (true quiescence,
not just the `disposed` status flip), unregister it, and remove its session
from the store.

The teardown ORDER is load-bearing for durability. The loop appends its
final `turn/end` + runs `session/flush` AFTER an abort, delivered through
`session.onAppend` → `session/event`; if the session-store effect (which
detaches `onAppend`) were torn down first, those closing events would never
reach persistence. So `dispose()`:
  1. runs the register+start effect disposer (sync: request loop stop),
  2. `await agent.done` (loop exits, final flush captured), THEN
  3. runs the session disposer (detach onAppend + delete store entry).

`SessionStore.createOwned()` exposes the session-create effect's disposer
(plain `create()` discards it — fiber-owned). `AgentLoop` funnels both
factory entrypoints (`createAgent`, `resumeWith`) through a shared
`startOwned` that composes the ordered teardown; the config path keeps a
fiber-owned agent by discarding the handle.

`ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for
the owner that created it.
This commit is contained in:
Tianyi Cui
2026-06-20 06:44:35 +08:00
parent 9ee22bc6f6
commit 2a4d89a4bd
6 changed files with 133 additions and 41 deletions
+59 -16
View File
@@ -11,7 +11,7 @@ import { Context, Service } from 'cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -120,23 +120,28 @@ export class AgentLoop extends Service implements AgentFactory {
*/
create(id: string, options: AgentOptions = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
// Config/programmatic path: the session is owned by THIS fiber (the plain
// create()), so disposing the AgentLoop/caller fiber removes it. No
// AgentHandle is needed — the register+start effect is fiber-owned too.
const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
return this.start(AgentId(id), options, session)
const { agent } = this.start(AgentId(id), options, session)
return agent
}
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
* metadata (validated `cwd`, lineage). The ACP bridge uses this so the
* client-generated session id becomes the live/persisted session id.
* client-generated session id becomes the live/persisted session id. Returns
* an {@link AgentHandle} the owner disposes to tear down exactly this agent.
*/
createAgent(options: CreateAgentOptions): Agent {
createAgent(options: CreateAgentOptions): AgentHandle {
// Check the agent id BEFORE creating the session: register() would reject a
// duplicate id only AFTER sessions.create(), leaving an orphaned live
// session (and lazy persistence state) that blocks reuse of that id.
this.assertAgentIdFree(options.agentId)
const session = this.ctx.sessions.create(options.sessionId, { meta: options.meta ?? {} })
return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
const owned = this.ctx.sessions.createOwned(options.sessionId, { meta: options.meta ?? {} })
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned)
}
/**
@@ -151,7 +156,7 @@ export class AgentLoop extends Service implements AgentFactory {
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
* by the time this runs the service exists.
*/
async resume(options: ResumeAgentOptions): Promise<Agent> {
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
// Read the service through `ctx.get('sessionPersistence')` — a direct
// global-store lookup keyed by the isolate symbol — NOT
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
@@ -183,7 +188,7 @@ export class AgentLoop extends Service implements AgentFactory {
* sessions store + registry are still read through `this.ctx` (both are in
* AgentLoop's static inject, so they resolve fine).
*/
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<Agent> {
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
this.assertAgentIdFree(options.agentId)
const { meta, events } = await persistence.load(SessionId(options.resumeSessionId))
// Re-check the agent id AFTER the await: the pre-load check above can go
@@ -196,7 +201,7 @@ export class AgentLoop extends Service implements AgentFactory {
// events make lastTurnNumber/deriveMessages continue; the backend already
// has state (cursor) from the load above, so onCreated is a no-op and the
// seed is not re-persisted.
const session = this.ctx.sessions.create(options.resumeSessionId, {
const owned = this.ctx.sessions.createOwned(options.resumeSessionId, {
seed: events,
meta: {
createdAt: meta.createdAt,
@@ -204,7 +209,7 @@ export class AgentLoop extends Service implements AgentFactory {
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
},
})
return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
return this.startOwned(AgentId(options.agentId), options.agentOptions ?? {}, owned)
}
/**
@@ -219,16 +224,54 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/** Shared: construct a ReactLoopAgent, register it, and start its loop (LIFO). */
private start(id: AgentId, options: AgentOptions, session: Session): ReactLoopAgent {
/**
* Shared: construct a ReactLoopAgent, register it, and start its loop. The
* register + loop-stop disposers live in ONE generator effect so they run
* LIFO on dispose (the loop-stop disposer — yielded last — runs first, then
* the registry unregister), so a throwing stop() cannot leak the registry
* entry. Returns the agent plus the effect's disposer (`disposeAgent`); the
* effect is owned by the caller fiber, so disposing that fiber also tears the
* agent down — the disposer is for an OWNER that needs to tear down ONE agent.
*/
private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const agent = new ReactLoopAgent(this.ctx, id, options, session)
// Generator effect: stop and unregister are independent disposables
// (LIFO), so a throwing stop() cannot leak the registry entry.
this.ctx.effect(function* (this: AgentLoop) {
const dispose = this.ctx.effect(function* (this: AgentLoop) {
yield this.ctx.agents.register(agent)
yield agent.start()
}.bind(this), 'agentLoop.start()')
return agent
return { agent, disposeAgent: async () => { await dispose() } }
}
/**
* Build an {@link AgentHandle} for an OWNED session + agent. The handle's
* `dispose()` tears down exactly this agent in the order durability requires:
*
* 1. run `disposeAgent` — the register+start effect's disposer. LIFO runs
* `agent.start()`'s (synchronous) disposer first: it sets `disposed`,
* aborts the in-flight step, and unblocks the loop's idle wait. Then the
* registry unregister runs. The loop has NOT necessarily exited yet — the
* start disposer only REQUESTS exit, it does not await it.
* 2. `await agent.done` — the loop-exit promise. The loop unwinds and runs
* its final `session/flush` + `turn/end`, delivered through the still-
* attached `session.onAppend` → `session/event`, so persistence captures
* the closing events. Only now is the agent truly quiescent.
* 3. run the session disposer — detach `onAppend` and remove the store
* entry. Done LAST so step 2's final flush is not dropped.
*/
private startOwned(
id: AgentId,
options: AgentOptions,
owned: { session: Session; dispose: () => Promise<void> },
): AgentHandle {
const { agent, disposeAgent } = this.start(id, options, owned.session)
return {
agent,
dispose: async () => {
await disposeAgent() // stop the loop (sync) + unregister
await agent.done // wait for the loop to actually exit (final flush captured)
await owned.dispose() // detach onAppend + remove the session store entry
},
}
}
}
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
+10 -10
View File
@@ -43,7 +43,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const agent = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
const { agent } = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
expect(agent.session.id).toBe('custom-session')
expect(agent.session.header.cwd).toBe('/w')
await ctx.fiber.dispose()
@@ -63,7 +63,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent works without meta (no cwd)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const agent = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' })
const { agent } = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' })
expect(agent.session.id).toBe('nometa-session')
expect(agent.session.header.cwd).toBeUndefined()
await ctx.fiber.dispose()
@@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' })).agent as ReactLoopAgent
expect(a2.session.header.cwd).toBeUndefined()
await ctx2.fiber.dispose()
})
@@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' })).agent as ReactLoopAgent
expect(a2.session.header.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w')
await ctx2.fiber.dispose()
@@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// disk, since a crash before the next turn would otherwise lose it.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// drop it on reload (the bug this guards).
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' })).agent as ReactLoopAgent
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
@@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: run one full turn, persisting it.
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as ReactLoopAgent
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
@@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' })).agent as ReactLoopAgent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length)
+30 -8
View File
@@ -54,6 +54,23 @@ export interface ResumeAgentOptions {
agentOptions?: AgentOptions
}
/**
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
* awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and
* removes the agent's session from the store, in an order that captures the
* loop's final `session/flush` before the session is detached.
*
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
* for the OWNER that created it. Config-created agents (the loop's own startup)
* are owned by the loop fiber and never need a handle.
*/
export interface AgentHandle {
agent: Agent
dispose(): Promise<void>
}
/**
* The agent-creation factory the loop implementation provides to the registry
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
@@ -61,14 +78,18 @@ export interface ResumeAgentOptions {
* depending on the concrete `dsh-agent-loop` package.
*/
export interface AgentFactory {
/** Create, start, and register a new agent on a caller-supplied session id. */
createAgent(options: CreateAgentOptions): Agent
/**
* Create, start, and register a new agent on a caller-supplied session id.
* Returns an {@link AgentHandle} — the owner disposes it to tear down exactly
* this agent (unregister + stop loop + await quiescence + remove session).
*/
createAgent(options: CreateAgentOptions): AgentHandle
/**
* Load a persisted session and resume an agent on it. Async because it awaits
* `ctx.sessionPersistence.load`; must be called after that service exists
* (consumers inject `sessionPersistence`).
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
*/
resume(options: ResumeAgentOptions): Promise<Agent>
resume(options: ResumeAgentOptions): Promise<AgentHandle>
}
/** Thrown when create/resume is called before an agent factory is registered. */
@@ -107,9 +128,10 @@ export class AgentRegistry extends Service {
* Create, start, and register a new agent through the registered factory.
* Distinct from {@link register} (which records an already-constructed
* agent): this constructs the agent and its session. Throws if no factory is
* registered.
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
* down exactly this agent.
*/
create(options: CreateAgentOptions): Agent {
create(options: CreateAgentOptions): AgentHandle {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.createAgent(options)
}
@@ -117,9 +139,9 @@ export class AgentRegistry extends Service {
/**
* Load a persisted session and resume an agent on it through the registered
* factory. Rejects if no factory is registered; the factory rejects if
* session persistence is not configured.
* session persistence is not configured. Returns an {@link AgentHandle}.
*/
async resume(options: ResumeAgentOptions): Promise<Agent> {
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.resume(options)
}
+10 -4
View File
@@ -82,8 +82,14 @@ describe('AgentRegistry factory seam', () => {
function stubFactory() {
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
createAgent(options) { calls.create.push(options); return stubAgent(options.agentId) },
resume(options) { calls.resume.push(options); return Promise.resolve(stubAgent(options.agentId)) },
createAgent(options) {
calls.create.push(options)
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
},
resume(options) {
calls.resume.push(options)
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
},
}
return { factory, calls }
}
@@ -102,11 +108,11 @@ describe('AgentRegistry factory seam', () => {
ctx.agents.setFactory(factory)
const created = ctx.agents.create({ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } })
expect(created.id).toBe('c1')
expect(created.agent.id).toBe('c1')
expect(calls.create).toEqual([{ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }])
const resumed = await ctx.agents.resume({ agentId: 'r1', resumeSessionId: 'old-sess' })
expect(resumed.id).toBe('r1')
expect(resumed.agent.id).toBe('r1')
expect(calls.resume).toEqual([{ agentId: 'r1', resumeSessionId: 'old-sess' }])
})
+23 -2
View File
@@ -230,6 +230,25 @@ export class SessionStore extends Service {
* non-absolute path (storage backends key directories off it).
*/
create(id?: string, options?: CreateSessionOptions): Session {
// Discard the store-removal disposer: a plain create() is owned by the
// calling fiber (disposing the fiber removes the session). An owner that
// needs to remove ONE session independently uses createOwned().
return this.createOwned(id, options).session
}
/**
* Like {@link create}, but ALSO returns the disposer for the session's
* store-removal effect — so an owner can remove exactly THIS session (detach
* `onAppend`, delete the store entry) without disposing the whole fiber.
*
* Used by the agent factory's {@link AgentHandle} teardown: an owned agent's
* `dispose()` stops the loop, awaits quiescence, unregisters the agent, and
* THEN runs this session disposer — so the loop's final `session/flush`
* (delivered via `onAppend` → `session/event`) is captured before `onAppend`
* is detached. The disposer is async (a cordis effect disposer) to compose
* with the agent teardown's promise chain.
*/
createOwned(id?: string, options?: CreateSessionOptions): { session: Session; dispose: () => Promise<void> } {
const sessionId = SessionId(id ?? `session-${++this.counter}`)
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const cwd = options?.meta?.cwd
@@ -244,7 +263,7 @@ export class SessionStore extends Service {
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
}
const session = new Session(sessionId, options?.seed, header)
this.ctx.effect(function* (this: SessionStore) {
const dispose = this.ctx.effect(function* (this: SessionStore) {
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
this.store.set(sessionId, session)
// Yield the rollback BEFORE emitting `session/created`: a generator
@@ -259,7 +278,9 @@ export class SessionStore extends Service {
}
this.ctx.emit('session/created', session)
}.bind(this), 'sessions.create()')
return session
// ctx.effect's disposer returns Promise<void>; normalize to an always-async
// disposer for the owner.
return { session, dispose: async () => { await dispose() } }
}
get(id: string): Session | undefined {