Merge remote-tracking branch 'origin/feat/windows-pwsh-default' into feat/windows-acl-sandbox

# Conflicts:
#	docs/module-graph.md
#	knip.json
#	packages/pty/pty-local/tests/index.spec.ts
#	scripts/check-workspace-constraints.ts
This commit is contained in:
Huanqi Cao
2026-08-09 00:29:55 +08:00
286 changed files with 15528 additions and 1684 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
2026-07-16-persistent-pty-sessions.md: d7d06dc8517780a37889e4f94dd0b99475dc5432
2026-07-16-persistent-pty-sessions.zh.md: 84ef8988ba657a87137f904df38e45808d32b483
2026-07-16-persistent-pty-sessions.md: 88296a9318b2c398fead382153bd2c652dbd9e68
2026-07-16-persistent-pty-sessions.zh.md: 40f26c67fe046ad292f05919501600bd2e146c62

View File

@@ -23,10 +23,10 @@ The implementation supports interactive shells and line-oriented REPLs on Linux
| Package | Role | ctx key |
|---|---|---|
| `dsh-pty` | `PtyService`, branded `PtySessionId`, backend registry, owner-scoped session contract, and result types | `ctx.pty` |
| `dsh-pty-local` | [`node-pty`](https://github.com/microsoft/node-pty)-based local backend, platform process inspection, bounded terminal buffer, sandbox resolution, and process-tree supervision | registers a backend on `ctx.pty` |
| `dsh-pty-local` | Persistent-shell backend over `ctx.subprocess.spawnTerminal()`: readiness, bounded terminal buffers, sandbox resolution, and owner-aware session lifecycle | registers a backend on `ctx.pty` |
| `dsh-tool-pty` | Six model-facing tools, task-runtime integration for background sends, guidance, and UI render intents | registers on `ctx.tools` |
Idle detection is backend behavior, not a second public seam. A remote or container backend may have authoritative readiness signals that do not resemble local `/proc` inspection; every `PtyBackend` therefore returns the common send result while owning its detection mechanism internally.
Readiness remains PTY-backend behavior, not a second public seam. The terminal-process provider supplies only substrate facts such as the foreground process group and whether it can prove that group is waiting on input; `dsh-pty-local` combines those facts with prompt and silence evidence into the common send result.
### Agent ownership and identity
@@ -40,12 +40,12 @@ Agent-scope disposal closes registrations first, then awaits quiescent teardown
A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning:
- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*PASSWORD*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them.
- It requires `ctx.sandbox` and the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default and wraps the shell argv once; that mode and workspace root remain the process boundary for the PTY lifetime. A write that would change the effective `sandbox/mode` is rejected before commit while the owner has any open PTY or unpublished spawn, with an instruction to wait for creation to settle and close those sessions first; same-effective-mode writes remain valid. The pending reservation spans backend setup through publication, so there is no race in which a wider terminal appears after a downgrade. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass.
- It supplies only terminal-specific environment overrides; the mounted subprocess provider applies the shared credential-shaped-name scrub before merging them.
- It requires the shared `ctx.sandboxPolicy`. At spawn, the backend resolves the owner's effective session mode over the deployment default; `danger-full-access` starts the shell directly, while confined modes require a same-world `ctx.sandbox` provider and wrap the shell argv once. That mode and workspace root remain the process boundary for the PTY lifetime. A write that would change the effective `sandbox/mode` is rejected before commit while the owner has any open PTY or unpublished spawn, with an instruction to wait for creation to settle and close those sessions first; same-effective-mode writes remain valid. The pending reservation spans backend setup through publication, so there is no race in which a wider terminal appears after a downgrade. `danger-full-access` is the existing explicit unconfined choice rather than a PTY-specific bypass.
Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary.
The implementation uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, `resize`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors derive foreground process groups and parent/child identity from `/proc` on Linux and `ps` on macOS.
The local subprocess terminal primitive uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors below that primitive derive foreground process groups and parent/child identity from `/proc` on Linux and `ps` on macOS. The [portable execution-world decision](../architecture/2026-07-28-portable-execution-world-consumers.md) owns this process/consumer split.
### Six model-facing tools
@@ -60,7 +60,7 @@ The implementation uses only public `node-pty` capabilities: child PID, `data` a
The UI render contract is exact and location-free. `terminal_send` uses terminal call/result cards only for foreground sends; its background form is generic `execute`. `terminal_open`, `terminal_read`, `terminal_signal`, `terminal_close`, and `terminal_list` use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. No PTY tool emits `locations`.
`terminal_send({ sessionId, text, submit?, run_in_background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics. `enableRunInBackground` defaults to true; false removes `run_in_background` from the schema and rejects the same undeclared argument if a caller forces it through execution.
`terminal_send({ sessionId, text, submit?, run_in_background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics. Cancellation marks queued input before signaling the real foreground group, so input cannot execute if an asynchronous pre-write inspection settles afterward. The canceled send retains its reservation until asynchronous foreground signalling settles, so a successor cannot become that signal's target. `enableRunInBackground` defaults to true; false removes `run_in_background` from the schema and rejects the same undeclared argument if a caller forces it through execution.
Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit. `dsh-tool-pty.maxResultBytes` defaults to 262144, rejects values below 64 so creation acknowledgements retain registry-issued ids, and caps each single-text UTF-8 result after normalized tool or pipeline errors, wait, session, pagination, truncation, generic task-status wrappers, policy denials or short-circuits, and post-execute replacements or blocks; the terminal definitions' last-mile `finalizeContent` callback leaves deliberately structured multi-block policy content unchanged. The renderer reserves suffix space and preserves code-point boundaries instead of treating the backend payload cap as the final model bound.
@@ -72,7 +72,7 @@ With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on `
### Local readiness detection
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires printable prompt text after that marker before declaring prompt readiness and runs three bounded fallback tiers. Carrying that state across data callbacks covers macOS delivery where the OSC marker and `PS1` arrive separately; the marker alone can no longer publish an empty MOTD. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`.
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`.
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1.
@@ -92,9 +92,9 @@ Background sends use the existing task completion notice and `task_output` resul
### Process-tree teardown
The top-level `node-pty` child is the ownership anchor. On close, the backend stops callbacks, snapshots its transitive descendants by parent PID in children-first order, sends `SIGTERM`, waits, rescans for children forked during shutdown, sends `SIGKILL` to the remaining descendant tree, and verifies that every non-zombie descendant left the process table while the shell is still alive. A matching Linux zombie has no executable work and therefore counts as quiescent, allowing shell shutdown to reap or reparent it. Only then does the backend stop the shell with its own TERM/grace/KILL sequence. Every captured PID includes process-start identity so reuse cannot redirect escalation.
The subprocess terminal handle owns the top-level terminal process and its session. On close it snapshots transitive descendants by parent PID in children-first order, sends `SIGTERM`, waits, rescans for children forked during shutdown, sends `SIGKILL` to the union, and verifies every non-zombie descendant left the process table before stopping the top-level process. A matching Linux zombie has no executable work and therefore counts as quiescent. Every captured PID includes process-start identity so reuse cannot redirect escalation.
Teardown reports root exit and survivor cleanup independently. It does not claim success merely because the shell exited; disposal resolves only after no captured non-quiescent tree member remains or returns a cleanup failure naming the survivors. A failed close is not cached forever: the registry and local session each clear the fence only when it still names that failed attempt, so a later explicit or lifecycle close retries after the external survivor condition changes without disturbing a newer concurrent attempt. Service disposal still clears its backend, reservation, and owner-detacher registries when a close fails. It never broadens ownership to every member of the root PID's POSIX session.
Teardown reports top-level exit and survivor cleanup independently. The PTY session does not claim success merely because the shell exited: it calls `SubprocessTerminalHandle.terminate()` and awaits whole-session quiescence, propagating a cleanup failure that names survivors. A failed close is not cached forever: the registry and local session clear the fence only when it still names that failed attempt, so a later explicit or lifecycle close retries without disturbing a newer concurrent attempt. Service disposal still clears its backend, reservation, and owner-detacher registries when a close fails.
### Composition and rollout
@@ -108,6 +108,7 @@ plugins:
mode: workspace-write
workspaceRoot: .
'@deepseek-ai/dsh-pty':
'@deepseek-ai/dsh-subprocess-local':
'@deepseek-ai/dsh-pty-local':
config:
scrollbackLines: 10000
@@ -141,11 +142,11 @@ The package ships concise tool guidance explaining persistent state, owner isola
**Add persistent mode to `bash`.** Rejected. Returning on readiness rather than process exit, retaining a process tree across calls, and exposing interactive stdin create a different ownership and failure contract.
**Require native master-fd access from `node-pty`.** Rejected. Its public API exposes no master fd. The local backend instead derives foreground groups and descendants from supported OS process metadata and treats unreadable metadata as a detector miss.
**Require native master-fd access from `node-pty`.** Rejected. Its public API exposes no master fd. The local subprocess terminal adapter derives foreground groups and descendants from supported OS process metadata and treats unreadable metadata as a detector miss.
**Signal every member of the root PID's POSIX session.** Rejected. `node-pty` may expose a helper PID whose session belongs to the launcher, so SID-wide teardown can signal unrelated harness or desktop processes. A PID-identity-fenced descendant tree is narrower and safe by construction.
**Publish `PtyIdleDetector` as a replaceable registry.** Rejected. Only the local backend needs these platform probes, while remote backends may receive readiness over their own protocol. Backend replacement already provides the necessary extension point.
**Publish `PtyIdleDetector` as a replaceable registry.** Rejected. Substrate-specific foreground facts come from the mounted terminal-process primitive, while prompt/silence readiness remains one private policy in `dsh-pty-local`. The filesystem/subprocess execution-world replacement is the necessary extension point.
**Add a PTY-specific `sleep` tool.** Rejected. `ctx.tasks` already owns bounded waiting, cancellation, completion notices, and model-facing collection. A second general wake mechanism would cross the agent-loop boundary and duplicate that contract.
@@ -155,9 +156,9 @@ The package ships concise tool guidance explaining persistent state, owner isola
## Verification
- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
- Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
- Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
- Subprocess process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
- Real `node-pty` and PTY-consumer tests jointly exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
- A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
- Package contracts, the architecture map, core data structures, generated catalogs, and the website API describe the same shipped surface.
- The repository CI-equivalent sequence owns type, lint, coverage, snapshot, documentation, build, hygiene, demo, and built-entry verification.
@@ -172,10 +173,10 @@ The package ships concise tool guidance explaining persistent state, owner isola
**Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic.
**A daemonized descendant can leave the captured tree.** A process that reparents before teardown is no longer discoverable from the `node-pty` root. The implementation accepts that cleanup gap instead of risking SID-wide signals to unrelated processes.
**A daemonized descendant can leave the local provider's captured tree.** A process that reparents before teardown is no longer discoverable from the `node-pty` root. The local terminal primitive accepts that cleanup gap instead of risking SID-wide signals to unrelated processes.
**A shell can cause external side effects.** Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy.
**Process loss destroys terminal state.** In-process sessions do not survive a harness crash or restart, and raw scrollback is not durable. Important work must be committed to files or another durable system.
**`node-pty` is a native dependency.** Installation, supported Node versions, prebuild availability, and platform behavior require built-artifact smokes on every supported OS.
**`node-pty` is a native dependency of `dsh-subprocess-local`.** Installation, supported Node versions, prebuild availability, and platform behavior require built-artifact smokes on every supported OS.

View File

@@ -14,7 +14,7 @@ harness 可以运行前台与后台命令、编辑文件和委派工作,但无
## 决策
可选的 `packages/pty/` 能力家族提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop`。
可选的 `packages/pty/` 功能家族提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop`。
当前实现在 Linux 和 macOS 上支持交互式 shell 与行式 REPL。全屏终端应用、按键序列、BEL 触发的控制流、进程丢失后的会话恢复以及跨 agent 共享会话都明确推迟。
@@ -23,10 +23,10 @@ harness 可以运行前台与后台命令、编辑文件和委派工作,但无
| 包 | 角色 | ctx key |
|---|---|---|
| `dsh-pty` | `PtyService`、branded `PtySessionId`、后端注册表、按 owner 隔离的会话契约和结果类型 | `ctx.pty` |
| `dsh-pty-local` | 基于 [`node-pty`](https://github.com/microsoft/node-pty) 的本地后端、平台进程检查、有界终端缓冲、沙箱解析和进程树监管 | 在 `ctx.pty` 上注册后端 |
| `dsh-pty-local` | 基于 `ctx.subprocess.spawnTerminal()` 的持久 shell 后端:就绪状态、有界终端缓冲、沙箱解析和感知 owner 的会话生命周期 | 在 `ctx.pty` 上注册后端 |
| `dsh-tool-pty` | 6 个面向模型的工具、后台发送的 task 运行时集成、使用指引和 UI 渲染意图 | 注册到 `ctx.tools` |
idle 检测属于后端行为,不是第二条公共 seam。远程或容器后端可能拥有完全不同于本地 `/proc` 检查的权威就绪信号;因此每个 `PtyBackend` 都返回统一的发送结果,同时在内部拥有自己的检测机制。
就绪判定仍属于 PTY 后端行为,不是第二条公共 seam。终端进程提供方只提供基底事实,例如前台进程组,以及能否证明该组正在等待输入;`dsh-pty-local` 将这些事实与提示符和静默证据组合成统一的发送结果。
### agent 所有权与身份
@@ -34,18 +34,18 @@ idle 检测属于后端行为,不是第二条公共 seam。远程或容器后
实现不提供插件加载期 auto-start 会话。`terminal_open` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。未来的声明式启动功能必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
agent scope dispose(资源释放)时先关闭注册,再等待全部所属 PTY 完全停稳。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端 reject,或返回的会话在回滚 close 时失败,调用方取消仍会原样保留其 `AbortSignal.reason`;该清理失败不会替换调用方原因,而会继续受追踪,留待后续 owner 或服务 dispose 处理。由 lifecycle dispose 触发的回滚 close 失败会使 spawn 与该 lifecycle dispose 都 reject,而 `PtyBackendCleanupError` 让后端在不替换调用方取消的前提下,为该 lifecycle dispose 保留自身的启动清理失败。若调用方取消已完成结算,而 dispose 尚未发生,该清理失败会继续作为受追踪的 owner activity 保留,直到后续 owner 或服务 dispose 消费并报告它,因此沙箱模式策略不会把清理失败误判为完全停稳。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。未发布的后端 setup 同样是受追踪的生命周期操作:owner 或服务 dispose 会中止服务自有的 signal,等待后端结算与回滚完成后才返回。即使后端 reject,或返回的会话在回滚 close 时失败,调用方取消仍会原样保留其 `AbortSignal.reason`;该清理失败不会替换调用方原因,而会继续受追踪,留待后续 owner 或服务 dispose 处理。由 lifecycle dispose 触发的回滚 close 失败会使 spawn 与该 lifecycle dispose 都 reject,而 `PtyBackendCleanupError` 让后端在不替换调用方取消的前提下,为该 lifecycle dispose 保留自身的启动清理失败。若调用方取消先于 dispose 完成结算,该清理失败会继续作为受追踪的 owner activity 保留,直到后续 owner 或服务 dispose 消费并报告它,因此沙箱模式策略不会把清理失败误判为静默。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。服务会先同步把会话预留给一次活跃发送,再返回该操作;后台发送同样会在 task id 对外可见前完成预留。第二次发送会以 `SEND_ACTIVE` 失败,因此输出与取消无法跨越操作所有权。
### 安全与进程边界
注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护:
- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*PASSWORD*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。
- 它要求 `ctx.sandbox` 和共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode,并只包装一次 shell argv;该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。只要 owner 有任何已打开的 PTY 或尚未发布的 spawn,任何会改变生效 `sandbox/mode` 的写入都会在提交前被拒绝,并提示先等待创建操作结算,再关闭这些会话;不会改变生效模式的写入仍然有效。这项进行中的预留从后端 setup 持续到发布完成,因此不存在降级后又出现权限更宽的终端这一竞态。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。
- 它只提供终端专用的环境覆盖;挂载的子进程提供方先清除名称形似凭据的环境变量,再合并这些覆盖。
- 它要求共享的 `ctx.sandboxPolicy`。后端在 spawn 时,以部署默认值为底折叠 owner 的有效 session mode;`danger-full-access` 会直接启动 shell,受限模式则要求同一执行世界中存在 `ctx.sandbox` 提供方,并只包装一次 shell argv。该 mode 与 workspace root 在 PTY 的整个生命周期中充当进程边界。只要 owner 有任何已打开的 PTY 或尚未发布的 spawn,任何会改变生效 `sandbox/mode` 的写入都会在提交前被拒绝,并提示先等待创建操作结算,再关闭这些会话;不会改变生效模式的写入仍然有效。这项进行中的预留从后端 setup 持续到发布完成,因此不存在降级后又出现权限更宽的终端这一竞态。`danger-full-access` 是现有的显式无约束选择,不另设 PTY 私有 bypass。
沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。
实现只使用 `node-pty` 的公共功能:子进程 PID、`data` 与 `exit` 通知、`write`、`resize` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和父子进程身份。
本地子进程终端原语只使用 `node-pty` 的公共功能:子进程 PID、`data` 与 `exit` 通知、`write` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。该原语下的平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导前台进程组和父子进程身份。[可移植执行环境决策](../architecture/2026-07-28-portable-execution-world-consumers.md)负责定义这种进程/消费方拆分。
### 6 个面向模型的工具
@@ -55,28 +55,28 @@ agent scope dispose(资源释放)时先关闭注册,再等待全部所属
| `terminal_send` | 发送文本、可选提交 Enter,并等待就绪或注册一个后台任务 | 有界 viewport、等待状态和会话状态;后台模式还返回 `taskId` |
| `terminal_read` | 从保留的 scrollback 读取一个有界页 | `{ text, totalLines, lineBegin, lineEnd, truncated }` |
| `terminal_signal` | 向当前前台进程组发送一种允许的信号 | `{ delivered, targetPgid }` |
| `terminal_close` | 关闭一个会话并等待进程树完全停稳 | `{ killed }` |
| `terminal_close` | 关闭一个会话并等待进程树静默退出 | `{ killed }` |
| `terminal_list` | 列出调用方的活会话 | 按 owner 隔离的会话摘要 |
UI 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发送使用 terminal 调用卡片和结果卡片;后台形式使用通用 `execute` 卡片。`terminal_open`、`terminal_read`、`terminal_signal`、`terminal_close` 和 `terminal_list` 分别使用通用 `execute`、`read`、`execute`、`delete` 和 `read` 卡片。所有 PTY 工具都不发出 `locations`。
`terminal_send({ sessionId, text, submit?, run_in_background? })` 将 `text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true`。`submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。`enableRunInBackground` 默认为 true;设为 false 时,schema 中会移除 `run_in_background`,调用方即使强行把这个未声明参数传入执行流程,也会被拒绝。
`terminal_send({ sessionId, text, submit?, run_in_background? })` 将 `text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true`。`submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。取消会在向真实前台进程组发送信号前将排队输入标记为已取消,因此即使异步的写入前检查随后才结算,该输入也无法执行。被取消的发送会保留其预留,直至异步前台信号发送结算,因此后续发送不会成为该信号的目标。`enableRunInBackground` 默认为 true;设为 false 时,schema 中会移除 `run_in_background`,调用方即使强行把这个未声明参数传入执行流程,也会被拒绝。
前台发送返回有界的渲染增量和两个独立事实:`waitReason`(`stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus`(`running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。`dsh-tool-pty.maxResultBytes` 默认为 262144;低于 64 的值会被拒绝,以确保创建确认保留 registry 签发的 id;每个单文本 UTF-8 结果在加入规范化的工具或流水线错误、等待、会话、分页、截断、通用 task 状态包装、策略拒绝或短路以及 post-execute 替换或阻断后,仍受该值限制;终端定义自有的末端 `finalizeContent` callback 会原样保留策略刻意返回的结构化多 block 内容。渲染器会为后缀预留空间并保持代码点边界,而不会把后端载荷上限当作面向模型结果的最终上限。
当 `run_in_background: true` 时,`dsh-tool-pty` 在 `ctx.tasks` 上注册进行中的发送,并立即返回 `taskId`。生产方把 `maxResultBytes` 写入 task 快照,使 `task_output`、kill 返回的终态状态和完成通知在加上通用元数据后,仍对完整结果执行同一上限。`task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 会解析当前前台 PGID 并发送真正的 `SIGINT`,即使应用已禁用终端 `ISIG` 也同样如此,且后续升级仍只通过 PTY 后端拥有的 teardown 路径进行。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。
`terminal_read` 从最新保留行起,朝更早的行分页。后端同时对保留的 scrollback 和返回页载荷执行行数与 UTF-8 字节上限,因此单个超长行无法绕过后端上限;工具随后再限制包含分页与截断元数据的完整渲染页。`truncated` 用于区分保留数据丢失与普通 viewport 增量。
`terminal_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和返回页载荷执行行数与 UTF-8 字节上限,因此单个超长行无法绕过后端上限;工具随后再限制包含分页与截断元数据的完整渲染页。`truncated` 用于区分保留数据丢失与普通 viewport 增量。
`terminal_signal` 接受闭合集 `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`。后端在执行时解析终端前台进程组。当目标组是顶层 shell 时拒绝 `SIGKILL`,并指引调用方使用 `terminal_close`;进程组解析失败时操作直接失败,而不是向猜测的 PID 发送信号。
### 本地就绪检测
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在该 marker 后出现可打印的 prompt 文本时才据此声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留这项状态,可以适配 macOS 分开交付 OSC marker 与 `PS1` 的情况;单独的 marker 不会发布空 MOTD。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上进行单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。
@@ -88,13 +88,13 @@ Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此
现有持久化 `tool/call` 与 `tool/result` 事件是模型发送文本和返回给模型的渲染输出的真源。`terminal_open` 通过已记录的工具结果返回 MOTD;前台 `send`/`read`/`list`/`signal`/`close` 结果走同一路径记录。PTY 包不会把原始字节流重复写入自定义会话事件。
后台发送复用现有后台任务完成通知和 `task_output` 结果路径,因此进入后续模型请求的任何输出同样持久化。原始终端字节只作为有界的进程内状态存在,既不持久化也不可恢复。未来的 opt-in transcript(文本记录)sink 必须拥有独立的保留、凭证和隐私契约。
后台发送复用现有后台任务完成通知和 `task_output` 结果路径,因此进入后续模型请求的任何输出同样持久化。原始终端字节只作为有界的进程内状态存在,既不持久化也不可恢复。未来的 opt-in transcript sink 必须拥有独立的保留、凭证和隐私契约。
### 进程树 teardown
顶层 `node-pty` 子进程是所有权锚点。关闭时,后端先停止 callback,再按父 PID 以子进程优先顺序捕获其传递子进程、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向剩余子孙进程树发送 `SIGKILL`,并在 shell 仍存活时验证每个非僵尸子孙进程都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳;shell 关闭时会回收它或将其重新挂接给负责回收的父进程。完成这些步骤后,后端才用 shell 自身的 TERM、宽限等待、KILL 序列停止 shell。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。
子进程终端句柄拥有顶层终端进程及其会话。关闭时,它按父 PID 以子进程优先顺序捕获传递后代、发送 `SIGTERM` 并等待,然后重新扫描关停期间 fork 出的子进程,向二者并集发送 `SIGKILL`,并在停止顶层进程前验证每个非僵尸后代都已离开进程表。身份匹配的 Linux 僵尸进程已无可执行工作,因此视为完全停稳。每个捕获的 PID 都包含进程启动身份,避免 PID 复用把升级信号发给无关进程。
teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的进程树中不再存在尚未完全停稳的成员后才完成,否则返回清理失败并列出存活者。失败的 close 不会永久缓存:注册表与本地会话各自仅在关闭围栏仍指向该次失败尝试时才将其清除,因此外部存活进程状态改变后,后续的显式 close 或生命周期 close 会重试,且不会干扰较新的并发尝试。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。所有权绝不会扩大到根 PID 所属 POSIX 会话的全部成员。
teardown 独立报告顶层进程退出与存活进程清理。PTY 会话不会只因 shell 退出就声称成功:它会调用 `SubprocessTerminalHandle.terminate()` 并等待整个会话完全停稳,若清理失败则向外传播并列出存活者。失败的 close 不会永久缓存:注册表与本地会话各自仅在关闭围栏仍指向该次失败尝试时才将其清除,因此后续的显式 close 或生命周期 close 会重试,且不会干扰较新的并发尝试。即使某个 close 失败,服务 dispose 仍会清空其后端、预留与 owner detacher 注册表。
### 组合与推行
@@ -108,6 +108,7 @@ plugins:
mode: workspace-write
workspaceRoot: .
'@deepseek-ai/dsh-pty':
'@deepseek-ai/dsh-subprocess-local':
'@deepseek-ai/dsh-pty-local':
config:
scrollbackLines: 10000
@@ -141,11 +142,11 @@ plugins:
**给 `bash` 增加持久模式。**拒绝。按就绪而不是进程退出返回、跨调用保留进程树、暴露交互式 stdin 会形成不同的所有权和失败契约。
**要求从 `node-pty` 获取原生 master fd。**拒绝。它的公共 API 不暴露 master fd。本地后端改为从受支持的 OS 进程元数据推导前台组与子孙进程,并把不可读元数据视为 detector miss。
**要求从 `node-pty` 获取原生 master fd。**拒绝。它的公共 API 不暴露 master fd。本地子进程终端适配器改为从受支持的 OS 进程元数据推导前台组与子孙进程,并把不可读元数据视为 detector miss。
**向根 PID 所属 POSIX 会话的全部成员发送信号。**拒绝。`node-pty` 可能暴露属于启动器会话的 helper PID,因此按 SID 清理可能向无关的 harness 或桌面进程发送信号。带 PID 启动身份校验的子孙进程树范围更窄,其安全边界由结构保证。
**发布可替换注册表 `PtyIdleDetector`。**拒绝。只有本地后端需要这些平台 probe,远程后端可能通过自己的协议接收就绪状态。替换后端已经提供所需扩展点。
**发布可替换注册表 `PtyIdleDetector`。**拒绝。基底专用的前台事实来自挂载的终端进程原语,提示符/静默就绪判定则仍是 `dsh-pty-local` 内部的一项私有策略。替换文件系统/子进程执行环境就是所需扩展点。
**新增 PTY 专用 `sleep` 工具。**拒绝。`ctx.tasks` 已经拥有有界等待、取消、完成通知和面向模型的收集。第二套通用唤醒机制会跨越 agent loop(智能体循环)边界并重复该契约。
@@ -155,9 +156,9 @@ plugins:
## 验证
- 逐文件测试覆盖并固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
- Linux 进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、僵尸进程的完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。
- 每文件覆盖率固定 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
- 子进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程完全停稳、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
- 真实 `node-pty` 与 PTY 消费方测试共同在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。
- 包契约、架构图、核心数据结构、生成目录和 website API 描述同一个已发布接口。
- 仓库 CI 等价序列负责类型、lint、覆盖率、快照、文档、构建、hygiene、demo 和 built-entry 验证。
@@ -172,10 +173,10 @@ plugins:
**持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。
**daemonized 子进程可能离开捕获树。**在 teardown 前 reparent 的进程无法再从 `node-pty` 根进程发现。实现接受这个清理缺口,不冒险按 SID 向无关进程发送信号。
**daemonized 后代进程可能离开本地提供方捕获的进程树。**在 teardown 前 reparent 的进程无法再从 `node-pty` 根进程发现。本地终端原语接受这个清理缺口,不冒险按 SID 向无关进程发送信号。
**Shell 可以造成外部副作用。**会话沙箱和环境清洗降低本地暴露,但无法撤销 push、API 调用或消息发送。无法容忍这些副作用的部署必须省略 PTY 或增加网络策略。
**进程丢失会销毁终端状态。**进程内会话无法跨 harness crash 或 restart 存活,原始 scrollback 也不持久化。重要工作必须提交到文件或其他持久系统。
**`node-pty` 是原生依赖。**安装、支持的 Node 版本、prebuild 可用性和平台行为都需要在每个支持 OS 上运行构建产物冒烟测试。
**`node-pty` 是 `dsh-subprocess-local` 的原生依赖。**安装、支持的 Node 版本、prebuild 可用性和平台行为都需要在每个支持 OS 上运行 built-artifact smoke。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
2026-07-22-durable-subagent-catalog-and-list-agents.md: b96d6e1dd36c58af67c8e93e62515672790ad009
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 856bac615db84bfe2898ec0838094c6bc29f77b2
2026-07-22-durable-subagent-catalog-and-list-agents.md: 61bda2a52f2ea5db0a582fe668643a13b967f091
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: a2bc36200d8c67b8da9dd3d55ea4179baa72e691

View File

@@ -23,7 +23,7 @@ Parent-to-child enumeration is a service capability with consumer-specific proje
- report corpus activity separately as `running` or `inactive`, without implying completion or resumability;
- return every resulting child in stable `createdAt` ascending, child-id ascending order.
Every ordinary local start receives a `one-shot` descriptor with an optional caller-owned display label, while the continuation manager persists a labeled `continuable` descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short `description` and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing `list_agents` adapter filters the service result to continuable children and maps `inactive` to its existing `complete` presentation; a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation.
Every ordinary local start receives a `one-shot` descriptor with an optional caller-owned display label, while the continuation manager persists a labeled `continuable` descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short `description` and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing `list_agents` adapter filters the service result to continuable children and refines status through the live Agent registry (`running`/`idle`/`complete`); a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation.
### Enumeration decision
@@ -52,9 +52,9 @@ If measured scale later requires an index, that index is derived state: session
A valid descriptor produces one child entry, a per-child inspection failure produces one diagnostic entry, and a candidate without a descriptor produces no entry. `mode` is durable creation policy; `activity` is a process-local corpus snapshot. Activity is neither `AgentStatus`, the manager's internal Activation state, nor a durable outcome, and the result does not expose the internal `createdAt` sorting key. Exact Activation states and durable outcomes such as successful completion, failure, cancellation, and stop reason require a separate durable activation record and are outside this feature.
The model-facing `list_agents` tool takes no arguments, derives `parentSessionId` from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It keeps diagnostics, drops `one-shot` child entries, maps a continuable child's `running` activity to `running` and `inactive` activity to `complete`, then renders `<id> [<status>] — <label>` or `<id> [diagnostic: <reason>]` in the surviving trace order. An empty projection renders `(no subagents)`.
The model-facing `list_agents` tool takes one optional `scope: 'children' | 'descendants'` argument, derives the root id from the current execution Agent, and resolves the request through an explicit request-to-spec step (`undefined` → `children`) before either execution or rendering. The resolved `children` scope calls `SubagentService.listChildren(rootSessionId)`, while `descendants` calls `SubagentService.listDescendants(rootSessionId)`. Its internal output projection keeps `id` and `parent` as branded `SessionId` values until the tool JSON boundary. It keeps diagnostics, drops `one-shot` child entries, derives status from the live Agent registry — `running` for an active driver, `idle` for a resident Agent between turns, and `complete` when no live Agent remains — then renders `<id> [<status>] — <label>` or `<id> [diagnostic: <reason>]` in stable catalog order. The `descendants` scope flattens the complete tree from one live-preferred corpus in stable pre-order, traverses ordinary and one-shot intermediates so deeper continuable agents are discovered, revalidates each cold candidate against its enumerated lifecycle, and adds `parentId`/`depth` to every entry. The tool inserts ` parent=<id> depth=<n>` before the label; `parent` is the durable direct-parent session id and may name an omitted ordinary session. For the current caller, only depth-1 child rows are `send_message` candidates, while deeper child rows may be selected for `interrupt_agent` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Discovery is a hint only — follow-up authority stays exact-direct-parent, and interrupt authority stays with the service's live-lineage check. An empty projection renders `(no subagents)`.
Diagnostics use three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, a read result whose immutable header differs from the traced candidate or no longer names the requested direct parent, a target that is no longer the located descriptor event, malformed descriptor content, and multiple descriptor events map to `corrupt`. An unknown descriptor version maps to `unsupported`. `SESSION_QUERY_SESSION_NOT_FOUND`, `SESSION_QUERY_EVENT_NOT_FOUND`, and `SESSION_QUERY_PERSISTENCE_FAILED` from a per-child read map to `unavailable`. This phase boundary is intentional: a persistence outage during the initial trace fails the operation, while the same outage beginning during candidate reads may produce one identical `unavailable` diagnostic per affected child; the first version neither coalesces those diagnostics nor promotes them to a global failure. A missing descriptor is instead a non-subagent exclusion without a diagnostic. Configuration/window errors and unrecognized failures are not child diagnostics and propagate as operation failures. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Sessions outside the trace's direct descendants are never read and produce no diagnostic.
In the superseded trace-based path, diagnostics used three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, a read result whose immutable header differs from the traced candidate or no longer names the requested direct parent, a target that is no longer the located descriptor event, malformed descriptor content, and multiple descriptor events mapped to `corrupt`. An unknown descriptor version mapped to `unsupported`. `SESSION_QUERY_SESSION_NOT_FOUND`, `SESSION_QUERY_EVENT_NOT_FOUND`, and `SESSION_QUERY_PERSISTENCE_FAILED` from a per-child read mapped to `unavailable`. This phase boundary was intentional: a persistence outage during the initial trace failed the operation, while the same outage beginning during candidate reads could produce one identical `unavailable` diagnostic per affected child; the first version neither coalesced those diagnostics nor promoted them to a global failure. A missing descriptor was instead a non-subagent exclusion without a diagnostic. Configuration/window errors and unrecognized failures were not child diagnostics and propagated as operation failures. Each diagnostic identified the child id and reason without exposing model-hidden descriptor content; the candidate was omitted while healthy siblings remained visible. Sessions outside the trace's direct descendants were never read and produced no diagnostic.
Diagnostics are transient query results, not session events or catalog state. Deriving a diagnostic performs no additional load beyond the `listEvents()` or conditional `readEvent()` operation whose result produced it.
@@ -93,8 +93,8 @@ The first version has no child deletion operation. If later product behavior del
## Testing
- `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics.
- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; and typed stable error codes. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, the narrowed load requirement without `sessionQuery`, and HMR disposal.
- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; typed stable error codes; and descendant listing's iterative stable pre-order, traversal through ordinary and one-shot intermediates, positioned diagnostics, lifecycle revalidation, and cancellation. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (one optional `scope` enum), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, registry-derived child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, the descendants scope's pre-order parent/depth annotations across a live waiting branch, cancellation forwarding to both scopes, the no-agent rejection, the `agents` load requirement without `sessionQuery`, and HMR disposal.
- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, the projection registry, and JSONL persistence, rendering `<id> [complete] — <label>`.
- The keyless snapshot scenario `subagent-diagnostic` (examples/headless-agent) pins the current listing's model-visible diagnostic classification, including a descriptor-less settled child surfacing as a `corrupt` diagnostic.
- The keyless ACP snapshot scenario `subagent-published-run-failure` publishes a real one-shot child, injects independent run-result and handle-disposal failures, and preserves both diagnostics in the parent tool result.

View File

@@ -23,7 +23,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su
- 将语料活动状态单独报告为 `running` 或 `inactive`,但不暗示已完成或可恢复;
- 按 `createdAt` 升序、再按 child id 升序稳定返回所有结果 child。
每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并将 `inactive` 映射为其现有的 `complete` 表示;UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`,会将其用于一次性显示;workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child,并通过在线 Agent 注册表细化状态(`running`/`idle`/`complete`);UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
### 枚举决策
@@ -52,9 +52,9 @@ subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务
有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。`mode` 是持久化创建策略;`activity` 是进程本地语料快照。活动状态既不是 `AgentStatus`、管理器内部的 Activation 状态,也不是持久化结果,结果不公开内部 `createdAt` 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本功能范围内。
面向模型的 `list_agents` 工具不接受参数,从当前正在执行的 Agent 推导 `parentSessionId`,并作为 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。它保留 diagnostic,丢弃 `one-shot` child 条目,将可继续 child 的 `running` 活动状态映射为 `running`、`inactive` 活动状态映射为 `complete`,然后按剩余的追踪顺序渲染 `<id> [<status>] — <label>` 或 `<id> [diagnostic: <reason>]`。空投影渲染为 `(no subagents)`。
面向模型的 `list_agents` 工具接受一个可选的 `scope: 'children' | 'descendants'` 参数,从当前执行 Agent 推导根 id,并在执行或渲染前通过显式的 request-to-spec 步骤解析请求(`undefined` → `children`)。解析后的 `children` scope 调用 `SubagentService.listChildren(rootSessionId)`,`descendants` scope 则调用 `SubagentService.listDescendants(rootSessionId)`。其内部输出投影中的 `id` 与 `parent` 会一直保持为品牌化的 `SessionId` 值,直到工具 JSON 边界。它保留 diagnostic,丢弃 `one-shot` child 条目,状态取自在线 Agent 注册表——driver 活跃为 `running`,驻留但处于轮次之间为 `idle`,没有在线 Agent 时为 `complete`——然后按稳定目录顺序渲染 `<id> [<status>] — <label>` 或 `<id> [diagnostic: <reason>]`。`descendants` scope 从一份实时优先语料按稳定 pre-order 展平完整树,遍历普通与一次性中间节点以发现更深的可继续 agent,依据枚举生命周期重新校验每个冷候选,并为每个条目附加 `parentId`/`depth`。工具会在 label 之前插入 ` parent=<id> depth=<n>`;`parent` 是持久化直接 parent 会话 id,可能指向被省略的普通会话。对于当前调用方,只有 depth-1 child 条目可作为 `send_message` 候选,更深的 child 条目则可供 `interrupt_agent` 选择([中断契约](2026-08-06-continuable-subagent-interrupt.md))。发现结果只是提示——follow-up 权限仍仅属于确切直接 parent,中断权限仍由服务的在线 lineage 检查决定。空投影渲染为 `(no subagents)`。
diagnostic 使用三种固定原因。格式错误的事件 surface、精确加载 child 时发现的 header 冲突、读取结果中的不可变 header 与追踪到的候选不一致或不再指向请求的直接 parent、读取目标不再是先前定位的描述符事件、格式错误的描述符内容和多个描述符事件映射为 `corrupt`。未知描述符版本映射为 `unsupported`。逐 child 读取产生的 `SESSION_QUERY_SESSION_NOT_FOUND`、`SESSION_QUERY_EVENT_NOT_FOUND` 和 `SESSION_QUERY_PERSISTENCE_FAILED` 映射为 `unavailable`。这项阶段边界是有意为之:初始追踪期间发生持久化故障会让操作失败,而同一故障如果始于候选读取期间,可能会让每个受影响的 child 分别产生一条相同的 `unavailable` diagnostic;第一版既不合并这些 diagnostic,也不会把它们提升为全局失败。缺少描述符则作为非 subagent 排除,且不产生 diagnostic。配置错误、窗口错误和未识别的失败不属于 child diagnostic,会作为操作失败继续向上传播。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。系统绝不会读取不属于追踪结果直接后代的会话,也不会为它们产生 diagnostic。
在已被取代的追踪读路径中,diagnostic 使用三种固定原因。格式错误的事件 surface、精确加载 child 时发现的 header 冲突、读取结果中的不可变 header 与追踪到的候选不一致或不再指向请求的直接 parent、读取目标不再是先前定位的描述符事件、格式错误的描述符内容和多个描述符事件映射为 `corrupt`。未知描述符版本映射为 `unsupported`。逐 child 读取产生的 `SESSION_QUERY_SESSION_NOT_FOUND`、`SESSION_QUERY_EVENT_NOT_FOUND` 和 `SESSION_QUERY_PERSISTENCE_FAILED` 映射为 `unavailable`。这项阶段边界是有意为之:初始追踪期间发生持久化故障会让操作失败,而同一故障如果始于候选读取期间,可能会让每个受影响的 child 分别产生一条相同的 `unavailable` diagnostic;第一版既不合并这些 diagnostic,也不会把它们提升为全局失败。缺少描述符则作为非 subagent 排除,且不产生 diagnostic。配置错误、窗口错误和未识别的失败不属于 child diagnostic,会作为操作失败继续向上传播。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。系统绝不会读取不属于追踪结果直接后代的会话,也不会为它们产生 diagnostic。
diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导 diagnostic 时,除了产生该结果的 `listEvents()` 或条件性 `readEvent()` 操作外,不会执行额外加载。
@@ -93,8 +93,8 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
## 测试
- `packages/subagent/subagent/tests/service.spec.ts` 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。`packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id,并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递,并保留相互独立的结果与 dispose diagnostic。
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;以及带类型的稳定错误码。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(无参数)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、child/diagnostic/空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、工具取消信号的转发、无调用 agent 时的拒绝、收窄后的加载要求(不再注入 `sessionQuery`),以及 HMR dispose。
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;带类型的稳定错误码;以及后代列表的迭代式稳定 pre-order、穿过普通与一次性中间节点、带位置 diagnostic、生命周期复验与取消。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(一个可选 `scope` 枚举)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、由注册表推导的 child/diagnostic/空结果文本形式、带持久化 label 的已结束 child 端到端列表、descendants scope 在在线 waiting 分支上的 pre-order parent/depth 注释、两个 scope 的取消信号转发、无调用 agent 时的拒绝、要求 `agents` 但不再注入 `sessionQuery` 的加载契约,以及 HMR dispose。
- 无密钥 ACP 快照场景 `subagent-list-agents`(examples/acp-agent)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`。
- 无密钥快照场景 `subagent-diagnostic`(examples/headless-agent)钉住现行列表的模型可见诊断分类,包括无描述符的定局 child 以 `corrupt` diagnostic 出现。
- 无密钥 ACP 快照场景 `subagent-published-run-failure` 会发布一个真实的一次性 child,注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
2026-07-27-web-subagent-conversations.md: edaaa97bbfcf0ce1751db84d8fc2d3ab07af8b03
2026-07-27-web-subagent-conversations.zh.md: 4c5e670cc46621b889d8463daa90afbf43bde30f
2026-07-27-web-subagent-conversations.md: 46c4949c56ebffe78e5ffda126a5784a1c648421
2026-07-27-web-subagent-conversations.zh.md: 6b2d7bbec1133cdeb2d0cd31a6f9a43b068d21f5

View File

@@ -20,7 +20,7 @@ Every opened child carries a catalog-derived address `{ parentSessionId, childSe
The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization.
The ordinary Stop action is absent from addressed child conversations. `SubagentService.followup()` owns admission only until inbox acceptance and intentionally exposes no public child cancellation operation. A later cancellation design needs an explicit authority and lifecycle contract rather than falling through to `session.cancel`.
Stopping an addressed child never falls through to `session.cancel`. `SubagentService.followup()` owns admission only until inbox acceptance and grants no cancellation handle; a running continuable child is stopped through the dedicated `subagent.interrupt` route under the [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md), which parks pending work instead of discarding it. One-shot children remain uncancellable from the Web.
This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md).
@@ -45,7 +45,7 @@ Healthy rows reuse the standard session projections retained in the list mirror.
Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior.
A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false and the child is not running; a running parent-offline child keeps the ordinary composer with its input and Send action disabled so independent Stop stays reachable, and the read-only takeover returns once it stops. With a live parent, Enter and Send admit another FIFO turn even while the child runs, while independent Stop routes through `subagent.interrupt` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Prompt failures retain the draft through the ordinary error behavior.
Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`; the Host also rejects any accidental call instead of activating persisted child history outside the direct-parent continuation seam.
@@ -89,7 +89,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
**Auto-resume an absent parent.** Rejected because continuation requires the exact live direct parent. Child navigation must not mutate the parent lifecycle.
**Expose ordinary cancellation.** Rejected because the accepted inbox turn outlives its admission request and the continuation seam exposes no authority-safe cancellation handle.
**Expose ordinary cancellation.** Rejected because the accepted inbox turn outlives its admission request and, at this decision's time, the continuation seam exposed no authority-safe cancellation handle. The later [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md) added that explicit authority as a dedicated subagent route; falling through to `session.cancel` remains rejected.
**Show only continuable children.** Rejected because the durable catalog deliberately describes both session-backed modes. One-shot transcripts remain useful even though they never accept follow-ups.
@@ -103,9 +103,9 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
- Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
- Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence.
- Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
- Client object tests pin retained and restored addresses, one-shot read-only and cancel rejection, history routing, continuable prompt and interrupt routing, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
- jsdom tests pin the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, and adaptive long-duration presentation, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the model seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown.
- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and the aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the model seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown.
- Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks.
## Consequences
@@ -114,4 +114,4 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
- Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected.
- A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path.
- Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut.
- The UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency.
- Beyond the current-turn Stop of a running continuable child ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)), the UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency.

View File

@@ -20,7 +20,7 @@ Web 产品通过页头操作公开选中会话中由会话支撑的直接 subage
通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。
已寻址 child 对话不提供普通 Stop 操作。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,并有意不公开任何 child 取消操作。后续取消设计需要显式的授权与生命周期契约,而不能回退到 `session.cancel`。
停止一个已寻址 child 绝不回退到 `session.cancel`。`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断契约](2026-08-06-continuable-subagent-interrupt.md),该契约会暂停而非丢弃待处理工作。one-shot child 在 Web 端仍不可取消。
本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。
@@ -45,7 +45,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。
one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此;parent 离线但仍在运行的 child 保留普通输入框,并禁用其输入区和 Send 操作,让独立的 Stop 保持可达,停止后只读替代恢复。parent 在线时,即使 child 正在运行,Enter 和 Send 也会准入另一个 FIFO 轮次,而独立的 Stop 经由 `subagent.interrupt` 路由([中断契约](2026-08-06-continuable-subagent-interrupt.md))。提示词失败会通过普通错误行为保留草稿。
已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`;Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行 seam 之外激活持久化 child 历史。
@@ -89,7 +89,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
**自动恢复缺失的 parent。** 不予采纳,因为继续执行要求确切的存活直接 parent。child 导航不得改变 parent 生命周期。
**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,而继续执行 seam 不会公开具备安全授权的取消句柄。
**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,且在本决定当时,继续执行 seam 未公开具备安全授权的取消句柄。后来的[当前轮次中断契约](2026-08-06-continuable-subagent-interrupt.md)以专用 subagent 路由补上了这项显式授权;回退到 `session.cancel` 仍被拒绝。
**只显示可继续 child。** 不予采纳,因为持久化目录有意描述由会话支撑的两种 mode。one-shot transcript 即使绝不接受后续消息,仍然有用。
@@ -103,9 +103,9 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
- 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读与取消拒绝、历史路由、可继续提示词与中断路由、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
- jsdom 测试固定后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行及自适应长耗时呈现,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 model seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 model seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。
- 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
## 后果
@@ -114,4 +114,4 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。
- UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。
- 除对正在运行的可继续 child 的当前轮次 Stop([中断契约](2026-08-06-continuable-subagent-interrupt.md))之外,UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
2026-07-28-continuable-subagent-conversations.md: a5a900c3bb30a8d965aabc0bf498f8399ee70e7a
2026-07-28-continuable-subagent-conversations.zh.md: bde8e874ba27950dc0fab8449c6ba2ee49737d41
2026-07-28-continuable-subagent-conversations.md: 56d9abb2b09577b2fba14b9a655a941417e0c493
2026-07-28-continuable-subagent-conversations.zh.md: 77f497ceeef35dcc519242dca30c81759cc3b907

View File

@@ -131,7 +131,7 @@ Parent-originated delivery requires the parent to be live when admitted and keep
### Durability, disposal, and recovery
Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service.
Without Tasks there is no `task_output`, `task_kill`, Task status, or per-message result promise. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the accepted message or dispose the Activation through `ctx.subagents`; the only public stop is the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md), which cancels the live target's current turn with `keepInbox` and leaves residency, pending work, and descendants intact.
Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions.
@@ -145,7 +145,7 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo
This version covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior.
It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, public residency query, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability.
It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public residency query, new live-Activation or descendant limit, or runtime cache; the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) added the one public stop operation on top of this lifecycle. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability.
## Alternatives considered
@@ -186,7 +186,7 @@ The implementation pins these behaviors:
- `followup()` accepts only the exact live direct parent and rechecks that identity at the final no-await inbox-admission boundary after any materialization; durable message provenance cannot authorize delivery.
- Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn.
- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result.
- This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host-scoped and manager-global teardown retain child-first cleanup.
- Caller signals stop start and follow-up only before inbox acceptance, while host-scoped and manager-global teardown retain child-first cleanup; the [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) is the one public stop and does not enter teardown.
- This version exposes no subagent steering operation or current-turn controller state.
- An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained.
- A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation.

View File

@@ -131,7 +131,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
### 持久性、dispose 与恢复
没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态、逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它。
没有 Task 后,系统不再提供 `task_output`、`task_kill`、Task 状态或逐消息结果 promise。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后,parent 不能通过 `ctx.subagents` 取消已接受的消息或 dispose 激活;唯一的公开停止操作是后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md),它以 `keepInbox` 取消在线目标的当前轮次,驻留、待处理工作与后代均保持不变。
宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。
@@ -145,7 +145,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
本版本覆盖可继续的进程内 child,一次性委派保持不变。远程提供方必须具备单独的激活 handle,以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。
它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。
它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存;后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md)在此生命周期之上补充了唯一的公开停止操作。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。
## 曾考虑的替代方案
@@ -186,7 +186,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
- `followup()` 只接受确切的在线直接 parent,并在任何物化之后的最终无 await 的 inbox 准入边界再次检查该身份;持久化消息来源信息不能授权投递。
- 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO,包括 child 已有开放轮次的情况。
- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target,也不定义 subagent 专属路由结果。
- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,限定到宿主的拆卸与管理器全局拆卸则保留 child-first 清理。
- 调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up,限定到宿主的拆卸与管理器全局拆卸则保留 child-first 清理;[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md)是唯一的公开停止操作,且不进入拆卸流程。
- 本版本不暴露 subagent steering 操作或当前轮次控制方状态。
- 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。
- 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
2026-08-06-continuable-subagent-interrupt.md: cb97a77434b43ee71fa30deee9811f7a45c53f1a
2026-08-06-continuable-subagent-interrupt.zh.md: ee05afd963584331def8c7d6acfa26830b7c1d0f

View File

@@ -0,0 +1,48 @@
# Agent Note: Continuable subagent current-turn interrupt
Status: implemented
English | [中文](2026-08-06-continuable-subagent-interrupt.zh.md)
## Problem
A running continuable subagent could not be stopped without destroying it. The continuation manager cancels child Agents only inside whole-Activation teardown (settlement, drain, scoped drain), `send_message`/`subagent.prompt` only add work, and the Web composer's Stop button was deliberately limited to ordinary sessions. A human watching a continuable child burn tokens on a wrong path had no lever short of killing the parent tree, and when the direct parent Agent was offline the child was entirely untouchable even though its Activation stayed live. One-shot runs have holder-owned disposal and task-kill; continuable children had no analogous current-turn control.
## Decision
`ctx.subagents.interrupt(targetSessionId, authority)` stops only the live target's current turn. The manager primitive authorizes synchronously, calls the existing `Agent.cancel(cause, { keepInbox: true })`, and returns `void` — fire-and-return: the cancel signal is guaranteed issued, target quiescence is not awaited. Nothing else changes: no Activation disposal, no handle release, no descendant cascade, no inbox clearing, and no `AgentLoop` or `CancelOptions` change. Because `keepInbox` parks the unclaimed pending queue at idle, an interrupt never auto-starts the next queued follow-up; work already claimed into the interrupted turn belongs to that turn and is not requeued. Once the interrupted driver is idle, an explicit waking send resumes the preserved FIFO order.
Authority is a closed two-variant union, deliberately wider than delivery authority because stopping a turn is idempotent and delivers no content:
- `{ kind: 'user', parentSessionId }` — a human presents the durable direct-parent address. The live target's `session.header.parentSession` must match; no live parent Agent, catalog read, or persistence access is involved, which is exactly what keeps a live child stoppable while its parent Agent is offline. Cancel cause `user`.
- `{ kind: 'ancestor', agent }` — an exact live ancestor Agent (direct parent or deeper). The caller must be the registry's current entry for its id (stale callers are rejected even for absent targets), must not be the target itself, and must appear in the Activation's materialization-time `ancestry` WeakSet. Cancel cause `parent`.
Targets are resolved only in the manager's process-local Activation map. An absent id — unknown, one-shot, or naturally settled — is an accepted no-op, which uniformly covers completion races and repeat requests without leaking durable-catalog information; a target whose disposal transaction is already open is likewise an accepted no-op after authorization. One-shot lifecycle (holder `dispose()`, task-kill) is untouched. `SubagentService.interrupt()` treats a manager-less composition as an accepted no-op rather than `CONTINUATION_UNAVAILABLE`, because without a manager no manager-owned live Activation can exist.
The Host RPC `subagent.interrupt` takes the continuable `SubagentAddress` and returns `{ accepted: true }`. Its implementation calls only the core primitive with `user` authority — deliberately no `catalogChild()`, `listChildren()`, `sessionQuery`, or parent-registry lookup. A live target with a mismatched parent address maps to `subagent-unauthorized`; unexpected failures map to `internal` without leaking error text onto the wire.
## Alternatives considered
**Route human interrupts through `session.cancel`.** The generic session cancel requires an attached ordinary session and rejects subagent-owned sessions; widening it would entangle subagent authority rules with ordinary session routing. A subagent-domain RPC keeps the address-based authorization and the parent-offline guarantee explicit.
**Await target quiescence and return the turn outcome.** Cancellation is cooperative, so quiescence is unbounded; holding the RPC (and a `ChildLock` slot) open invites timeouts and convoying against delivery and disposal. Acceptance-of-signal is the only fact the caller needs, and races (natural completion, disposal) already settle idempotently.
**Reuse whole-Activation disposal for interrupt.** Disposal cancels without `keepInbox`, flushes, captures, and releases the handle — it destroys queued work and the child's residency. Interrupt is a control operation on one turn, not a lifecycle operation on the Activation.
**Extend `send_message`/`followup` authority to ancestors while at it.** Delivery injects content into a conversation and is not idempotent; its exact-direct-parent authority stays unchanged. Only interrupt gets the wider ancestor and address-based user authority.
**Auto-resume the parked queue after an interrupt.** Immediately starting queued follow-up B after aborting A would make the interrupt look ignored and steal the human's window to redirect the child. Parking until an explicit waking send keeps the stop observable and the FIFO order intact.
## Consequences
A human or ancestor can stop a runaway continuable turn without losing the child, its unclaimed queued work, or its running descendants; the cost is a deliberately weak postcondition (`accepted` means "signal issued", so a target may remain visibly `running` until it observes the signal) that clients must render honestly. The parked-queue rule means an interrupted child sits idle with retained work until a waking message arrives after the driver is idle — an intentional human-in-the-loop pause, not a scheduler defect. A waking send accepted during abort convergence currently remains queued without latching wake; Issue #1838 tracks the shared agent-loop correction.
The address-only RPC exposes one bit of live residency: an absent target is accepted while a live target under a mismatched parent returns `subagent-unauthorized`. The single-user local Host trust model accepts that observability; a future multi-principal Host must revisit both authority and response indistinguishability.
The Web surface keeps Send and Stop as independent actions for a running continuable child: the client `Session.cancel()` routes Stop through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep their existing primary Send/Stop toggle through `session.cancel`), while Send continues to queue follow-ups. A running parent-offline continuable child keeps the default composer with input and Send disabled but Stop reachable, returning to the read-only takeover once it stops ([Web subagent conversations](2026-07-27-web-subagent-conversations.md) owns the surrounding catalog and composer contract).
The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` passes `exec.agent` as the `ancestor` authority and adds none of its own: the core primitive verifies live registry identity and recorded lineage, so the tool can name a direct child or a deeper descendant with the same generic `agent_id` parameter — deliberately not `subagent_id`, which would imply direct children only. Discovery rides `list_agents({ scope: 'descendants' })` over the new `SubagentService.listDescendants()` one-trace pre-order walk with verified `parentId`/`depth` per entry ([durable catalog note](2026-07-22-durable-subagent-catalog-and-list-agents.md) owns the listing contract); discovery is a hint, never authority. `send_message` keeps its exact-direct-parent authority — only interrupt is ancestor-wide.
## Testing
Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar's independent Send and Stop actions with the parent-offline locked-input/Send state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold real child turns open with replay hang entries and prove the parent-offline UI-to-RPC abort path, queued Send, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshot executes `list_agents({ scope: 'descendants' })` and `interrupt_agent` through the assembled application against one settled child, while recorded request headers continue to pin both schemas.

View File

@@ -0,0 +1,48 @@
# Agent Note: Continuable subagent 当前轮次中断
Status: implemented
[English](2026-08-06-continuable-subagent-interrupt.md) | 中文
## Problem
一个正在运行的 continuable subagent 无法在不销毁它的前提下被停止。继续执行管理器只在整个 Activation 拆除(结算、drain、scoped drain)内部取消子 Agent,`send_message`/`subagent.prompt` 只能增加工作,而 Web composer 的 Stop 按钮被刻意限制在普通会话。人类眼看着一个 continuable child 在错误路径上烧 token,除了干掉整个 parent 树没有任何手段;当直接 parent Agent 离线时,即使 child 的 Activation 仍然在线,它也完全不可触及。一次性运行有持有方拥有的 disposal 和 task-kill;continuable child 没有对应的当前轮次控制。
## Decision
`ctx.subagents.interrupt(targetSessionId, authority)` 只停止在线目标的当前轮次。管理器原语同步完成鉴权,调用现有的 `Agent.cancel(cause, { keepInbox: true })`,然后返回 `void`——fire-and-return:保证取消信号已发出,但不等待目标静止。其余一切不变:不 dispose Activation、不释放 handle、不级联后代、不清空 inbox,也不改动 `AgentLoop` 或 `CancelOptions`。由于 `keepInbox` 让尚未领取的待处理队列停在 idle,中断绝不会自动启动下一个排队的 follow-up;已被领取进入中断轮次的工作属于该轮次,不会重新入队。被中断的 driver 进入 idle 后,一次显式唤醒发送会按保留的 FIFO 顺序恢复。
授权是一个封闭的双变体 union,刻意比投递权限更宽,因为停止一个轮次是幂等的且不投递任何内容:
- `{ kind: 'user', parentSessionId }`——人类出示持久化直接 parent 地址。在线目标的 `session.header.parentSession` 必须匹配;不涉及在线 parent Agent、目录读取或持久化访问,这正是 parent Agent 离线时在线 child 仍可被停止的原因。取消 cause 为 `user`。
- `{ kind: 'ancestor', agent }`——一个确切在线的 ancestor Agent(直接 parent 或更深)。调用方必须是注册表中其 id 的当前条目(过期调用方即使目标不存在也被拒绝),不得是目标本身,并且必须出现在 Activation 物化时记录的 `ancestry` WeakSet 中。取消 cause 为 `parent`。
目标只在管理器进程本地的 Activation map 中解析。不存在的 id——未知、一次性或已自然结算——是被接受的 no-op,统一覆盖完成竞态和重复请求而不泄露持久化目录信息;disposal 事务已打开的目标在鉴权后同样是被接受的 no-op。一次性生命周期(持有方 `dispose()`、task-kill)不受影响。`SubagentService.interrupt()` 把未绑定管理器的组合视为被接受的 no-op 而不是 `CONTINUATION_UNAVAILABLE`,因为没有管理器就不可能存在管理器拥有的在线 Activation。
Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 `{ accepted: true }`。它的实现只以 `user` 授权调用核心原语——刻意不调用 `catalogChild()`、`listChildren()`、`sessionQuery` 或 parent 注册表查找。parent 地址不匹配的在线目标映射为 `subagent-unauthorized`;意外失败映射为 `internal`,不把错误文本泄漏到 wire。
## Alternatives considered
**让人类中断走 `session.cancel`。** 通用会话取消要求附着的普通会话并拒绝 subagent 拥有的会话;放宽它会把 subagent 权限规则缠进普通会话路由。subagent 域的 RPC 让基于地址的鉴权和 parent 离线保证保持显式。
**等待目标静止并返回轮次结果。** 取消是协作式的,静止时间无上界;让 RPC(以及一个 `ChildLock` 槽位)保持打开会招致超时并与投递、disposal 形成排队。调用方需要的唯一事实是信号已被接受,而竞态(自然完成、disposal)本就幂等收敛。
**复用整个 Activation 的 disposal 来做中断。** disposal 的取消不带 `keepInbox`,还会 flush、capture 并释放 handle——它销毁排队工作和 child 的驻留。中断是针对一个轮次的控制操作,不是针对 Activation 的生命周期操作。
**顺手把 `send_message`/`followup` 权限扩展到 ancestor。** 投递向对话注入内容且不幂等;其确切直接 parent 权限保持不变。只有中断获得更宽的 ancestor 与基于地址的用户授权。
**中断后自动恢复被暂停的队列。** 在中止 A 后立即启动排队的 follow-up B 会让中断看起来被忽略,并夺走人类重新引导 child 的窗口。暂停到显式唤醒发送为止,让停止可观察且 FIFO 顺序完整。
## Consequences
人类或 ancestor 可以停止一个失控的 continuable 轮次,而不丢失 child、其尚未领取的排队工作或正在运行的后代;代价是一个刻意保持弱的后置条件(`accepted` 表示“信号已发出”,目标在观察到信号前可能仍显示 `running`),客户端必须如实呈现。暂停队列规则意味着被中断的 child 会带着保留的工作停在 idle,直到 driver 进入 idle 后收到唤醒消息——这是有意的 human-in-the-loop 暂停,不是调度器缺陷。在 abort 收敛期间被接受的唤醒发送目前会保持排队而不锁存 wake;Issue #1838 跟踪共享的 agent-loop 修正。
仅凭地址的 RPC 会暴露一位在线驻留信息:不存在的目标会被接受,而 parent 不匹配的在线目标会返回 `subagent-unauthorized`。单用户本地 Host 的信任模型接受这种可观察性;未来的多主体 Host 必须重新审视权限和响应不可区分性。
在 Web 侧,正在运行的 continuable child 使用相互独立的 Send 与 Stop 操作:客户端 `Session.cancel()` 将 Stop 路由到 `subagent.interrupt`(one-shot 地址保持不可取消,普通会话仍通过 `session.cancel` 保留既有的 primary Send/Stop 切换),同时 Send 继续将后续消息加入队列。parent 离线但仍在运行的 continuable child 保留默认 composer,禁用输入区与 Send,但 Stop 仍然可达;停止后恢复只读替代(周边目录与 composer 契约由 [Web subagent 对话](2026-07-27-web-subagent-conversations.md)拥有)。
`dsh-tool-subagent-control` 中面向模型的 `interrupt_agent(agent_id)` 工具把 `exec.agent` 作为 `ancestor` 授权传入,自身不增加任何权限:核心原语校验在线注册表身份与记录的 lineage,因此该工具可以用同一个通用 `agent_id` 参数指定直接 child 或更深的后代——刻意不用会暗示仅限直接 child 的 `subagent_id`。发现依赖 `list_agents({ scope: 'descendants' })`,其底层是新的 `SubagentService.listDescendants()` 单次追踪 pre-order 遍历,每个条目带经校验的 `parentId`/`depth`(列表契约由[持久化目录 note](2026-07-22-durable-subagent-catalog-and-list-agents.md)拥有);发现只是提示,绝非权限。`send_message` 保持其确切直接 parent 权限——只有中断是 ancestor 级的。
## Testing
`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的独立 Send 与 Stop 操作及 parent 离线时锁定输入区和 Send 的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)通过多条 replay hang 条目保持多个真实 child 轮次打开,端到端证明 parent 离线时从 UI 到 RPC 的中止路径、Send 入队、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置;keyless ACP 快照通过组装应用,针对一个已结算的 child 执行 `list_agents({ scope: 'descendants' })` 与 `interrupt_agent`,同时已录制的请求 header 仍固定这两个 schema。