fix(agent): contain a throwing agent/disposed listener in the register disposer (Codex review)

Codex found a real teardown-leak (A): the AgentHandle's composite effect runs
its disposers as a `.then()` chain, and the register disposer emitted
`agent/disposed` UNCONTAINED. A throwing listener rejected the chain, skipping
the LATER session-detach disposer — stranding the session in the store with
`onAppend` attached (a leak AND a durability hole, since the new composite
design relies on detach running). Verified by tracing fiber.ts:299-301
(`task = task.then(dispose)`) against the yield order in AgentLoop.start.

Wrap the disposer's `agent/disposed` emit in try/catch + logger.warn (the
store entry is already removed before the emit — the useful state is captured
— so logging and continuing is correct, mirroring the guarded `agent/status`
emit in ReactLoopAgent). The sibling `agent/created` emit stays uncontained on
purpose: its throw is MEANT to propagate and roll the registration back.

Regression test (acp dispose.spec): register a throwing `agent/disposed`
listener, drive a clean turn, dispose, assert the session was STILL removed.
Confirmed it FAILS without the guard (the throw escapes dispose and detach is
skipped) and passes with it.

Also (B): document the new `prepare`/`enter`/`announce` ordered-teardown
lifecycle primitives in the dsh-session README (they are public cross-package
methods now consumed by dsh-agent-loop).
This commit is contained in:
Tianyi Cui
2026-06-20 07:47:24 +08:00
parent 7a94d36c46
commit 5a5b7d19c3
3 changed files with 51 additions and 1 deletions

View File

@@ -12,6 +12,16 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
- `ctx.sessions.get(id: string): Session | undefined`
- `ctx.sessions.list(): Session[]`
#### Advanced: ordered-teardown lifecycle primitives
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.enter(session): () => void` — wire `onAppend``session/event` and add the session to the store; returns the DETACH disposer. Does NOT emit `session/created` (the caller yields the disposer first, then calls `announce`, so a throwing listener rolls the attach back). The id was already validated by `prepare`, which runs in the same synchronous sequence, so `enter` does not re-check.
- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session.
`dsh-agent-loop`'s `AgentLoop.start` is the canonical consumer: it yields `enter`'s detach disposer, the registry unregister, and the loop-stop disposer into ONE composite effect, so teardown stops + awaits the loop (final flush captured) BEFORE detaching the session — whether the trigger is the `AgentHandle`'s `dispose()` or a fiber unload.
### Events
| Event | Mode | Purpose |