deepseek-harness/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md

51 lines
7.5 KiB
Markdown
Raw Normal View History

# Agent Note: Agent lifecycle and ownership contracts
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
Status: implemented
English | [中文](2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md)
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
## Problem
Several ACP and tool-bash limitations were symptoms of the same missing ownership contract: 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.
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus 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.
2026-07-05 22:58:25 +08:00
## Decision
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
Three contract changes: the queue-aware cancel, the `AgentHandle` disposer, and the bash owner token.
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
### 1. Queue-aware `Agent.cancel(cause?)`
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
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 active turn if any, and keeps a cause-less pre-run marker so a prompt cancelled before claim never runs while a later prompt remains independent. An effective call emits `agent/cancel-requested` with the typed `user | parent` cause before clearing or aborting; idle cancellation emits nothing and cannot strand the next prompt. `whenIdle()` reaches post-cancel quiescence, and ACP `session/cancel` maps to `user`. The [explicit turn-cancellation decision](2026-07-16-explicit-turn-cancellation.md) owns the current cause, signal-lifetime, and cooperative-settlement contract.
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
### 2. `AgentHandle` async disposer
`ctx.agents.create`/`resume` (and the `AgentFactory` interface) return `AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — a registry observer holding only the bare `Agent` cannot tear it down. The caller fiber and registered factory provider are structural co-owners: caller unload enforces structured ownership, while provider unload must stop old instances whose scoped dependency surface resolves through that provider. All three paths reach the same memoized teardown: stop the loop, await its exit and idle flushes (true quiescence, not just the `disposed` status flip), detach the agent, detach its session, and unwind its scope. Each public ID becomes reusable when its exact registry entry detaches; there is no separate reservation-release phase. Config-created agents are already owned by the `AgentLoop` fiber (the handle is discarded). ACP holds each fresh session's disposer in its `SessionRecord` and runs it on disconnect or plugin teardown, so a bare client disconnect leaves no registered agent and no session-store entry. A create that loses the close race disposes its unpublished handle.
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
**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 removing the session store's append publication hooks 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 contained `agent/disposed` and `session/disposed` notifications cannot reject the chain or skip later teardown.
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
### 3. Bash owner token in the Service Definition
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
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` method (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.id` (the shared registry/session 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.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.)
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus 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.
2026-07-05 22:58:25 +08:00
## Verification
These invariants hold and are pinned by tests:
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
- ACP disconnect or plugin teardown leaves no registered agent and no session-store entry for any bridge-owned session, including a create racing connection closure.
- `session/cancel` before a queued prompt starts prevents that prompt from running; a later accepted prompt remains an independent queued turn.
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
- 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.
2026-07-11 22:55:40 +08:00
## Session owner tokens are unique among live agents
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. 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 capability keeps `owner` opaque and never interprets it — the correct Service Definition / Service provider / Consumer split.
docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus 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.
2026-07-05 22:58:25 +08:00
## Alternatives considered
- **A public `BashTask.owner` field** instead of the `BashExecutor.ownerOf(id)` Service Definition method — 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 removal of the store-owned append publication hooks 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-API Agent Note](../simplification/2026-06-20-public-agent-stop-api.md)).
docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus 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.
2026-07-05 22:58:25 +08:00
## Consequences
refactor(tool-bash): own background tasks by session token, not a plugin-local Map Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles (access control AND holding a live Agent for completion notices), both now stateless: - Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`exec.agent?.session.header.id`) with `!== undefined` semantics (an empty-string token is still a real owner). The owner is stamped at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the executor, so it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. - Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds the live agent by scanning `ctx.get('agents')?.list()` for a matching `session.header.id` (read via `ctx.get` — the listener runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No registry / owner gone → drop the notice cleanly. Token is `session.header.id` (NOT `session.id`): every other subsystem keys off the header id, and the test fakes populate only `session.header.id`, so reading `session.id` would make every fake unowned and pass the isolation tests for the wrong reason. Tests give A and B DISTINCT real session tokens (a same-token-different-Agent case is now ALLOWED — identity no longer matters); the HMR test inverts to assert ownership SURVIVES a tool-bash reload; a new test covers the owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC proposed->implemented (recording all three seams + the session-id-uniqueness precondition) and updates the tool-bash README + the now-implemented RFC's cross-links.
2026-06-20 08:14:27 +08:00
This touched public interfaces (`Agent`, `AgentFactory`, the bash seam) deliberately, not as a local ACP patch. Synchronous agent delivery remains simple; the async lifecycle path is additive for owners that need it.