deepseek-harness/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md
2026-07-12 03:36:43 +08:00

6 KiB

RFC: Agent lifecycle and ownership seams

Status: implemented

Problem

Several ACP and tool-bash limitations were symptoms of the same missing seam: plugins could create or resume agents through ctx.agents, but they could not own and dispose one agent independently, and long-running bash tasks carried no stable owner in the executor itself. ACP aborted and awaited agents on disconnect but could not unregister just that session's agent; session/cancel could not cancel queued-but-not-yet-started work; and tool-bash kept task ownership in a plugin-local Map, so an HMR reload could make an old task look unowned.

Decision

Three seams: the queue-aware cancel, the AgentHandle disposer, and the bash owner token.

1. Queue-aware Agent.cancel(reason?)

A new cancel() verb on the Agent interface — the single public stop primitive. (It originally shipped alongside a narrower step-only abort(); that verb was later removed as unused, leaving cancel() the only public way to stop work.) It clears the inbox's queued + steering FIFOs, aborts the in-flight step if any, and drives a turn-scoped cancellation marker the driver loop checks at every turn-decision point — so a prompt that is queued-but-not-yet-started never runs, a cancel landing in the pre-step / continuation window drops the about-to-run turn (ending it aborted), and a later prompt cannot be batched into the cancelled turn. whenIdle() reaches post-cancel quiescence. ACP session/cancel maps to cancel(). The marker is armed ONLY when there is something to cancel, so an idle no-op cancel cannot strand the next prompt.

2. AgentHandle async disposer

ctx.agents.create/resume (and the AgentFactory interface) return AgentHandle = { agent: Agent; dispose(): Promise<void> }. 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. ctx.agents.get(id) still returns a bare Agent. Config-created agents stay owned by the AgentLoop fiber (the handle is discarded). ACP holds each session's disposer in its SessionRecord and runs it on disconnect/teardown, so a bare client disconnect leaves no registered agent and no session-store entry — even when session/load races teardown (the just-resumed handle is disposed before the closed-guard throw).

Teardown ORDER is load-bearing for durability, and the implementation folds the session lifecycle into the agent's SINGLE composite cordis effect (SessionStore.prepare/enter/announce, replacing a sibling-effect split). A fiber unload disposes sibling effects concurrently (Promise.all), which would race the session's onAppend detach against the loop's closing session/flush and drop the closing turn/end; inside one effect the disposers run as an ordered LIFO chain (loop stopped + await agent.done BEFORE the session detaches), so the loop's final flush is captured on BOTH the handle's dispose() and a fiber unload. The register disposer's agent/disposed emit is contained (a throwing listener must not reject the chain and skip the later session detach).

3. Bash owner token in the seam

Background task ownership belongs to the executor. BashExecSpec.owner carries an optional opaque token, ownerOf(id) reads it, and dsh-tool-bash stamps the calling session token at start. bash_output and bash_kill reject mismatched callers; completion notices locate the live agent by session token through the registry. Keeping ownership on the task preserves the fence across tool-plugin reloads. The completion listener remains effect-scoped, so a notice that settles during the reload gap may still be dropped.

Verification

These invariants hold and are pinned by tests:

  • ACP disconnect/session close leaves no registered agent AND no session-store entry for that session, even when session/load races teardown.
  • session/cancel before a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn.
  • A tool-bash HMR reload does NOT make an existing background task readable or killable by a different session (ownership survives on the executor).
  • Existing non-ACP demos still work without managing handles explicitly; config-created agents remain owned by the AgentLoop plugin fiber.

Session owner tokens are unique among live agents

The bash owner-token comparison relies on session.header.id being unique among live agents. SessionStore.enter() rejects a duplicate live session id, and the async agent factory reserves both agent and session ids across persistence loading and unpublished setup before rechecking the store at publication. A programmatic caller therefore cannot publish two live agents with one session token. The access policy (token comparison) stays in tool-bash (the consumer); the bash seam stores only an opaque owner string and never interprets it — the correct interface/implementation/consumer split.

Alternatives considered

  • A public BashTask.owner field instead of the BashExecutor.ownerOf(id) seam — rejected: one read path, no redundant API.
  • Sibling cordis effects for the agent's session lifecycle — rejected: a fiber unload disposes sibling effects concurrently (Promise.all), racing the session's onAppend detach against the loop's closing session/flush; the single composite effect's ordered LIFO chain is what captures the closing turn/end on both disposal paths.
  • A separate step-only abort() beside cancel() — shipped originally, then removed as unused; cancel() is the single public stop primitive (the public-stop-surface RFC).

Consequences

This touched public interfaces (Agent, AgentFactory, the bash seam) deliberately, not as a local ACP patch. The simple synchronous Agent.send() ergonomics were preserved; the async lifecycle path is additive, for owners that need it.