DSH / Atlas
2026-06-18implementedarchitecture

Agent lifecycle and ownership contracts

Agent 生命周期与所有权约定

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 q

English

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 job ownership in a plugin-local Map, so an HMR reload could make an old task look unowned.

Decision

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

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

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 owns the current cause, signal-lifetime, and cooperative-settlement contract.

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.

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.

3. Bash owner token in the Service Definition

Background-job ownership moved from a tool-bash plugin-local Map<string, Agent> into the executor. ShellExecRequest gains an optional owner?: string; the resolved ShellExecSpec 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 ShellExecutor.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.shell.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.getonJobDone 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-shell fiber), it SURVIVES a tool-bash HMR reload — closing the old XXX(tool-bash-owner-hmr) gap. (The onJobDone 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 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.
  • A tool-bash HMR reload does NOT make an existing background job 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 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.

Alternatives considered

  • A public BashTask.owner field instead of the ShellExecutor.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).

Consequences

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.

中文

问题

ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个所有权约定缺失的症状:插件可以通过 ctx.agents 创建或恢复 agent(智能体),但无法独立拥有和 dispose(资源释放)单个 agent,而长时间运行的 bash 任务在执行器中也没有稳定的所有者。ACP 在断连时中止并等待 agent,却无法仅注销该会话的 agent;session/cancel 无法取消已入队但尚未开始的工作;tool-bash 将任务所有权保存在插件本地的 Map 中,因此一次 HMR(热模块替换)重载就可能让旧任务看起来无主。

决策

三项约定变更:队列感知的取消、AgentHandle 释放器,以及 bash 所有者令牌。

1. 队列感知的 Agent.cancel(cause?)

Agent 接口新增 cancel() 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 abort() 一同交付;后者后来因无人使用而移除,使 cancel() 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 agent/cancel-requested,携带类型化的 user | parent cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。whenIdle() 会在取消后达到完全停稳,ACP 的 session/cancel 映射到 user显式轮次取消决策规定了当前的 cause、signal 生命周期与协作式结算约定。

2. AgentHandle 异步释放器

ctx.agents.create/resume(以及 AgentFactory 接口)返回 AgentHandle = { agent: Agent; dispose(): Promise<void> }。释放器是一种消费方能力——仅持有裸 Agent 的注册表观察者无法将其拆除。调用方 fiber 和已注册的 factory 提供方是结构上的共同所有者:调用方卸载强制结构化所有权,而提供方卸载必须停止旧实例,因为其实例作用域的依赖 surface 通过该提供方解析。三条路径都会进入同一个记忆化的拆除过程:停止循环、等待其退出与空闲刷写完成(完全停稳,而非仅把状态翻转为 disposed)、分离 agent、分离其会话,然后解除其 scope。每个公开 ID 在其精确注册表条目分离时变得可复用;不存在独立的保留释放阶段。由配置创建的 agent 已归 AgentLoop fiber 所有(handle 被丢弃)。ACP 在其 SessionRecord 中保存每个全新会话的释放器,并在断连或插件拆除时运行它,因此单纯的客户端断连不会留下已注册 agent 或会话存储条目。在与关闭的竞态中落败的创建流程会 dispose 其尚未发布的 handle。

拆除顺序对持久性至关重要,实现将会话生命周期折叠进 agent 的单个复合 Cordis effect(SessionStore.prepare/enter/announce,取代兄弟 effect 拆分)。fiber 卸载会并发释放兄弟 effect(Promise.all),这会让会话存储的 append 发布钩子移除与循环关闭时的 session/flush 竞争,从而丢失关闭的 turn/end;在一个 effect 内,释放器作为有序的 LIFO 链运行(停止循环 + await agent.done 在会话分离之前),因此无论 handle 的 dispose() 还是 fiber 卸载,都会捕获循环的最终刷写。被隔离的 agent/disposedsession/disposed 通知无法拒绝该链或跳过后续拆除。

3. Service Definition 中的 Bash 所有者令牌

后台任务所有权从 tool-bash 插件本地的 Map<string, Agent> 移入执行器。ShellExecRequest 新增可选的 owner?: string;解析后的 ShellExecSpec 将其作为必需但可空的 owner: string | undefined 携带(被遗忘的 owner 是可见的 undefined,而非静默缺失的属性)。执行器把 token 存在任务上,并通过新的 ShellExecutor.ownerOf(id): string | undefined 方法暴露它(不放在公开的 BashTask 上——只有一条读取路径,没有冗余 API)。tool-bash 完全删除其 Map:它在 start 时将 exec.agent?.id(共享的注册表/会话 id)盖章为 owner,bash_output/bash_kill 则以 !== undefined 语义把 ctx.shell.ownerOf(id) 与调用方 token 比较(空字符串 token 仍是真实 owner)。完成通知通过扫描 ctx.get('agents')?.list() 查找 agent.id === ownerToken 的存活 agent(经 ctx.get 读取——onJobDone 运行在 bash fiber 这一外部 fiber 上,直接使用 ctx.agents proxy 会抛异常)。由于所有权现在保存在执行器的任务上(随 dsh-shell fiber dispose),它能跨越 tool-bash HMR 重载,关闭旧的 XXX(tool-bash-owner-hmr) 缺口。(onJobDone 监听器仍受 tool-bashapply effect 约束,因此落在重载间隙的完成仍会丢失一条通知——既有的重载间隙丢失——但所有权隔离本身已经不受 HMR 影响。)

验证

以下不变式已经成立,并由测试固定:

  • ACP 断连或插件拆除后,任何由桥接层拥有的会话都不留下已注册 agent 或会话存储条目,包括与连接关闭竞争的创建流程。
  • 已入队的提示词启动前执行 session/cancel,能阻止该提示词运行;后来接受的提示词仍是独立的已入队轮次。
  • tool-bash HMR 重载不会使另一个会话能够读取或终止已有的后台任务(所有权保留在执行器上)。
  • 既有的非 ACP 演示无需显式管理 handle 仍能工作;由配置创建的 agent 仍归 AgentLoop 插件 fiber 所有。

会话所有者令牌在存活 agent 中唯一

bash 所有者 token 比较依赖共享的 Agent.id/SessionId 在存活 agent 中唯一。并发的同 ID 操作可以都私下准备,但发布时会依次登记会话和 agent;SessionStore.enter() 拒绝重复的存活会话 id,每个失败事务都回滚自己的私有状态。因此程序化调用方无法发布两个共享同一会话 token 的存活 agent。访问策略(token 比较)留在 Consumer tool-bash;bash 能力只存储不透明的 owner 字符串且从不解释它——这是正确的 Service Definition / Service Provider / Consumer 拆分。

曾考虑的替代方案

  • 公开的 BashTask.owner 字段而非 ShellExecutor.ownerOf(id) Service Definition 方法:否决。一条读取路径即可,无需冗余 API。
  • 为 agent 的会话生命周期使用兄弟 Cordis effect:否决。fiber 卸载时并发释放兄弟 effect(Promise.all),store 拥有的 append 发布钩子的移除与循环的关闭 session/flush 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 turn/end
  • cancel() 之外另设一个仅中止步骤的 abort():最初发布过,后因无人使用而移除;cancel() 是唯一的公开停止原语(见公开停止接口 Agent Note)。

后果

本变更有意触及公开接口(AgentAgentFactory、bash seam),而非作为 ACP 的局部补丁。同步 agent 交付仍然简单;异步生命周期路径是增量添加的,供需要它的所有者使用。