Merge pull request #3127 from deepseek-harness/worktree/job-kill-delivery

feat(jobs): human job kill, timeout promotion, and workflow run_in_background
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-09-21 17:13:28 +08:00
commit 4b54e59a52
218 files changed
+5763 -1670

No files matched your search

@@ -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-08-08-web-background-job-display.md
2026-08-08-web-background-job-display.md: 049785e9cd5dea2d92d9fc5a14dfeddac9b64469
2026-08-08-web-background-job-display.zh.md: 1dd7ee9215fa034a0a7abbe75bd5f6eb6907e9ee
2026-08-08-web-background-job-display.md: 1ebd600e4d423f95ad663c333572a990d0c9efa7
2026-08-08-web-background-job-display.zh.md: 56d062b5079e15c90cffc30b538efe162bc19d9d
@@ -99,7 +99,7 @@ A running one-shot background subagent therefore appears both there and in the s
**No web path calls `ctx.jobs.read()`.** It consumes the single output cursor, so a browser read would silently take bytes the model's `job_output` will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site.
**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running.
**No cancellation.** That phase owed a decision the seam then did not answer: `kill()` marked terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running. The [human job kill note](2026-08-26-human-job-kill.md) later resolved it: `kill` now takes an explicit `reported` claim, and the web stop control passes `reported: false` so the completion notice stays due.
**No output watermark on the frame.** The output phase's delta channel is where an anchor field earns its place; one added now would have no reader.
@@ -133,7 +133,7 @@ Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts
**Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint.
**`stopping` is rarely visible.** Only the model's `job_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change.
**`stopping` is visible on every kill path.** The model's `job_kill` and the web stop control both produce it; carrying it in the union from the start is what kept the human-kill phase off the wire format.
**Two entry points for one running subagent.** Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind.
@@ -99,7 +99,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
**没有任何 Web 路径调用 `ctx.jobs.read()`。** 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 `job_output` 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。
**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。
**不做中断。** 那一期欠一个 seam 当时没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。[人工任务终止 Note](2026-08-26-human-job-kill.zh.md) 后来解决了它:`kill` 现在接受显式的 `reported` 认领,Web 停止控件传 `reported: false`,完成通知因此仍会投递。
**帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。
@@ -133,7 +133,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
**终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。
**`stopping` 很少可见。** 只有模型的 `job_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。
**`stopping` 在每条 kill 路径上都可见。** 模型的 `job_kill` 与 Web 停止控件都会产生它;从一开始就把它放进联合类型,正是人工 kill 这一期不必动线路格式的原因。
**一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。
@@ -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-26-human-job-kill.md
2026-08-26-human-job-kill.md: efffdbd85bf2fa665565ff584a6938cfee827516
2026-08-26-human-job-kill.zh.md: 206acca008518dd689351e40a99ff1571f3d1a8f
@@ -0,0 +1,36 @@
# Agent Note: Human job kill — an unclaimed terminal report instead of a second cancellation path
Status: implemented
Update: the `reported` option this note introduced is gone with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The registry keeps no report bit; `dsh-tool-jobs` claims only its own `job_kill` and waits in a ledger, so a human `job.kill` leaves the notice due by construction. The reason merge, the `job.kill` Remote, and the two-press control are unchanged.
English | [中文](2026-08-26-human-job-kill.zh.md)
## Problem
The [web job display note](2026-08-08-web-background-job-display.md) shipped the task list read-only and recorded why: `JobRegistry.kill()` marks the job `reported`, and the [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.md) completion reporter suppresses the settlement notice for a reported job. That coupling is correct for the one caller that existed — the model's `job_kill`, whose own tool result already tells the model what it did — but a human pressing a stop button has no model-visible channel at all. A kill written against that contract would leave the model believing its task is still running, exactly the stale-world-model failure Claude Code ships today (its `/tasks` kill sets `notified: true` and the model learns nothing) and Kimi avoids (a model `TaskStop` suppresses its own notification; a human stop delivers one).
## Decision
A delivery claim means "the terminal state has a committed delivery path to the model", so the fix is to stop conflating cancellation with claiming that delivery.
- **A human kill claims no delivery.** `JobRegistry.kill(id, { reason? })` records the reason only; the delivery ledger lives in `dsh-tool-jobs`, which claims a job for the model's own `job_kill` and for its waits, so a `job.kill` from the task list leaves the settlement to the existing completion reporter (wakeup/inject, wake budget, truncation all unchanged). It never clears a claim the model already holds, and a terminal record stays exactly as settlement left it.
- **A recorded kill reason merges into a `killed` settlement's detail** — producer facts first (`signal: SIGTERM; cancelled by the user`) — so the completion notice and the web row both say who stopped the work without a new snapshot field or notice template. A job that outruns its kill (settles `completed`/`failed`) keeps the producer detail alone. This is the reconciliation Codex spells out in its `<turn_aborted>` guidance: when the user stops something, the model is told rather than left to infer.
- **`job.kill` is a Job Controller Remote** (`@deepseek-ai/dsh-api-job-controller`, beside `job.follow`; it began life as `session.killJob` on the Session Controller and moved with the observation stream when that package split out) with the same live-only Agent lookup (`ctx.agents.get`) as `session.cancel`: a running job's owner is alive by the registry's ownership contract, and killing from a list must not revive a Session any more than listing does. Unknown and foreign jobs collapse to one `job/not-found` rejection scoped to the lookup — a producer-cancel throw propagates per the registry contract instead of masquerading as a lookup failure; the controller requires `ctx.jobs` to load, so a composition without a registry has no kill Remote at all. The registry's owner fence is the only access rule: the Session Controller's subagent ownership fence is not applied, so a child session's own jobs are killable from its list (review rejected exporting that fence's helpers from the Session Controller package, and the rule adds nothing for a job the child owns), and the controller depends on neither the agent registry nor the Session Controller. No approval interaction: this single-user local BFF treats it as the same class of action as the turn-cancel button.
- **The task list's stop control is two-press** (arm, then confirm within 3s, matching the Kimi `s`+`y` and OpenCode double-Esc shape), rendered only on running job rows — rows with a `jobId`. Standalone activity rows (workflow runs) have no kill handle and no button. The pending press disables the control and stays pending on an admitted kill — the unary response and the jobs frames have no cross-carrier ordering, so only the authoritative frame (the row leaving the killable set) releases it; a rejected kill shows a brief hint. Because the kill addresses `ctx.jobs` by id, every job kind gains the control at once — bash, pwsh, pty, and one-shot background subagents.
## Alternatives considered
- **A `notify: boolean` option** — rejected because the registry cannot promise a notice (quiet delivery, unowned jobs, owner teardown all legitimately drop one); the ledger's claim names the fact the tool actually controls: whether one of its own tool results already delivered the terminal state.
- **A separate `interrupt()`/`stopByUser()` method** — one cancellation path with an explicit claim beats two methods whose only difference is a boolean, and Kimi's split (`stop` vs `stopByUser`) shipped with its TUI calling the wrong one.
- **Saying nothing to the model (Claude Code's shape)** — rejected; their own source comments question it, and the DSH blocker note recorded the stale-belief failure as the reason the control did not ship earlier.
## Testing
`tool-jobs` pins the ledger (a kill the model did not request leaves the notice due; its own `job_kill` and waits claim it) and the delivered notice text verbatim, reason included; `jobs-local` pins the detail merge on `killed` and the outran-kill case. `kill.host.spec.ts` pins the Remote command: admission, unowned-job kill from an agentless session, `job/not-found` for unknown/foreign. The client suites pin the RPC passthrough and the two-press control (arm, confirm, disarm timer, failure hint, single armed row, stale-phase cleanup); the keyless web e2e drives the button end to end.
## Consequences
- The completion notice for a human kill wakes an idle owner (default `wakeup` delivery) — a deliberate cost: an unclaimed completion the model never learns about is the failure this note exists to fix.
- `kill`'s positional `reason` parameter is gone (pre-release, no shim); the options object is the only form.
- A killed job's `detail` may now carry two clauses joined by `; `. Anything parsing `detail` as a single producer fact must treat it as opaque text, which `JobView.detail` always declared it to be.
@@ -0,0 +1,36 @@
# Agent Note:人工任务终止——用「未认领的终态报告」而非第二条取消路径
状态:已实现
Update:本文引入的 `reported` 选项已随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md) 消失。注册表不再保留播报位;`dsh-tool-jobs` 只在台账里认领自己的 `job_kill` 与等待,因此人类的 `job.kill` 天然让通知保持待送达。原因合并、`job.kill` Remote 与两击式控件保持不变。
[English](2026-08-26-human-job-kill.md) | 中文
## 问题
[Web 任务展示 Note](2026-08-08-web-background-job-display.zh.md) 把任务列表交付为只读并记下了原因:`JobRegistry.kill()` 会把任务标记为 `reported`,而 [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.zh.md) 的完成播报员对已 reported 的任务抑制结算通知。这个耦合对当时唯一的调用方是正确的——模型的 `job_kill` 自己的 tool result 已经告诉模型它做了什么——但按下停止按钮的人没有任何模型可见渠道。照那份契约写一个人工 kill,模型会一直以为任务还在跑,正是 Claude Code 今天在售的过期世界模型失败(其 `/tasks` kill 置 `notified: true`,模型什么也学不到),也正是 Kimi 避开的(模型 `TaskStop` 抑制自己的通知;人工 stop 投递一条)。
## 决策
送达认领的本义是「终态已有一条向模型送达的承诺路径」,所以修法是不再把「取消」与「认领这次送达」混为一谈。
- **人类的 kill 不认领送达。** `JobRegistry.kill(id, { reason? })` 只记录原因;送达台账在 `dsh-tool-jobs` 里,它只为模型自己的 `job_kill` 与等待认领任务,因此来自任务列表的 `job.kill` 让结算照常走完成播报员(唤醒/注入、唤醒预算、截断全部不变)。它绝不清除模型已持有的认领,终态记录也保持结算时的原样。
- **记录下来的 kill reason 合入 `killed` 结算的 detail**——生产者事实在前(`signal: SIGTERM; cancelled by the user`)——完成通知与 Web 行因此都能说清是谁停的工作,无需新快照字段或新通知模板。跑赢 kill 的任务(结算为 `completed`/`failed`)只保留生产者 detail。这正是 Codex 在 `<turn_aborted>` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。
- **`job.kill` 是 Job Controller 的 Remote**(`@deepseek-ai/dsh-api-job-controller`,与 `job.follow` 并列;它最初是 Session Controller 上的 `session.killJob`,该包拆出时随观测流一起迁走),与 `session.cancel` 一样只查活体 Agent(`ctx.agents.get`):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个只作用于查找的 `job/not-found` 拒绝——生产者 cancel 抛错按注册表契约原样传播,绝不伪装成查找失败;控制器加载就要求 `ctx.jobs`,没有注册表的组合根本没有 kill Remote。注册表的所有者围栏是唯一的访问规则:不施加 Session Controller 的 subagent 所有权栅栏,子会话自己的 job 可以从它的列表里被杀停(评审否决了从 Session Controller 包导出该栅栏的实现函数,而这条规则对子会话自有的 job 也没有额外意义),控制器既不依赖 agent 注册表也不依赖 Session Controller。不加 approval 交互:这个单用户本地 BFF 把它视为与停轮按钮同级的操作。
- **任务列表的停止控件为两击式**(先武装、3 秒内确认,对齐 Kimi 的 `s`+`y` 与 OpenCode 的双 Esc),只渲染在运行中的 job 行——即带 `jobId` 的行。独立 activity 行(workflow 运行)没有 kill 句柄也就没有按钮。请求进行中按钮禁用,且受理成功后保持 pending——unary 响应与 jobs 帧没有跨载体顺序保证,只有权威帧(行离开可杀集合)才释放控件;被拒绝的 kill 短暂提示。由于 kill 按 id 寻址 `ctx.jobs`,所有任务 kind 一次性获得该控件——bash、pwsh、pty 与一次性后台 subagent。
## 备选方案
- **`notify: boolean` 选项**——否决:注册表承诺不了通知(quiet 投递、无主任务、owner 拆除都会正当地丢弃一条);台账的认领命名的是工具真正控制的事实:它自己的某个 tool result 是否已经送达了终态。
- **独立的 `interrupt()`/`stopByUser()` 方法**——一条取消路径带显式认领胜过两个只差一个布尔的方法;Kimi 的拆分(`stop` 与 `stopByUser`)上线时 TUI 就调错了那个。
- **对模型保持沉默(Claude Code 的形状)**——否决;他们自己的源码注释都在质疑它,而 DSH 的阻塞 Note 正是把过期认知失败记为该控件此前不能上线的原因。
## 测试
`tool-jobs` 钉住台账(不是模型请求的 kill 让通知保持待送达;它自己的 `job_kill` 与等待认领它)与逐字的带原因投递通知文本;`jobs-local` 钉住 `killed` 的 detail 合并与跑赢 kill 的情形。`kill.host.spec.ts` 钉住 Remote 命令:受理、无 agent 会话 kill 无主任务、未知/他人的 `job/not-found`。客户端套件钉住 RPC 透传与两击控件(武装、确认、解除计时、失败提示、单一武装行、过期相位清理);无密钥 web e2e 端到端驱动按钮。
## 后果
- 人工 kill 的完成通知会唤醒空闲 owner(默认 `wakeup` 投递)——这是刻意的代价:模型永远学不到的未认领完成,正是本 Note 要修的失败。
- `kill` 的位置参数 `reason` 已删除(pre-release,无垫片);options 对象是唯一形式。
- 被 kill 任务的 `detail` 可能携带以 `; ` 连接的两个子句。任何把 `detail` 当单一生产者事实解析的代码都必须把它当不透明文本——`JobView.detail` 一直如此声明。
@@ -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-26-shell-execute-projection-and-jobs-at-start.md
2026-08-26-shell-execute-projection-and-jobs-at-start.md: 2a03cd9f99645c10377dc9d2a5fdc68cb5ab0a4a
2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md: bdc6cc432dbb8e3a0a8d0fc86cf055f3938ff201
@@ -0,0 +1,52 @@
# Agent Note: One shell execute() — foreground as a projection, timeout as a bounded wait on the job
Status: implemented
Update: the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md) owns the output ring, the cursors, and the pull sources every registered command feeds.
English | [中文](2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md)
## Problem
A bash command that outran its foreground timeout was killed, discarding the work — the single most common way a long build or install failed under the agent. Fixing that inside the old seam was structurally awkward: `ctx.shell` had two execution methods, `run()` (deadline fused in, promise-only, no handle to keep) and `start()` (handle, no deadline), so "keep this already-running foreground command" had no expression — the deadline owner could only kill, and the caller had nothing to re-register with `ctx.jobs`. The two methods had also drifted: different stdout budgets, a documented "start ignores timeoutMs" wart, and sync-vs-async spawn-failure behavior that differed per path.
Peer evidence pointed one way. Kimi keeps a timed-out command running by default (`bashAutoBackgroundOnTimeout`), rebinding nothing — the process task detaches, the caller signal unhooks, and the partial output returns with the handle. Claude Code's timeout handler calls `background()` on the same `ShellCommand` instead of killing, with an explicit comment that respawning would leak cleanups and duplicate events. Codex has no hand-over because it never had a "foreground spawn" at all: every `exec_command` is a bounded observation window over a session, and "still running" is a normal return carrying the handle. All three agree on the underlying shape: there is one way to execute, and foreground is a property of the wait, not the spawn.
The first shipped version of this change expressed the hand-over on the seam: an `onExpiry: 'offer'` policy, `ShellExecution.promotion`, and a `ShellPromotionOffer` the tool had to answer synchronously at the deadline. Review rejected it: the protocol existed only because the tool registered the job at the timeout instead of at the start, so every executor carried a relay/timer/detach arm and every consumer a timing obligation that no execution needs.
## Decision
**The seam converges to `resolve()` + `execute()`.** `execute(spec)` returns `Promise<ShellExecution>` — the live `ShellProcess` itself plus the foreground projection `result()` (split collected streams, first-cause `timedOut`/`aborted`, rejecting only for infrastructure failures). `run()` and `start()` are deleted (pre-release, no shims); the historical differences they encoded became explicit inputs: `ShellExecSpec.onExpiry` is `'kill'` (the default) or `'none'`, and one stdout budget (`spec.stdoutMaxBytes`) applies everywhere. There is no hand-over protocol: a caller that wants to wait only for a while runs the command under `'none'` and bounds its own wait, and the handle stays valid after the caller stops waiting.
**Preparation shares the execution deadline.** The handle is published after asynchronous confinement finishes. Preparation cancellation or failure rejects `execute`; expiry returns a settled timed-out handle with empty output. A late preparation result cannot spawn work. Provider facts are installed synchronously before subprocess settlement so an immediately failed launch still receives its sandbox classification.
**Spawn failures are contained uniformly**: a synchronous spawn throw and an asynchronous rejection both settle the handle as `killed` with the note on the read path, while `result()` rejects with the original error (identity preserved — the sandbox executors map runner-attributed failures to `SANDBOX_UNAVAILABLE` in their `result()` decoration and stamp handle facts in `onProcessDone`, both keyed on the one handle instance).
**With a job registry composed, `tool-bash`/`tool-pwsh` register every command with `ctx.jobs` at its start.** `run_in_background` returns the id at once. A foreground call starts the same job and waits on it with the registry's `wait(id, timeoutMs, owner, signal)`, the existing `timeoutMs` being the wait bound — no second knob. A command that settles within the wait returns the ordinary foreground result from the handle's `result()` and the tool removes the job record (`JobRegistry.remove`), so the model never sees an id and the list does not accumulate every `ls`. A command that outlives the wait keeps running as the job it already was, and the call returns `{ kind: 'promoted', jobId, timeoutMs, output }` — rendered as `[still running after <ms>; moved to background job <id>]` plus hand-off guidance — seeded with one consuming registry read, so `job_output` continues exactly after it. Nothing changes hands at the timeout: not the process, not the abort signal, not the output. The tool never passes its own signal to the process; cancelling the call kills the job, and a kill from outside the call (the human's stop control, a parallel `job_kill`) reaches the process through the job's `cancel` hook, whose reason the tool renders as `[stopped: <reason>]` in the foreground result (`stopped` on the value) so the model reads why instead of a command failure.
**The background surface follows the registry, and the registry stays optional.** The tool's `inject` does not name `jobs`. It registers a foreground-only definition while no registry is composed, replaces it with the job-backed definition inside a `ctx.inject(['jobs'], …)` fork for as long as `ctx.jobs` is present, and restores the foreground-only one when the registry unloads while the plugin stays. A composition without the registry therefore keeps a plain `bash` whose schema advertises neither `run_in_background` nor the hand-over, and the model sees no `job_*` tools. Registration is best-effort: `promoteOnTimeout: false`, or a registry that refuses the job at its start (the owner's admission limit, no attached controller), runs the command under the executor's `'kill'` deadline instead, logged.
**The registry reports what a waiting caller already collected.** The `settled` event carries `awaited`: whether the settlement released a live `wait`, which is true for the tool waiting on its own foreground command exactly as for a `job_output` wait. `dsh-tool-jobs` withholds the completion notice for awaited settlements and for kills the model requested through `job_kill`, and its private claim ledger is gone; a timed-out or aborted wait has already left the registry's waiter set, so a later settlement notifies as usual. A human kill from the web controller is neither, so the owning agent still receives the notice, reason included.
## Alternatives considered
- **A third method (`begin()`) beside `run`/`start`** — the first design. Rejected in review: the three "methods" were two orthogonal inputs (deadline policy; which projection the caller awaits) wearing method names, and the peer products all model one execution with projected views. Converging deleted the budget/wart drift instead of adding a surface.
- **Codex's session model** (every call an observation window, polling via `write_stdin`) — rejected as a model-facing vocabulary change duplicating what `ctx.jobs` already provides; its early-yield result contract survives in the promoted arm.
- **A separate hand-over threshold config** — rejected; "timeout means stop blocking the turn, not kill the work" needs no second timer, and an explicit model-passed `timeoutMs` bounds the wait the same way (both peers behave so; the schema text says it).
- **A promotion offer on the seam** (`onExpiry: 'offer'`, `ShellExecution.promotion`, `ShellPromotionOffer` with a synchronous `accept()`/`decline()` answer) — the version this PR first shipped. Rejected in review because the deferred registration was the only reason for the protocol: the executor's offer arm relayed the caller's signal, armed its own timer, and detached the signal on accept, all of which a consumer holding the handle can do itself, and none of which is needed once the job exists from the start.
- **A tool-owned deadline over `onExpiry: 'none'` without registering at start** — removes the seam protocol but keeps the command invisible and unkillable until the timeout; rejected because a listed, stoppable command from its first second is the point of the human kill.
- **A hard `jobs` dependency for the shell tools** — rejected: a composition that wants a shell without three model-visible `job_*` tools must stay possible, so the tool swaps definitions on the registry's presence instead.
- **Exempting waited-on commands from the admission cap, and an `attached` marker in `JobView`** — deferred: a refusal at the start falls back to the deadline kill, and grouping running foreground commands in the Web task list is a follow-up.
## Testing
Executor level (real processes): the `'kill'` and `'none'` arms classify first-cause; a caller abort before the deadline stays the first cause when the process outlives the deadline; a pre-aborted signal classifies `aborted`; preparation expiry yields a settled timed-out handle without spawning (sandbox suites); sync-throw containment with error identity through `result()`. Tool level: a real `printf …; sleep 30` with `timeoutMs: 250` is listed as `bash-1` while the call waits, returns the still-running text with the early output, and its next `job_output` read repeats nothing; a command that finishes within the wait returns the foreground result with the registry announcing `registered`, `settled` (`awaited: true`), `removed` and an empty list afterwards; a registry kill during the wait renders `[stopped: cancelled by the user]` before the signal marker; cancelling the call kills its job with `tool call aborted` as the reason; a start failure is the tool's error result with no job left; admission saturation falls back to the deadline kill with the warn; `promoteOnTimeout: false` keeps the kill, emits no job events, and drops the description sentence; the schema flips to job-backed when a registry loads later and back to foreground-only when it unloads, while disposing the tool plugin under a registry removes the tool without logging; pwsh mirrors every case over a scripted executor. Registry level: `awaited` for a live wait, `false` for none, timed-out, or aborted waits, and `true` while another wait on the job timed out; `remove` drops a settled record with a `removed` event and refuses a live job, an unknown id, and a foreign caller. Render texts are pinned verbatim.
## Consequences
- Every `ShellExecutor` consumer moved: the tools, the hook runner, tmux-context, and the webworker sandbox stack now speak `execute()`; executor test suites keep scenario intent through local `run`/`start` shims over the new seam.
- The `bash`/`pwsh` tool descriptions and `timeoutMs` schema text depend on the registry's presence (model-visible); the output union carries the `promoted` arm, and the foreground arm gained the optional `stopped` reason — the PTC system-prompt snapshots re-recorded with it.
- A registered command has no deadline at all: for a foreground call the timeout bounds only the wait, and stopping the command afterwards is `job_kill` or the web stop control.
- The Web task list shows every foreground command while it runs and streams it through `job.list`/`job.follow`; a settled one leaves with its result.
- `JobRegistry` gained `remove` and the `settled` event's `awaited` flag; `dsh-tool-jobs` keeps only the set of jobs the model killed.
- `start()`-era behavior where a synchronous spawn throw escaped to the caller is gone: callers read failures from `result()`/the read path. The sandbox "sync EACCES names the runner" classification now arrives as the `result()` rejection or the handle's `runnerFailed` fact.
@@ -0,0 +1,52 @@
# Agent Note:唯一的 shell execute()——前台是投影,超时是对 job 的有界等待
状态:已实现
Update:[jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)拥有每条已登记命令现在填充的输出环、游标与拉取源。
[English](2026-08-26-shell-execute-projection-and-jobs-at-start.md) | 中文
## 问题
跑过前台超时的 bash 命令会被杀掉、工作作废——这是长构建或安装在 agent 手里失败的头号方式。在旧 seam 里修它结构上很别扭:`ctx.shell` 有两个执行方法,`run()`(deadline 内置、只给 promise、没有可保留的句柄)与 `start()`(有句柄、无 deadline),于是「保住这个已在运行的前台命令」无从表达——deadline 的所有者只会杀,调用方也拿不到可向 `ctx.jobs` 重新注册的东西。两个方法还各自漂移:stdout 预算不同、文档里挂着「start 忽略 timeoutMs」的疣、同步与异步 spawn 失败的行为按路径分叉。
同行证据指向同一个方向。Kimi 默认让超时的命令继续运行(`bashAutoBackgroundOnTimeout`),什么都不重绑——进程任务脱离、调用方信号解钩、部分输出随句柄返回。Claude Code 的超时处理对同一个 `ShellCommand` 调用 `background()` 而不是杀掉,并明确注释重新 spawn 会泄漏清理并重复事件。Codex 根本没有移交,因为它从未有过「前台 spawn」:每次 `exec_command` 都是对一个 session 的有界观察窗,「仍在运行」是携带句柄的正常返回。三家在底层形状上一致:执行只有一种方式,前台是等待的属性,不是 spawn 的属性。
本变更最初交付的版本把移交放在了 seam 上:`onExpiry: 'offer'` 策略、`ShellExecution.promotion`,以及工具必须在 deadline 处同步应答的 `ShellPromotionOffer`。评审否决了它:这套协议存在的唯一原因是工具把 job 登记推迟到了超时点而不是启动点,于是每个执行器都要背一条中继/计时/解绑分支,每个消费方都要背一条任何执行都不需要的时序义务。
## 决策
**seam 收敛为 `resolve()` + `execute()`。** `execute(spec)` 返回 `Promise<ShellExecution>`——活的 `ShellProcess` 本体加前台投影 `result()`(分流收集、首因 `timedOut`/`aborted` 归类、只为基础设施失败 reject)。`run()` 与 `start()` 删除(pre-release,无垫片);它们编码的历史差异变成显式输入:`ShellExecSpec.onExpiry` 取 `'kill'`(默认)或 `'none'`,stdout 预算统一为 `spec.stdoutMaxBytes`。没有任何移交协议:只想等一段时间的调用方以 `'none'` 运行命令并自己限定等待时长,调用方停止等待后句柄依然有效。
**准备与执行共用 deadline。** 异步沙箱准备完成后才发布句柄。准备期间的取消或失败会拒绝 `execute`;到期则返回已结算的超时句柄,输出为空。迟到的准备结果不会启动任务。提供方事实在 subprocess 结算前同步安装,立即失败的启动也能获得对应的沙箱归类。
**spawn 失败统一收容**:同步抛错与异步 rejection 都把句柄结算为 `killed`、说明进入读路径,而 `result()` 以原始错误 reject(保留同一性——沙箱执行器在其 `result()` 装饰里把可归因于 runner 的失败映射为 `SANDBOX_UNAVAILABLE`,并在 `onProcessDone` 里盖章句柄事实,两者都以同一个句柄实例为键)。
**组合中有 job 注册表时,`tool-bash`/`tool-pwsh` 在每条命令启动时就把它登记到 `ctx.jobs`。** `run_in_background` 立即返回 id。前台调用启动同样的 job,并用注册表的 `wait(id, timeoutMs, owner, signal)` 等待它,等待上限就是现有的 `timeoutMs`——没有第二个旋钮。在等待内结算的命令用句柄的 `result()` 返回普通前台结果,工具随即移除该 job 记录(`JobRegistry.remove`),模型从不看到 id,列表也不会堆满每一条 `ls`。超过等待仍在运行的命令继续作为它本来就是的那个 job 运行,调用返回 `{ kind: 'promoted', jobId, timeoutMs, output }`——渲染为 `[still running after <ms>; moved to background job <id>]` 加交接指引——并以一次消费式注册表读取打底,因此 `job_output` 恰好从此处接续。超时那一刻没有任何东西易手:进程没有、中止信号没有、输出也没有。工具从不把自己的信号交给进程;取消调用会杀掉 job,而来自调用之外的杀停(人的停止控件、并行的 `job_kill`)经 job 的 `cancel` 钩子到达进程,工具把其原因渲染为前台结果里的 `[stopped: <reason>]`(值上的 `stopped`),模型读到的是原因而不是命令失败。
**后台能力面跟随注册表,注册表保持可选。** 工具的 `inject` 不写 `jobs`。没有注册表时它注册纯前台定义;`ctx.jobs` 出现后,`ctx.inject(['jobs'], …)` fork 用带 job 的定义替换它,注册表存在多久就保持多久;注册表在插件仍在时卸载,则恢复纯前台定义。因此没有注册表的组合保留一个 schema 既不宣传 `run_in_background` 也不宣传移交的普通 `bash`,模型也看不到任何 `job_*` 工具。登记是尽力而为的:`promoteOnTimeout: false`,或注册表在启动时拒绝该 job(持有者的准入上限、没有已附加的控制器),都改为在执行器的 `'kill'` deadline 下运行命令,并记录日志。
**注册表报告等待方已经收走了什么。** `settled` 事件带 `awaited`:本次结算是否释放了一个存活的 `wait`,工具等待自己的前台命令时为真,`job_output` 的等待亦然。`dsh-tool-jobs` 对已 awaited 的结算和模型经 `job_kill` 请求的杀停不发完成通知,其私有的认领台账已删除;超时或中止的等待早已离开注册表的等待方集合,之后的结算照常通知。来自 web 控制器的人类杀停两者都不是,因此拥有者 agent 照常收到带原因的通知。
## 备选方案
- **在 `run`/`start` 旁加第三个方法(`begin()`)**——最初的设计。评审中否决:三个「方法」其实是披着方法名的两根正交输入(deadline 策略;调用方等待哪个投影),同行产品全都建模为一次执行加投影视图。收敛顺带删掉了预算/疣的漂移,而不是再添一层表面。
- **Codex 的 session 模型**(每次调用都是观察窗、经 `write_stdin` 轮询)——否决:那是重复 `ctx.jobs` 已有能力的模型侧词汇变更;其早退结果契约保留在 promoted arm 里。
- **独立的移交阈值配置**——否决;「超时意味着别再阻塞回合,而不是杀掉工作」不需要第二个计时器,模型显式传的 `timeoutMs` 同样限定等待(两家同行皆如此;schema 文案已说明)。
- **seam 上的转移 offer**(`onExpiry: 'offer'`、`ShellExecution.promotion`、需同步 `accept()`/`decline()` 应答的 `ShellPromotionOffer`)——本 PR 最初交付的版本。评审否决,因为推迟登记是这套协议存在的唯一原因:执行器的 offer 分支中继调用方信号、自设计时器、accept 时解绑信号,这些持有句柄的消费方都能自己做,而 job 一开始就存在后一样都不需要。
- **不在启动时登记、只由工具在 `onExpiry: 'none'` 之上自设 deadline**——去掉了 seam 协议,但命令在超时前依然不可见、不可杀;否决,因为从第一秒起就被列出、可停止的命令正是人类杀停的意义所在。
- **shell 工具硬依赖 `jobs`**——否决:想要 shell 却不想让模型看到三个 `job_*` 工具的组合必须仍然可能,所以工具改为随注册表的存在切换定义。
- **让被等待的命令不占准入名额,以及 `JobView` 上的 `attached` 标记**——延期:启动时被拒回落到 deadline 杀,Web 任务列表对运行中前台命令的分组留作后续。
## 测试
执行器层(真实进程):`'kill'` 与 `'none'` 两个分支按首因归类;deadline 前的调用方 abort 在进程活过 deadline 时仍是首因;预先 abort 的信号归类 `aborted`;准备到期返回已结算的超时句柄且不 spawn(沙箱套件);同步抛错收容且错误同一性穿过 `result()`。工具层:真实的 `printf …; sleep 30` 配 `timeoutMs: 250` 在调用等待期间就以 `bash-1` 列出,返回带早期输出的仍在运行文本,其下一次 `job_output` 读取不重复任何内容;在等待内完成的命令返回前台结果,注册表依次播报 `registered`、`settled`(`awaited: true`)、`removed`,之后列表为空;等待期间的注册表杀停在信号标记前渲染 `[stopped: cancelled by the user]`;取消调用以 `tool call aborted` 为原因杀掉其 job;启动失败是工具的错误结果且不留 job;准入饱和回落到 deadline 杀并告警;`promoteOnTimeout: false` 保持杀、不发任何 job 事件、撤掉描述句;注册表稍后加载时 schema 切到带 job 的版本、卸载时切回纯前台,而在注册表存在时释放工具插件会移除工具且不记录任何错误;pwsh 以脚本化执行器镜像每个用例。注册表层:存活等待为 `awaited`,无人等待、超时或中止的等待为 `false`,另一个等待超时时仍为 `true`;`remove` 以 `removed` 事件丢弃已结算记录,并拒绝存活 job、未知 id 与外部调用方。渲染文本逐字钉死。
## 后果
- 每个 `ShellExecutor` 消费方都已迁移:工具、hook runner、tmux-context 与 webworker 沙箱栈现在都说 `execute()`;执行器测试套件用本地 `run`/`start` 垫片保住场景语义。
- `bash`/`pwsh` 的工具描述与 `timeoutMs` schema 文案取决于注册表是否存在(模型可见);输出 union 保留 `promoted` arm,前台 arm 新增可选的 `stopped` 原因——PTC 系统提示词快照随之重录。
- 已登记的命令完全没有 deadline:前台调用的超时只限定等待,之后停下命令靠 `job_kill` 或 Web 停止控件。
- Web 任务列表显示每条运行中的前台命令并经 `job.list`/`job.follow` 流式观看;完成的命令随结果离开。
- `JobRegistry` 新增 `remove` 与 `settled` 事件的 `awaited` 标志;`dsh-tool-jobs` 只保留模型杀停过的 job 集合。
- `start()` 时代同步 spawn 抛错逃逸给调用方的行为不复存在:调用方从 `result()`/读路径读失败。沙箱「同步 EACCES 指名 runner」的分类现在以 `result()` rejection 或句柄的 `runnerFailed` 事实呈现。
@@ -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-09-01-workflow-run-in-background.md
2026-09-01-workflow-run-in-background.md: 5a141b384186e7b4f9e8125c37eaf2c01a614a6d
2026-09-01-workflow-run-in-background.zh.md: 102a398d95eef6df07443a919c19dae57b5c1e74
@@ -0,0 +1,31 @@
# Agent Note: Workflow runs gain `run_in_background` over the job registry
Status: implemented
Update: with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md) the run registers a `JobSpec` (no `record` flag), narrates through `JobHandle.append` and `updateProgress`, and returns its rendered value as `JobOutcome.result`, which the model's first read after settlement carries once.
English | [中文](2026-09-01-workflow-run-in-background.zh.md)
## Problem
A `workflow` call blocked the parent turn until the whole script settled: a long orchestration (an audit fanning out over hundreds of files) held the model hostage for its entire wall-clock, with no way to keep working, no live progress for a human, and cancellation as the only exit. Every other long-running execution surface — bash, pwsh, one-shot subagents — already had a `run_in_background` route into `ctx.jobs`. The [record merge](../architecture/2026-09-01-jobs-absorb-activity-record.md) also removed the foreground workflow's activity mirror on the explicit promise that live workflow narration would return as a background record job.
## Decision
The `workflow` tool gains `run_in_background: true` (exposed and accepted while the `enableRunInBackground` config holds, default on): the call registers the run as an owned `kind: 'workflow'` job with an observation record and returns `{ kind: 'background', jobId, runId }` immediately.
- **The run belongs to the job, not the tool step.** The engine run starts inside the job starter with no `exec.signal`; `job_kill`, the job list's stop control, and owner teardown are the cancellation paths, each forwarding its reason into `run.cancel`. A synchronous engine rejection (meta/parse failure) propagates out of the starter, so nothing registers and the model sees the ordinary correctable error.
- **Settlement is the job's settlement.** `done` chains from `run.result`: dispose (a disposal failure warns and never rejects into the registry), stop the mirrors, then map the stop reason — `completed` carries the same rendered return value as the foreground path in `JobOutcome.output` (so the completion notice and `job_output` deliver it), `cancelled` settles `killed` and leaves the detail to the registry's kill-reason merge (the forwarded cancel reason is the same string), `error` settles `failed` with the script's failure message.
- **The record mirror replaces the deleted activity mirror.** `src/record.ts` subscribes `workflow/phase`, `workflow/log`, and member lifecycle events once per plugin and routes them into tracked runs' `JobHandle` faces as the same text lines the activity mirror wrote, on the `log` channel so the model's `job_output` never renders them; `updateProgress` tracks the current phase. There is no contained-error wrapper: `append`/`updateProgress` are non-throwing surfaces (post-settlement appends drop inside the registry), and a straggling event finds no tracked run.
- **The output schema becomes a `kind`-discriminated union** (`background` | `foreground`), matching bash's shape; the foreground envelope gains `kind: 'foreground'` for symmetry. The durable run-start/member/run-end session recording keeps its `exec.parent === undefined` gate on both paths, with the background run-end written from the job's `done` chain.
- **`workflow` joins `JobKindMap`** by declaration merging from the tool package, like `pwsh` and `pty`.
## Alternatives considered
- **A start/poll API on the tool itself** (a `workflow_status` companion): duplicates `job_output`/`job_kill` for one producer and leaves the run outside owner teardown and the session-header list; the job registry already is the start/poll surface.
- **Streaming intermediate values to the model** (partial results through `readOutput`): a workflow's value is the script's single return; per-agent intermediates are script-internal (`log()` narrates them for humans through the record). A consuming model cursor would invite polling loops against an inherently final-value producer.
- **Bridging `exec.signal` into the background run:** the returning tool step's abort (turn cancellation) would kill work the model just intentionally detached; bash's background route set the precedent that only the registry cancels.
## Consequences
The model can fire long orchestrations and keep working; humans watch phases, logs, and member lifecycles stream in the session-header job list and can stop the run there. A background run's value arrives only at settlement (`job_output` before that returns status alone). The snapshot tree pins the schema, prompt, and PTC stub changes; a recorded-session scenario replaying a full background run is deferred and tracked in the package README's limitations.
@@ -0,0 +1,31 @@
# Agent Note:workflow 运行经任务注册表获得 `run_in_background`
状态:已实现
Update:随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md),该运行注册的是 `JobSpec`(没有 `record` 标志),通过 `JobHandle.append` 与 `updateProgress` 叙述进度,并把渲染后的值作为 `JobOutcome.result` 返回,模型在结算后的第一次读取携带它一次。
[English](2026-09-01-workflow-run-in-background.md) | 中文
## 问题
`workflow` 调用会阻塞父级轮次直到整个脚本结算:一次长编排(扇出到数百个文件的审计)把模型按在原地耗完全部墙钟时间,模型无法继续工作,人类看不到实时进度,取消是唯一出口。其他每个长时执行面——bash、pwsh、一次性 subagent——都早已有通往 `ctx.jobs` 的 `run_in_background` 路径。[record 合并](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)删掉前台 workflow 的 activity 镜像时,也明确承诺实时 workflow 叙述会以后台 record 任务的形式回归。
## 决策
`workflow` 工具获得 `run_in_background: true`(在 `enableRunInBackground` 配置生效时公开并受理,默认开启):调用把运行注册为自有的 `kind: 'workflow'` 任务并声明观察 record,立刻返回 `{ kind: 'background', jobId, runId }`。
- **运行属于任务,不属于工具步骤。** 引擎运行在任务 starter 内启动且不带 `exec.signal`;取消路径是 `job_kill`、任务列表的停止控件与 owner 释放,各自把原因转发进 `run.cancel`。引擎的同步拒绝(meta/解析失败)从 starter 传播出去,因此什么都不注册,模型看到的是普通的可修正错误。
- **结算即任务结算。** `done` 从 `run.result` 链起:先 dispose(释放失败只告警,绝不 reject 进注册表),再停掉镜像,最后映射结束原因——`completed` 在 `JobOutcome.output` 里携带与前台路径相同的渲染返回值(完成播报与 `job_output` 由此送达它),`cancelled` 结算为 `killed` 并把 detail 留给注册表的 kill 原因合并(转发的取消原因就是同一字符串),`error` 以脚本失败消息结算为 `failed`。
- **record 镜像顶替被删的 activity 镜像。** `src/record.ts` 按插件订阅一次 `workflow/phase`、`workflow/log` 与成员生命周期事件,并把它们以 activity 镜像当年写下的同样文本行以 `log` 通道路由进被跟踪运行的 `JobHandle` 面(模型的 `job_output` 从不渲染它们);`updateProgress` 跟随当前 phase。这里没有收容错误的包装层:`append`/`updateProgress` 是不抛出的表面(结算后的 append 在注册表内丢弃),迟到的事件找不到被跟踪的运行。
- **输出 schema 变为 `kind` 判别联合**(`background` | `foreground`),与 bash 的形态一致;前台包络为对称获得 `kind: 'foreground'`。持久的 run-start/成员/run-end 会话记录在两条路径上都保留 `exec.parent === undefined` 门槛,后台的 run-end 从任务的 `done` 链写出。
- **`workflow` 进入 `JobKindMap`**,由工具包声明合并,与 `pwsh`、`pty` 相同。
## 曾考虑的替代方案
- **工具自带 start/poll 接口**(一个 `workflow_status` 伴生工具):为单一生产者复制 `job_output`/`job_kill`,还把运行留在 owner 释放与会话头部列表之外;任务注册表本身就是 start/poll 表面。
- **向模型流式中间值**(经 `readOutput` 给部分结果):workflow 的值就是脚本的单一 return;每个 agent 的中间结果是脚本内部事务(`log()` 经 record 向人类叙述它们)。给天然只有终值的生产者一个消费型模型游标,只会招来轮询循环。
- **把 `exec.signal` 桥接进后台运行:** 已返回的工具步骤的中止(轮次取消)会杀掉模型刚刚有意脱管的工作;bash 的后台路径已立下先例——只有注册表取消它。
## 后果
模型可以点燃长编排后继续工作;人类在会话头部任务列表里看着 phase、log 与成员生命周期流入,并能就地停止运行。后台运行的值只在结算时抵达(此前的 `job_output` 只返回状态)。快照树钉住 schema、提示词与 PTC 桩的变化;回放完整后台运行的 recorded-session 场景延期,记录在包 README 的限制一节。
@@ -34,6 +34,7 @@ class RosterOnlyJobRegistry extends JobRegistry {
readAt(): never { throw new Error('unsupported') }
kill(): never { throw new Error('unsupported') }
wait(): never { throw new Error('unsupported') }
remove(): never { throw new Error('unsupported') }
attachController(): () => void { return () => {} }
}
+16 -3
View File
@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await scaffold?.close()
})
it('shows a running background job in the header without a refresh, then flips it to cancelled on settlement', async () => {
it('shows a running background job in the header without a refresh, then kills it from the two-press stop control', async () => {
let phase = 'running'
onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`))
// Polling for zero would pass at t=0 before delivery and prove nothing.
@@ -105,16 +105,29 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await row.waitFor({ timeout: 10_000 })
await expect.poll(() => row.textContent()).toContain(COMMAND)
const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd, { runningJobs: 'keep' })
await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
phase = 'settled'
expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested')
// The whole human path: arm, confirm, job.kill, registry kill, jobs
// frames flipping the row — no registry call from the test.
const stop = page.locator('[data-kill-state]')
await stop.waitFor({ timeout: 10_000 })
await stop.click()
await expect.poll(() => stop.getAttribute('data-kill-state')).toBe('armed')
await stop.click()
// Exact: the running trigger's name ('1 background job running') contains this label.
const idle = page.getByRole('button', { name: '1 background job', exact: true })
await idle.waitFor({ timeout: 20_000 })
// The unclaimed report's reason lands in the settled row's detail.
await expect.poll(
() => page.getByRole('list', { name: 'Background jobs' }).textContent(),
{ timeout: 15_000 },
).toContain('cancelled by the user')
expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('killed')
const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE)
+1 -1
View File
@@ -110,7 +110,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => {
await expand.click()
await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 })
const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd, { runningJobs: 'keep' })
await compareOrRefreshGolden(STREAMING_EXPECTED, streaming, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
+12 -2
View File
@@ -1538,13 +1538,18 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st
/**
* Capture the region's aria snapshot at a settled milestone: poll until two
* consecutive normalized captures are equal — a single-shot capture races the
* last React commits.
* last React commits. A foreground shell command is a job while it runs, and
* its removal reaches the browser one coalesced roster frame after the tool
* result, so by default the capture first waits for the session header's
* running-job control to leave; a scenario whose milestone is a running job
* keeps it with `runningJobs: 'keep'`.
* @param page - the page under test.
* @param selector - the region locator selector.
* @param workspaceCwd - normalization input.
* @param options - `normalizeAge` collapses relative-time buckets to `{{age}}`
* for a region whose rows are dated from live wall-clock state;
* `replacements` tokenizes scenario-owned values before generic normalization.
* `replacements` tokenizes scenario-owned values before generic normalization;
* `runningJobs` is `'settle'` (default: wait for no running-job control) or `'keep'`.
* @returns the stable normalized snapshot.
*/
export async function captureStableAria(
@@ -1554,8 +1559,13 @@ export async function captureStableAria(
options: {
normalizeAge?: boolean
replacements?: readonly (readonly [value: string, token: string])[]
runningJobs?: 'settle' | 'keep'
} = {},
): Promise<string> {
if ((options.runningJobs ?? 'settle') === 'settle') {
await page.getByRole('button', { name: /background jobs? running/ })
.waitFor({ state: 'detached', timeout: 10_000 })
}
const region = page.locator(selector).first()
const age = options.normalizeAge === true
const normalize = (snapshot: string): string => {
@@ -1,4 +1,7 @@
- list "Background jobs":
- listitem:
- button "Finished 1" [expanded]
- button "Clear"
- listitem:
- button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: "bash printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done signal: SIGTERM; web e2e cancellation {{duration}}"
- text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done
@@ -1,6 +1,7 @@
- list "Background jobs":
- listitem:
- button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done {{duration}} bash running
- button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done bash {{duration}}
- button "Stop task printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done"
- text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done
- button "Copy"
- text: streamed-marker-line
+2 -2
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 docs/config-catalog.md
config-catalog.md: 4ce0bc0a4f660fe3f27091012072c5799231f12b
config-catalog.zh.md: b8077cd13fff1f7c3b0a89f9f9c335a8716c18ac
config-catalog.md: 08821db1fb58d0b88639e27ec7687cdbf17b329e
config-catalog.zh.md: 1cd039f3e95934a56e6d83c7f3c5e13dd63f3c35
+41 -7
View File
@@ -202,7 +202,7 @@ export interface Config {
}
```
Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts)
Source: [`packages/api/job-controller/src/index.ts:35`](../packages/api/job-controller/src/index.ts)
<a id="deepseek-aidsh-api-session-controller"></a>
@@ -3156,12 +3156,25 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv`
```ts config-catalog
/** Configuration for the bash tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
/**
* Expose `run_in_background` while a job registry is composed (default
* true); disabled calls are also rejected. Without a registry the tool is
* foreground-only regardless.
*/
enableRunInBackground?: boolean
/**
* Keep a foreground command that reaches its timeout running as a
* background job instead of killing it (default true). Applies only while
* background execution is available: with `enableRunInBackground` false or
* no job registry, the executor's deadline kills the command. A foreground
* command the registry refuses at its start (admission or a missing
* controller) also runs under the deadline kill.
*/
promoteOnTimeout?: boolean
}
```
Source: [`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts)
Source: [`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts)
<a id="deepseek-aidsh-tool-bash-persistent"></a>
@@ -3290,7 +3303,7 @@ export interface Config {
export type CompletionDelivery = 'quiet' | 'wakeup'
```
Source: [`packages/jobs/tool-jobs/src/index.ts:52`](../packages/jobs/tool-jobs/src/index.ts)
Source: [`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
<a id="deepseek-aidsh-tool-lsp"></a>
@@ -3337,12 +3350,25 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv`
```ts config-catalog
/** Configuration for the pwsh tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
/**
* Expose `run_in_background` while a job registry is composed (default
* true); disabled calls are also rejected. Without a registry the tool is
* foreground-only regardless.
*/
enableRunInBackground?: boolean
/**
* Keep a foreground command that reaches its timeout running as a
* background job instead of killing it (default true). Applies only while
* background execution is available: with `enableRunInBackground` false or
* no job registry, the executor's deadline kills the command. A foreground
* command the registry refuses at its start (admission or a missing
* controller) also runs under the deadline kill.
*/
promoteOnTimeout?: boolean
}
```
Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
Source: [`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts)
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
@@ -3592,10 +3618,18 @@ export interface Config {
toolName?: string
/** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
maxResultChars?: number
/**
* Expose `run_in_background` (default true); disabled calls are also
* rejected. A background run needs a live `ctx.jobs` registry with a
* controller serving the caller (`dsh-jobs-local` plus `dsh-tool-jobs` in
* the shipped composition); without one the call fails with the missing
* piece named.
*/
enableRunInBackground?: boolean
}
```
Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts)
Source: [`packages/workflow/tool-workflow/src/index.ts:44`](../packages/workflow/tool-workflow/src/index.ts)
<a id="deepseek-aidsh-tool-workspace-dependencies"></a>
+40 -6
View File
@@ -204,7 +204,7 @@ export interface Config {
}
```
来源:[`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts)
来源:[`packages/api/job-controller/src/index.ts:35`](../packages/api/job-controller/src/index.ts)
<a id="deepseek-aidsh-api-session-controller"></a>
@@ -3160,12 +3160,25 @@ export type TokenMeterConfig = Record<string, never>
```ts config-catalog
/** Configuration for the bash tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
/**
* Expose `run_in_background` while a job registry is composed (default
* true); disabled calls are also rejected. Without a registry the tool is
* foreground-only regardless.
*/
enableRunInBackground?: boolean
/**
* Keep a foreground command that reaches its timeout running as a
* background job instead of killing it (default true). Applies only while
* background execution is available: with `enableRunInBackground` false or
* no job registry, the executor's deadline kills the command. A foreground
* command the registry refuses at its start (admission or a missing
* controller) also runs under the deadline kill.
*/
promoteOnTimeout?: boolean
}
```
来源:[`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts)
来源:[`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts)
<a id="deepseek-aidsh-tool-bash-persistent"></a>
@@ -3294,7 +3307,7 @@ export interface Config {
export type CompletionDelivery = 'quiet' | 'wakeup'
```
来源:[`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts)
来源:[`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
<a id="deepseek-aidsh-tool-lsp"></a>
@@ -3341,12 +3354,25 @@ export interface Config {
```ts config-catalog
/** Configuration for the pwsh tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
/**
* Expose `run_in_background` while a job registry is composed (default
* true); disabled calls are also rejected. Without a registry the tool is
* foreground-only regardless.
*/
enableRunInBackground?: boolean
/**
* Keep a foreground command that reaches its timeout running as a
* background job instead of killing it (default true). Applies only while
* background execution is available: with `enableRunInBackground` false or
* no job registry, the executor's deadline kills the command. A foreground
* command the registry refuses at its start (admission or a missing
* controller) also runs under the deadline kill.
*/
promoteOnTimeout?: boolean
}
```
来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
来源:[`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts)
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
@@ -3596,6 +3622,14 @@ export interface Config {
toolName?: string
/** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
maxResultChars?: number
/**
* Expose `run_in_background` (default true); disabled calls are also
* rejected. A background run needs a live `ctx.jobs` registry with a
* controller serving the caller (`dsh-jobs-local` plus `dsh-tool-jobs` in
* the shipped composition); without one the call fails with the missing
* piece named.
*/
enableRunInBackground?: boolean
}
```
+2 -2
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 docs/event-producer-consumer.md
event-producer-consumer.md: 741593e3f869994c260b580d9dddb18bdbf5193f
event-producer-consumer.zh.md: 323d37214254a54a9ca13d1918523cb1c92fd32a
event-producer-consumer.md: 2281f3bbd07597f945c41ea04ff96dca60b8c2fd
event-producer-consumer.zh.md: a615881f831503c2f8abcf23336f87f05640a87a
+2 -2
View File
@@ -79,8 +79,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## Non-harness or undeclared event strings seen in package source
+2 -2
View File
@@ -81,8 +81,8 @@
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## Non-harness or undeclared event strings seen in package source
+2 -2
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 docs/module-graph.md
module-graph.md: 936324af096fa6b17e260e9d9cd6d03a34fb9bd7
module-graph.zh.md: c68f95c65af7df50dbfd61bf79d754c137c7f709
module-graph.md: a731583e80d78c9bcb637028a879da9e7772a254
module-graph.zh.md: 5e76dca74a18895bf9f4ea0187a919c017cd4cd2
+2 -1
View File
@@ -964,6 +964,7 @@ flowchart TD
pkg_llm_replay --> pkg_session
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_jobs
pkg_tool_workflow --> pkg_llm
pkg_tool_workflow --> pkg_session
pkg_tool_workflow --> pkg_system_prompt
@@ -1578,7 +1579,7 @@ flowchart TD
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
+2 -1
View File
@@ -966,6 +966,7 @@ flowchart TD
pkg_llm_replay --> pkg_session
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_jobs
pkg_tool_workflow --> pkg_llm
pkg_tool_workflow --> pkg_session
pkg_tool_workflow --> pkg_system_prompt
@@ -1580,7 +1581,7 @@ flowchart TD
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
+2 -2
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 docs/subsystems/jobs.md
jobs.md: d54b1ecdd8957c162268c8abbc1df6d764695ac7
jobs.zh.md: 3b0db7c654d02ace451e73203923d816e9262230
jobs.md: 0d235c6101ff20c9ef530bc08efc98000ce05fd0
jobs.zh.md: fbb650bbc10e4c35451b193cdf7317b28980752c
+33 -1
View File
@@ -294,6 +294,14 @@ type JobEvent =
readonly type: 'settled'
readonly job: JobView
readonly cause: JobSettleCause
/**
* Whether this settlement released a live {@link JobRegistry.wait}. That
* waiter's caller receives the terminal projection as its own result, so
* a completion reporter treats an awaited settlement as already delivered
* and reports only the unawaited ones. A wait that timed out or was
* aborted before the settlement does not count.
*/
readonly awaited: boolean
}
| {
readonly type: 'output'
@@ -358,6 +366,19 @@ Host service backing the generated `ctx.remote.job` namespace.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable<JobFollowFrame>
/**
* Kill one background job on a human's behalf. The request's session is
* the fenced read's caller, so the job must be one that session can see:
* the registry's owner fence is the only access rule, and a child session's
* own jobs are killable from its list like any other. The kill records
* `cancelled by the user` as its reason; it is not one the model requested,
* so the owning agent still receives the completion notice, and a shell
* tool waiting on that job reads the reason in its own result.
* @param request - Session whose job list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('kill') kill(request: JobKillRequest): JobKillValue
```
Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts)
@@ -372,7 +393,8 @@ Implementations must honor these semantics:
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left.
- Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter and reports whether it released one (`awaited`), so a completion reporter can skip settlements a waiting caller already collected.
- A settled record stays listed until its owner's disposal, service disposal, or an explicit remove by a caller that collected the terminal state itself and never handed the id out.
- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other.
- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.
@@ -451,6 +473,16 @@ abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'al
*/
abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise<JobView>
/**
* Drop one settled job's record from the visible set and announce
* `removed`. For a caller that collected the terminal state through its own
* {@link wait} and never handed the id to the model, such as a shell tool's
* foreground call. Throws for a job that is still live, unknown, or foreign.
* @param id - settled job to drop.
* @param caller - removing session checked against the owner.
*/
abstract remove(id: JobId, caller?: SessionId): void
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
* owners its registering context's scope covers, and {@link start} refuses an
+33 -1
View File
@@ -294,6 +294,14 @@ type JobEvent =
readonly type: 'settled'
readonly job: JobView
readonly cause: JobSettleCause
/**
* Whether this settlement released a live {@link JobRegistry.wait}. That
* waiter's caller receives the terminal projection as its own result, so
* a completion reporter treats an awaited settlement as already delivered
* and reports only the unawaited ones. A wait that timed out or was
* aborted before the settlement does not count.
*/
readonly awaited: boolean
}
| {
readonly type: 'output'
@@ -358,6 +366,19 @@ Host service backing the generated `ctx.remote.job` namespace.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable<JobFollowFrame>
/**
* Kill one background job on a human's behalf. The request's session is
* the fenced read's caller, so the job must be one that session can see:
* the registry's owner fence is the only access rule, and a child session's
* own jobs are killable from its list like any other. The kill records
* `cancelled by the user` as its reason; it is not one the model requested,
* so the owning agent still receives the completion notice, and a shell
* tool waiting on that job reads the reason in its own result.
* @param request - Session whose job list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('kill') kill(request: JobKillRequest): JobKillValue
```
Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts)
@@ -372,7 +393,8 @@ Implementations must honor these semantics:
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left.
- Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter and reports whether it released one (`awaited`), so a completion reporter can skip settlements a waiting caller already collected.
- A settled record stays listed until its owner's disposal, service disposal, or an explicit remove by a caller that collected the terminal state itself and never handed the id out.
- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other.
- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.
@@ -451,6 +473,16 @@ abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'al
*/
abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise<JobView>
/**
* Drop one settled job's record from the visible set and announce
* `removed`. For a caller that collected the terminal state through its own
* {@link wait} and never handed the id to the model, such as a shell tool's
* foreground call. Throws for a job that is still live, unknown, or foreign.
* @param id - settled job to drop.
* @param caller - removing session checked against the owner.
*/
abstract remove(id: JobId, caller?: SessionId): void
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
* owners its registering context's scope covers, and {@link start} refuses an
+2 -2
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 docs/subsystems/shell.md
shell.md: 734b9e7c72948be23d2c6a2c82c6af27c1e2812b
shell.zh.md: bfe455c2e5f680f743188e8accb70008a95ab8f9
shell.md: ad7e7f12ac977d45de3b8ae621a6f848f3ae43b5
shell.zh.md: f0e9bd6ea00629f406638b7031eb456e9bc951c2
+21 -21
View File
@@ -27,6 +27,8 @@ interface ShellExecRequest {
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/** Deadline policy at `timeoutMs` expiry (default `'kill'`). */
onExpiry?: ShellExpiryPolicy | undefined
/**
* Foreground stdout capture budget in bytes. Absent uses the executor's
* default output cap. Trusted in-process consumers use this when they must
@@ -34,7 +36,7 @@ interface ShellExecRequest {
* tool does not expose it as a parameter.
*/
stdoutMaxBytes?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
/** Abort signal — implementations kill the command when it fires, and treat a signal that is already aborted as fired. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
@@ -68,19 +70,21 @@ interface ShellExecRequest {
```ts type-equiv
/**
* A resolved execution spec. {@link ShellExecutor.resolve} fills and caps the
* required fields; {@link ShellExecutor.start} ignores `timeoutMs` because
* background processes have no executor timeout.
* required fields; under `onExpiry: 'none'` the resolved `timeoutMs` arms no
* timer and is only echoed into {@link ShellRunResult.timeoutMs}.
*/
interface ShellExecSpec {
command: string
workdir: string
timeoutMs: number
/** Deadline policy at `timeoutMs` expiry ({@link ShellExecutor.resolve} defaults it to `'kill'`). */
onExpiry: ShellExpiryPolicy
/**
* Resolved foreground stdout capture budget in bytes. `run()` uses it for
* stdout; background jobs and stderr keep the executor's own output cap.
* Resolved stdout capture budget in bytes, applied to every execution's
* stdout; stderr keeps the executor's own output cap.
*/
stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */
/** Abort signal — implementations kill the command when it fires, and treat a signal that is already aborted as fired. */
signal?: AbortSignal | undefined
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
@@ -244,37 +248,33 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
execute resolves with the process handle after preparation. "Foreground" is a property of what the caller awaits, not of the spawn — a caller that awaits ShellExecution.result ran the command in the foreground; one that keeps the handle ran it in the background. A caller that waits only for a while runs the command under `onExpiry: 'none'` and bounds its own wait; the handle stays valid after the caller stops waiting.
Implementations must honor these semantics:
- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.
- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.
- ShellExecution.result rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a descriptive result: first-cause `timedOut`/`aborted`, the spec's `timeoutMs` echoed.
- The handle is published after preparation. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on the read path, while `result()` carries the same failure as its rejection.
- `onExpiry: 'none'` arms no deadline; `'kill'` kills at expiry. Expiry during preparation returns a settled timed-out handle without output.
- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.
- A still-running process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a process survives an executor-only reload.
```ts cordis-catalog
/**
* Apply implementation-owned defaults and caps to a request before execution.
* @param request - the caller's request; omitted fields get this
* implementation's defaults, capped fields are clamped.
* @returns the fully-specified spec to hand to {@link run}/{@link start}.
* @returns the fully-specified spec to hand to {@link execute}.
*/
abstract resolve(request: ShellExecRequest): ShellExecSpec
/**
* Run preparation and the foreground command under the resolved timeout.
* Prepare and spawn the command under its resolved deadline.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the outcome; nonzero exits, timeout kills, and abort kills
* resolve with a descriptive result rather than reject.
* @returns the prepared handle, including its result projection;
* preparation timeout yields an already-settled handle with no output.
* @throws on preparation failure or caller cancellation before process publication.
*/
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
/**
* Prepare a background process asynchronously and publish its live handle.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the live process handle after preparation; cancellation or setup failure rejects.
*/
abstract start(spec: ShellExecSpec): Promise<ShellProcess>
abstract execute(spec: ShellExecSpec): Promise<ShellExecution>
```
Source: [`packages/shell/shell/src/index.ts`](../../packages/shell/shell/src/index.ts)
+21 -21
View File
@@ -27,6 +27,8 @@ interface ShellExecRequest {
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/** Deadline policy at `timeoutMs` expiry (default `'kill'`). */
onExpiry?: ShellExpiryPolicy | undefined
/**
* Foreground stdout capture budget in bytes. Absent uses the executor's
* default output cap. Trusted in-process consumers use this when they must
@@ -34,7 +36,7 @@ interface ShellExecRequest {
* tool does not expose it as a parameter.
*/
stdoutMaxBytes?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
/** Abort signal — implementations kill the command when it fires, and treat a signal that is already aborted as fired. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
@@ -68,19 +70,21 @@ interface ShellExecRequest {
```ts type-equiv
/**
* A resolved execution spec. {@link ShellExecutor.resolve} fills and caps the
* required fields; {@link ShellExecutor.start} ignores `timeoutMs` because
* background processes have no executor timeout.
* required fields; under `onExpiry: 'none'` the resolved `timeoutMs` arms no
* timer and is only echoed into {@link ShellRunResult.timeoutMs}.
*/
interface ShellExecSpec {
command: string
workdir: string
timeoutMs: number
/** Deadline policy at `timeoutMs` expiry ({@link ShellExecutor.resolve} defaults it to `'kill'`). */
onExpiry: ShellExpiryPolicy
/**
* Resolved foreground stdout capture budget in bytes. `run()` uses it for
* stdout; background jobs and stderr keep the executor's own output cap.
* Resolved stdout capture budget in bytes, applied to every execution's
* stdout; stderr keeps the executor's own output cap.
*/
stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */
/** Abort signal — implementations kill the command when it fires, and treat a signal that is already aborted as fired. */
signal?: AbortSignal | undefined
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
@@ -244,37 +248,33 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
execute resolves with the process handle after preparation. "Foreground" is a property of what the caller awaits, not of the spawn — a caller that awaits ShellExecution.result ran the command in the foreground; one that keeps the handle ran it in the background. A caller that waits only for a while runs the command under `onExpiry: 'none'` and bounds its own wait; the handle stays valid after the caller stops waiting.
Implementations must honor these semantics:
- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.
- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.
- ShellExecution.result rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a descriptive result: first-cause `timedOut`/`aborted`, the spec's `timeoutMs` echoed.
- The handle is published after preparation. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on the read path, while `result()` carries the same failure as its rejection.
- `onExpiry: 'none'` arms no deadline; `'kill'` kills at expiry. Expiry during preparation returns a settled timed-out handle without output.
- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.
- A still-running process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a process survives an executor-only reload.
```ts cordis-catalog
/**
* Apply implementation-owned defaults and caps to a request before execution.
* @param request - the caller's request; omitted fields get this
* implementation's defaults, capped fields are clamped.
* @returns the fully-specified spec to hand to {@link run}/{@link start}.
* @returns the fully-specified spec to hand to {@link execute}.
*/
abstract resolve(request: ShellExecRequest): ShellExecSpec
/**
* Run preparation and the foreground command under the resolved timeout.
* Prepare and spawn the command under its resolved deadline.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the outcome; nonzero exits, timeout kills, and abort kills
* resolve with a descriptive result rather than reject.
* @returns the prepared handle, including its result projection;
* preparation timeout yields an already-settled handle with no output.
* @throws on preparation failure or caller cancellation before process publication.
*/
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
/**
* Prepare a background process asynchronously and publish its live handle.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the live process handle after preparation; cancellation or setup failure rejects.
*/
abstract start(spec: ShellExecSpec): Promise<ShellProcess>
abstract execute(spec: ShellExecSpec): Promise<ShellExecution>
```
Source: [`packages/shell/shell/src/index.ts`](../../packages/shell/shell/src/index.ts)
+2 -2
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 docs/tool-catalog.md
tool-catalog.md: c60d3979e65f3c5a2bc363fd082a3f63a446e8b5
tool-catalog.zh.md: dc061b51681cc73f9d10059276bcdfa5b3268cff
tool-catalog.md: 104329d7f472dc2273b802f26df9bd830858f187
tool-catalog.zh.md: 336ac892ad399a243dd2eb4921b0fe18e6bd64f1
+12 -8
View File
@@ -21,9 +21,9 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.ptcRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter. |
| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | Creator mode provides two read-only runtime inspection tools. The Cordis host runner supplies the inspection registry; Client queries require a connected page. Author persistent changes as bundles and install them with plugin_manager. |
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. |
@@ -588,7 +588,7 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr
### `bash`
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
```json
{
@@ -604,7 +604,7 @@ Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
},
"workdir": {
"type": "string",
@@ -624,7 +624,7 @@ Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs
Source: [`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/src/index.ts)
The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.
The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter.
<a id="deepseek-aidsh-tool-present"></a>
@@ -675,7 +675,7 @@ Deliveries belong to the calling Session; Web ui-deliverables supplies source-fi
### `pwsh`
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
```json
{
@@ -691,7 +691,7 @@ Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Eac
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
},
"workdir": {
"type": "string",
@@ -2366,7 +2366,7 @@ Script-body hooks:
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground by default: this call returns when the whole script finishes. Set `run_in_background: true` for a long run: the call returns a job id immediately, the run keeps orchestrating in the background, and its return value arrives with the job's completion notice (check on it with `job_output`, stop it with `job_kill`).
```json
{
@@ -2432,6 +2432,10 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
"type": "object",
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
"additionalProperties": true
},
"run_in_background": {
"type": "boolean",
"description": "Run as a background job: return a job id immediately instead of waiting; the return value arrives with the completion notice."
}
},
"required": [
+12 -8
View File
@@ -25,9 +25,9 @@
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.ptcRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 |
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 |
| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | 创造模式提供两个只读运行时检查工具。Cordis host runner 提供检查注册表;Client 查询需要已连接页面。持久化变更编写为组合包,再通过 plugin_manager 安装。 |
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 |
@@ -592,7 +592,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
### `bash`
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
```json
{
@@ -608,7 +608,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
},
"workdir": {
"type": "string",
@@ -628,7 +628,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
来源:[`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/src/index.ts)
bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。
bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。
<a id="deepseek-aidsh-tool-present"></a>
@@ -679,7 +679,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
### `pwsh`
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
```json
{
@@ -695,7 +695,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
},
"workdir": {
"type": "string",
@@ -2374,7 +2374,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),抛出的错误**总会**终止脚本,绝不会退化为单个条目的 `null`。
约束:并发上限和 agent 总数上限均会生效;不提供文件系统、网络、定时器或 Node.js API。具体工作由 agent 完成,脚本只负责编排。该运行在前台执行:整个脚本完成后,调用才会返回。
约束:并发上限和 agent 总数上限均会生效;不提供文件系统、网络、定时器或 Node.js API。具体工作由 agent 完成,脚本只负责编排。该运行默认在前台执行:整个脚本完成后,调用才会返回。长时间运行请设置 `run_in_background: true`:调用会立刻返回任务 id,运行在后台继续编排,其返回值随任务的完成播报送达(用 `job_output` 查看进展,用 `job_kill` 停止)。
```json
{
@@ -2440,6 +2440,10 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"type": "object",
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
"additionalProperties": true
},
"run_in_background": {
"type": "boolean",
"description": "Run as a background job: return a job id immediately instead of waiting; the return value arrives with the completion notice."
}
},
"required": [
+2 -2
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 packages/api/job-controller/README.md
README.md: d3698758cc144c033037759a834944870948284d
README.zh.md: d9744b4d19cfff6d73923d0d26e3e817bc54c1a3
README.md: ea343a8a201b50901af0033b39662fab4dd50555
README.zh.md: ce8f166cec4e180e491cd48377f20db2e7177e75
+6 -4
View File
@@ -1,5 +1,5 @@
---
description: "Host and Client job streams: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor."
description: "Host and Client job control: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor, and a kill on a human's behalf."
kind: "package-reference"
---
# Job Controller
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
## Summary
`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.list` mirrors the jobs one session can see as whole-set frames, and `job.follow` delivers one job's retained output from an absolute byte offset. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders. Neither stream touches the model's consuming cursor or the completion-notice ledger.
`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.list` mirrors the jobs one session can see as whole-set frames, and `job.follow` delivers one job's retained output from an absolute byte offset; its one command, `job.kill`, stops a job on a human's behalf. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders and whose `kill` its stop control calls. Neither stream touches the model's consuming cursor or its completion notices, and a human kill is not the model's own.
## Table of Contents
@@ -22,9 +22,11 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.list({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.follow({ sessionId?, jobId, from? })` passes `sessionId` to registry `get` and `readAt` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them.
The Host controller requires the live Agent registry and the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without them. `job.list({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.follow({ sessionId?, jobId, from? })` passes `sessionId` to registry `get` and `readAt` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them.
The Client entry installs `ctx.jobs` (`IJobs`), backed by the package-internal `ClientJobsModel`. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`.
`job.kill({ sessionId, jobId })` cancels one job the session can see with the reason `cancelled by the user`, which the registry merges into the killed job's detail. It is not a kill the model requested — `dsh-tool-jobs` withholds a notice only for the model's own `job_kill` and for settlements a live wait collected — so the owning agent's completion notice stays due, reason included, and a shell tool still waiting on that job reads the reason as `[stopped: cancelled by the user]` in its own result. It answers `{ outcome: 'requested' }` or `{ outcome: 'already-finished' }` and rejects an id the session cannot see as `job/not-found`; the registry's owner fence is the only access rule, so a child session's own jobs are killable from its list like any other.
The Client entry installs `ctx.jobs` (`IJobs`), backed by the package-internal `ClientJobsModel`. `kill(sessionId, jobId)` forwards to `job.kill` and returns the Remote result for the caller's admission verdict. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`.
### Config
+6 -4
View File
@@ -1,5 +1,5 @@
---
description: "Host 与 Client 的 job 流:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标。"
description: "Host 与 Client 的 job 控制:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标,并代人类停止一个 job。"
kind: "package-reference"
---
# Job Controller
@@ -8,7 +8,7 @@ kind: "package-reference"
## 概述
`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.list` 以整集帧镜像一个会话看得到的 job;`job.follow` 从绝对字节偏移发送一个 job 的保留输出。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图。两条流都不触碰模型的消耗型游标与完成播报台账。
`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.list` 以整集帧镜像一个会话看得到的 job;`job.follow` 从绝对字节偏移发送一个 job 的保留输出;它唯一的命令 `job.kill` 代人类停止一个 job。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图,其停止控件调用它的 `kill`。两条流都不触碰模型的消耗型游标与完成通知,人类 kill 也不是模型自己的杀停。
## 目录
@@ -22,9 +22,11 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.list({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.follow({ sessionId?, jobId, from? })` 把 `sessionId` 传给注册表的 `get` 与 `readAt`——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。
Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.list({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.follow({ sessionId?, jobId, from? })` 把 `sessionId` 传给注册表的 `get` 与 `readAt`——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。
Client 入口安装 `ctx.jobs`(`IJobs`),由包内部的 `ClientJobsModel` 支撑。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。
`job.kill({ sessionId, jobId })` 以 `cancelled by the user` 为原因取消一个该会话看得到的 job,注册表把该原因合并进被杀 job 的 detail。它不是模型请求的杀停——`dsh-tool-jobs` 只对模型自己的 `job_kill` 与存活等待收走的结算不发通知——因此拥有者 agent 的完成通知照常送达,并带上原因;仍在等待该 job 的 shell 工具则在自己的结果里以 `[stopped: cancelled by the user]` 读到原因。它回答 `{ outcome: 'requested' }` 或 `{ outcome: 'already-finished' }`,对该会话看不到的 id 以 `job/not-found` 拒绝;注册表的所有者围栏是唯一的访问规则,因此子会话自己的 job 也能像其他 job 一样从它的列表里被杀停。
Client 入口安装 `ctx.jobs`(`IJobs`),由包内部的 `ClientJobsModel` 支撑。`kill(sessionId, jobId)` 转发到 `job.kill` 并把 Remote 结果交给调用方判定准入。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。
### 配置
@@ -1,6 +1,6 @@
/**
* Job Controller client half: installs `ctx.jobs` over the generated `job`
* Remote namespace. The plugin resolves both Remote faces it drives while its
* Job Controller client half: installs `ctx.jobs` (rosters, observations, and
* the human kill) over the generated `job` Remote namespace. The plugin resolves both Remote faces it drives while its
* own context is current, because stream (re)opens run on caller stacks — a
* React event, a carrier retry — whose dynamic context has not declared
* `remote.job`.
@@ -18,7 +18,7 @@ export type { JobsSnapshot, ObservedJob } from './model.ts'
// declaration emit drops the value import above.
export type { IJobs } from './service.ts'
export type {
JobChunk, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest, JobView,
JobChunk, JobKillRequest, JobKillValue, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest, JobView,
} from '../types.ts'
/** Required Client Remote services. */
@@ -3,15 +3,16 @@
* namespace — one `job.list` roster stream per watched session and one
* `job.follow` stream per observed job — so overlapping viewers share a
* stream, rosters resume whole after a reconnect, and observations resume
* from the model's cursor.
* from the model's cursor, plus the human kill passthrough over `job.kill`.
* @module @deepseek-ai/dsh-api-job-controller/client/service
*/
import { Service, type Context } from '@deepseek-ai/cordis'
import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from '../types.ts'
import type { JobKillRequest, JobKillValue, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from '../types.ts'
import type { ClientJobsModel, JobsSource } from './model.ts'
/** The generated `job` namespace face the stream runners drive. */
@@ -30,6 +31,12 @@ export interface JobRemote {
* @returns the frame sequence of one generation.
*/
follow(request: JobFollowRequest, signal?: AbortSignal): AsyncIterable<JobFollowFrame>
/**
* Kill one job on the human's behalf.
* @param request - the session whose list carries the job, and the job id.
* @returns the registry's admission, or the business/transport failure.
*/
kill(request: JobKillRequest): Promise<RemoteResult<JobKillValue>>
}
/** Remote faces the runners drive: the Gateway stream factory and the `job` namespace. */
@@ -57,6 +64,15 @@ export interface IJobs {
* @returns stop function releasing this observer's reference.
*/
observe(sessionId: SessionId | undefined, id: JobId): () => void
/**
* Kill one background job from a session's job list. Pure RPC passthrough:
* row state converges through the roster stream, and the caller (the
* job-list control) owns error presentation.
* @param sessionId - session whose job list carries the job.
* @param id - the job row's registry id.
* @returns the registry's admission, or the business/transport failure.
*/
kill(sessionId: SessionId, id: JobId): Promise<RemoteResult<JobKillValue>>
}
/** One reference-counted stream. */
@@ -104,6 +120,10 @@ export class ClientJobs extends Service implements IJobs {
}, 'job-controller.client.streams')
}
kill(sessionId: SessionId, id: JobId): Promise<RemoteResult<JobKillValue>> {
return this.remote.job.kill({ sessionId, jobId: id })
}
watchRows(sessionId: SessionId): () => void {
return this.acquire(
this.rowsEntries,
+38 -5
View File
@@ -1,18 +1,20 @@
/**
* Host job Remote owner: streams the background-job roster one session can
* see and one job's retained output to browsers over the generated `job`
* namespace. Both streams are projections of `ctx.jobs`; the model's
* consuming cursor and notice state never observe them.
* namespace, and stops a job on a human's behalf. The streams are
* projections of `ctx.jobs`; the model's consuming cursor and notice state
* never observe them, and a human kill is not the model's own, so the
* completion notice still reaches the owning agent.
* @module @deepseek-ai/dsh-api-job-controller
*/
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-jobs'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { observeJobOutput } from './observe.ts'
import { streamJobRows } from './rows.ts'
import type { JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from './types.ts'
import type { JobKillRequest, JobKillValue, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from './types.ts'
export type * from './types.ts'
@@ -50,7 +52,7 @@ export class JobController extends TypertRemoteService {
private readonly observeMaxFrameBytes: number
/**
* @param ctx - Host context carrying the job registry.
* @param ctx - Host context carrying the live Agent registry and the job registry.
* @param config - observation cadence and framing policy.
*/
constructor(ctx: Context, config: Config) {
@@ -92,6 +94,37 @@ export class JobController extends TypertRemoteService {
maxFrameBytes: this.observeMaxFrameBytes,
}, signal)
}
/**
* Kill one background job on a human's behalf. The request's session is
* the fenced read's caller, so the job must be one that session can see:
* the registry's owner fence is the only access rule, and a child session's
* own jobs are killable from its list like any other. The kill records
* `cancelled by the user` as its reason; it is not one the model requested,
* so the owning agent still receives the completion notice, and a shell
* tool waiting on that job reads the reason in its own result.
* @param request - Session whose job list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('kill')
kill(request: JobKillRequest): JobKillValue {
const jobs = this.ctx.jobs
try {
jobs.get(request.jobId, request.sessionId)
} catch (error) {
// `unknown job` and `belongs to another session` both mean this session's
// list no longer carries a killable row; the client renders one story.
throw new RemoteError('job/not-found', String(error), {
sessionId: request.sessionId,
jobId: request.jobId,
})
}
// Same synchronous span as the lookup, so nothing can remove the job in
// between — and a producer-cancel throw propagates per the registry
// contract (job state unchanged) instead of masquerading as job-not-found.
const outcome = jobs.kill(request.jobId, request.sessionId, 'cancelled by the user')
return { outcome }
}
}
export default JobController
+21 -1
View File
@@ -1,6 +1,7 @@
/**
* Wire types of the generated `job` Remote namespace: the per-session roster
* stream and the per-job observation stream. Client-safe: no Host imports.
* stream, the per-job observation stream, and the human kill. Client-safe: no
* Host imports.
* @module @deepseek-ai/dsh-api-job-controller/types
*/
@@ -25,6 +26,25 @@ export interface JobListFrame {
readonly jobs: readonly JobView[]
}
/** Human-initiated cancellation of one background job visible to a session. */
export interface JobKillRequest {
/** Session whose job list carries the job; the fenced lookup reads as it. */
readonly sessionId: SessionId
readonly jobId: JobId
}
/** Receipt after the registry accepted the human kill request. */
export interface JobKillValue {
readonly outcome: 'requested' | 'already-finished'
}
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/** The session's job list no longer carries a killable row under that id. */
'job/not-found': { readonly sessionId: SessionId; readonly jobId: JobId }
}
}
/** Target of one `job.follow` stream: the job, its owning session, and an optional resume offset. */
export interface JobFollowRequest {
/**
@@ -14,11 +14,12 @@ afterEach(async () => {
})
/** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */
async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCalls: unknown[] }> {
async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCalls: unknown[]; killCalls: unknown[] }> {
const ctx = new Context()
contexts.add(ctx)
const observeCalls: unknown[] = []
const rowsCalls: unknown[] = []
const killCalls: unknown[] = []
const connection: ConnectionHandle = {
isLoopback: true,
generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} },
@@ -38,6 +39,10 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCal
observeCalls.push(request)
return (async function* (): AsyncGenerator<JobFollowFrame> {})()
},
kill: async (request: unknown) => {
killCalls.push(request)
return { ok: true as const, value: { outcome: 'requested' as const } }
},
}
ctx.reflect.provide('remote', {
$stream: <Item>(options: RemoteStreamOptions<Item>) => new RemoteStream(connection, options),
@@ -45,7 +50,7 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCal
})
ctx.reflect.provide('remote.job', job)
await ctx.plugin(JobClient)
return { ctx, observeCalls, rowsCalls }
return { ctx, observeCalls, rowsCalls, killCalls }
}
async function flush(): Promise<void> {
@@ -70,4 +75,11 @@ describe('Job Controller Client apply', () => {
await flush()
expect(ctx.jobs.state.getSnapshot()).toEqual({ rows: {}, observed: {} })
})
it('forwards a kill to the job namespace with the row\'s session', async () => {
const { ctx, killCalls } = await mount()
await expect(ctx.jobs.kill('session-1' as SessionId, 'bash-1' as never))
.resolves.toEqual({ ok: true, value: { outcome: 'requested' } })
expect(killCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }])
})
})
@@ -0,0 +1,149 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { JobId, JobOutcome } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { describe, expect, it } from 'vitest'
import { JobController } from '../src/index.ts'
function producer(label = 'sleep 60') {
let settle!: (outcome: JobOutcome) => void
const cancels: (string | undefined)[] = []
const spec = {
kind: 'bash' as const,
label,
run: () => ({
cancel: (reason?: string) => { cancels.push(reason) },
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
}),
}
return { spec, cancels, settle: (outcome: JobOutcome) => { settle(outcome) } }
}
async function registerAgent(ctx: Context, session: Session): Promise<Agent> {
const agent = {
id: session.id,
session,
inbox: unsupportedInbox(),
status: 'idle',
ctx,
} as Agent
await ctx.agents.register(agent)
return agent
}
async function harness(): Promise<{
ctx: Context
session: Session
agent: Agent
controller: JobController
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('kill-test')
await ctx.plugin(TypertRegistry)
await ctx.plugin(JobController, {})
const session = ctx.sessions.create()
const agent = await registerAgent(ctx, session)
return { ctx, session, agent, controller: ctx.jobController }
}
function failureCode(run: () => unknown): string {
try {
run()
} catch (error) {
const failure = remoteErrorOf(error)
if (failure !== undefined) return failure.code
throw error
}
throw new Error('expected a RemoteError failure')
}
describe('JobController.kill', () => {
it('kills an owned running job without claiming the terminal report', async () => {
const { ctx, session, agent, controller } = await harness()
const task = producer('pnpm run watch')
const id = ctx.jobs.start({ ...task.spec, owner: agent.id })
expect(controller.kill({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'requested' })
expect(task.cancels).toEqual(['cancelled by the user'])
// The kill reason is recorded for the killed detail; the notice ledger
// lives in tool-jobs, which never learns of this kill, so the completion
// notice stays due.
expect(ctx.jobs.get(id, agent.id)).toMatchObject({ status: 'stopping' })
})
it('reports an already-finished job instead of failing', async () => {
const { ctx, session, agent, controller } = await harness()
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent.id })
task.settle({ status: 'completed', detail: 'exit code: 0' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(controller.kill({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'already-finished' })
expect(ctx.jobs.get(id, agent.id).status).toBe('completed')
})
it('kills an unowned job from a session without a live agent', async () => {
const { ctx, controller } = await harness()
const task = producer('unowned work')
const id = ctx.jobs.start(task.spec)
const cold = ctx.sessions.create()
expect(controller.kill({ sessionId: cold.id, jobId: id })).toEqual({ outcome: 'requested' })
})
it('rejects an unknown job id as job/not-found', async () => {
const { session, controller } = await harness()
expect(failureCode(() => controller.kill({ sessionId: session.id, jobId: 'bash-99' as JobId })))
.toBe('job/not-found')
})
it('rejects a foreign session\'s job as job/not-found', async () => {
const { ctx, agent, controller } = await harness()
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent.id })
// The other session cannot see the owned job.
const other = ctx.sessions.create()
expect(failureCode(() => controller.kill({ sessionId: other.id, jobId: id })))
.toBe('job/not-found')
expect(ctx.jobs.get(id, agent.id).status).toBe('running')
})
it('propagates a producer cancel throw instead of masking it as job/not-found', async () => {
const { ctx, session, agent, controller } = await harness()
const id = ctx.jobs.start({
kind: 'bash',
label: 'flaky cancel',
owner: agent.id,
run: () => ({
cancel: () => { throw new Error('cancel boom') },
done: new Promise(() => {}),
}),
})
// The registry contract: a producer throw propagates with job state
// unchanged — the Remote must not rewrite it into a lookup failure.
expect(() => controller.kill({ sessionId: session.id, jobId: id })).toThrow('cancel boom')
expect(ctx.jobs.get(id, agent.id).status).toBe('running')
})
it('kills a child session\'s own job from its list: the owner fence is the only access rule', async () => {
const { ctx, controller } = await harness()
const child = ctx.sessions.create(undefined, { meta: { origin: 'subagent' } })
const childAgent = await registerAgent(ctx, child)
const task = producer('child work')
const id = ctx.jobs.start({ ...task.spec, owner: childAgent.id })
expect(controller.kill({ sessionId: child.id, jobId: id })).toEqual({ outcome: 'requested' })
expect(task.cancels).toEqual(['cancelled by the user'])
expect(ctx.jobs.get(id, childAgent.id).status).toBe('stopping')
})
})
@@ -253,6 +253,7 @@ function bench() {
const streams: { options: StreamOptions; stream: FakeStream<JobFollowFrame> & FakeStream<JobListFrame> }[] = []
const observeCalls: unknown[] = []
const rowsCalls: unknown[] = []
const killCalls: unknown[] = []
const remote = {
$stream: (options: StreamOptions) => {
const stream = new FakeStream<never>()
@@ -268,16 +269,26 @@ function bench() {
rowsCalls.push(request)
return { [Symbol.asyncIterator]: async function* () { /* never yields */ } }
},
kill: async (request: unknown) => {
killCalls.push(request)
return { ok: false as const, error: { code: 'job/not-found', message: 'gone' } }
},
},
}
const jobs = new ClientJobs(ctx, remote as never, model)
return { ctx, model, jobs, streams, observeCalls, rowsCalls }
return { ctx, model, jobs, streams, observeCalls, rowsCalls, killCalls }
}
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
const anchor = (): JobFollowFrame => ({ type: 'opened', job: view(), from: 0 })
describe('ClientJobs roster streams', () => {
it('forwards a kill to the job namespace and returns the Remote verdict', async () => {
const { jobs, killCalls } = bench()
await expect(jobs.kill(S1, ID)).resolves.toMatchObject({ ok: false })
expect(killCalls).toEqual([{ sessionId: 'alice', jobId: ID }])
})
it('shares one roster stream per session and drops the rows after the last release', async () => {
const { model, jobs, streams, rowsCalls } = bench()
const first = jobs.watchRows(S1)
@@ -15,6 +15,7 @@
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../core/agent" },
{ "path": "../../core/session" },
{ "path": "../../jobs/jobs" },
{ "path": "../../typert/protocol" }
@@ -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 packages/api/session-controller/README.md
README.md: 6d8479ea7a88a1a68df559b04cfd13c9f44421cd
README.zh.md: 2591bbf86fba57deabc0a4282c868d84b24ec105
README.md: 684616e7e6011c125e8c4dd4b087d551522ab339
README.zh.md: 50b37df21b360a02edbbc099a433fc74e07ba3bd
+1 -1
View File
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
## Summary
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, human background-job kill, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
## Table of Contents
+1 -1
View File
@@ -8,7 +8,7 @@ kind: "package-reference"
## 概述
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill(技能)发现和 Agent(智能体)范围的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、人工后台任务终止、工作区路径打开、用户可调用 skill(技能)发现和 Agent(智能体)范围的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
## 目录
+2 -2
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 packages/client/ui-jobs/README.md
README.md: b6c8f39d40ca4630313659a94adc305f4c379119
README.zh.md: 871948194dfcc9a5f0a9ab1f795556944b19144f
README.md: 6f2b856a970cc869676bbda57d749cf4fb7ceba0
README.zh.md: b99723e841cfb3d6c52ba0ead7e1ab4dd1952e51
+3 -2
View File
@@ -31,6 +31,8 @@ Load the plugin through the web-app manifest; it renders nothing until the sessi
The `job.list` stream `ctx.jobs` mirrors is the single roster: each `JobView` row carries lifecycle, duration, the live `progress` line or the terminal `detail`, and its retained byte count — a live job, or a settled one with retained output, is what makes a row expandable. There is no second roster to join.
Running job rows also carry a two-press stop control: the first press arms it, the confirming press within three seconds calls `ctx.jobs.kill`, and the row converges through the roster stream (`stopping`, then the settled section, whose detail carries `cancelled by the user`). The kill claims nothing in the model's notice ledger, so the owning agent still receives the standard completion notice — the model is told the user stopped its task rather than left to infer it ([decision](../../../.agents/notes/implemented/feature/2026-08-26-human-job-kill.md)). The settled section folds behind its count while live work exists and can be cleared client-side.
### The expanded panel
Expanding an observable row opens that job's output observation stream from `ctx.jobs` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream interruptions render as notices above the panel.
@@ -58,7 +60,7 @@ One slot entry in the header actions band (after the preset label, before the su
<a id="further-exploration"></a>
## Further Exploration
- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.list` and `job.follow` streams and the `ctx.jobs` service behind the rows and the panel.
- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.list` and `job.follow` streams, the `job.kill` Remote, and the `ctx.jobs` service behind the rows, the panel, and the stop control.
- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the ring and projection semantics.
- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures.
@@ -79,7 +81,6 @@ None; the package never assembles or sends provider requests.
These limits define current package constraints, not a task backlog.
- **No kill control** — cancellation stays with the model's `job_kill`.
- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up.
<a id="dev-note"></a>
+3 -2
View File
@@ -31,6 +31,8 @@ kind: "package-reference"
`ctx.jobs` 镜像的 `job.list` 流是唯一名册:每个 `JobView` 行携带生命周期、时长、实时 `progress` 行或终态 `detail`,以及其保留字节数——进行中的 job,或留有保留输出的已结束 job,就是行可展开的依据。不存在需要 join 的第二份名册。
运行中的 job 行还带一个两击式停止控件:首击武装,三秒内的确认击调用 `ctx.jobs.kill`,行状态经名册流收敛(先 `stopping`,再入已结束分组,其 detail 携带 `cancelled by the user`)。该 kill 不在模型的播报台账里认领任何东西,任务的 owner agent 因此照常收到标准完成通知——模型被明确告知用户停止了它的任务,而不是留给它去猜([决策](../../../.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md))。已结束分组在有进行中工作时折叠在其计数之后,并可在客户端清空。
### 展开的面板
展开可观察的行会从 `ctx.jobs`(由 `dsh-api-job-controller` 安装)打开该 job 的输出观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。
@@ -58,7 +60,7 @@ kind: "package-reference"
<a id="further-exploration"></a>
## 进一步探索
- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 行与面板背后的 `job.list`、`job.follow` 流与 `ctx.jobs` 服务。
- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 行、面板与停止控件背后的 `job.list`、`job.follow` 流、`job.kill` Remote 与 `ctx.jobs` 服务。
- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有环与投影语义的注册表契约。
- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。
@@ -79,7 +81,6 @@ kind: "package-reference"
这些限制界定当前包约束,不是任务清单。
- **没有 kill 控件**——取消仍归模型的 `job_kill`。
- **不渲染 channel 标签**——stdout 与 stderr 块拼接为一条流;按 channel 着色是展示层的后续工作。
<a id="dev-note"></a>
+1
View File
@@ -43,6 +43,7 @@
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
@@ -47,8 +47,8 @@
display: flex;
flex-direction: column;
gap: 1px;
width: 440px;
max-width: min(520px, calc(100vw - 32px));
width: 500px;
max-width: min(560px, calc(100vw - 32px));
max-height: min(480px, calc(100vh - 140px));
margin: 0;
padding: 3px;
@@ -66,8 +66,11 @@
/* Section labels over the live rows and the settled tail. */
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 4px 1px;
padding: 4px 4px 3px;
padding: 4px 0 3px;
border-top: 0.5px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
@@ -80,11 +83,146 @@
border-top: 0;
}
/* Fold toggle and clear action on the settled section line. */
.sectionToggle,
.sectionClear {
display: inline-flex;
gap: 3px;
align-items: center;
padding: 2px 4px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
cursor: pointer;
}
.sectionToggle:hover,
.sectionToggle:focus-visible,
.sectionClear:hover,
.sectionClear:focus-visible {
background: var(--dsw-alias-fill-l1);
color: var(--dsw-alias-label-secondary);
}
.sectionChevron {
transform: rotate(-90deg);
transition: transform 120ms ease;
}
.sectionChevronOpen {
transform: none;
}
.item {
display: flex;
flex-direction: column;
}
/* Row plus its optional kill button; the row button keeps its full-width hit
area by flexing, so non-job rows render identically to before. */
.rowLine {
display: flex;
gap: 6px;
align-items: stretch;
}
/* Live rows read as filled cards so running work outranks the settled tail;
the inner padding frames the row and both trailing controls on the fill,
and the hover wash sits on the card so the controls never escape it. */
.rowLineLive {
padding: 4px 8px 4px 2px;
border-radius: 10px;
background: var(--dsw-alias-fill-l2);
}
.rowLineLive:hover {
background: color-mix(in srgb, var(--dsw-alias-label-primary) 9%, transparent);
}
/* The kind badge shares the card fill, so it pops on the menu surface instead. */
.rowLineLive .kind {
background: var(--dsw-specific-menu);
}
.rowLine .row {
flex: 1;
min-width: 0;
}
/* The card carries the hover wash; the inner row stays transparent so the
two washes never stack, keeping keyboard focus its own visible state. */
.rowLineLive .row:hover {
background: transparent;
}
.rowLineLive .row:focus-visible {
background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent);
}
/* The expand affordance on live rows: a persistent bordered square, so the
control reads as a button before hover (it lives inside the row toggle). */
.chevronBox {
display: inline-flex;
flex: none;
align-items: center;
align-self: center;
justify-content: center;
width: 20px;
height: 20px;
border: 0.5px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-specific-menu);
}
/* Two-press kill affordance on running job rows: same persistent square. */
.stop {
display: inline-flex;
flex: none;
align-items: center;
align-self: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0.5px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-specific-menu);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.stop:hover,
.stop:focus-visible {
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
color: var(--dsw-alias-state-error-primary);
}
/* The armed press widens into a labeled danger pill so the confirm step is
legible without hover; the state-error token is the one StateDot uses. */
.stopArmed,
.stopArmed:hover,
.stopArmed:focus-visible {
gap: 4px;
width: auto;
padding: 0 7px;
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
}
.stopLabel {
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.stopFailed {
color: var(--dsw-alias-state-error-primary);
}
.row {
display: flex;
align-items: center;
@@ -223,5 +361,5 @@
}
.noticeError {
color: var(--dsw-alias-label-error, var(--dsw-alias-label-secondary));
color: var(--dsw-alias-state-error-primary);
}
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEve
import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconChevronDownOutlineRegular, StateDot, TerminalBlock, useDismissOnOutsidePointer,
IconChevronDownOutlineRegular, IconStopFillRegular, StateDot, TerminalBlock, useDismissOnOutsidePointer,
type StateDotState, type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -29,6 +29,12 @@ export interface JobListInjected {
* Reference-counted by the client service, so panels can overlap safely.
*/
observe: (sessionId: SessionId | undefined, id: JobView['id']) => () => void
/**
* Kill one background job on the human's behalf. Resolves `true` when the
* registry admitted the request (`requested` or `already-finished`); row
* state itself converges through the jobs control frames.
*/
killJob: (sessionId: SessionId, jobId: string) => Promise<boolean>
}
/** Full props for the session-header job-list action. */
@@ -43,6 +49,12 @@ const NO_JOBS: readonly JobView[] = []
/** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */
const VIEWPORT_MARGIN = 12
/** How long an armed kill waits for its confirming press before disarming. */
const KILL_ARM_MS = 3_000
/** How long a failed kill keeps its hint before the button resets. */
const KILL_FAILED_MS = 4_000
function isLive(job: JobView): boolean {
return job.status === 'running' || job.status === 'stopping'
@@ -153,14 +165,24 @@ function ordered(jobs: readonly JobView[]): JobView[] {
})
}
/**
* Two-press kill affordance state: `armed` waits for the confirming second
* press (and disarms on a timer), `pending` covers the in-flight RPC until the
* row's own status flip removes the button, `failed` shows briefly after a
* rejected kill.
*/
type KillState = 'idle' | 'armed' | 'pending' | 'failed'
/** One job row plus, when observable and expanded, its live output panel. */
function JobItem({ job, view, expanded, now, onToggle, t }: {
function JobItem({ job, view, expanded, now, onToggle, kill, t }: {
job: JobView
view: ObservedJob | undefined
expanded: boolean
/** Clock sample live rows derive their running duration from. */
now: number
onToggle: () => void
/** Present on running rows: the human-kill button state and press handler. */
kill?: { state: KillState; onPress: () => void }
t: TranslateNS<typeof NS>
}) {
const live = isLive(job)
@@ -185,15 +207,17 @@ function JobItem({ job, view, expanded, now, onToggle, t }: {
<span className={css.main}>
<span className={css.primary}>
<span className={css.label} title={job.label}>{job.label}</span>
{durationCell}
</span>
<span className={css.secondary}>
<span className={css.secondary} title={detail ?? status}>
<span className={css.kind}>{job.kind}</span>
<span className={css.status} title={detail ?? status}>{detail ?? status}</span>
{detail !== undefined ? <span className={css.status}>{detail}</span> : null}
{durationCell}
</span>
</span>
{/* A live row is always observable: its output may still arrive. */}
<IconChevronDownOutlineRegular size={14} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
<span className={css.chevronBox}>
<IconChevronDownOutlineRegular size={12} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
</span>
</>
)
: (
@@ -203,28 +227,63 @@ function JobItem({ job, view, expanded, now, onToggle, t }: {
<span className={css.label} title={job.label}>{job.label}</span>
<span className={css.status} title={detail ?? status}>{detail ?? status}</span>
{durationCell}
{observable ? <IconChevronDownOutlineRegular size={14} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} /> : null}
{observable
? (
<span className={css.chevronBox}>
<IconChevronDownOutlineRegular size={12} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
</span>
)
: null}
</>
)
const killTitle = kill === undefined
? undefined
: kill.state === 'armed'
? t('kill.confirm')
: kill.state === 'failed' ? t('kill.failed') : t('kill.stop', { label: job.label })
return (
<li className={css.item}>
{observable
? (
<button
type="button"
className={live ? css.row : `${css.row} ${css.rowSettled}`}
aria-expanded={expanded}
aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: job.label })}
onClick={onToggle}
>
{body}
</button>
)
: (
<span className={`${css.row} ${css.rowSettled} ${css.rowStatic}`}>
{body}
</span>
)}
<div className={live ? `${css.rowLine} ${css.rowLineLive}` : css.rowLine}>
{observable
? (
<button
type="button"
className={live ? css.row : `${css.row} ${css.rowSettled}`}
aria-expanded={expanded}
aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: job.label })}
onClick={onToggle}
>
{body}
</button>
)
: (
<span className={`${css.row} ${css.rowSettled} ${css.rowStatic}`}>
{body}
</span>
)}
{kill !== undefined
? (
<button
type="button"
className={
kill.state === 'armed'
? `${css.stop} ${css.stopArmed}`
: kill.state === 'failed' ? `${css.stop} ${css.stopFailed}` : css.stop
}
data-kill-state={kill.state}
disabled={kill.state === 'pending'}
aria-label={killTitle}
title={killTitle}
onClick={kill.onPress}
>
<IconStopFillRegular size={10} />
{/* The armed press must be legible without hover: the button
widens into a labeled confirm pill instead of a tint only. */}
{kill.state === 'armed' ? <span className={css.stopLabel}>{t('kill.confirmAction')}</span> : null}
</button>
)
: null}
</div>
{expanded && view !== undefined
? (
<div className={css.panel}>
@@ -257,17 +316,26 @@ function JobItem({ job, view, expanded, now, onToggle, t }: {
* session can see at least one job. Expanding an observable row (a live job,
* or a settled one with retained output) starts its observation stream, and
* collapsing (or closing the popover) stops it — output only flows while
* someone is watching.
* @param props - runtime slot currency, the jobs snapshot hook, the roster
* and observation controls, and the namespace translator.
* someone is watching. A running row carries a two-press stop button that
* requests a human kill through the job controller.
* @param props - runtime slot currency, the jobs snapshot hook, the roster,
* observation, and kill controls, and the namespace translator.
* @returns the trigger and its popover list, or null when there is nothing to show.
*/
export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: JobListActionProps) {
export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, t }: JobListActionProps) {
const jobs = useJobs(state => state.rows[sessionId]) ?? NO_JOBS
const observedViews = useJobs(state => state.observed)
const [open, setOpen] = useState(false)
const [expandedKey, setExpandedKey] = useState<string | undefined>(undefined)
const [now, setNow] = useState(() => Date.now())
// Settled-section fold: an explicit user toggle wins; before one, the tail
// folds only while live work exists (a settled-only list opens expanded).
const [settledOpen, setSettledOpen] = useState<boolean | undefined>(undefined)
// Rows the user cleared from the settled tail (client-side hide only; the
// registry keeps its records and a later settlement reappears normally).
const [clearedKeys, setClearedKeys] = useState<ReadonlySet<string>>(() => new Set())
// One kill affordance advances at a time: arming a row disarms any other.
const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude<KillState, 'idle'> } | undefined>(undefined)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLUListElement>(null)
@@ -277,7 +345,12 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
const rows = useMemo(() => ordered(jobs), [jobs])
const liveRows = useMemo(() => rows.filter(isLive), [rows])
const settledRows = useMemo(() => rows.filter(job => !isLive(job)), [rows])
const settledRows = useMemo(
() => rows.filter(job => !isLive(job) && !clearedKeys.has(String(job.id))),
[rows, clearedKeys],
)
const settledExpanded = settledOpen ?? liveRows.length === 0
const visibleCount = liveRows.length + settledRows.length
useDismissOnOutsidePointer(rootRef, open, setOpen)
@@ -330,11 +403,11 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
return observe(sessionId, activeJob)
}, [sessionId, activeJob, observe])
// The last job disappearing removes this control; close first so focus
// does not vanish from an unmounting node.
// The last visible job disappearing removes this control; close first so
// focus does not vanish from an unmounting node.
useEffect(() => {
if (rows.length === 0 && open) setOpen(false)
}, [rows.length, open])
if (visibleCount === 0 && open) setOpen(false)
}, [visibleCount, open])
// An expanded row that left the list (owner disposal) folds its panel.
useEffect(() => {
@@ -343,12 +416,64 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
}
}, [rows, expandedKey])
if (rows.length === 0) return null
// An armed kill disarms on a timer, and a failed one clears its hint; the
// pending phase instead waits for the RPC (or the row's own status flip).
// The cleanup clears the timer on every phase change, so a firing timer
// always describes the current phase and may reset unconditionally.
useEffect(() => {
if (killPhase === undefined || killPhase.state === 'pending') return
const timer = setTimeout(
() => { setKillPhase(undefined) },
killPhase.state === 'armed' ? KILL_ARM_MS : KILL_FAILED_MS,
)
return () => { clearTimeout(timer) }
}, [killPhase])
// A phase whose row stopped being killable (settled, stopping, removed)
// has no button to describe any more.
useEffect(() => {
if (killPhase !== undefined
&& !rows.some(job => String(job.id) === killPhase.key && job.status === 'running')) {
setKillPhase(undefined)
}
}, [rows, killPhase])
const pressKill = (job: JobView): void => {
const key = String(job.id)
if (killPhase?.key !== key || killPhase.state !== 'armed') {
setKillPhase({ key, state: 'armed' })
return
}
setKillPhase({ key, state: 'pending' })
void killJob(sessionId, key).then((ok) => {
// An admitted kill stays pending: the unary response (HTTP) and the jobs
// frames (control stream) have no cross-carrier ordering, so re-enabling
// here could offer a duplicate kill while the row still reads `running`.
// The authoritative jobs frame flips the row to `stopping`, which removes
// the button and clears the phase through the killable-set effect above.
setKillPhase(current => current?.key === key && !ok
? { key, state: 'failed' }
: current)
})
}
if (visibleCount === 0) return null
const countKey = liveRows.length > 0
? (liveRows.length === 1 ? 'count.live.one' : 'count.live.other')
: (rows.length === 1 ? 'count.idle.one' : 'count.idle.other')
const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : rows.length })
: (visibleCount === 1 ? 'count.idle.one' : 'count.idle.other')
const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : visibleCount })
const clearSettled = (): void => {
setClearedKeys((current) => {
const next = new Set(current)
for (const job of settledRows) next.add(String(job.id))
return next
})
if (expandedKey !== undefined && settledRows.some(job => String(job.id) === expandedKey)) {
setExpandedKey(undefined)
}
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key !== 'Escape' || !open) return
@@ -367,6 +492,14 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
onToggle={() => {
setExpandedKey(current => current === String(job.id) ? undefined : String(job.id))
}}
{...job.status === 'running'
? {
kill: {
state: killPhase?.key === String(job.id) ? killPhase.state : 'idle' as const,
onPress: () => { pressKill(job) },
},
}
: {}}
t={t}
/>
)
@@ -400,9 +533,24 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
: null}
{liveRows.map(item)}
{settledRows.length > 0
? <li className={css.sectionHeader} aria-hidden="true">{t('section.settled')}</li>
? (
<li className={css.sectionHeader}>
<button
type="button"
className={css.sectionToggle}
aria-expanded={settledExpanded}
onClick={() => { setSettledOpen(!settledExpanded) }}
>
<IconChevronDownOutlineRegular size={12} className={settledExpanded ? `${css.sectionChevron} ${css.sectionChevronOpen}` : css.sectionChevron} />
{t('section.settledCount', { count: settledRows.length })}
</button>
<button type="button" className={css.sectionClear} onClick={clearSettled}>
{t('section.clear')}
</button>
</li>
)
: null}
{settledRows.map(item)}
{settledExpanded ? settledRows.map(item) : null}
</ul>
)
: null}
+8 -4
View File
@@ -1,10 +1,11 @@
/**
* Background-job plugin, browser half: contributes one session-header action
* that renders this session's jobs. Job rows and per-row observation streams
* both arrive through the `jobs` client service; this plugin holds no
* transport state of its own.
* that renders this session's jobs. Job rows, per-row observation streams,
* and the human kill all go through the `jobs` client service; this plugin
* holds no transport state of its own.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import { JobListAction } from './JobListAction.tsx'
import type { JobListInjected } from './JobListAction.tsx'
import type {} from '@deepseek-ai/dsh-api-job-controller/client'
@@ -22,7 +23,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
export type { JobListActionProps, JobListInjected } from './JobListAction.tsx'
/** Required services: the jobs rosters and observations, the slot registry, and dictionaries. */
/** Required services: the jobs rosters, observations, and kill, the slot registry, and dictionaries. */
export const inject = ['jobs', 'slots', 'locale']
/**
@@ -44,6 +45,9 @@ export function apply(ctx: ClientContext): void {
hooks: { jobs: ctx.jobs.state },
watchRows: sessionId => ctx.jobs.watchRows(sessionId),
observe: (sessionId, id) => ctx.jobs.observe(sessionId, id),
// The brand is nominal typing only; the row key is the registry id the
// roster stream delivered, so the wire boundary stamps it back here.
killJob: async (sessionId, jobId) => (await ctx.jobs.kill(sessionId, jobId as JobId)).ok,
}),
}, JobListAction),
)
+12 -2
View File
@@ -11,9 +11,14 @@ export const zh = {
'count.idle.other': '{count} 个后台任务',
'list.aria': '后台任务',
'section.live': '进行中',
'section.settled': '已结束',
'section.settledCount': '已结束 {count}',
'section.clear': '清空',
'row.expandAria': '展开 {label} 的实时输出',
'row.collapseAria': '收起 {label} 的实时输出',
'kill.stop': '停止任务 {label}',
'kill.confirm': '再次点击确认停止',
'kill.confirmAction': '确认停止',
'kill.failed': '停止失败',
'status.running': '运行中',
'status.stopping': '正在停止',
'status.completed': '已完成',
@@ -49,9 +54,14 @@ export const en: Record<JobKey, string> = {
'count.idle.other': '{count} background jobs',
'list.aria': 'Background jobs',
'section.live': 'Running',
'section.settled': 'Finished',
'section.settledCount': 'Finished {count}',
'section.clear': 'Clear',
'row.expandAria': 'Show live output of {label}',
'row.collapseAria': 'Hide live output of {label}',
'kill.stop': 'Stop task {label}',
'kill.confirm': 'Click again to confirm',
'kill.confirmAction': 'Confirm stop',
'kill.failed': 'Stop failed',
'status.running': 'running',
'status.stopping': 'stopping',
'status.completed': 'completed',
@@ -28,6 +28,10 @@ const observed: [string | undefined, string][] = []
/** Roster watches handed to the stubbed jobs service. */
const watched: string[] = []
/** Job ids the stubbed bound session was asked to kill, and its scripted result. */
const kills: [string, string][] = []
let killResult: { ok: boolean } = { ok: true }
/** Boot the browser half over a real slot tree that declares the header list. */
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
const ctx = new Context()
@@ -48,6 +52,10 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
observed.push([sessionId, id])
return () => {}
},
kill: async (sessionId: string, jobId: string) => {
kills.push([sessionId, jobId])
return killResult
},
} as never)
// The locale plugin binds a settings scope, which reads the connection handle
// and the forwarded-event port.
@@ -69,7 +77,7 @@ describe('ui-jobs browser half', () => {
expect(inject).toEqual(['jobs', 'slots', 'locale'])
})
it('exposes the jobs source and the roster and observation controls through the inject face', async () => {
it('exposes the jobs source and the roster, observation, and kill controls through the inject face', async () => {
const { ctx } = await bench()
const entry = ctx.slots
.entries('conversation.session.header.actions')
@@ -84,6 +92,14 @@ describe('ui-jobs browser half', () => {
const stopper = face.observe(SessionId('session'), JobId('bash-1'))
expect(observed).toEqual([['session', 'bash-1']])
stopper()
// The kill control routes through the job service with the row's session
// and reports the admission verdict.
killResult = { ok: true }
await expect(face.killJob(SessionId('sess-live'), 'bash-3')).resolves.toBe(true)
killResult = { ok: false }
await expect(face.killJob(SessionId('sess-live'), 'bash-4')).resolves.toBe(false)
expect(kills).toEqual([['sess-live', 'bash-3'], ['sess-live', 'bash-4']])
})
it('registers the header action, and fiber teardown removes it (HMR safety)', async () => {
@@ -38,13 +38,14 @@ function props(
jobs: readonly JobView[],
observe: JobListActionProps['observe'] = () => () => {},
observed: Readonly<Record<string, ObservedJob>> = {},
killJob: JobListActionProps['killJob'] = async () => true,
watchRows: JobListActionProps['watchRows'] = () => () => {},
): JobListActionProps {
const jobsState: JobsSnapshot = { rows: jobs.length > 0 ? { [SESSION]: jobs } : {}, observed }
function useJobs<T>(select: (value: JobsSnapshot) => T): T {
return select(jobsState)
}
return { sessionId: SESSION, useJobs, watchRows, observe, t } as JobListActionProps
return { sessionId: SESSION, useJobs, watchRows, observe, killJob, t } as JobListActionProps
}
function openList(): void {
@@ -60,7 +61,7 @@ describe('JobListAction visibility', () => {
it('watches the session roster while mounted and releases it on unmount', () => {
const stop = vi.fn()
const watchRows = vi.fn(() => stop)
const { unmount } = render(<JobListAction {...props([], undefined, {}, watchRows)} />)
const { unmount } = render(<JobListAction {...props([], undefined, {}, undefined, watchRows)} />)
expect(watchRows).toHaveBeenCalledWith(SESSION)
expect(stop).not.toHaveBeenCalled()
unmount()
@@ -69,7 +70,7 @@ describe('JobListAction visibility', () => {
it('counts live jobs on the trigger', () => {
render(<JobListAction {...props([
job(),
outputJob(),
job({ id: 'subagent-1' as JobView['id'], kind: 'subagent', label: 'explore' }),
])} />)
expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
@@ -104,21 +105,49 @@ describe('JobListAction rows', () => {
render(<JobListAction {...props([job({ status: 'stopping', progress: 'winding down' })])} />)
openList()
const list = screen.getByRole('list', { name: zh['list.aria'] })
expect(within(list).getAllByRole('listitem')).toHaveLength(1)
expect(within(list).getByText('pnpm run build')).toBeDefined()
expect(within(list).getByText('winding down')).toBeDefined()
})
it('labels each non-empty section and drops the heading of an empty one', () => {
const settled = [job({ id: 'bash-2' as JobView['id'], status: 'completed', finishedAt: 1_700_000_012_000 })]
const { rerender } = render(<JobListAction {...props([job(), ...settled])} />)
it('folds the settled tail behind its count while live work exists, and clears on demand', () => {
const settled = [job({ id: 'bash-2' as JobView['id'], label: 'settled work', status: 'completed', finishedAt: 1_700_000_012_000 })]
render(<JobListAction {...props([job(), ...settled])} />)
openList()
expect(screen.getByText(zh['section.live'])).toBeDefined()
expect(screen.getByText(zh['section.settled'])).toBeDefined()
// A live-only list keeps its own heading and drops the settled one.
rerender(<JobListAction {...props([job()])} />)
expect(screen.getByText(zh['section.live'])).toBeDefined()
expect(screen.queryByText(zh['section.settled'])).toBeNull()
const toggle = screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') })
// Folded by default while something runs; the toggle expands it.
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('settled work')).toBeNull()
fireEvent.click(toggle)
expect(screen.getByText('settled work')).toBeDefined()
// Clearing hides the tail client-side and drops the whole section line.
fireEvent.click(screen.getByRole('button', { name: zh['section.clear'] }))
expect(screen.queryByText('settled work')).toBeNull()
expect(screen.queryByRole('button', { name: zh['section.clear'] })).toBeNull()
})
it('opens the settled tail by default when nothing is live', () => {
render(<JobListAction {...props([
job({ status: 'completed', finishedAt: 1_700_000_012_000 }),
])} />)
openList()
expect(screen.getByText('pnpm run build')).toBeDefined()
expect(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }).getAttribute('aria-expanded')).toBe('true')
})
it('clearing drops an expanded settled panel with its rows', () => {
const observe = vi.fn(() => () => {})
render(<JobListAction {...props(
[outputJob({ id: 'bash-2' as JobView['id'], label: 'settled work', status: 'completed', finishedAt: 1_700_000_012_000 })],
observe,
)} />)
openList()
// Nothing live: the tail is open; expand the settled row's output panel.
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'settled work') }))
expect(observe).toHaveBeenCalledWith(SESSION, 'bash-2')
fireEvent.click(screen.getByRole('button', { name: zh['section.clear'] }))
expect(screen.queryByText('settled work')).toBeNull()
expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'settled work') })).toBeNull()
})
it('shows a settled duration and ticks a live one', () => {
@@ -130,6 +159,7 @@ describe('JobListAction rows', () => {
job({ id: 'bash-4' as JobView['id'], status: 'completed', startedAt: 1_699_999_900_000, finishedAt: 1_699_999_972_000 }),
])} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '3') }))
expect(screen.getByText('5秒')).toBeDefined()
expect(screen.getByText('12秒')).toBeDefined()
expect(screen.getByText('1小时3分')).toBeDefined()
@@ -138,18 +168,22 @@ describe('JobListAction rows', () => {
expect(screen.getByText('6秒')).toBeDefined()
})
it('lists rows with kind, label, and progress, terminal detail, or status', () => {
it('lists rows with kind, label, and detail-or-status', () => {
render(<JobListAction {...props([
job(),
outputJob(),
outputJob({ id: 'bash-2' as JobView['id'], status: 'failed', detail: 'exit code: 3', finishedAt: 1_700_000_002_000 }),
])} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }))
const list = screen.getByRole('list', { name: zh['list.aria'] })
const items = within(list).getAllByRole('listitem')
expect(items).toHaveLength(2)
// The section line is a listitem too now: live row, section, settled row.
expect(items).toHaveLength(3)
expect(items[0]?.textContent).toContain('pnpm run build')
expect(items[0]?.textContent).toContain(zh['status.running'])
expect(items[1]?.textContent).toContain('exit code: 3')
// A live row's second line is kind · duration; the status word only
// appears through a detail (none while running).
expect(items[0]?.textContent).toMatch(/小时|分|秒/)
expect(items[2]?.textContent).toContain('exit code: 3')
})
it('orders live rows first by start and settled rows newest-first with tie-breaks', () => {
@@ -171,9 +205,11 @@ describe('JobListAction rows', () => {
job({ id: 'bash-l2' as JobView['id'], label: 'live-2', startedAt: 3 }),
])} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '5') }))
const labels = within(screen.getByRole('list', { name: zh['list.aria'] }))
.getAllByRole('listitem')
.map(item => item.querySelector('[title]')?.getAttribute('title'))
.filter((title): title is string => title != null && !title.startsWith('已运行') && !title.startsWith('耗时'))
expect(labels).toEqual([
'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e',
])
@@ -186,7 +222,7 @@ describe('JobListAction rows', () => {
job({ id: 'bash-1' as JobView['id'], label: 'first', status: 'completed', startedAt: 1_700_000_000_000, finishedAt: 1_700_000_000_000 + 100 }),
])} />)
openList()
expect(within(screen.getByRole('list')).getAllByRole('listitem').map(row => row.querySelector('[title]')?.getAttribute('title')))
expect(within(screen.getByRole('list')).getAllByRole('listitem').map(row => row.querySelector('[title]')?.getAttribute('title')).filter(title => title !== undefined))
.toEqual(['first', 'second'])
})
@@ -195,10 +231,10 @@ describe('JobListAction rows', () => {
job({ status: 'killed', detail: 'signal: SIGTERM', finishedAt: 1_700_000_000_000 + 2_000 }),
])} />)
openList()
expect(within(screen.getByRole('list')).getAllByRole('listitem')[0]?.textContent).toContain('signal: SIGTERM')
expect(within(screen.getByRole('list')).getByText('signal: SIGTERM')).toBeDefined()
})
it('renders every status word, including the stopping transition', () => {
it('renders settled status words and every lifecycle indicator', () => {
render(<JobListAction {...props([
job({ id: 'bash-1' as JobView['id'], label: 'a', status: 'running' }),
job({ id: 'bash-2' as JobView['id'], label: 'b', status: 'stopping' }),
@@ -207,7 +243,8 @@ describe('JobListAction rows', () => {
job({ id: 'bash-5' as JobView['id'], label: 'e', status: 'failed', finishedAt: 1_700_000_000_000 }),
])} />)
openList()
for (const word of ['运行中', '正在停止', '已完成', '已取消', '已失败']) {
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '3') }))
for (const word of ['已完成', '已取消', '已失败']) {
expect(within(screen.getByRole('list')).getByText(word)).toBeDefined()
}
expect([...screen.getByRole('list').querySelectorAll('li [data-state]')].map(node => node.getAttribute('data-state')))
@@ -251,7 +288,7 @@ describe('JobListAction observation', () => {
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true })
try {
render(<JobListAction {...props([job()])} />)
render(<JobListAction {...props([outputJob()])} />)
openList()
const menu = screen.getByRole('list', { name: zh['list.aria'] })
// 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin.
@@ -272,7 +309,7 @@ describe('JobListAction observation', () => {
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true })
try {
render(<JobListAction {...props([job()])} />)
render(<JobListAction {...props([outputJob()])} />)
openList()
const menu = screen.getByRole('list', { name: zh['list.aria'] })
// max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin.
@@ -307,7 +344,7 @@ describe('JobListAction observation', () => {
gapBefore: false,
streaming: true,
}
render(<JobListAction {...props([job()], observe, { 'bash-1': view })} />)
render(<JobListAction {...props([outputJob()], observe, { 'bash-1': view })} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1')
@@ -339,7 +376,7 @@ describe('JobListAction observation', () => {
streaming: false,
error: 'connection lost',
}
render(<JobListAction {...props([job()], undefined, { 'bash-1': view })} />)
render(<JobListAction {...props([outputJob()], undefined, { 'bash-1': view })} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
expect(screen.getByText(zh['output.gap'])).toBeDefined()
@@ -348,7 +385,7 @@ describe('JobListAction observation', () => {
it('stops observation when the popover closes via Escape', () => {
const stop = vi.fn()
render(<JobListAction {...props([job()], () => stop)} />)
render(<JobListAction {...props([outputJob()], () => stop)} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' })
@@ -360,11 +397,122 @@ describe('JobListAction observation', () => {
const stop = vi.fn()
// One stable observe identity across renders, as the inject face provides.
const observe = () => stop
const { rerender } = render(<JobListAction {...props([job()], observe)} />)
const { rerender } = render(<JobListAction {...props([outputJob()], observe)} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
rerender(<JobListAction {...props([job({ id: 'bash-2' as JobView['id'], label: 'other' })], observe)} />)
rerender(<JobListAction {...props([outputJob({ id: 'bash-2' as JobView['id'], label: 'other' })], observe)} />)
expect(stop).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull()
})
})
describe('JobListAction human kill', () => {
const stopTitle = (label: string): string => zh['kill.stop'].replace('{label}', label)
it('offers the stop control only on running rows', () => {
render(<JobListAction {...props([
job(),
job({ id: 'bash-2' as JobView['id'], label: 'done', status: 'completed', finishedAt: 1_700_000_100_000 }),
])} />)
openList()
expect(screen.getByTitle(stopTitle('pnpm run build'))).toBeDefined()
expect(screen.queryByTitle(stopTitle('done'))).toBeNull()
})
it('arms on the first press and kills on the confirming press', async () => {
const killJob = vi.fn(async () => true)
render(<JobListAction {...props([job()], undefined, {}, killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(killJob).not.toHaveBeenCalled()
expect(stop.getAttribute('data-kill-state')).toBe('armed')
expect(screen.getByTitle(zh['kill.confirm'])).toBe(stop)
// The armed step is legible without hover: the button carries the label.
expect(stop.textContent).toBe(zh['kill.confirmAction'])
await act(async () => { fireEvent.click(stop) })
expect(killJob).toHaveBeenCalledWith(SESSION, 'bash-1')
// An admitted kill stays pending: the unary response and the jobs frames
// have no cross-carrier ordering, so only the authoritative frame (the row
// leaving the killable set) releases the control — never the response.
expect(stop.getAttribute('data-kill-state')).toBe('pending')
expect(stop.hasAttribute('disabled')).toBe(true)
})
it('an armed press disarms after the confirmation window', () => {
vi.useFakeTimers()
const killJob = vi.fn(async () => true)
render(<JobListAction {...props([job()], undefined, {}, killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(stop.getAttribute('data-kill-state')).toBe('armed')
act(() => { vi.advanceTimersByTime(3_000) })
expect(stop.getAttribute('data-kill-state')).toBe('idle')
expect(killJob).not.toHaveBeenCalled()
})
it('a rejected kill shows its hint and then resets', async () => {
// Fake timers from the start so the failed-hint reset arms on the fake clock.
vi.useFakeTimers()
const killJob = vi.fn(async () => false)
render(<JobListAction {...props([job()], undefined, {}, killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
await act(async () => { fireEvent.click(stop) })
expect(stop.getAttribute('data-kill-state')).toBe('failed')
expect(screen.getByTitle(zh['kill.failed'])).toBe(stop)
act(() => { vi.advanceTimersByTime(4_000) })
expect(stop.getAttribute('data-kill-state')).toBe('idle')
})
it('arming a second row disarms the first', () => {
render(<JobListAction {...props([
job(),
job({ id: 'bash-2' as JobView['id'], label: 'pnpm run watch' }),
])} />)
openList()
const first = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(first)
expect(first.getAttribute('data-kill-state')).toBe('armed')
const second = screen.getByTitle(stopTitle('pnpm run watch'))
fireEvent.click(second)
expect(second.getAttribute('data-kill-state')).toBe('armed')
expect(first.getAttribute('data-kill-state')).toBe('idle')
})
it('a kill resolving after another row armed leaves the newer phase alone', async () => {
let resolveKill!: (ok: boolean) => void
const killJob = vi.fn(() => new Promise<boolean>((resolve) => { resolveKill = resolve }))
render(<JobListAction {...props([
job(),
job({ id: 'bash-2' as JobView['id'], label: 'pnpm run watch' }),
], undefined, {}, killJob)} />)
openList()
const first = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(first)
fireEvent.click(first)
expect(killJob).toHaveBeenCalledTimes(1)
// While the first kill is in flight, the user arms the second row; the
// late resolution must not clobber that newer phase.
const second = screen.getByTitle(stopTitle('pnpm run watch'))
fireEvent.click(second)
expect(second.getAttribute('data-kill-state')).toBe('armed')
await act(async () => { resolveKill(false) })
expect(second.getAttribute('data-kill-state')).toBe('armed')
expect(first.getAttribute('data-kill-state')).toBe('idle')
})
it('clears an armed phase whose row stopped being killable', () => {
const { rerender } = render(<JobListAction {...props([job()])} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(stop.getAttribute('data-kill-state')).toBe('armed')
rerender(<JobListAction {...props([job({ status: 'stopping' })])} />)
// The stopping row offers no kill control any more, and the stale phase is gone.
expect(screen.queryByTitle(stopTitle('pnpm run build'))).toBeNull()
expect(document.querySelector('[data-kill-state]')).toBeNull()
})
})
+1 -1
View File
@@ -134,7 +134,7 @@ async function queryTmuxLocation(
].join('\n')
let result: ShellRunResult
try {
result = await bash.run(bash.resolve({ command, signal }))
result = await (await bash.execute(bash.resolve({ command, signal }))).result()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logger.warn(`tmux location query failed: ${message}; injecting no location this turn`)
@@ -5,11 +5,14 @@ import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
import type { Config } from '@deepseek-ai/dsh-tmux-context'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
/** Empty offset readers for fakes that never produce output. */
const silentReader = { readFrom: (fromByte: number) => ({ text: '', nextOffset: fromByte, lossy: false }) }
const SIGNAL = new AbortController().signal
/** One `#{...}`-joined tmux reading line for the eight queried fields. */
@@ -61,18 +64,25 @@ class FakeBash extends ShellExecutor {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
onExpiry: request.onExpiry ?? 'kill',
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
override async execute(spec: ShellExecSpec): Promise<ShellExecution> {
if (spec.onExpiry === 'none') throw new Error('tmux-context must never start a background job')
this.commands.push(spec.command)
if (this.runError) throw this.runError
return this.result
}
override async start(): Promise<ShellProcess> {
throw new Error('tmux-context must never start a background job')
return {
status: 'completed',
exitCode: 0,
signal: null,
done: Promise.resolve(),
readOutput: () => ({ delta: '', lossy: false }),
observed: { stdout: silentReader, stderr: silentReader },
kill: () => false,
result: () => this.runError ? Promise.reject(this.runError) : Promise.resolve(this.result),
}
}
}
@@ -9,6 +9,13 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { MemoryVfs } from '../../src/storage/memory.ts'
import { setActiveVfs } from '../../src/storage/active.ts'
import { processAlive, signalProcess } from '../../src/node/process-table.ts'
import type { ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
}
vi.mock('node:child_process', async () => await import('../../src/node/builtin_modules/implemented/child_process.ts'))
@@ -55,14 +62,14 @@ async function setup(mode: 'read-only' | 'workspace-write' | 'danger-full-access
describe('Worker Landlock through the production sandbox stack', () => {
it('allows workspace and temp writes while classifying an outside write as denied', async () => {
const bash = await setup('workspace-write')
const allowed = await bash.run(bash.resolve({
const allowed = await run(bash, bash.resolve({
command: `echo workspace > ${WORKSPACE}/allowed.txt; echo temp > /tmp/allowed.txt`,
}))
expect(allowed.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(vfs.readFileSync(`${WORKSPACE}/allowed.txt`, 'utf8')).toBe('workspace\n')
expect(vfs.readFileSync('/dsh/tmp/allowed.txt', 'utf8')).toBe('temp\n')
const denied = await bash.run(bash.resolve({ command: `echo denied > ${OUTSIDE}/denied.txt` }))
const denied = await run(bash, bash.resolve({ command: `echo denied > ${OUTSIDE}/denied.txt` }))
expect(denied.exitCode).toBe(1)
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
expect(vfs.existsSync(`${OUTSIDE}/denied.txt`)).toBe(false)
@@ -70,24 +77,24 @@ describe('Worker Landlock through the production sandbox stack', () => {
it('keeps read-only confined and danger-full-access unwrapped', async () => {
const readOnly = await setup('read-only')
const strict = await readOnly.run(readOnly.resolve({
const strict = await run(readOnly, readOnly.resolve({
command: `echo discarded > /dev/null; echo denied > ${WORKSPACE}/strict.txt`,
}))
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false)
const unrestricted = await setup('danger-full-access')
const result = await unrestricted.run(unrestricted.resolve({ command: `echo allowed > ${OUTSIDE}/full.txt` }))
const result = await run(unrestricted, unrestricted.resolve({ command: `echo allowed > ${OUTSIDE}/full.txt` }))
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
expect(vfs.readFileSync(`${OUTSIDE}/full.txt`, 'utf8')).toBe('allowed\n')
})
it('does not leak a concurrent command policy into another process', async () => {
const bash = await setup('read-only')
const strict = bash.run(bash.resolve({
const strict = run(bash, bash.resolve({
command: `sleep 0.02; echo denied > ${WORKSPACE}/strict.txt`,
}))
const writable = bash.run(bash.resolve({
const writable = run(bash, bash.resolve({
command: `echo allowed > ${WORKSPACE}/writable.txt`,
sandboxPolicy: { mode: 'workspace-write', workspaceRoot: WORKSPACE },
}))
@@ -1178,12 +1178,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }],
returns: 'anchor, coalesced output frames, and the terminal status.',
},
{
signature: '@Remote(\'kill\') kill(request: JobKillRequest): JobKillValue',
description: 'Kill one background job on a human\'s behalf. The request\'s session is the fenced read\'s caller, so the job must be one that session can see: the registry\'s owner fence is the only access rule, and a child session\'s own jobs are killable from its list like any other. The kill records `cancelled by the user` as its reason; it is not one the model requested, so the owning agent still receives the completion notice, and a shell tool waiting on that job reads the reason in its own result.',
parameters: [{ name: 'request', description: 'Session whose job list carries the job, and the job id.' }],
returns: 'the registry\'s admission of the kill request.',
},
],
},
{
key: 'jobs',
summary: 'Abstract background job registry.',
description: 'Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: \'teardown\'`, because a job whose owner is being destroyed has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: \'scope\' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model\'s consuming cursor and observers\' absolute offsets read the same bytes and never disturb each other.\n- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.',
description: 'Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: \'teardown\'`, because a job whose owner is being destroyed has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter and reports whether it released one (`awaited`), so a completion reporter can skip settlements a waiting caller already collected.\n- A settled record stays listed until its owner\'s disposal, service disposal, or an explicit remove by a caller that collected the terminal state itself and never handed the id out.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: \'scope\' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model\'s consuming cursor and observers\' absolute offsets read the same bytes and never disturb each other.\n- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.',
methods: [
{
signature: 'abstract readonly events: JobEvents',
@@ -1232,6 +1238,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'id', description: 'job to wait for.' }, { name: 'timeoutMs', description: 'positive finite wait bound in milliseconds.' }, { name: 'caller', description: 'waiting session checked against the owner.' }, { name: 'signal', description: 'optional cancellation of the wait itself.' }],
returns: 'projection at settlement or timeout.',
},
{
signature: 'abstract remove(id: JobId, caller?: SessionId): void',
description: 'Drop one settled job\'s record from the visible set and announce `removed`. For a caller that collected the terminal state through its own wait and never handed the id to the model, such as a shell tool\'s foreground call. Throws for a job that is still live, unknown, or foreign.',
parameters: [{ name: 'id', description: 'settled job to drop.' }, { name: 'caller', description: 'removing session checked against the owner.' }],
},
{
signature: 'abstract attachController(name: string): () => void',
description: 'Attach an effect-scoped controller that can read and stop jobs. It serves the owners its registering context\'s scope covers, and start refuses an owner no attached controller serves.',
@@ -2344,27 +2355,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'shell',
summary: 'Abstract bash execution service.',
description: 'Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.\n- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.\n- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.\n- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.',
description: 'Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nexecute resolves with the process handle after preparation. "Foreground" is a property of what the caller awaits, not of the spawn — a caller that awaits ShellExecution.result ran the command in the foreground; one that keeps the handle ran it in the background. A caller that waits only for a while runs the command under `onExpiry: \'none\'` and bounds its own wait; the handle stays valid after the caller stops waiting.\n\nImplementations must honor these semantics:\n\n- ShellExecution.result rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a descriptive result: first-cause `timedOut`/`aborted`, the spec\'s `timeoutMs` echoed.\n- The handle is published after preparation. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on the read path, while `result()` carries the same failure as its rejection.\n- `onExpiry: \'none\'` arms no deadline; `\'kill\'` kills at expiry. Expiry during preparation returns a settled timed-out handle without output.\n- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.\n- A still-running process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a process survives an executor-only reload.',
methods: [
{
signature: 'abstract resolve(request: ShellExecRequest): ShellExecSpec',
description: 'Apply implementation-owned defaults and caps to a request before execution.',
parameters: [{ name: 'request', description: 'the caller\'s request; omitted fields get this implementation\'s defaults, capped fields are clamped.' }],
returns: 'the fully-specified spec to hand to {@link run}/{@link start}.',
returns: 'the fully-specified spec to hand to {@link execute}.',
},
{
signature: 'abstract run(spec: ShellExecSpec): Promise<ShellRunResult>',
description: 'Run preparation and the foreground command under the resolved timeout.',
signature: 'abstract execute(spec: ShellExecSpec): Promise<ShellExecution>',
description: 'Prepare and spawn the command under its resolved deadline.',
parameters: [{ name: 'spec', description: 'a resolved spec from {@link resolve}, never a raw request.' }],
returns: 'the outcome; nonzero exits, timeout kills, and abort kills resolve with a descriptive result rather than reject.',
returns: 'the prepared handle, including its result projection; preparation timeout yields an already-settled handle with no output.',
throws: ['on preparation failure or caller cancellation before process publication.'],
},
{
signature: 'abstract start(spec: ShellExecSpec): Promise<ShellProcess>',
description: 'Prepare a background process asynchronously and publish its live handle.',
parameters: [{ name: 'spec', description: 'a resolved spec from {@link resolve}, never a raw request.' }],
returns: 'the live process handle after preparation; cancellation or setup failure rejects.',
},
],
},
{
@@ -5027,7 +5032,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'JobEvent',
declaration: 'export type JobEvent = {\n readonly type: \'registered\' | \'progress\' | \'stopping\' | \'removed\';\n readonly job: JobView;\n} | {\n readonly type: \'settled\';\n readonly job: JobView;\n readonly cause: JobSettleCause;\n} | {\n readonly type: \'output\';\n readonly id: JobId;\n readonly owner?: SessionId;\n readonly total: number;\n};',
declaration: 'export type JobEvent = {\n readonly type: \'registered\' | \'progress\' | \'stopping\' | \'removed\';\n readonly job: JobView;\n} | {\n readonly type: \'settled\';\n readonly job: JobView;\n readonly cause: JobSettleCause;\n readonly awaited: boolean;\n} | {\n readonly type: \'output\';\n readonly id: JobId;\n readonly owner?: SessionId;\n readonly total: number;\n};',
},
{
name: 'JobEventFilter',
@@ -5061,6 +5066,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'JobId',
declaration: 'export type JobId = Branded<\'JobId\'>;',
},
{
name: 'JobKillRequest',
declaration: 'export interface JobKillRequest {\n readonly sessionId: SessionId;\n readonly jobId: JobId;\n}',
},
{
name: 'JobKillValue',
declaration: 'export interface JobKillValue {\n readonly outcome: \'requested\' | \'already-finished\';\n}',
},
{
name: 'JobKind',
declaration: 'export type JobKind = JobKindMap[keyof JobKindMap];',
@@ -6435,11 +6448,19 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ShellExecRequest',
declaration: 'export interface ShellExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}',
declaration: 'export interface ShellExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n onExpiry?: ShellExpiryPolicy | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}',
},
{
name: 'ShellExecSpec',
declaration: 'export interface ShellExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}',
declaration: 'export interface ShellExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n onExpiry: ShellExpiryPolicy;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}',
},
{
name: 'ShellExecution',
declaration: 'export interface ShellExecution extends ShellProcess {\n result(): Promise<ShellRunResult>;\n}',
},
{
name: 'ShellExpiryPolicy',
declaration: 'export type ShellExpiryPolicy = \'kill\' | \'none\';',
},
{
name: 'ShellObservedStreams',
+2 -2
View File
@@ -65,7 +65,7 @@ export interface RunHookResult {
* @returns the decoded output plus the run's wall-clock duration.
*/
export async function runHook(
bash: ShellExecutor,
bash: Pick<ShellExecutor, 'resolve' | 'execute'>,
hook: CommandHook,
options: RunHookOptions,
now: () => number,
@@ -84,7 +84,7 @@ export async function runHook(
}
try {
const result = await bash.run(bash.resolve(request))
const result = await (await bash.execute(bash.resolve(request))).result()
// ShellRunResult.exitCode is `number | null` (null = died by signal); the
// protocol's exit-code contract is numeric, so a signal death maps to
// `undefined` (a non-blocking error — no clean exit code to act on).
@@ -1,21 +1,22 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import type { ShellExecRequest, ShellExecSpec, ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell'
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
import type { RunHookOptions } from '@deepseek-ai/dsh-hook-protocol'
/**
* A minimal stand-in for the bits of {@link ShellExecutor} that {@link runHook}
* actually calls (`resolve` then `run`). `runHook` is pure plumbing over those
* two methods, so a duck-typed recorder is the right test hook — the REAL
* executor (dsh-bash-local) is exercised end-to-end by the hook-bridge plugins
* that consume this library, not here.
* actually calls (`resolve` then `execute().result()`). `runHook` is pure
* plumbing over those two methods, so a duck-typed recorder is the right test
* hook — the REAL executor (dsh-bash-local) is exercised end-to-end by the
* hook-bridge plugins that consume this library, not here.
*/
function recordingBash(run: (spec: ShellExecSpec) => Promise<ShellRunResult>): {
bash: ShellExecutor
bash: Pick<ShellExecutor, 'resolve' | 'execute'>
specs: ShellExecSpec[]
} {
const specs: ShellExecSpec[] = []
const bash = {
const reader = { readFrom: (from: number) => ({ text: '', nextOffset: from, lossy: false }) }
const bash: Pick<ShellExecutor, 'resolve' | 'execute'> = {
resolve(request: ShellExecRequest): ShellExecSpec {
// Carry the request through verbatim, defaulting the required spec fields —
// exactly what dsh-bash-local's resolve does for the fields runHook sets.
@@ -23,6 +24,7 @@ function recordingBash(run: (spec: ShellExecSpec) => Promise<ShellRunResult>): {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 0,
onExpiry: request.onExpiry ?? 'kill',
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
@@ -30,11 +32,21 @@ function recordingBash(run: (spec: ShellExecSpec) => Promise<ShellRunResult>): {
sandboxPolicy: request.sandboxPolicy,
}
},
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
async execute(spec: ShellExecSpec): Promise<ShellExecution> {
specs.push(spec)
return run(spec)
// Only `result()` is consulted; the live-handle members are inert.
return {
status: 'completed',
exitCode: 0,
signal: null,
done: Promise.resolve(),
readOutput: () => ({ delta: '', lossy: false }),
observed: { stdout: reader, stderr: reader },
kill: () => false,
result: () => run(spec),
}
},
} as unknown as ShellExecutor
}
return { bash, specs }
}
@@ -628,7 +628,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// Force the executor to reject (an infrastructure fault) so runHook's catch
// yields a HookOutput with exitCode undefined → the `exitCode` spread false arm.
const bash = ctx.shell
bash.run = (() => Promise.reject(new Error('executor down')))
bash.execute = (() => ({ result: () => Promise.reject(new Error('executor down')) }) as never)
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
@@ -493,7 +493,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n') }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.shell.run = (() => Promise.reject(new Error('executor down')))
ctx.shell.execute = (() => ({ result: () => Promise.reject(new Error('executor down')) }) as never)
ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
+13 -6
View File
@@ -333,6 +333,12 @@ export class LocalJobRegistry extends JobRegistry {
return this.waitJob(this.expect(id, caller), timeoutMs, signal)
}
remove(id: JobId, caller?: SessionId): void {
const job = this.expect(id, caller)
if (!isTerminal(job.status)) throw new Error(`job ${id} is still ${job.status}; kill it and wait for settlement before removing it`)
this.drop([job])
}
attachController(name: string): () => void {
// One token per call keeps duplicate labels independently disposable.
const token = Symbol(name)
@@ -564,8 +570,9 @@ export class LocalJobRegistry extends JobRegistry {
/**
* Record the first terminal outcome, release waiters, then announce the
* settlement. First-wins preserves a teardown force-failure against late
* producer settlement. The settled event follows every released waiter, so
* a consumer that claims while waiting always claims before the event.
* producer settlement. The settled event follows every released waiter and
* reports whether it released one: a timed-out or aborted wait has already
* left the set, so only a wait still owed the projection counts.
*/
private settle(job: TrackedJob, outcome: JobOutcome, cause: JobSettleCause): void {
if (isTerminal(job.status)) return
@@ -593,7 +600,7 @@ export class LocalJobRegistry extends JobRegistry {
job.waitResolvers.clear()
for (const resolveWait of waitResolvers) resolveWait()
job.markSettled()
this.emit({ type: 'settled', job: this.view(job), cause }, job.owner)
this.emit({ type: 'settled', job: this.view(job), cause, awaited: waitResolvers.length > 0 }, job.owner)
// The ring's stream ends with settlement; the signal follows the committed
// settlement so an observer that wakes on it reads the terminal state.
this.emitOutput(job)
@@ -619,11 +626,11 @@ export class LocalJobRegistry extends JobRegistry {
const owned = [...this.store.values()].filter(job => job.owner === owner)
this.cancelForTeardown(owned, 'owner disposed')
await Promise.all(owned.map(job => job.settled))
this.remove(owned)
this.drop(owned)
}
/** Drop settled records and announce each removal, the one visible-set change no per-job record carries. */
private remove(jobs: readonly TrackedJob[]): void {
private drop(jobs: readonly TrackedJob[]): void {
for (const job of jobs) {
this.store.delete(job.id)
this.emit({ type: 'removed', job: this.view(job) }, job.owner)
@@ -642,7 +649,7 @@ export class LocalJobRegistry extends JobRegistry {
// stream registers from its own context — is still reachable here.
// Without the removals it keeps the rows it last received after a
// registry reload.
this.remove(all)
this.drop(all)
// Detach cross-fiber owner effects after the shared store is quiescent.
const ownerCleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
@@ -602,6 +602,83 @@ describe('LocalJobRegistry.wait', () => {
})
})
describe('LocalJobRegistry settled event awaited flag', () => {
it('reports a settlement that released a live wait as awaited', async () => {
const ctx = await harness()
const seen = collect(ctx, { owners: 'all' }, ['settled'])
const p = producer()
const id = ctx.jobs.start(p.spec)
const wait = ctx.jobs.wait(id, 5_000)
p.settle({ status: 'completed' })
await wait
expect(seen).toMatchObject([{ type: 'settled', awaited: true }])
})
it('reports a settlement nobody waited on, or whose waits timed out or aborted, as not awaited', async () => {
const ctx = await harness()
const seen = collect(ctx, { owners: 'all' }, ['settled'])
const unwatched = producer()
const unwatchedId = ctx.jobs.start(unwatched.spec)
unwatched.settle({ status: 'completed' })
await tick()
const abandoned = producer()
const abandonedId = ctx.jobs.start(abandoned.spec)
await ctx.jobs.wait(abandonedId, 5)
const controller = new AbortController()
const aborted = ctx.jobs.wait(abandonedId, 5_000, undefined, controller.signal)
controller.abort()
await expect(aborted).rejects.toThrow('wait aborted')
abandoned.settle({ status: 'completed' })
await tick()
expect(seen).toMatchObject([
{ type: 'settled', job: { id: unwatchedId }, awaited: false },
{ type: 'settled', job: { id: abandonedId }, awaited: false },
])
})
it('counts a wait still owed the projection even when another wait on the job timed out', async () => {
const ctx = await harness()
const seen = collect(ctx, { owners: 'all' }, ['settled'])
const p = producer()
const id = ctx.jobs.start(p.spec)
const patient = ctx.jobs.wait(id, 5_000)
await ctx.jobs.wait(id, 5)
p.settle({ status: 'completed' })
await patient
expect(seen).toMatchObject([{ type: 'settled', awaited: true }])
})
})
describe('LocalJobRegistry.remove', () => {
it('drops a settled record from the visible set and announces the removal', async () => {
const ctx = await harness()
const seen = collect(ctx, { owners: 'all' }, ['removed'])
const p = producer()
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
ctx.jobs.remove(id)
expect(ctx.jobs.list()).toEqual([])
expect(() => ctx.jobs.get(id)).toThrow(`unknown job ${id}`)
expect(seen).toMatchObject([{ type: 'removed', job: { id, status: 'completed', detail: 'exit code: 0' } }])
})
it('refuses a live job, an unknown id, and a foreign caller', async () => {
const ctx = await harness()
const owner = await liveAgent(ctx, 'owner')
const p = producer({ owner })
const id = ctx.jobs.start(p.spec)
expect(() => { ctx.jobs.remove(id, owner.id) }).toThrow(`job ${id} is still running`)
expect(() => { ctx.jobs.remove(id) }).toThrow('belongs to another session')
expect(() => { ctx.jobs.remove(JobId('bash-99'), owner.id) }).toThrow('unknown job bash-99')
p.settle({ status: 'completed' })
await tick()
ctx.jobs.remove(id, owner.id)
expect(ctx.jobs.list(owner.id)).toEqual([])
})
})
describe('LocalJobRegistry owner isolation', () => {
it('fences every view operation to the owning session and keeps unowned jobs open', async () => {
const ctx = await harness()
+2 -2
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 packages/jobs/jobs/README.md
README.md: d5c67eccec42a7eb10179495f0397bf394c8d6ff
README.zh.md: 41899dcd018c0d33ff42a1c3d5b521317c129e11
README.md: 1c7a933d6fa1bda03b37bf3768ac9798cb7d1325
README.zh.md: a36924fe3ffbfa83e565e0aeecfcb6ffbeb0519d
+3 -3
View File
@@ -69,7 +69,7 @@ This section explains the design decisions behind the contract and points at the
- **Contract and implementation are separate packages.** `JobRegistry` is an abstract Cordis service; loading the class directly throws, so a misconfigured composition fails at load instead of registering an empty `ctx.jobs`.
- **One registry per process, owner-relative answers.** One instance serves every composition in the process, so registrations and deliveries are relative to the registering scope: a controller or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it.
- **Access is fenced by the owner's session id.** Ids are predictable, so authorization — not secrecy — is the boundary.
- **Settlement is first-wins, and its event follows every released waiter.** One terminal record, released waiters, then one round of contained event delivery; a consumer that claims a settlement while waiting therefore always claims before the event, so `dsh-tool-jobs` never announces a completion the model already collected.
- **Settlement is first-wins, and its event follows every released waiter.** One terminal record, released waiters, then one round of contained event delivery; the `settled` event reports whether it released a live `wait` (`awaited`), so `dsh-tool-jobs` never announces a completion a waiting caller already collected, whichever plugin was waiting.
- **Registrations outlive producer and controller fibers.** Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record.
### Source map
@@ -84,7 +84,7 @@ This section explains the design decisions behind the contract and points at the
### Service operations
Each read or control operation accepts an optional caller `SessionId`; omitting it permits only unowned jobs: `list` and `get` return fresh projections, `read` advances the model's cursor and hands out the producer's result once after settlement, `readAt` reads retained chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status and records the reason for the terminal `detail`, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; `events.subscribe` delivers registration, progress, stopping, settlement, removal, and output commits at owner, scope, or process granularity.
Each read or control operation accepts an optional caller `SessionId`; omitting it permits only unowned jobs: `list` and `get` return fresh projections, `read` advances the model's cursor and hands out the producer's result once after settlement, `readAt` reads retained chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status and records the reason for the terminal `detail`, `wait` blocks up to a timeout, `remove` drops a settled record a caller collected through its own wait and never handed out, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; `events.subscribe` delivers registration, progress, stopping, settlement, removal, and output commits at owner, scope, or process granularity.
</details>
@@ -123,7 +123,7 @@ These limits define when the contract is a poor fit. They are current package co
- **The contract is in-process** — `JobSpec.run()` passes callbacks and the registry resolves the live `Agent` behind the owner session; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam.
- **The model's cursor is the only consuming read** — independent observers use the non-consuming `readAt` and never move it.
- **Foreground work cannot be promoted** — producers choose foreground or background before starting.
- **A settled record stays listed until it is removed** — by its owner's disposal, service disposal, or an explicit `remove` from the caller that collected it; the registry keeps no retention count of settled jobs.
<a id="dev-note"></a>
### Dev Note
+3 -3
View File
@@ -69,7 +69,7 @@ kind: "package-reference"
- **约定与实现分属不同包。** `JobRegistry` 是抽象 Cordis 服务;直接加载该类会抛出异常,因此错误配置的组合会在加载时失败,而不是注册一个空的 `ctx.jobs`。
- **每个进程一个注册表,按所有者返回结果。** 一个实例服务进程内的每套组合,因此注册与投递都相对注册方所在 scope:从不带 scope 的上下文注册的控制器或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,恰好服务于该组合下组合出的 agent。
- **访问以所有者的会话 id 为界。** id 可预测,因此是授权——而非保密——构成边界。
- **结算首次优先,其事件跟在每个被释放的等待方之后。** 一条终止记录、释放的等待方,然后是一轮受到隔离的事件投递;在等待中认领结算的消费方因此总是在事件之前认领,`dsh-tool-jobs` 永远不会播报模型已经收走的完成。
- **结算首次优先,其事件跟在每个被释放的等待方之后。** 一条终止记录、释放的等待方,然后是一轮受到隔离的事件投递;`settled` 事件报告它是否释放了一个存活的 `wait`(`awaited`),因此无论是哪个插件在等待,`dsh-tool-jobs` 永远不会播报等待方已经收走的完成。
- **注册的存续期长于生产方与控制器 fiber。** 所有者与服务释放会取消正在运行的工作并等待守约的生产方;销毁期间的取消若抛出异常,只会将记录强制标记为失败。
### 源码地图
@@ -84,7 +84,7 @@ kind: "package-reference"
### 服务操作
每个读取或控制操作接收可选的调用方 `SessionId`,省略时仅允许访问无主 job:`list` 与 `get` 返回全新投影,`read` 推进模型游标并在结算后把生产方的 result 交出一次,`readAt` 按绝对偏移读取保留块且不消耗任何东西,`kill` 在改变状态前调用生产方取消并为终态 `detail` 记录原因,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;`events.subscribe` 按所有者、scope 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。
每个读取或控制操作接收可选的调用方 `SessionId`,省略时仅允许访问无主 job:`list` 与 `get` 返回全新投影,`read` 推进模型游标并在结算后把生产方的 result 交出一次,`readAt` 按绝对偏移读取保留块且不消耗任何东西,`kill` 在改变状态前调用生产方取消并为终态 `detail` 记录原因,`wait` 阻塞至超时,`remove` 丢弃调用方经自己的等待收走且从未交出的已结算记录,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;`events.subscribe` 按所有者、scope 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。
</details>
@@ -123,7 +123,7 @@ kind: "package-reference"
- **约定是进程内的**——`JobSpec.run()` 传入回调,注册表解析拥有者会话背后的活体 `Agent`;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。
- **模型游标是唯一的消耗式读取**——独立观察者使用非消耗的 `readAt`,从不移动它。
- **前台工作无法转为后台**——生产方在启动前选择前台或后台。
- **已结算记录会一直列出直到被移除**——由持有者释放、服务释放,或收走它的调用方显式 `remove`;注册表不对已结算 job 保留任何数量上限。
<a id="dev-note"></a>
### 开发备注
+16 -2
View File
@@ -59,8 +59,12 @@ declare module '@deepseek-ai/cordis' {
* predictable, so authorization — not secrecy — is the boundary.
* - Settlement is first-wins: one terminal record, released waiters, then one
* round of contained event delivery, even against a late producer outcome.
* The `settled` event follows every released waiter, so a consumer that
* claims a settlement while waiting always claims before the event.
* The `settled` event follows every released waiter and reports whether it
* released one (`awaited`), so a completion reporter can skip settlements a
* waiting caller already collected.
* - A settled record stays listed until its owner's disposal, service
* disposal, or an explicit {@link remove} by a caller that collected the
* terminal state itself and never handed the id out.
* - {@link start} refuses work while no attached job controller serves the
* spec's owner, so a producer cannot start work that owner cannot collect
* or stop. One registry serves every composition in the process, so this
@@ -164,6 +168,16 @@ export abstract class JobRegistry extends Service {
*/
abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise<JobView>
/**
* Drop one settled job's record from the visible set and announce
* `removed`. For a caller that collected the terminal state through its own
* {@link wait} and never handed the id to the model, such as a shell tool's
* foreground call. Throws for a job that is still live, unknown, or foreign.
* @param id - settled job to drop.
* @param caller - removing session checked against the owner.
*/
abstract remove(id: JobId, caller?: SessionId): void
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
* owners its registering context's scope covers, and {@link start} refuses an
+8
View File
@@ -207,6 +207,14 @@ export type JobEvent =
readonly type: 'settled'
readonly job: JobView
readonly cause: JobSettleCause
/**
* Whether this settlement released a live {@link JobRegistry.wait}. That
* waiter's caller receives the terminal projection as its own result, so
* a completion reporter treats an awaited settlement as already delivered
* and reports only the unawaited ones. A wait that timed out or was
* aborted before the settlement does not count.
*/
readonly awaited: boolean
}
| {
readonly type: 'output'
+12 -11
View File
@@ -46,6 +46,7 @@ async function setup() {
readAt(): never { throw new Error('unsupported') }
kill(): never { throw new Error('unsupported') }
wait(): never { throw new Error('unsupported') }
remove(): never { throw new Error('unsupported') }
attachController(): never { throw new Error('unsupported') }
}
await ctx.plugin(InvariantRegistry)
@@ -72,7 +73,7 @@ describe('job-registry invariants', () => {
read({ ...RUNNING, status: 'stopping', output: { total: 4, earliest: 0 } })
emit({ type: 'stopping', job: { ...RUNNING, status: 'stopping', output: { total: 4, earliest: 0 } } })
read({ ...DONE, status: 'killed', output: { total: 4, earliest: 0 } })
emit({ type: 'settled', job: { ...DONE, status: 'killed', output: { total: 4, earliest: 0 } }, cause: 'kill' })
emit({ type: 'settled', job: { ...DONE, status: 'killed', output: { total: 4, earliest: 0 } }, cause: 'kill', awaited: false })
emit({ type: 'output', id: ID, total: 4 })
read(undefined)
expect(() => { emit({ type: 'removed', job: { ...DONE, status: 'killed', output: { total: 4, earliest: 0 } } }) }).not.toThrow()
@@ -83,7 +84,7 @@ describe('job-registry invariants', () => {
read({ ...RUNNING, progress: '3/4' })
expect(() => { emit({ type: 'progress', job: { ...RUNNING, progress: '3/4' } }) }).not.toThrow()
read(DONE)
expect(() => { emit({ type: 'settled', job: DONE, cause: 'producer' }) }).not.toThrow()
expect(() => { emit({ type: 'settled', job: DONE, cause: 'producer', awaited: false }) }).not.toThrow()
read(undefined)
expect(() => { emit({ type: 'removed', job: DONE }) }).not.toThrow()
})
@@ -116,7 +117,7 @@ describe('job-registry invariants', () => {
}, /must announce a live status without finishedAt/],
['progress after settlement', ({ emit, read }) => {
read(DONE)
emit({ type: 'settled', job: DONE, cause: 'producer' })
emit({ type: 'settled', job: DONE, cause: 'producer', awaited: false })
emit({ type: 'progress', job: { ...DONE, progress: 'late' } })
}, /progress announced for job bash-1 after its settlement/],
['stopping with a terminal status', ({ emit, read }) => {
@@ -125,28 +126,28 @@ describe('job-registry invariants', () => {
}, /stopping announced for job bash-1 with a terminal status/],
['settled twice', ({ emit, read }) => {
read(DONE)
emit({ type: 'settled', job: DONE, cause: 'producer' })
emit({ type: 'settled', job: DONE, cause: 'producer' })
emit({ type: 'settled', job: DONE, cause: 'producer', awaited: false })
emit({ type: 'settled', job: DONE, cause: 'producer', awaited: false })
}, /settled announced twice for job bash-1/],
['settled with a live status', ({ emit, read }) => {
read(RUNNING)
emit({ type: 'settled', job: { ...RUNNING, finishedAt: 20 }, cause: 'producer' })
emit({ type: 'settled', job: { ...RUNNING, finishedAt: 20 }, cause: 'producer', awaited: false })
}, /must announce a terminal status, got "running"/],
['settled without finishedAt', ({ emit, read }) => {
read(DONE)
emit({ type: 'settled', job: { ...RUNNING, status: 'completed' }, cause: 'producer' })
emit({ type: 'settled', job: { ...RUNNING, status: 'completed' }, cause: 'producer', awaited: false })
}, /finishedAt no earlier than startedAt/],
['settled before it started', ({ emit, read }) => {
read(DONE)
emit({ type: 'settled', job: { ...DONE, finishedAt: 9 }, cause: 'producer' })
emit({ type: 'settled', job: { ...DONE, finishedAt: 9 }, cause: 'producer', awaited: false })
}, /finishedAt no earlier than startedAt/],
['settled with a progress line', ({ emit, read }) => {
read(DONE)
emit({ type: 'settled', job: { ...DONE, progress: '9/10' }, cause: 'producer' })
emit({ type: 'settled', job: { ...DONE, progress: '9/10' }, cause: 'producer', awaited: false })
}, /must announce a cleared progress line/],
['settled while the registry still reads it live', ({ emit, read }) => {
read(RUNNING)
emit({ type: 'settled', job: DONE, cause: 'producer' })
emit({ type: 'settled', job: DONE, cause: 'producer', awaited: false })
}, /announces completed at 20 while the registry reads running at undefined/],
['removed before settlement', ({ emit, read }) => {
read(RUNNING)
@@ -156,7 +157,7 @@ describe('job-registry invariants', () => {
}, /removed announced for job bash-1 before its settlement/],
['removed while the registry still returns the job', ({ emit, read }) => {
read(DONE)
emit({ type: 'settled', job: DONE, cause: 'producer' })
emit({ type: 'settled', job: DONE, cause: 'producer', awaited: false })
emit({ type: 'removed', job: DONE })
}, /removed announced for job bash-1 that the registry still returns/],
['output ahead of the read total', ({ emit, read }) => {
+2
View File
@@ -69,6 +69,8 @@ class StubJobRegistry extends JobRegistry {
return Promise.resolve(this.view(id, caller))
}
remove(_id: JobId, _caller?: SessionId): void {}
attachController(_name: string): () => void {
return () => {}
}
+2 -2
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 packages/jobs/tool-jobs/README.md
README.md: 1c4c60a32e628963b4751702d522b66b73dae411
README.zh.md: ff730b7b53d1f45cd2c5004b5d5b1df975ef53c8
README.md: 38cf89961d3bbf13d803bef206cbe843726f31ab
README.zh.md: 9a6c1c01103f03c5e9915094714bcecadf473042
+3 -3
View File
@@ -37,7 +37,7 @@ The three tools return `{ text, job }`, `PublicJobSnapshot[]`, and `{ outcome: '
### Completion notices
When a job finishes, the owning agent receives `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` as an in-session message. A busy agent has the notice injected into its next step — the turn cannot close while the inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle agent is instead woken with a follow-up turn, because an unclaimed notice is a completion the model never learns about. The plugin keeps its own ledger of what the model already collected: a `job_kill` it accepted and a `wait` that returned the terminal state claim the job, so no redundant notice follows, and a settlement caused by owner or service teardown is skipped because nobody is left to read it.
When a job finishes, the owning agent receives `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.` as an in-session message. A busy agent has the notice injected into its next step — the turn cannot close while the inbox holds it, so several jobs settling together cost one step rather than one turn each. An idle agent is instead woken with a follow-up turn, because an unclaimed notice is a completion the model never learns about. Completions the model already collected get no notice: the registry reports a settlement that released a live `wait` as `awaited` — whether a `job_output` wait or a shell tool waiting on its own foreground command — and the plugin remembers the kills the model requested through `job_kill`; a settlement caused by owner or service teardown is skipped because nobody is left to read it.
Waking is bounded: each owner may be woken `maxConsecutiveWakes` times before further notices degrade to injection, and claiming any user-authored message restores the budget. The bound exists because the chain is self-exciting — a woken turn may start the background job whose completion wakes it again. `completionDelivery: quiet` keeps even idle owners on the injection lane, which deterministic transcripts need.
@@ -82,7 +82,7 @@ This section explains the design decisions behind the tools and points at the co
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: tool registrations, the notice ledger and settlement subscription, prompt section, output capping |
| [`src/index.ts`](src/index.ts) | Plugin entry: tool registrations, the settlement subscription and model-kill set, prompt section, output capping |
| [`src/render.ts`](src/render.ts) | Model-facing rendering: the public projection, status lines, and the consuming delta (stdout, `[stderr]` section, dropped-output notice) |
| — | No runtime invariant companion is published; this model-facing adapter has no independent lifecycle stream; execution relations are owned by the capability seam it calls. |
@@ -92,7 +92,7 @@ This section explains the design decisions behind the tools and points at the co
### Notice delivery lanes
The settlement subscription (`{ owners: 'scope' }`) skips jobs the ledger already claimed, unowned jobs, and teardown settlements, then resolves the agent registered for the owner session. A `wakeup` delivery opens a turn on an idle owner while the budget lasts, tracked per exact `Agent` in a `WeakMap`; claiming a user-authored message (`agent/inbox/claimed`) resets that owner's budget. A busy owner — or any notice past the budget, or `quiet` delivery — is injected into the next-step inbox instead. A wait claims when it starts and withdraws on timeout or abort; a removal drops the ledger entry, so the ledger only ever holds live jobs the model touched.
The settlement subscription (`{ owners: 'scope' }`) skips settlements the registry reports as `awaited`, jobs the model killed through `job_kill`, unowned jobs, and teardown settlements, then resolves the agent registered for the owner session. A `wakeup` delivery opens a turn on an idle owner while the budget lasts, tracked per exact `Agent` in a `WeakMap`; claiming a user-authored message (`agent/inbox/claimed`) resets that owner's budget. A busy owner — or any notice past the budget, or `quiet` delivery — is injected into the next-step inbox instead. The registry counts only a wait still owed the projection at settlement, so a wait that timed out or was aborted leaves a later settlement to notify as usual; a removal drops the job from the model-kill set, which only ever holds live jobs the model killed.
</details>
+3 -3
View File
@@ -37,7 +37,7 @@ kind: "package-reference"
### 完成通知
任务完成时,拥有它的 agent 会收到会话内消息 `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.`。繁忙的 agent 会在下一步收到注入的通知——inbox 尚有内容时轮次无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的 agent 则被一个 follow-up 轮次唤醒,因为无人领取的通知就是模型永远不会知道的完成。插件自己记着模型已经收走了什么:被接受的 `job_kill` 与返回终态的 `wait` 会认领该任务,之后不再有多余通知;所有者或服务 teardown 导致的结算会被跳过,因为没有人能读它。
任务完成时,拥有它的 agent 会收到会话内消息 `background job <id> (<kind>: <label>) finished [status: ...]. Read its output with job_output.`。繁忙的 agent 会在下一步收到注入的通知——inbox 尚有内容时轮次无法结束,因此同时结算的多个任务只花掉一步,而不是各占一轮。空闲的 agent 则被一个 follow-up 轮次唤醒,因为无人领取的通知就是模型永远不会知道的完成。模型已经收走的完成不再通知:注册表把释放了存活 `wait` 的结算报告为 `awaited`——无论是 `job_output` 的等待,还是 shell 工具在等待自己的前台命令——插件则记着模型经 `job_kill` 请求的杀停;所有者或服务 teardown 导致的结算会被跳过,因为没有人能读它。
唤醒是有界的:每个所有者最多可被唤醒 `maxConsecutiveWakes` 次,此后的通知降级为注入;领取任何用户撰写的消息都会恢复预算。设界是因为这条链会自激——被唤醒的一轮可能启动某个后台任务,而它的完成又会唤醒同一个所有者。`completionDelivery: quiet` 让空闲所有者也在注入通道上,确定性 transcript(文本记录)需要的正是这一点。
@@ -82,7 +82,7 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:工具注册、播报台账与结算订阅、提示词区段、输出上限 |
| [`src/index.ts`](src/index.ts) | 插件入口:工具注册、结算订阅与模型杀停集合、提示词区段、输出上限 |
| [`src/render.ts`](src/render.ts) | 面向模型的渲染:公共投影、状态行与消耗式增量(stdout、`[stderr]` 段、丢失输出提示) |
| — | 不发布运行时不变式伴生入口;执行关系归能力 seam 所有。 |
@@ -92,7 +92,7 @@ kind: "package-reference"
### 通知投递通道
结算订阅(`{ owners: 'scope' }`)跳过台账已认领的任务、无所有者的任务与 teardown 结算,然后解析登记在拥有者会话下的 agent。`wakeup` 投递在预算内为空闲所有者开启一轮,按确切 `Agent` 记录在 `WeakMap` 中;领取用户撰写的消息(`agent/inbox/claimed`)会重置该所有者的预算。繁忙的所有者——或超出预算的任何通知,以及 `quiet` 投递——改为注入 next-step inbox。等待在开始时认领、在超时或中止时撤回;移除会删除台账条目,因此台账只保有模型触碰过的存活任务。
结算订阅(`{ owners: 'scope' }`)跳过注册表报告为 `awaited` 的结算、模型经 `job_kill` 杀停的任务、无所有者的任务与 teardown 结算,然后解析登记在拥有者会话下的 agent。`wakeup` 投递在预算内为空闲所有者开启一轮,按确切 `Agent` 记录在 `WeakMap` 中;领取用户撰写的消息(`agent/inbox/claimed`)会重置该所有者的预算。繁忙的所有者——或超出预算的任何通知,以及 `quiet` 投递——改为注入 next-step inbox。注册表只计入结算时仍被欠着投影的等待,因此超时或中止的等待会让之后的结算照常通知;移除会把任务从模型杀停集合里删掉,该集合只保有模型杀停过的存活任务。
</details>
+16 -66
View File
@@ -37,17 +37,6 @@ export const inject = ['tools', 'jobs', 'systemPrompt']
*/
export type CompletionDelivery = 'quiet' | 'wakeup'
/**
* One model touch of a live job's settlement: a wait in flight or a kill the
* model requested. `live` says whether the tool result that made the claim
* will still carry the settlement; a wait's claim dies the moment its caller
* aborts, before the registry's rejection reaches the tool.
*/
interface Claim {
readonly id: JobId
live: boolean
}
/** Configures bounded `job_output` waits and completion-notice delivery. */
export interface Config {
/** Wait duration applied when `job_output` sets `wait` without `timeout_ms` (default 30s). */
@@ -188,11 +177,6 @@ function presentJobCall(title: string, kind: 'read' | 'execute', rawInput?: stri
return { card: 'generic', title, kind, ...rawInput !== undefined ? { rawInput } : {} }
}
/** True for the three terminal statuses. */
function isTerminal(job: JobView): boolean {
return job.status === 'completed' || job.status === 'killed' || job.status === 'failed'
}
/** The consuming read as the model sees it: the delta, then the result once, then the status line. */
function readBody(read: JobRead): { text: string; job: PublicJobSnapshot } {
const delta = renderModelDelta(read.chunks, read.lossy, read.job.output.spillPaths ?? [])
@@ -266,35 +250,16 @@ export function apply(ctx: Context, config: Config): void {
text: 'Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job\'s work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.',
})
// Jobs whose terminal state this plugin already handed to the model through
// a tool result: a terminal `job_output`, a wait that returned it, or a
// `job_kill` it requested. A wait claims when it starts, not when it
// returns — settlement releases waiters before it announces, but their
// continuations run after the event. Each call holds its own claim, so a
// timed-out wait drops only its own while a concurrent wait on the same job
// keeps the settlement covered. The settlement event drops every claim on
// the job, so the ledger holds only live jobs the model already touched.
const claims = new Set<Claim>()
const claim = (id: JobId): Claim => {
const entry: Claim = { id, live: true }
claims.add(entry)
return entry
}
// Drop every claim on the job; true when one of them still covered the settlement.
const settleClaims = (id: JobId): boolean => {
let covered = false
for (const entry of claims) {
if (entry.id !== id) continue
claims.delete(entry)
if (entry.live) covered = true
}
return covered
}
// Live jobs whose kill the model itself requested through `job_kill`: that
// tool result is the model's delivery, so the settlement notice would only
// repeat it. A wait needs no entry here — the registry reports a settlement
// that released a live wait as `awaited`, whichever plugin was waiting.
const killedByModel = new Set<JobId>()
// A busy owner is injected: the notice waits in its next-step inbox, which
// the turn cannot close over, so jobs settling together cost one step. An
// idle owner is woken instead, because an unclaimed notice is a completion
// the model never learns about. Either way, disposal before the claim
// idle owner is woken instead, because an undelivered notice is a completion
// the model never learns about. Either way, disposal before delivery
// discards it with the owner, and a teardown settlement has no reader left.
//
// The registry routes each settlement to the scope this plugin was mounted
@@ -302,12 +267,12 @@ export function apply(ctx: Context, config: Config): void {
// this listener owns delivery, not the choice of whom to deliver to.
ctx.jobs.events.subscribe({ owners: 'scope' }, (event) => {
if (event.type === 'removed') {
settleClaims(event.job.id)
killedByModel.delete(event.job.id)
return
}
if (event.type !== 'settled') return
const claimed = settleClaims(event.job.id)
if (claimed || event.cause === 'teardown' || event.job.owner === undefined) return
const delivered = killedByModel.delete(event.job.id) || event.awaited
if (delivered || event.cause === 'teardown' || event.job.owner === undefined) return
// The destination is the agent registered for the owner session now. An
// owned job needed the agent registry to start, so the registry is only
// absent here when it left before settlement — and then no inbox is left.
@@ -365,26 +330,11 @@ export function apply(ctx: Context, config: Config): void {
const id = validateJobId(args.job_id)
const jobs = ctx.jobs
if (args.wait === true) {
const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
const entry = claim(id)
// The registry rejects an aborted wait on a later microtask, and a
// settlement announced in between must not find this claim live: the
// tool result carries the abort, so the notice is the model's only
// completion record. ToolRuntime never dispatches an aborted call, so
// the listener covers every abort. A timeout needs no such guard —
// the registry resolves it from a timer callback, and the wait's
// continuation runs before anything else can settle the job.
const abandon = (): void => { entry.live = false }
exec.signal.addEventListener('abort', abandon, { once: true })
try {
const view = await jobs.wait(id, timeout, exec.agent?.id, exec.signal)
if (!isTerminal(view)) claims.delete(entry)
} catch (error: unknown) {
claims.delete(entry)
throw error
} finally {
exec.signal.removeEventListener('abort', abandon)
}
// A settlement that releases this wait is reported `awaited`, so the
// notice listener above skips it: this result carries the terminal
// state. A timed-out or aborted wait has left the registry's waiter
// set before any later settlement, which then notifies as usual.
await jobs.wait(id, Math.min(args.timeout_ms ?? waitDefault, waitCap), exec.agent?.id, exec.signal)
}
return readBody(jobs.read(id, exec.agent?.id))
},
@@ -445,7 +395,7 @@ export function apply(ctx: Context, config: Config): void {
const result = jobs.kill(id, exec.agent?.id, args.reason)
// The model's own kill is its delivery: the settlement notice would only
// repeat what this tool result already said.
if (result === 'requested') claim(id)
if (result === 'requested') killedByModel.add(id)
// A projection describes current state without consuming pending output.
const job = publicJob(jobs.get(id, exec.agent?.id))
return Promise.resolve({
@@ -729,6 +729,26 @@ describe('completion notice delivery', () => {
expect(inject).not.toHaveBeenCalled()
})
it('delivers the completion notice for a human kill, reason included', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner: owner.id, label: 'pnpm run watch' })
const id = ctx.jobs.start(p.spec)
// A kill outside the model's own job_kill (the web client's stop button)
// claims nothing in the ledger, so the notice stays due.
ctx.jobs.kill(id, owner.id, 'cancelled by the user')
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
const message = inject.mock.calls[0]![0] as { content: readonly { type: string; text: string }[] }
expect(message.content[0]!.text).toBe(
'background job bash-1 (bash: pnpm run watch) finished '
+ '[status: killed, signal: SIGTERM; cancelled by the user]. Read its output with job_output.',
)
})
it('never wakes an idle owner under quiet delivery', async () => {
const { ctx } = await setup({ completionDelivery: 'quiet' })
const inject = vi.fn()
+2 -2
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 packages/shell/bash-local/README.md
README.md: a91eae878ec1fd6db115e11c6a4fea86763f8153
README.zh.md: f4f5af410310086f7e639c492c31b10943e0f0c8
README.md: cc53845f382d933a9062a6e9ff10e5ec6416e036
README.zh.md: 04f69abadbe298952ef7fa1dcd6f577189fe35c5
+5 -4
View File
@@ -52,16 +52,17 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
### Running commands
Run a command with `run` and read its output from the result. A nonzero exit, a timeout, or a cancellation resolves with a descriptive result — only infrastructure failures reject. Per-call `timeoutMs` overrides are capped by the configuration, while `workdir` falls back to the configured default when unset; a trusted foreground caller can also raise the stdout capture budget for one call, while stderr and background runs keep `maxOutputBytes`. The environment is model-friendly by default: `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` keep pagers and ANSI colors from garbling output, and an explicit caller-provided entry still wins.
Run a command by awaiting the execution's `result()` projection. A nonzero exit, a timeout, or a cancellation resolves with a descriptive result — only infrastructure failures reject. Per-call `timeoutMs` overrides are capped by the configuration, while `workdir` falls back to the configured default when unset; a trusted caller can also raise the per-call stdout capture budget, while stderr keeps `maxOutputBytes`. The environment is model-friendly by default: `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` keep pagers and ANSI colors from garbling output, and an explicit caller-provided entry still wins.
```text
const result = await ctx.shell.run(ctx.shell.resolve({ command: 'ls -la' }))
const execution = await ctx.shell.execute(ctx.shell.resolve({ command: 'ls -la' }))
const result = await execution.result()
if (result.timedOut) console.log('timed out after', result.timeoutMs)
```
### Background processes
Await `start` to run a command in the background; it resolves with the prepared process handle and no execution timeout applies. Cancellation or preparation failure rejects before a handle is published. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
Resolve with `onExpiry: 'none'` and await `execute` to run a command in the background; no deadline is armed. Cancellation or preparation failure rejects before a handle is published. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
<a id="adjusting-budgets-at-runtime"></a>
### Adjusting budgets at runtime
@@ -93,7 +94,7 @@ The executor is a Service Provider for the `ctx.shell` seam built on the subproc
### Main flow
A call runs through three steps: `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config (capping per-call overrides); `run` fuses the config-clamped timeout with the caller's abort signal into one deadline and spawns `['bash', '-c', command]` through `ctx.subprocess` with explicit byte caps and the `graceMs`; the settled subprocess outcome is classified — only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, a self-signaled command reports neither — and projected into a `ShellRunResult` with collected output.
A call runs through three steps: `resolve()` fills `workdir`/`timeoutMs`/`onExpiry`/`stdoutMaxBytes` from config and the request (capping per-call overrides); `execute` wires the deadline per expiry policy — `'kill'` fuses the clamped timeout with the caller's abort signal, `'none'` arms nothing — and spawns `['bash', '-c', command]` through `ctx.subprocess` with explicit byte caps and the `graceMs`; the settled outcome is classified first-cause — only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, a self-signaled command reports neither — and `result()` projects it into a `ShellRunResult` with collected output.
The foreground deadline starts before argv preparation and retains the same signal and remaining budget through execution. Preparation timeout returns empty output, `timedOut: true`, and null `exitCode` and `signal`; caller cancellation before process publication still rejects. Late preparation success or failure cannot trigger a spawn.
+5 -4
View File
@@ -52,16 +52,17 @@ kind: "package-reference"
### 运行命令
用 `run` 运行命令并从结果读取输出。非零退出、超时或取消都会 resolve 为描述性结果——只有基础设施失败才 reject。每次调用的 `timeoutMs` 覆盖值受配置上限约束,`workdir` 未设置时则回退到配置的默认值;受信任的前台调用方还可以为单次调用提高 stdout 捕获预算,而 stderr 与后台运行仍使用 `maxOutputBytes`。环境默认面向模型:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 可防止分页器与 ANSI 颜色破坏输出,调用方显式提供的条目仍然优先。
通过等待 execution 的 `result()` 投影来运行命令。非零退出、超时或取消都会 resolve 为描述性结果——只有基础设施失败才 reject。每次调用的 `timeoutMs` 覆盖值受配置上限约束,`workdir` 未设置时则回退到配置的默认值;受信任的调用方还可以为单次调用提高 stdout 捕获预算,而 stderr 仍使用 `maxOutputBytes`。环境默认面向模型:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 可防止分页器与 ANSI 颜色破坏输出,调用方显式提供的条目仍然优先。
```text
const result = await ctx.shell.run(ctx.shell.resolve({ command: 'ls -la' }))
const execution = await ctx.shell.execute(ctx.shell.resolve({ command: 'ls -la' }))
const result = await execution.result()
if (result.timedOut) console.log('timed out after', result.timeoutMs)
```
### 后台进程
等待 `start` 即可在后台运行命令;它完成准备后返回进程句柄,且不应用执行超时。取消或准备失败会在发布句柄前拒绝调用。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止提供方管理的 range;`done` 在直接命令关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
以 `onExpiry: 'none'` 解析并等待 `execute` 返回句柄即可在后台运行命令;不布置任何 deadline。取消或准备失败会在发布句柄前拒绝调用。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止提供方管理的 range;`done` 在直接命令关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
<a id="adjusting-budgets-at-runtime"></a>
### 运行时调整预算
@@ -93,7 +94,7 @@ if (result.timedOut) console.log('timed out after', result.timeoutMs)
### 主要流程
一次调用分三步:`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`(并限制每次调用的覆盖值);`run` 把按配置钳位的超时与调用方的中止信号融合为一个 deadline,再以显式字节上限与 `graceMs` 通过 `ctx.subprocess` spawn `['bash', '-c', command]`;结算的 subprocess 结果被分类——只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告——并投影为带收集输出的 `ShellRunResult`。
一次调用分三步:`resolve()` 从配置与请求填充 `workdir`/`timeoutMs`/`onExpiry`/`stdoutMaxBytes`(并限制每次调用的覆盖值);`execute` 按到期策略布置 deadline——`'kill'` 把钳位后的超时与调用方的中止信号融合为一个 deadline,`'none'` 不布置任何 deadline——再以显式字节上限与 `graceMs` 通过 `ctx.subprocess` spawn `['bash', '-c', command]`;结算的 subprocess 结果被分类——只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告——并投影为带收集输出的 `ShellRunResult`。
前台 deadline 从 argv 准备开始,并在准备与执行之间保持同一信号和剩余预算。准备阶段超时返回空输出、`timedOut: true`,且 `exitCode` 和 `signal` 均为 `null`;调用方在发布进程前取消仍会拒绝调用。准备晚到的成功或失败不会触发 spawn。
+124 -75
View File
@@ -12,7 +12,7 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { SHELL_SETTINGS_NAMESPACE, ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellProcessRead, ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess, ShellProcessRead, ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type {} from '@deepseek-ai/dsh-settings'
import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
@@ -142,8 +142,8 @@ export class LocalBashExecutor extends ShellExecutor {
* Resolve a request into a fully-specified spec: fill `workdir` from
* `config.cwd` (else `process.cwd()`), and `timeoutMs` from
* `config.timeoutMs`, capped at `config.maxTimeoutMs`. The tool layer calls
* this before {@link run}/{@link start}, so those methods receive explicit
* values and never re-default.
* this before {@link execute}, so it receives explicit values and never
* re-defaults.
*/
resolve(request: ShellExecRequest): ShellExecSpec {
const timeoutMs = clampTimeout(
@@ -158,6 +158,7 @@ export class LocalBashExecutor extends ShellExecutor {
command: request.command,
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
onExpiry: request.onExpiry ?? 'kill',
stdoutMaxBytes,
...request.signal ? { signal: request.signal } : {},
// Carry stdin/ordinary env/trusted dshEnv through verbatim — optional,
@@ -210,120 +211,150 @@ export class LocalBashExecutor extends ShellExecutor {
return { stdout, stderr }
}
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
return (await this.runArgv(spec, ['bash', '-c', spec.command])).result
async execute(spec: ShellExecSpec): Promise<ShellExecution> {
return this.executeArgv(spec, ['bash', '-c', spec.command])
}
/**
* Run an explicit argv with the foreground lifecycle, environment, output,
* timeout, and cancellation semantics of this executor. Subclasses use this
* Execute an explicit argv with the lifecycle, environment, output,
* deadline, and cancellation semantics of this executor. Subclasses use this
* after replacing the public command's shell argv at an execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argvOrPrepare - exact argv, or preparation cancelled by the same deadline as execution.
* @returns the foreground result and whether argv reached the subprocess provider.
* @param argvOrPrepare - exact argv, or preparation using the execution cancellation signal.
* @param onStarted - installs provider facts synchronously before the handle can settle.
* @returns the live execution handle; spawn rejection settles the handle as
* killed while `result()` carries the same failure as its rejection.
*/
protected async runArgv(
protected async executeArgv(
spec: ShellExecSpec,
argvOrPrepare: readonly string[] | ((signal: AbortSignal) => Promise<readonly string[]>),
): Promise<{ result: ShellRunResult; spawnRequested: boolean }> {
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
let argv: readonly string[]
onStarted?: (process: ShellExecution) => void,
): Promise<ShellExecution> {
// Deadline wiring by expiry policy. Each arm supplies the spawn signal,
// the result projection's first-cause classification, and the disarm the
// settlement continuation runs. `ShellExpiryPolicy` has exactly these two
// members, so the `else` arm is `'none'`.
let spawnSignal: AbortSignal | undefined
let classify: () => { timedOut: boolean; aborted: boolean }
let disarm = (): void => {}
if (spec.onExpiry === 'kill') {
// One fused deadline combines timeout and upstream cancellation; only
// this executor's timeout reason counts as timedOut, outer deadlines as aborts.
const d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
spawnSignal = d.signal
classify = () => {
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
return { timedOut, aborted: d.signal.aborted && !timedOut }
}
disarm = () => { d[Symbol.dispose]() }
} else {
// No deadline: callers stop the process through kill() or spec.signal.
spawnSignal = spec.signal
classify = () => ({ timedOut: false, aborted: spec.signal?.aborted === true })
}
let argv: readonly string[] = []
let preparationTimedOut = false
if (typeof argvOrPrepare === 'function') {
const signal = spawnSignal ?? new AbortController().signal
const cancelled = Promise.withResolvers<never>()
const abort = (): void => { cancelled.reject(d.signal.reason) }
d.signal.addEventListener('abort', abort, { once: true })
const abort = (): void => { cancelled.reject(signal.reason) }
signal.addEventListener('abort', abort, { once: true })
try {
argv = await Promise.race([
Promise.resolve().then(() => { d.signal.throwIfAborted(); return argvOrPrepare(d.signal) }),
Promise.resolve().then(() => { signal.throwIfAborted(); return argvOrPrepare(signal) }),
cancelled.promise,
])
d.signal.throwIfAborted()
signal.throwIfAborted()
} catch (error) {
if (timeoutOf(d.signal, 'BASH_TIMEOUT') === undefined) throw error
return {
spawnRequested: false,
result: {
exitCode: null, signal: null, timedOut: true, aborted: false, timeoutMs: spec.timeoutMs,
stdout: { text: '', truncated: false }, stderr: { text: '', truncated: false },
},
if (!classify().timedOut) {
disarm()
throw error
}
} finally { d.signal.removeEventListener('abort', abort) }
preparationTimedOut = true
} finally { signal.removeEventListener('abort', abort) }
} else { argv = argvOrPrepare }
const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, d.signal))
const outcome = await handle.done
const collected = LocalBashExecutor.collected(handle)
// Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return {
spawnRequested: true,
result: {
...outcome,
timedOut,
aborted,
timeoutMs: spec.timeoutMs,
stdout: finalOutput(collected.stdout),
stderr: finalOutput(collected.stderr),
},
// A synchronous spawn throw (pre-aborted signal, alternative subprocess
// implementations) is contained into the same settled-killed shape as an
// asynchronous spawn rejection, so execute() itself never throws for a
// spawn problem and result() carries the failure uniformly.
let running: SubprocessHandle | undefined
let syncSpawnError: { error: unknown } | undefined
try {
if (!preparationTimedOut) {
running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, spec.stdoutMaxBytes, spawnSignal))
}
} catch (error) {
syncSpawnError = { error }
}
}
async start(spec: ShellExecSpec): Promise<ShellProcess> {
return Promise.resolve(this.startArgv(spec, ['bash', '-c', spec.command]))
}
/**
* Start an explicit argv with the background lifecycle, environment, output,
* cancellation, and managed-range ownership semantics of this executor.
* Subclasses use this after replacing the public command's shell argv at an
* execution boundary.
* @param spec - resolved execution settings and caller-owned command metadata.
* @param argv - exact executable and arguments to hand to `ctx.subprocess`.
* @returns the live background handle; provider rejection settles it as killed.
*/
protected startArgv(spec: ShellExecSpec, argv: readonly string[]): ShellProcess {
// Background runs ignore timeoutMs; callers stop them through kill() or spec.signal.
spec.signal?.throwIfAborted()
const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, argv, this.config.maxOutputBytes, spec.signal))
const collected = LocalBashExecutor.collected(running)
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
}
const collected = running !== undefined
? LocalBashExecutor.collected(running)
: { stdout: emptyReader, stderr: emptyReader }
const spawnThrow = (): unknown => (syncSpawnError as { error: unknown }).error
const spawned = preparationTimedOut
? Promise.resolve({ exitCode: null, signal: null })
: running !== undefined ? running.done
// The original throw is preserved for callers even when it was not an Error.
// eslint-disable-next-line prefer-promise-reject-errors
: Promise.reject(spawnThrow())
// A provider rejection produces no process output, so the subprocess
// service has nothing to buffer: once the provider rejected, its
// stage-neutral note is the whole stderr stream for every reader. The
// observed reader serves it at offset 0 and the consuming read folds it in
// exactly once.
let providerFailureNote: string | undefined
// observed reader serves it at offset 0, the consuming read folds it in
// exactly once, and the retained error is the result() projection's
// rejection.
let providerFailure: { error: unknown; note: string } | undefined
let providerFailureReported = false
const consumeProviderFailure = (): string => {
if (providerFailureNote === undefined || providerFailureReported) return ''
if (providerFailure === undefined || providerFailureReported) return ''
providerFailureReported = true
return providerFailureNote
return providerFailure.note
}
const observedStderr: SubprocessOutputReader = {
readFrom: (fromByte) => {
if (providerFailureNote === undefined) return collected.stderr.readFrom(fromByte)
const note = Buffer.from(providerFailureNote, 'utf8')
if (providerFailure === undefined) return collected.stderr.readFrom(fromByte)
const note = Buffer.from(providerFailure.note, 'utf8')
return { text: note.subarray(Math.min(fromByte, note.length)).toString('utf8'), nextOffset: note.length, lossy: false }
},
}
let stdoutOffset = 0
let stderrOffset = 0
const proc: ShellProcess = {
let resultPromise: Promise<ShellRunResult> | undefined
const proc: ShellExecution = {
status: 'running',
exitCode: null,
signal: null,
observed: { stdout: collected.stdout, stderr: observedStderr },
done: running.done.then((outcome) => {
done: spawned.then((outcome) => {
// Any signal termination is killed, including a command signaling itself.
if (proc.status === 'running') {
proc.status = spec.signal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
proc.status = spawnSignal?.aborted === true || outcome.signal !== null ? 'killed' : 'completed'
}
proc.exitCode = outcome.exitCode
proc.signal = outcome.signal
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
disarm()
}, (error: unknown) => {
// Background provider failures settle as killed and surface on stderr for every reader.
// A live handle whose rejection follows this execution's own
// termination — kill() or the spawn signal's abort — reports its
// terminal outcome: a provider that terminated the range before the
// target started has no exit to report and rejects with the
// cancellation reason instead. The result projection classifies it
// from the deadline wiring; nothing is a provider failure. A
// synchronous spawn throw never produced a handle and stays a failure.
if (running !== undefined && (proc.status === 'killed' || spawnSignal?.aborted === true)) {
proc.status = 'killed'
this.onProcessDone(proc, collected.stderr.readFrom(0).text, false)
disarm()
return
}
// Provider failures settle the handle as killed and surface on stderr for every reader.
proc.status = 'killed'
let detail = 'unprintable provider failure'
try {
@@ -331,8 +362,9 @@ export class LocalBashExecutor extends ShellExecutor {
} catch {
// Provider-owned rejection values cannot make ShellProcess.done reject.
}
providerFailureNote = `subprocess failed before reporting an outcome: ${detail}`
this.onProcessDone(proc, providerFailureNote, true, error)
providerFailure = { error, note: `subprocess failed before reporting an outcome: ${detail}` }
this.onProcessDone(proc, providerFailure.note, true, error)
disarm()
}),
readOutput: (): ShellProcessRead => {
const out = collected.stdout.readFrom(stdoutOffset)
@@ -359,10 +391,27 @@ export class LocalBashExecutor extends ShellExecutor {
kill: (): boolean => {
if (proc.status !== 'running') return false
proc.status = 'killed'
running.terminate()
running?.terminate()
return true
},
result: (): Promise<ShellRunResult> => {
resultPromise ??= proc.done.then(() => {
// Infrastructure-failure parity with the historical foreground path:
// a spawn that never produced a process rejects the projection.
if (providerFailure !== undefined) throw providerFailure.error
return {
exitCode: proc.exitCode,
signal: proc.signal,
...classify(),
timeoutMs: spec.timeoutMs,
stdout: finalOutput(collected.stdout),
stderr: finalOutput(collected.stderr),
}
})
return resultPromise
},
}
if (!preparationTimedOut) onStarted?.(proc)
return proc
}
+162 -33
View File
@@ -5,9 +5,21 @@ import { afterAll, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import SubprocessRuntime from '@deepseek-ai/dsh-subprocess'
import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import type { ShellExecSpec, ShellExecution, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
}
/** Historical background shorthand: execute with no deadline armed. */
function start(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellExecution> {
return x.execute({ ...spec, onExpiry: 'none' })
}
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
@@ -44,7 +56,7 @@ async function readUntil(proc: ShellProcess, expected: string, timeoutMs = 5_000
describe('LocalBashExecutor.run', () => {
it('resolves with output and the effective timeout', async () => {
const { bash } = await setup({ timeoutMs: 5_000 })
const result = await bash.run(bash.resolve({ command: 'echo hi' }))
const result = await run(bash, bash.resolve({ command: 'echo hi' }))
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('hi\n')
expect(result.timeoutMs).toBe(5_000)
@@ -52,21 +64,21 @@ describe('LocalBashExecutor.run', () => {
it('uses config cwd, overridable per call', async () => {
const { bash } = await setup({ cwd: '/tmp' })
const fromConfig = await bash.run(bash.resolve({ command: 'pwd' }))
const fromConfig = await run(bash, bash.resolve({ command: 'pwd' }))
expect(fromConfig.stdout.text.trim()).toMatch(/\/tmp$/)
const fromCall = await bash.run(bash.resolve({ command: 'pwd', workdir: '/' }))
const fromCall = await run(bash, bash.resolve({ command: 'pwd', workdir: '/' }))
expect(fromCall.stdout.text.trim()).toBe('/')
})
it('defaults cwd to process.cwd()', async () => {
const { bash } = await setup()
const result = await bash.run(bash.resolve({ command: 'pwd' }))
const result = await run(bash, bash.resolve({ command: 'pwd' }))
expect(result.stdout.text.trim()).toBe(process.cwd())
})
it('caps per-call timeouts at maxTimeoutMs', async () => {
const { bash } = await setup({ timeoutMs: 1_000, maxTimeoutMs: 2_000 })
const result = await bash.run(bash.resolve({ command: 'true', timeoutMs: 99_999 }))
const result = await run(bash, bash.resolve({ command: 'true', timeoutMs: 99_999 }))
expect(result.timeoutMs).toBe(2_000)
})
@@ -90,7 +102,7 @@ describe('LocalBashExecutor.run', () => {
const { bash } = await setup({ maxOutputBytes: 100 })
expect(bash.resolve({ command: 'true' }).stdoutMaxBytes).toBe(100)
const result = await bash.run(bash.resolve({
const result = await run(bash, bash.resolve({
command: 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2',
stdoutMaxBytes: 500,
}))
@@ -103,7 +115,7 @@ describe('LocalBashExecutor.run', () => {
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
const result = await run(bash, bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
expect(result.timedOut).toBe(true)
// Mutually exclusive: a timeout classifies as timedOut, never also aborted.
expect(result.aborted).toBe(false)
@@ -113,7 +125,7 @@ describe('LocalBashExecutor.run', () => {
it('propagates abort signals', async () => {
const { bash } = await setup()
const controller = new AbortController()
const pending = bash.run(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
const pending = run(bash, bash.resolve({ command: 'sleep 60', signal: controller.signal }))
setTimeout(() => { controller.abort() }, 50)
const result = await pending
expect(result.aborted).toBe(true)
@@ -127,7 +139,7 @@ describe('LocalBashExecutor.run', () => {
// fused-signal classification reports the cause that cut the command short,
// and here nothing the executor owns did.
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'kill -TERM $$' }))
const result = await run(bash, bash.resolve({ command: 'kill -TERM $$' }))
expect(result.signal).toBe('SIGTERM')
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
@@ -135,7 +147,7 @@ describe('LocalBashExecutor.run', () => {
it('rejects on spawn failure (bad workdir)', async () => {
const { bash } = await setup()
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
await expect(run(bash, bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
})
it('resolve() carries stdin/env/dshEnv onto the spec, and run() threads them to the command', async () => {
@@ -150,7 +162,7 @@ describe('LocalBashExecutor.run', () => {
expect(spec.stdin).toBe('piped\n')
expect(spec.env).toEqual({ SEAM_VAR: 'env-ok' })
expect(spec.dshEnv).toEqual({ DSH_SEAM_VAR: 'dsh-ok' })
const result = await bash.run(spec)
const result = await run(bash, spec)
expect(result.stdout.text).toBe('piped\n[env-ok][dsh-ok]\n')
})
@@ -167,7 +179,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('start returns a running handle before the child is allowed to finish', async (t) => {
const { bash } = await setup()
const directory = mkdtempSync(join(spillDir, 'start-'))
const proc = await bash.start(bash.resolve({
const proc = await start(bash, bash.resolve({
command: 'echo ready; while [ ! -f release ]; do sleep 0.02; done; echo done',
workdir: directory,
}))
@@ -187,12 +199,12 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('threads stdin and extra env into a background process', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({
const proc = (await start(bash, bash.resolve({
command: 'cat; echo "[$BG_VAR][$DSH_BG_VAR]"',
stdin: 'bg-stdin\n',
env: { BG_VAR: 'bg-env' },
dshEnv: { DSH_BG_VAR: 'bg-dsh-env' },
}))
})))
const output = await readUntil(proc, '[bg-env][bg-dsh-env]')
expect(output).toContain('bg-stdin')
await proc.done
@@ -201,7 +213,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('readOutput is consuming: increments are never re-delivered, and reads stay valid after exit', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
const proc = (await start(bash, bash.resolve({ command: 'echo first; sleep 1; echo second' })))
const first = await readUntil(proc, 'first\n')
expect(first).toBe('first\n')
await proc.done
@@ -214,28 +226,28 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('readOutput marks stderr sections', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({ command: 'echo out; echo err >&2' }))
const proc = (await start(bash, bash.resolve({ command: 'echo out; echo err >&2' })))
await proc.done
expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
})
it('readOutput reports stderr-only deltas without a leading newline', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({ command: 'echo err >&2' }))
const proc = (await start(bash, bash.resolve({ command: 'echo err >&2' })))
await proc.done
expect(proc.readOutput().delta).toBe('[stderr]\nerr\n')
})
it('readOutput adds a separator only when stdout lacks a trailing newline', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({ command: 'printf out; echo err >&2' }))
const proc = (await start(bash, bash.resolve({ command: 'printf out; echo err >&2' })))
await proc.done
expect(proc.readOutput().delta).toBe('out\n[stderr]\nerr\n')
})
it('readOutput flags lossy reads and reports stdout spill paths', async () => {
const { bash } = await setup({ maxOutputBytes: 100 })
const proc = await bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' }))
const proc = (await start(bash, bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done' })))
await proc.done
const read = proc.readOutput()
// Window slid past offset 0 → lossy, spill path points at the full stream.
@@ -245,7 +257,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('readOutput reports stderr spill paths', async () => {
const { bash } = await setup({ maxOutputBytes: 100 })
const proc = await bash.start(bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' }))
const proc = (await start(bash, bash.resolve({ command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i >&2; done' })))
await proc.done
const read = proc.readOutput()
expect(read.lossy).toBe(true)
@@ -255,7 +267,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('kill() requests managed-range termination: true once, false after settlement', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({ command: 'sleep 60' }))
const proc = (await start(bash, bash.resolve({ command: 'sleep 60' })))
expect(proc.kill()).toBe(true)
await proc.done
expect(proc.status).toBe('killed')
@@ -265,7 +277,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('kill() returns false for a naturally completed process', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({ command: 'true' }))
const proc = (await start(bash, bash.resolve({ command: 'true' })))
await proc.done
expect(proc.status).toBe('completed')
expect(proc.kill()).toBe(false)
@@ -276,7 +288,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
// The child echoes AFTER arming the trap, so waiting for the marker
// guarantees SIGTERM is already ignored when the kill lands (a fixed sleep
// is load-flaky: a slow spawn would take the SIGTERM before the trap).
const proc = await bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
const proc = (await start(bash, bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' })))
await readUntil(proc, 'armed')
proc.kill()
await proc.done
@@ -287,7 +299,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('a spec.signal abort settles the handle as killed, not completed', async () => {
const { bash } = await setup()
const controller = new AbortController()
const proc = await bash.start(bash.resolve({ command: 'sleep 60', signal: controller.signal }))
const proc = (await start(bash, bash.resolve({ command: 'sleep 60', signal: controller.signal })))
controller.abort()
await proc.done
expect(proc.status).toBe('killed')
@@ -296,13 +308,21 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('a self-signal exit settles the handle as killed, not completed', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({ command: 'kill -TERM $$' }))
const proc = (await start(bash, bash.resolve({ command: 'kill -TERM $$' })))
await proc.done
expect(proc.status).toBe('killed')
expect(proc.exitCode).toBeNull()
expect(proc.signal).toBe('SIGTERM')
})
it('observed readers delegate to the collectors once a process spawned', async () => {
const { bash } = await setup()
const proc = (await start(bash, bash.resolve({ command: 'echo err 1>&2' })))
await proc.done
expect(proc.observed.stderr.readFrom(0).text).toBe('err\n')
expect(proc.observed.stdout.readFrom(0).text).toBe('')
})
it('reports both unread stderr and an asynchronous provider rejection exactly once', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
@@ -327,7 +347,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
waitForExit: async () => true,
} satisfies SubprocessHandle)
const proc = await bash.start(bash.resolve({ command: 'true' }))
const proc = (await start(bash, bash.resolve({ command: 'true' })))
await expect(proc.done).resolves.toBeUndefined()
expect(proc.status).toBe('killed')
const output = proc.readOutput().delta
@@ -337,6 +357,59 @@ describe('LocalBashExecutor.start (background process handles)', () => {
expect(proc.readOutput().delta).toBe('')
})
it('treats a rejection after its own abort as the aborted outcome, not a provider failure', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
}
const rejected = Promise.withResolvers<SubprocessOutcome>()
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
stdin: undefined,
stdout: undefined,
stderr: undefined,
control: undefined,
collected: { stdout: emptyReader, stderr: emptyReader },
done: rejected.promise,
terminate: vi.fn(),
waitForExit: async () => true,
} satisfies SubprocessHandle)
const controller = new AbortController()
const ex = (await start(bash, bash.resolve({ command: 'true', signal: controller.signal })))
controller.abort()
// A provider that terminated the range before the target started has no
// exit to report and rejects with the cancellation reason instead.
rejected.reject(controller.signal.reason)
await expect(ex.done).resolves.toBeUndefined()
expect(ex.status).toBe('killed')
await expect(ex.result()).resolves.toMatchObject({ aborted: true, timedOut: false, exitCode: null })
expect(ex.readOutput().delta).toBe('')
})
it('treats a rejection after kill() as the killed outcome, not a provider failure', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
}
const rejected = Promise.withResolvers<SubprocessOutcome>()
vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue({
stdin: undefined,
stdout: undefined,
stderr: undefined,
control: undefined,
collected: { stdout: emptyReader, stderr: emptyReader },
done: rejected.promise,
terminate: vi.fn(),
waitForExit: async () => true,
} satisfies SubprocessHandle)
const ex = (await start(bash, bash.resolve({ command: 'true' })))
expect(ex.kill()).toBe(true)
rejected.reject(new Error('subprocess terminated before target start'))
await expect(ex.done).resolves.toBeUndefined()
expect(ex.status).toBe('killed')
await expect(ex.result()).resolves.toMatchObject({ aborted: false, timedOut: false, exitCode: null })
expect(ex.readOutput().delta).toBe('')
})
it('settles an unprintable provider rejection instead of rejecting done', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
@@ -357,7 +430,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
waitForExit: async () => true,
} satisfies SubprocessHandle)
const proc = await bash.start(bash.resolve({ command: 'true' }))
const proc = (await start(bash, bash.resolve({ command: 'true' })))
await expect(proc.done).resolves.toBeUndefined()
expect(proc.status).toBe('killed')
expect(proc.readOutput().delta).toContain('unprintable provider failure')
@@ -366,7 +439,7 @@ describe('LocalBashExecutor.start (background process handles)', () => {
it('an asynchronous creation failure settles as killed with a stage-neutral note', async () => {
const { bash } = await setup()
const proc = await bash.start(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))
const proc = (await start(bash, bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' })))
// done resolves (never rejects) even though the process never ran.
await expect(proc.done).resolves.toBeUndefined()
expect(proc.status).toBe('killed')
@@ -392,7 +465,7 @@ describe('process lifecycle ownership (the subprocess service, not the executor)
// The child prints its own pid ($$ = the detached bash group leader) so
// the test can probe liveness through the public read API alone.
const proc = await bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
const proc = (await start(bash, bash.resolve({ command: 'echo $$; sleep 60' })))
const pid = Number((await readUntil(proc, '\n')).trim())
expect(Number.isInteger(pid) && pid > 0).toBe(true)
@@ -417,10 +490,10 @@ describe('process lifecycle ownership (the subprocess service, not the executor)
await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.shell as LocalBashExecutor
const finished = await bash.start(bash.resolve({ command: 'echo done' }))
const finished = (await start(bash, bash.resolve({ command: 'echo done' })))
await finished.done
expect(finished.status).toBe('completed')
const trapping = await bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' }))
const trapping = (await start(bash, bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' })))
await readUntil(trapping, 'armed')
await managerFiber.dispose()
@@ -431,3 +504,59 @@ describe('process lifecycle ownership (the subprocess service, not the executor)
expect(trapping.signal).toBe('SIGKILL')
})
})
describe('cancellation against a hanging backend', () => {
/** A subprocess service whose process runs until the test settles it and ignores the spawn signal. */
class HangingSubprocessRuntime extends SubprocessRuntime {
settle: (outcome: { exitCode: number | null; signal: NodeJS.Signals | null }) => void = () => {}
override async terminalEnvironment(): Promise<never> { throw new Error('fixture does not open terminals') }
override async resolveExecutable(command: string): Promise<string> { return command }
override spawnTerminal(): Promise<never> { throw new Error('bash spawns pipes, never terminals') }
private readonly reader: SubprocessOutputReader = {
readFrom: () => ({ text: '', lossy: false, nextOffset: 0 }),
}
override spawn(): SubprocessHandle {
return {
stdin: undefined,
stdout: undefined,
stderr: undefined,
control: undefined,
collected: { stdout: this.reader, stderr: this.reader },
done: new Promise((resolve) => { this.settle = resolve }),
terminate: () => {},
waitForExit: async () => true,
}
}
}
it('a caller abort before the deadline stays the first cause when the process outlives the deadline', async () => {
const ctx = new Context()
const subprocess = new HangingSubprocessRuntime(ctx)
await ctx.plugin(LocalBashExecutor)
const controller = new AbortController()
const ex = (await ctx.shell.execute(ctx.shell.resolve({ command: 'sleep 30', timeoutMs: 20, signal: controller.signal })))
controller.abort()
// The fake ignores the relayed abort, so the process outlives the deadline
// the way a real one does inside its termination grace.
await new Promise(resolve => setTimeout(resolve, 40))
subprocess.settle({ exitCode: null, signal: 'SIGTERM' })
const result = await ex.result()
expect(result.aborted).toBe(true)
expect(result.timedOut).toBe(false)
})
})
describe('execute() projections under none policy', () => {
it('classifies a no-deadline execution: clean settle, then an aborted one', async () => {
const { bash } = await setup()
const clean = (await start(bash, bash.resolve({ command: 'echo bg' })))
await clean.done
await expect(clean.result()).resolves.toMatchObject({ exitCode: 0, timedOut: false, aborted: false })
const controller = new AbortController()
const killed = (await start(bash, bash.resolve({ command: 'sleep 30', signal: controller.signal })))
controller.abort()
await killed.done
await expect(killed.result()).resolves.toMatchObject({ timedOut: false, aborted: true })
})
})
+2 -2
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 packages/shell/bash-sandbox/README.md
README.md: d9dbc85252a202dc7800eb727abd7700f5989fae
README.zh.md: 23ca4b5c59da2998b2b0aa546954591bf62da546
README.md: fea09e4ce13b0e7bd2841ff069c2aa61b30731bd
README.zh.md: 5daed6969cf1e66cff2241423d010aa0b860beb5
+3 -3
View File
@@ -81,14 +81,14 @@ The executor is the sandboxing Service Provider for the `ctx.shell` seam: it inh
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: `SandboxBashExecutor`, per-process fact retention, run/start wrapping |
| [`src/index.ts`](src/index.ts) | Plugin entry: `SandboxBashExecutor`, per-process fact retention, execution preparation |
| [`src/helpers.ts`](src/helpers.ts) | Denial, runner-failure, and runner-spawn-failure classification |
| — | No runtime invariant companion is published; classification is observable in results, and this package exposes no independent event sequence or mutable data relation beyond contracts enforced at its owning seam. |
| `tests/` | Exercised behavior across the bwrap, Landlock, and Seatbelt runners |
### Main flow
For a confined mode, `resolve()` stamps the per-call policy (the session's mode override, or the deployment fallback); `run` and `start` wrap the bash argv through the provider and hand the confined argv to the inherited subprocess path. At settlement the executor classifies the outcome: a runner failure outranks a denial because the command never ran, a failed run whose stderr carries the backend's denial dialect is reported `denied: true`, and every confined run carries its mode and enforcement facts. `danger-full-access` bypasses the provider entirely and stamps `denied: false`.
For a confined mode, `resolve()` stamps the per-call policy (the session's mode override, or the deployment fallback); `execute` awaits preparation of the bash argv through the provider and hands the confined argv to the inherited subprocess path. At settlement the executor classifies the outcome: a runner failure outranks a denial because the command never ran, a failed run whose stderr carries the backend's denial dialect is reported `denied: true`, and every confined run carries its mode and enforcement facts. `danger-full-access` bypasses the provider entirely and stamps `denied: false`.
### Invariants
@@ -170,7 +170,7 @@ These limits define when this executor is not a general security boundary. They
- **Confinement covers file effects only** — network restriction and a uniform process-visibility guarantee are absent, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `job_output`; a synchronous subprocess throw that names the runner path rejects `start()` before a handle is published.
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `job_output`; a synchronous subprocess throw is contained into the same settled-killed handle, with the runner-attributed failure carried by the `result()` rejection.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.
<a id="dev-note"></a>
+3 -3
View File
@@ -81,14 +81,14 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:`SandboxBashExecutor`、按进程保留事实、run/start 包装 |
| [`src/index.ts`](src/index.ts) | 插件入口:`SandboxBashExecutor`、按进程保留事实、execute 包装 |
| [`src/helpers.ts`](src/helpers.ts) | 拒绝、runner 失败与 runner spawn 失败分类 |
| — | 不发布运行时不变式伴生入口;分类可在结果中观察,且除归属 seam 所强制执行的约定外,本包不公开独立事件序列或可变数据关系。 |
| `tests/` | 跨 bwrap、Landlock 与 Seatbelt runner 演练的行为 |
### 主要流程
对受限模式,`resolve()` 标记每次调用的策略(会话的模式覆盖值,或部署回退);`run` 与 `start` 把 bash argv 经提供方包装,再把受限 argv 交给继承的 subprocess 路径。结算时执行器对结果分类:runner 失败优先于拒绝(命令从未运行),stderr 携带后端拒绝方言的失败运行报告 `denied: true`,每次受限运行都携带模式与强制执行事实。`danger-full-access` 完全绕过提供方,并标记 `denied: false`。
对受限模式,`resolve()` 标记每次调用的策略(会话的模式覆盖值,或部署回退);`execute` 把 bash argv 经提供方包装,再把受限 argv 交给继承的 subprocess 路径。结算时执行器对结果分类:runner 失败优先于拒绝(命令从未运行),stderr 携带后端拒绝方言的失败运行报告 `denied: true`,每次受限运行都携带模式与强制执行事实。`danger-full-access` 完全绕过提供方,并标记 `denied: false`。
### 不变式
@@ -170,7 +170,7 @@ kind: "package-reference"
- **限制只覆盖文件影响**——不提供网络限制和统一的进程可见性保证,因此这些模式不是通用安全沙箱。
- **拒绝从失败命令的 stderr 推断**——后端特征使该推断可跨平台使用,但包含相同特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
- **异步观测到的后台 runner 失败没有即时错误通道**——它记录在已结算进程上,并在调用方用 `job_output` 读取通用任务时呈现;同步 subprocess throw 若指明 runner 路径,则会在发布句柄前拒绝 `start()`。
- **异步观测到的后台 runner 失败没有即时错误通道**——它记录在已结算进程上,并在调用方用 `job_output` 读取通用任务时呈现;同步的子进程抛错被收容为同样的已结算 killed 句柄,可归因于 runner 的失败由 `result()` 的 rejection 携带。
- **`danger-full-access` 有意绕过 `ctx.sandbox`**——它是显式无约束模式,不是更宽的沙箱 profile。
<a id="dev-note"></a>
+50 -51
View File
@@ -10,7 +10,7 @@
*/
import { Context } from '@deepseek-ai/cordis'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedArgv,
@@ -86,70 +86,69 @@ export class SandboxBashExecutor extends LocalBashExecutor {
return { ...super.resolve(request), sandboxPolicy: request.sandboxPolicy ?? this.ctx.sandboxPolicy.resolve() }
}
override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
override async execute(spec: ShellExecSpec): Promise<ShellExecution> {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') {
const result = await super.run(spec)
return { ...result, sandbox: { mode, denied: false } }
return SandboxBashExecutor.decorateResult(
await super.execute(spec),
result => ({ ...result, sandbox: { mode, denied: false } }),
)
}
let confined: ConfinedArgv | undefined
let result: ShellRunResult
let spawnRequested: boolean
try {
({ result, spawnRequested } = await this.runArgv(spec, async (signal) => {
const prepared = await this.confine(spec.command, { ...policy, mode }, signal)
signal.throwIfAborted()
confined = prepared
return prepared.argv
}))
} catch (error) {
const ex = await this.executeArgv(spec, async (signal) => {
const prepared = await this.confine(spec.command, { ...policy, mode }, signal)
signal.throwIfAborted()
confined = prepared
return prepared.argv
}, (process) => {
const facts = confined as ConfinedArgv
this.processFacts.set(process, {
mode,
enforcement: facts.enforcement,
denialSignatures: facts.denialSignatures,
runnerFailureRules: facts.runnerFailureRules,
runnerProgram: facts.argv[0],
workdir: spec.workdir,
})
})
return SandboxBashExecutor.decorateResult(ex, (result) => {
if (confined === undefined) return { ...result, sandbox: { mode, denied: false } }
const { enforcement, denialSignatures, runnerFailureRules } = confined
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, denialSignatures), enforcement } }
}, (error) => {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
if (confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
if (!spawnRequested) return { ...result, sandbox: { mode, denied: false } }
// Spawn admission follows the successful preparation that captured these facts.
const facts = confined as ConfinedArgv
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, facts.runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, facts.denialSignatures), enforcement: facts.enforcement } }
})
}
override async start(spec: ShellExecSpec): Promise<ShellProcess> {
const policy = spec.sandboxPolicy as SandboxExecutionPolicy
const { mode } = policy
if (mode === 'danger-full-access') return super.start(spec)
const confined = await this.confine(spec.command, { ...policy, mode }, spec.signal)
spec.signal?.throwIfAborted()
let proc: ShellProcess
try {
proc = this.startArgv(spec, confined.argv)
} catch (error) {
// LocalSubprocessRuntime reports ENOENT/EACCES with the failed executable path through async
// `done` rejection; this covers alternatives that throw the same error synchronously.
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
/**
* Decorate the handle's foreground projection in place, memoized once. The
* handle keeps its identity (never wrapped in a second object) because the
* per-process facts and `onProcessDone` key on the exact instance.
*/
private static decorateResult(
ex: ShellExecution,
map: (result: ShellRunResult) => ShellRunResult,
mapError?: (error: unknown) => never,
): ShellExecution {
const base = ex.result.bind(ex)
let decorated: Promise<ShellRunResult> | undefined
ex.result = () => {
decorated ??= base().then(map, mapError)
return decorated
}
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, {
mode,
enforcement,
denialSignatures,
runnerFailureRules,
runnerProgram: confined.argv[0],
workdir: spec.workdir,
})
return proc
return ex
}
/**
+18 -6
View File
@@ -11,6 +11,18 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
}
/** Historical background shorthand: execute with no deadline armed. */
function start(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellExecution> {
return x.execute({ ...spec, onExpiry: 'none' })
}
/**
* Keyless integration of the real provider and executor through public run/start paths. With
@@ -54,7 +66,7 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.
it('read-only denies a write — the file must NOT exist, and EROFS text classifies as a denial', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
const result = await run(bash, bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
@@ -65,12 +77,12 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.
const outside = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'workspace-write')
const inside = await bash.run(bash.resolve({ command: `printf bwrap-ok > ${workdir}/allowed.txt` }))
const inside = await run(bash, bash.resolve({ command: `printf bwrap-ok > ${workdir}/allowed.txt` }))
expect(inside.exitCode).toBe(0)
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('bwrap-ok')
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
const denied = await run(bash, bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
expect(denied.exitCode).not.toBe(0)
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
@@ -79,7 +91,7 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const task = await bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
const task = (await start(bash, bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` })))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
@@ -89,11 +101,11 @@ describe.skipIf(!bwrapUsable)('bash-sandbox: real bwrap confinement through ctx.
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const command = `printf escalated > ${workdir}/escalated.txt`
const strict = await bash.run(bash.resolve({ command }))
const strict = await run(bash, bash.resolve({ command }))
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
const retried = await run(bash, bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
@@ -58,21 +58,24 @@ async function setup() {
})
const start = (timeoutMs = 10, signal?: AbortSignal) => {
const observed: { done: boolean; result?: ShellRunResult; error?: unknown } = { done: false }
const promise = ctx.shell.run(ctx.shell.resolve({ command: 'fixture command', timeoutMs, signal }))
const execution = ctx.shell.execute(ctx.shell.resolve({ command: 'fixture command', timeoutMs, signal }))
const promise = execution.then(process => process.result())
runs.push(promise)
void promise.then(
(result) => { observed.done = true; observed.result = result },
(error: unknown) => { observed.done = true; observed.error = error },
)
return { promise, observed }
return { promise, observed, execution }
}
return { ctx, prepared, entered, spawned, completion, wrap, confine, spawn, terminate, start }
}
describe('bash preparation deadline', () => {
it.each(['success', 'rejection'] as const)('times out unresolved preparation and prevents late %s from spawning', async (late) => {
it.each([
{ late: 'success' }, { late: 'rejection' },
] as const)('times out preparation and prevents late $late from spawning', async ({ late }) => {
const test = await setup()
const run = test.start()
const run = test.start(10)
const signal = await test.entered.promise
await vi.advanceTimersByTimeAsync(10)
expect(run.observed.done).toBe(true)
@@ -11,6 +11,18 @@ import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
}
/** Historical background shorthand: execute with no deadline armed. */
function start(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellExecution> {
return x.execute({ ...spec, onExpiry: 'none' })
}
/**
* KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap
@@ -59,7 +71,7 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug
it('read-only denies a write — the file must NOT exist, the result carries denial + enforcement facts', async () => {
const workdir = await tempDir(tmpdir())
const bash = await sandboxedBash(workdir, 'read-only')
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
const result = await run(bash, bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
@@ -70,12 +82,12 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug
const outside = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'workspace-write')
const inside = await bash.run(bash.resolve({ command: `printf landlock-ok > ${workdir}/allowed.txt` }))
const inside = await run(bash, bash.resolve({ command: `printf landlock-ok > ${workdir}/allowed.txt` }))
expect(inside.exitCode).toBe(0)
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement })
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('landlock-ok')
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
const denied = await run(bash, bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
expect(denied.exitCode).not.toBe(0)
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement })
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
@@ -84,7 +96,7 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const task = await bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
const task = (await start(bash, bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` })))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement })
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
@@ -94,11 +106,11 @@ describe.skipIf(!landlockUsable)('bash-sandbox: real Landlock confinement throug
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const command = `printf escalated > ${workdir}/escalated.txt`
const strict = await bash.run(bash.resolve({ command }))
const strict = await run(bash, bash.resolve({ command }))
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: enforcement })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
const retried = await run(bash, bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: enforcement })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
@@ -16,6 +16,18 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
}
/** Historical background shorthand: execute with no deadline armed. */
function start(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellExecution> {
return x.execute({ ...spec, onExpiry: 'none' })
}
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
const FATAL_PREFIX = 'landlock-run: '
@@ -92,12 +104,12 @@ describe('partial Landlock runner-failure classification', () => {
}
const bash = await setupConfiguredRunner(runner)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
const error = await run(bash, bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(runner)
const task = await bash.start(bash.resolve({ command: 'true' }))
const task = (await start(bash, bash.resolve({ command: 'true' })))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`subprocess failed before reporting an outcome: Error: spawn ${runner}`)
@@ -124,14 +136,14 @@ describe('partial Landlock runner-failure classification', () => {
? { command: 'true', env: { PATH: dir } }
: { command: 'true', workdir: dir }
const error = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
const error = await run(bash, bash.resolve(request)).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
// Empirically, Darwin and Linux Node 24 preserve the passed bare/relative
// argv[0] in this spawn error rather than resolving it to an absolute path.
expect((error as Error).message).toContain(`spawn ${runner} ENOENT`)
const task = await bash.start(bash.resolve(request))
const task = (await start(bash, bash.resolve(request)))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain(`subprocess failed before reporting an outcome: Error: spawn ${runner} ENOENT`)
@@ -155,22 +167,21 @@ describe('partial Landlock runner-failure classification', () => {
// Node/libuv may expose execve's ENOEXEC directly (Darwin) or retry a
// no-shebang executable through /bin/sh (Linux). Neither path supplies the
// ENOENT/EACCES with the exact failed executable path required for runner attribution.
const foreground = await bash.run(bash.resolve(request)).catch((value: unknown) => value)
const foreground = await run(bash, bash.resolve(request)).catch((value: unknown) => value)
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
if (foreground instanceof Error) {
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((foreground as { path?: unknown }).path).toBeUndefined()
let background: unknown
try {
await bash.start(bash.resolve(request))
} catch (error) {
background = error
}
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((background as { path?: unknown }).path).toBeUndefined()
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
// Containment: the handle settles killed instead of the spawn throwing.
const background = (await start(bash, bash.resolve(request)))
await background.done
expect(background.status).toBe('killed')
const backgroundError = await background.result().catch((value: unknown) => value)
expect(backgroundError).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect((backgroundError as { path?: unknown }).path).toBeUndefined()
expect(backgroundError).not.toBeInstanceOf(SandboxUnavailableError)
} else {
expect(foreground).toMatchObject({
exitCode: 127,
@@ -179,7 +190,7 @@ describe('partial Landlock runner-failure classification', () => {
})
expect((foreground as { stderr: { text: string } }).stderr.text.length).toBeGreaterThan(0)
const background = await bash.start(bash.resolve(request))
const background = (await start(bash, bash.resolve(request)))
await background.done
expect(background.status).toBe('completed')
expect(background.exitCode).toBe(127)
@@ -199,7 +210,7 @@ describe('partial Landlock runner-failure classification', () => {
'keeps child exit %i ordinary when the partial-enforcement notice is the only runner line',
async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
const result = await run(bash, bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
@@ -208,7 +219,7 @@ describe('partial Landlock runner-failure classification', () => {
it.each([126, 127])('keeps a successfully launched Landlock child exit %i as an ordinary outcome', async (exitCode) => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
const result = await run(bash, bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
@@ -216,7 +227,7 @@ describe('partial Landlock runner-failure classification', () => {
it.each([1, 2])('keeps a Landlock fatal line at exit %i as insufficient runner-failure evidence', async (exitCode) => {
const bash = await setup(exitCode)
const result = await bash.run(bash.resolve({ command: 'true' }))
const result = await run(bash, bash.resolve({ command: 'true' }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n${FATAL}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
@@ -224,7 +235,7 @@ describe('partial Landlock runner-failure classification', () => {
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
const error = await run(bash, bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
@@ -233,7 +244,7 @@ describe('partial Landlock runner-failure classification', () => {
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
const result = await run(bash, bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
})
@@ -241,7 +252,7 @@ describe('partial Landlock runner-failure classification', () => {
it('applies the same evidence rule to notice-only background exits', async () => {
const bash = await setup()
for (const command of ['exit 1', 'exit 2', `exit ${LAUNCHER_FAILURE_EXIT}`]) {
const task = await bash.start(bash.resolve({ command }))
const task = (await start(bash, bash.resolve({ command })))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
@@ -250,7 +261,7 @@ describe('partial Landlock runner-failure classification', () => {
it('classifies a background notice plus child Permission denied as denial', async () => {
const bash = await setup()
const task = await bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
const task = (await start(bash, bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' })))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
@@ -258,7 +269,7 @@ describe('partial Landlock runner-failure classification', () => {
it('makes a background fatal line outrank denial text after the notice', async () => {
const bash = await setup(LAUNCHER_FAILURE_EXIT)
const task = await bash.start(bash.resolve({ command: 'true' }))
const task = (await start(bash, bash.resolve({ command: 'true' })))
await task.done
expect(task.sandbox).toEqual({
mode: 'read-only',
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
import type { CollectedOutput, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
@@ -21,6 +21,17 @@ import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-
import { classifyDenial } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
}
/** Historical background shorthand: execute with no deadline armed. */
function start(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellExecution> {
return x.execute({ ...spec, onExpiry: 'none' })
}
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
afterAll(() => {
@@ -93,6 +104,24 @@ function executionPolicy(mode: SandboxMode, workspaceRoot = resolve(process.cwd(
}
describe('the provider hand-off', () => {
it('reports caller cancellation when a failed launch result is read after abort', async () => {
const { ctx, bash } = await setup({}, () => passthrough(['node']))
const controller = new AbortController()
const reason = new Error('caller abandoned the failed launch')
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => {
throw Object.assign(new Error('launch refused'), { code: 'ENOENT', syscall: 'spawn node', path: 'node' })
})
try {
const execution = await bash.execute(bash.resolve({ command: 'true', signal: controller.signal }))
await execution.done
controller.abort(reason)
await expect(execution.result()).rejects.toBe(reason)
} finally {
spawn.mockRestore()
await ctx.fiber.dispose()
}
})
it('preserves cancellation when a pending subprocess launch later rejects', async () => {
const { ctx, bash } = await setup({}, () => passthrough(['node']))
const entered = Promise.withResolvers<undefined>()
@@ -108,8 +137,8 @@ describe('the provider hand-off', () => {
terminate: () => {}, waitForExit: () => Promise.resolve(true),
}
})
const pending = bash.run(bash.resolve({ command: 'true', signal: controller.signal }))
const rejected = expect(pending).rejects.toBe(reason)
const pending = run(bash, bash.resolve({ command: 'true', signal: controller.signal }))
const rejected = expect(pending).resolves.toMatchObject({ aborted: true, timedOut: false })
try {
await entered.promise
controller.abort(reason)
@@ -122,7 +151,7 @@ describe('the provider hand-off', () => {
}
})
it.each(['run', 'start'] as const)('cancels %s while confinement is pending without spawning', async (operation) => {
it.each(['kill', 'none'] as const)('cancels %s while confinement is pending without spawning', async (onExpiry) => {
const entered = Promise.withResolvers<AbortSignal>()
const response = Promise.withResolvers<ConfinedArgv>()
const { ctx, bash } = await setup({}, (_argv, _policy, signal) => {
@@ -132,7 +161,7 @@ describe('the provider hand-off', () => {
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
const controller = new AbortController()
const reason = new Error('cancel pending confinement')
const pending = bash[operation](bash.resolve({ command: 'true', signal: controller.signal }))
const pending = bash.execute(bash.resolve({ onExpiry, command: 'true', signal: controller.signal }))
const rejected = expect(pending).rejects.toBe(reason)
try {
const signal = await entered.promise
@@ -155,7 +184,7 @@ describe('the provider hand-off', () => {
const { ctx, bash } = await setup({}, () => { entered.resolve(undefined); return response.promise })
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
let published = false
const pending = bash.start(bash.resolve({ command: 'printf ready' })).then((process) => { published = true; return process })
const pending = start(bash, bash.resolve({ command: 'printf ready' })).then((process) => { published = true; return process })
try {
await entered.promise
expect(published).toBe(false)
@@ -173,7 +202,7 @@ describe('the provider hand-off', () => {
it('hands the provider the exact bash argv and the per-call policy, and runs the returned argv', async () => {
const { bash, calls } = await setup()
const result = await bash.run(bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
const result = await run(bash, bash.resolve({ command: 'echo \'a b\' "c\'d"' }))
expect(result.stdout.text).toBe('a b c\'d\n')
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
expect(calls).toEqual([{
@@ -186,7 +215,7 @@ describe('the provider hand-off', () => {
const returnedArgv = ['env', 'DSH_WRAP=1', 'bash', '-c', 'printf "%s" "$DSH_WRAP"']
const { ctx, bash } = await setup({}, () => ({ argv: returnedArgv, enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
const result = await run(bash, bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
expect(result.stdout.text).toBe('1')
expect(spawn).toHaveBeenCalledTimes(1)
expect(spawn.mock.calls[0]?.[0].argv).toEqual(returnedArgv)
@@ -213,7 +242,7 @@ describe('the provider hand-off', () => {
}))
try {
const result = await bash.run(bash.resolve({
const result = await run(bash, bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: { DSH_ORDER_FILE: order },
@@ -227,21 +256,21 @@ describe('the provider hand-off', () => {
it('workspace-write rides the policy, workspaceRoot falling back to process.cwd() when not configured', async () => {
const { bash, calls } = await setup({ mode: 'workspace-write' })
const result = await bash.run(bash.resolve({ command: 'true' }))
const result = await run(bash, bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(calls[0]?.policy).toEqual({ mode: 'workspace-write', workspaceRoot: resolve(process.cwd()) })
})
it('an explicit workspaceRoot on the policy wins', async () => {
const { calls, bash } = await setup({ mode: 'workspace-write', workspaceRoot: '/ws', cwd: tmpdir() })
await bash.run(bash.resolve({ command: 'true' }))
await run(bash, bash.resolve({ command: 'true' }))
expect(calls[0]?.policy.workspaceRoot).toBe(resolve('/ws'))
})
it('the provider is consulted per wrap (no caching in the consumer): run and start each hand off', async () => {
const { bash, calls } = await setup()
await bash.run(bash.resolve({ command: 'true' }))
const task = await bash.start(bash.resolve({ command: 'true' }))
await run(bash, bash.resolve({ command: 'true' }))
const task = (await start(bash, bash.resolve({ command: 'true' })))
await task.done
expect(calls).toHaveLength(2)
})
@@ -252,8 +281,8 @@ describe('fail closed', () => {
it('propagates the provider\'s structured SANDBOX_UNAVAILABLE on run() and start()', async () => {
const { bash } = await setup({}, () => { throw new SandboxUnavailableError('read-only') })
const spec = bash.resolve({ command: 'echo hi' })
await expect(bash.run(spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
await expect(bash.start(spec)).rejects.toThrow(SandboxUnavailableError)
await expect(run(bash, spec)).rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
await expect(start(bash, spec)).rejects.toThrow(SandboxUnavailableError)
})
it('preserves an already-aborted foreground call as cancellation', async () => {
@@ -261,7 +290,7 @@ describe('fail closed', () => {
const controller = new AbortController()
const reason = new Error('caller cancelled before spawn')
controller.abort(reason)
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
await expect(run(bash, bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
})
it.each(RUNNER_FORMS)(
@@ -275,7 +304,7 @@ describe('fail closed', () => {
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
const failure = await run(bash, bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
@@ -289,7 +318,7 @@ describe('fail closed', () => {
const { bash } = await setup({ mode: 'danger-full-access' })
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
const failure = await run(bash, bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
@@ -310,18 +339,19 @@ describe('fail closed', () => {
throw Object.assign(new Error('spawn ENOEXEC'), { code: 'ENOEXEC', syscall: 'spawn' })
})
const foreground = await bash.run(bash.resolve({ command: 'true' })).catch((error: unknown) => error)
const foreground = await run(bash, bash.resolve({ command: 'true' })).catch((error: unknown) => error)
expect(foreground).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(foreground).not.toBeInstanceOf(SandboxUnavailableError)
let background: unknown
try {
await bash.start(bash.resolve({ command: 'true' }))
} catch (error) {
background = error
}
expect(background).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(background).not.toBeInstanceOf(SandboxUnavailableError)
// Containment: the handle settles killed instead of the spawn throwing,
// and the projection carries the original error, never the fail-closed one.
const background = (await start(bash, bash.resolve({ command: 'true' })))
await background.done
expect(background.status).toBe('killed')
const backgroundError = await background.result().catch((error: unknown) => error)
expect(backgroundError).toMatchObject({ code: 'ENOEXEC', syscall: 'spawn' })
expect(backgroundError).not.toBeInstanceOf(SandboxUnavailableError)
expect(background.readOutput().delta).toContain('subprocess failed before reporting an outcome:')
})
it('classifies a synchronous SubprocessRuntime EACCES with the exact runner path', async () => {
@@ -338,10 +368,14 @@ describe('fail closed', () => {
throw Object.assign(new Error('spawn EACCES'), { code: 'EACCES', syscall: 'spawn', path: runner })
})
await expect(bash.run(bash.resolve({ command: 'true' })))
await expect(run(bash, bash.resolve({ command: 'true' })))
.rejects.toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
await expect(bash.start(bash.resolve({ command: 'true' })))
.rejects.toThrow(expect.objectContaining({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE }))
// The background view of the same failure: a killed handle whose facts
// attribute the launch failure to the runner.
const background = (await start(bash, bash.resolve({ command: 'true' })))
await background.done
expect(background.status).toBe('killed')
expect(background.sandbox).toMatchObject({ runnerFailed: true })
})
it('keeps a synchronous cwd-owned ENOENT as the original start() error', async () => {
@@ -357,12 +391,11 @@ describe('fail closed', () => {
const failure = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT', syscall: `spawn ${runner}`, path: runner })
vi.spyOn(ctx.subprocess, 'spawn').mockImplementation(() => { throw failure })
try {
let thrown: unknown
try {
await bash.start(bash.resolve({ command: 'true', workdir }))
} catch (error) {
thrown = error
}
const background = (await start(bash, bash.resolve({ command: 'true', workdir })))
await background.done
expect(background.status).toBe('killed')
// A cwd-owned failure keeps the original error: not the runner's fault.
const thrown = await background.result().catch((error: unknown) => error)
expect(thrown).toBe(failure)
expect(thrown).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
@@ -374,7 +407,7 @@ describe('fail closed', () => {
describe('danger-full-access', () => {
it('runs unwrapped: the provider is never consulted, facts carry no enforcement', async () => {
const { bash, calls } = await setup({ mode: 'danger-full-access' })
const result = await bash.run(bash.resolve({ command: 'echo free' }))
const result = await run(bash, bash.resolve({ command: 'echo free' }))
expect(result.stdout.text).toBe('free\n')
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
expect(calls).toHaveLength(0)
@@ -382,7 +415,7 @@ describe('danger-full-access', () => {
it('start() passes through unwrapped and stamps nothing at settle', async () => {
const { bash, calls } = await setup({ mode: 'danger-full-access' })
const task = await bash.start(bash.resolve({ command: 'echo free-bg' }))
const task = (await start(bash, bash.resolve({ command: 'echo free-bg' })))
await task.done
expect(task.sandbox).toBeUndefined()
expect(task.readOutput().delta).toContain('free-bg')
@@ -401,20 +434,20 @@ describe('per-call sandbox policy (the session and escalation carrier)', () => {
const { bash, calls } = await setup()
const explicit = executionPolicy('workspace-write', '/session/project')
expect(bash.resolve({ command: 'true', sandboxPolicy: explicit }).sandboxPolicy).toEqual(explicit)
await bash.run(bash.resolve({ command: 'true', sandboxPolicy: explicit }))
await bash.run(bash.resolve({ command: 'true' }))
await run(bash, bash.resolve({ command: 'true', sandboxPolicy: explicit }))
await run(bash, bash.resolve({ command: 'true' }))
expect(calls.map(call => call.policy)).toEqual([explicit, executionPolicy('read-only')])
})
it('an escalated run reports the mode it ACTUALLY ran under', async () => {
const { bash } = await setup()
const result = await bash.run(bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') }))
const result = await run(bash, bash.resolve({ command: 'true', sandboxPolicy: executionPolicy('workspace-write') }))
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
})
it('escalating to danger-full-access bypasses the provider entirely — the grant, not a probe, is the authority there', async () => {
const { bash, calls } = await setup()
const result = await bash.run(bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') }))
const result = await run(bash, bash.resolve({ command: 'echo free', sandboxPolicy: executionPolicy('danger-full-access') }))
expect(result.stdout.text).toBe('free\n')
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
expect(calls).toHaveLength(0)
@@ -425,8 +458,8 @@ describe('per-call sandbox policy (the session and escalation carrier)', () => {
// once — anything keyed off the configured default would misreport the
// escalated one at its settle stamp.
const { bash } = await setup()
const escalated = await bash.start(bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') }))
const plain = await bash.start(bash.resolve({ command: 'true' }))
const escalated = (await start(bash, bash.resolve({ command: 'sleep 0.3; echo "x: Permission denied" >&2; exit 1', sandboxPolicy: executionPolicy('workspace-write') })))
const plain = (await start(bash, bash.resolve({ command: 'true' })))
await plain.done
await escalated.done
expect(escalated.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
@@ -435,7 +468,7 @@ describe('per-call sandbox policy (the session and escalation carrier)', () => {
it('an escalated danger-full-access background job carries no facts (nothing confined it)', async () => {
const { bash, calls } = await setup()
const task = await bash.start(bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') }))
const task = (await start(bash, bash.resolve({ command: 'echo bg-free', sandboxPolicy: executionPolicy('danger-full-access') })))
await task.done
expect(task.sandbox).toBeUndefined()
expect(task.readOutput().delta).toContain('bg-free')
@@ -478,7 +511,7 @@ describe('result facts', () => {
denialSignatures: UNIX_SIGNATURES,
runnerFailureRules: RUNNER_FAILURE,
}))
const result = await bash.run(bash.resolve({ command: `exit ${exitCode}` }))
const result = await run(bash, bash.resolve({ command: `exit ${exitCode}` }))
expect(result.exitCode).toBe(exitCode)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})
@@ -490,7 +523,7 @@ describe('result facts', () => {
const lockedDir = join(deniedRoot, 'locked')
mkdirSync(lockedDir)
chmodSync(lockedDir, 0o555)
const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
const result = await run(bash, bash.resolve({ command: `echo x > ${lockedDir}/f` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
} finally {
@@ -500,7 +533,7 @@ describe('result facts', () => {
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const result = await bash.run(bash.resolve({ command: 'true' }))
const result = await run(bash, bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
})
@@ -515,7 +548,7 @@ describe('background sandbox facts', () => {
}))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const task = await bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
const task = (await start(bash, bash.resolve({ command: 'true', workdir: join(parent, 'missing') })))
await task.done
expect(task.status).toBe('killed')
@@ -550,7 +583,7 @@ describe('background sandbox facts', () => {
waitForExit: async () => true,
} satisfies SubprocessHandle)
const task = await bash.start(bash.resolve({ command: 'true' }))
const task = (await start(bash, bash.resolve({ command: 'true' })))
await task.done
expect(task.readOutput().delta).toContain('subprocess failed before reporting an outcome: undefined')
@@ -563,7 +596,7 @@ describe('background sandbox facts', () => {
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
const { bash } = await setup()
const task = await bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
const task = (await start(bash, bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' })))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
})
@@ -573,20 +606,20 @@ describe('background sandbox facts', () => {
// the command never ran — the late twin of the confine-time throw, with
// the matched fatal stderr line carried as the cause.
const { bash } = await setup()
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
await expect(run).rejects.toThrow('fake-runner: ruleset rejected')
const pending = run(bash, bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
await expect(pending).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
await expect(pending).rejects.toThrow('fake-runner: ruleset rejected')
})
it('a foreground runner failure outranks denial: runner error text may contain denial words', async () => {
const { bash } = await setup()
await expect(bash.run(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
await expect(run(bash, bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
.rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
})
it('a settled background runner failure stamps runnerFailed (no error channel remains), not denied', async () => {
const { bash } = await setup()
const task = await bash.start(bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' }))
const task = (await start(bash, bash.resolve({ command: 'echo "fake-runner: cannot open rule path: /x: Permission denied" >&2; exit 125' })))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
})
@@ -604,8 +637,8 @@ describe('background sandbox facts', () => {
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
})
const slow = await bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
const quick = await bash.start(bash.resolve({ command: 'true' }))
const slow = (await start(bash, bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' })))
const quick = (await start(bash, bash.resolve({ command: 'true' })))
await quick.done
await slow.done
expect(slow.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
@@ -614,7 +647,7 @@ describe('background sandbox facts', () => {
it('a signal-killed task is never a denial (null exit code)', async () => {
const { bash } = await setup()
const task = await bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
const task = (await start(bash, bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' })))
// Let the stderr land before the kill so the classifier sees the
// signature and must still refuse it on the null exit code alone.
await vi.waitFor(() => { expect(task.readOutput().delta).toContain('Permission denied') })
@@ -625,7 +658,7 @@ describe('background sandbox facts', () => {
it('disposal kills wrapped background jobs (inherited HMR safety)', async () => {
const { ctx, bash } = await setup()
const task = await bash.start(bash.resolve({ command: 'sleep 30' }))
const task = (await start(bash, bash.resolve({ command: 'sleep 30' })))
await ctx.fiber.dispose()
expect(task.status).toBe('killed')
})
@@ -11,6 +11,18 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
}
/** Historical background shorthand: execute with no deadline armed. */
function start(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellExecution> {
return x.execute({ ...spec, onExpiry: 'none' })
}
/**
* Keyless macOS integration of the real provider and executor through public run/start paths.
@@ -53,7 +65,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
it('read-only denies a write — the file must NOT exist, and EPERM text classifies as a denial', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const result = await bash.run(bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
const result = await run(bash, bash.resolve({ command: `echo hi > ${workdir}/denied.txt` }))
expect(result.exitCode).not.toBe(0)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'denied.txt'))).toBe(false)
@@ -67,12 +79,12 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
const outside = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'workspace-write')
const inside = await bash.run(bash.resolve({ command: `printf seatbelt-ok > ${workdir}/allowed.txt` }))
const inside = await run(bash, bash.resolve({ command: `printf seatbelt-ok > ${workdir}/allowed.txt` }))
expect(inside.exitCode).toBe(0)
expect(inside.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'allowed.txt'), 'utf8')).toBe('seatbelt-ok')
const denied = await bash.run(bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
const denied = await run(bash, bash.resolve({ command: `echo hi > ${outside}/denied.txt` }))
expect(denied.exitCode).not.toBe(0)
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
expect(existsSync(join(outside, 'denied.txt'))).toBe(false)
@@ -91,7 +103,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
].join('\n'))
const bash = await sandboxedBash(workdir, 'workspace-write')
await bash.run(bash.resolve({
await run(bash, bash.resolve({
command: 'true',
env: { BASH_ENV: hook },
dshEnv: {
@@ -107,7 +119,7 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
it('classifies a background denial once the task settles', async () => {
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const task = await bash.start(bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` }))
const task = (await start(bash, bash.resolve({ command: `echo hi > ${workdir}/bg-denied.txt` })))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'bg-denied.txt'))).toBe(false)
@@ -117,11 +129,11 @@ describe.skipIf(!seatbeltUsable)('bash-sandbox: real Seatbelt confinement throug
const workdir = await tempDir(homedir())
const bash = await sandboxedBash(workdir, 'read-only')
const command = `printf escalated > ${workdir}/escalated.txt`
const strict = await bash.run(bash.resolve({ command }))
const strict = await run(bash, bash.resolve({ command }))
expect(strict.exitCode).not.toBe(0)
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(existsSync(join(workdir, 'escalated.txt'))).toBe(false)
const retried = await bash.run(bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
const retried = await run(bash, bash.resolve({ command, sandboxPolicy: { mode: 'workspace-write', workspaceRoot: workdir } }))
expect(retried.exitCode).toBe(0)
expect(retried.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(readFileSync(join(workdir, 'escalated.txt'), 'utf8')).toBe('escalated')
+2 -2
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 packages/shell/pwsh-local/README.md
README.md: 752a0c6cdae826102ca2de35ba671f7b116ff20d
README.zh.md: 731f6ccc9b20d06e39e7f447c29dd17bcacba2a1
README.md: 558da2c4a74194a5822eb17ba01853efd021d3dd
README.zh.md: 9cde01a0880a93c2689f37c357cfad49482d5b77
+4 -3
View File
@@ -57,16 +57,17 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
### Running commands
Run a command with `run` and read its output from the result; a nonzero exit, a timeout, or a cancellation resolves descriptively, and only infrastructure failures reject. The command string rides as one argument to `-Command`: PowerShell parses the text itself and no intermediate shell exists, so there is no shell-quoting layer to escape and native Win32 paths pass through unchanged. Every command pins UTF-8 output first, so non-ASCII output is not garbled even on the Windows PowerShell 5.1 fallback. The environment is model-friendly: `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb` — a POSIX concept), with explicit caller-provided entries still winning.
Run a command by awaiting the execution's `result()` projection; a nonzero exit, a timeout, or a cancellation resolves descriptively, and only infrastructure failures reject. The command string rides as one argument to `-Command`: PowerShell parses the text itself and no intermediate shell exists, so there is no shell-quoting layer to escape and native Win32 paths pass through unchanged. Every command pins UTF-8 output first, so non-ASCII output is not garbled even on the Windows PowerShell 5.1 fallback. The environment is model-friendly: `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb` — a POSIX concept), with explicit caller-provided entries still winning.
```text
const result = await ctx.shell.run(ctx.shell.resolve({ command: 'Get-ChildItem' }))
const execution = await ctx.shell.execute(ctx.shell.resolve({ command: 'Get-ChildItem' }))
const result = await execution.result()
if (result.timedOut) console.log('timed out after', result.timeoutMs)
```
### Background processes
Await `start` to run a command in the background; it resolves with the prepared process handle and no execution timeout applies. Cancellation or preparation failure rejects before a handle is published. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
Resolve with `onExpiry: 'none'` and await `execute` to run a command in the background; no deadline is armed. Cancellation or preparation failure rejects before a handle is published. `readOutput()` merges the stream deltas into one consuming read, marking stderr under a `[stderr]` section; `kill()` terminates the provider-managed range; `done` settles when the direct command closes and never rejects. Job ids, ownership, polling, and notices belong to the generic `ctx.jobs` runtime, which the tool layer registers the handle with.
<a id="adjusting-budgets-at-runtime"></a>
### Adjusting budgets at runtime
+4 -3
View File
@@ -57,16 +57,17 @@ kind: "package-reference"
### 运行命令
用 `run` 运行命令并从结果读取输出;非零退出、超时或取消都会返回描述性结果,只有基础设施失败才会导致调用被拒绝。命令字符串作为单个参数传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层,原生 Win32 路径也原样通过。每条命令都先固定 UTF-8 输出,因此即使在 Windows PowerShell 5.1 兜底上,非 ASCII 输出也不会乱码。环境默认面向模型:`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`——那是 POSIX 概念),调用方显式提供的条目仍然优先。
通过等待执行句柄的 `result()` 投影来运行命令;非零退出、超时或取消都会返回描述性结果,只有基础设施失败才会导致调用被拒绝。命令字符串作为单个参数传给 `-Command`:由 PowerShell 自己解析文本,不存在中间 shell,因此没有需要转义的 shell 引号层,原生 Win32 路径也原样通过。每条命令都先固定 UTF-8 输出,因此即使在 Windows PowerShell 5.1 兜底上,非 ASCII 输出也不会乱码。环境默认面向模型:`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`——那是 POSIX 概念),调用方显式提供的条目仍然优先。
```text
const result = await ctx.shell.run(ctx.shell.resolve({ command: 'Get-ChildItem' }))
const execution = await ctx.shell.execute(ctx.shell.resolve({ command: 'Get-ChildItem' }))
const result = await execution.result()
if (result.timedOut) console.log('timed out after', result.timeoutMs)
```
### 后台进程
等待 `start` 即可在后台运行命令;它完成准备后返回进程句柄,且不应用执行超时。取消或准备失败会在发布句柄前拒绝调用。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止由提供方管理的 range;`done` 在 direct command 关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
以 `onExpiry: 'none'` 解析并等待 `execute` 返回句柄即可在后台运行命令;不布置任何 deadline。取消或准备失败会在发布句柄前拒绝调用。`readOutput()` 把流增量合并为一次消费式读取,并在 `[stderr]` 分段下标记 stderr;`kill()` 终止提供方管理的 range;`done` 在直接命令关闭时结算且绝不 reject。job id、所有权、轮询与通知属于通用 `ctx.jobs` 运行时,工具层会把句柄注册进去。
<a id="adjusting-budgets-at-runtime"></a>
### 运行时调整预算
Loaded 100 of 218 files, more files were not shown because too many files have changed in this diff. Show more