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

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 {