Define the in-file RFC contract in docs/rfc/README.md § The file format: the header block (`# RFC: <title>` plus a dateless Status enum cross-checked against the lifecycle folder), the per-lifecycle body skeleton (a Problem opener everywhere; Proposal/Alternatives considered/ Acceptance criteria/Risks in proposed/; present-tense Decision/ Consequences with proposal-era headings banned in implemented/; the frozen proposal shape in rejected/), and a mandatory Alternatives considered section with a date-fenced grandfather comment for pre-format RFCs whose alternatives are not reconstructible from the record. Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the enum, 29 Context openers become Problem, the 39 legacy-format XXX debt markers are resolved and banned from reappearing, proposal-era sections in implemented RFCs are rewritten to shipped reality (including the web/fs/subagent seam RFCs' migration plans and test checklists, closing the doc-tiers deferred-work item on the web seam), every RFC gains an Alternatives considered section or the grandfather comment, and the bilingual pair is re-mirrored and re-recorded. Move the generated index tables out of README.md into a fully generated docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and verify-rfc-classification checks its freshness and rejects index-shaped rows in the curated README — which makes room for the format contract to live in the README front door instead of a separate FORMAT.md. The decision record, and the first RFC written in the new format, is docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
7.3 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 moved from a tool-bash plugin-local Map<string, Agent> into the executor. BashExecRequest gains an optional owner?: string; the resolved BashExecSpec carries it as required-but-nullable owner: string | undefined (a forgotten owner is a visible undefined, never a silently-absent property). The executor stores the token on its task and exposes it via a new BashExecutor.ownerOf(id): string | undefined seam (NOT on the public BashTask — one read path, no redundant API). tool-bash deletes its Map entirely: it stamps exec.agent?.session.header.id as the owner at start, and bash_output/bash_kill compare ctx.bash.ownerOf(id) to the caller's token with !== undefined semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning ctx.get('agents')?.list() for agent.session.header.id === ownerToken (read via ctx.get — onTaskDone runs on the bash fiber, a foreign fiber, where the ctx.agents proxy would throw). Because ownership now lives on the task in the executor (disposed with the dsh-bash fiber), it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. (The onTaskDone listener is still effect-scoped to tool-bash's apply, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
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/loadraces teardown. session/cancelbefore a queued prompt starts prevents that prompt from running and cannot batch the next prompt into the cancelled turn.- A
tool-bashHMR 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
AgentLoopplugin fiber.
Seam precondition (recorded)
The bash owner-token comparison relies on session.header.id being unique among live agents. The agent registry does NOT enforce this — it rejects a duplicate agentId, not a duplicate session id, and createAgent accepts an arbitrary sessionId. This is NOT reachable via ACP (UUID sessionId, agentId === sessionId, duplicate-load rejected), so it is not a live product hole, but a programmatic caller that registers two agents with the same session id would break bash isolation and mis-route the completion notice. 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/impl/consumer split.
The planned resolution is to remove the precondition by construction — see unify the agent id and the session id: once an agent IS its session (one id), the registry's existing unique-agentId check is a unique-session-id guarantee and no two live agents can share a session token.
Alternatives considered
- A public
BashTask.ownerfield instead of theBashExecutor.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'sonAppenddetach against the loop's closingsession/flush; the single composite effect's ordered LIFO chain is what captures the closingturn/endon both disposal paths. - A separate step-only
abort()besidecancel()— 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.