diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index ed1df3a4fe..ce21dc1c9a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 049785e9cd..1ebd600e4d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 1dd7ee9215..56d062b507 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -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。 diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml new file mode 100644 index 0000000000..151857373f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md new file mode 100644 index 0000000000..efffdbd85b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md @@ -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 `` 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. diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md new file mode 100644 index 0000000000..206acca008 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md @@ -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 在 `` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。 +- **`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` 一直如此声明。 diff --git a/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.i18n.yaml new file mode 100644 index 0000000000..5359a7b2cd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md new file mode 100644 index 0000000000..2a03cd9f99 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md @@ -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` — 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 ; moved to background job ]` 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: ]` 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. diff --git a/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md new file mode 100644 index 0000000000..bdc6cc432d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md @@ -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`——活的 `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 ; moved to background job ]` 加交接指引——并以一次消费式注册表读取打底,因此 `job_output` 恰好从此处接续。超时那一刻没有任何东西易手:进程没有、中止信号没有、输出也没有。工具从不把自己的信号交给进程;取消调用会杀掉 job,而来自调用之外的杀停(人的停止控件、并行的 `job_kill`)经 job 的 `cancel` 钩子到达进程,工具把其原因渲染为前台结果里的 `[stopped: ]`(值上的 `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` 事实呈现。 diff --git a/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.i18n.yaml b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.i18n.yaml new file mode 100644 index 0000000000..1bc2f5e95d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md new file mode 100644 index 0000000000..5a141b3841 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.zh.md b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.zh.md new file mode 100644 index 0000000000..102a398d95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.zh.md @@ -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 的限制一节。 diff --git a/apps/desktop/tests/host-update-tasks.spec.ts b/apps/desktop/tests/host-update-tasks.spec.ts index 7e1ccc4663..52c92b52ea 100644 --- a/apps/desktop/tests/host-update-tasks.spec.ts +++ b/apps/desktop/tests/host-update-tasks.spec.ts @@ -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 () => {} } } diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 44427ce945..fa53fe4e0a 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -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) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 9aa70ec2bd..c88923e8e6 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -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([]) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 05fc0f28f4..a91c45a1c6 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -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 { + 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 => { diff --git a/apps/web/tests/snapshots/live-job-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md index ada83b7f3e..1eac30142b 100644 --- a/apps/web/tests/snapshots/live-job-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/settled.expected.md @@ -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 diff --git a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md index 566aea30c0..3dfd14e290 100644 --- a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md @@ -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 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1bb4e69c24..1c45cc59da 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4ce0bc0a4f..08821db1fb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b8077cd13f..1cd039f3e9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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) @@ -3160,12 +3160,25 @@ export type TokenMeterConfig = Record ```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) @@ -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) @@ -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) @@ -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 } ``` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 8bdab546de..aa70f43588 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 741593e3f8..2281f3bbd0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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 diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 323d372142..a615881f83 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ec94e38ea0..ff7f0f858c 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 diff --git a/docs/module-graph.md b/docs/module-graph.md index 936324af09..a731583e80 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c68f95c65a..5e76dca74a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -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) | diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index f69e46f9a0..059d83dd5f 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -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 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index d54b1ecdd8..0d235c6101 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -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 + +/** + * 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 +/** + * 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 diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 3b0db7c654..fbb650bbc1 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -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 + +/** + * 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 +/** + * 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 diff --git a/docs/subsystems/shell.i18n.yaml b/docs/subsystems/shell.i18n.yaml index 9c6c29422c..ebf38519d4 100644 --- a/docs/subsystems/shell.i18n.yaml +++ b/docs/subsystems/shell.i18n.yaml @@ -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 diff --git a/docs/subsystems/shell.md b/docs/subsystems/shell.md index 734b9e7c72..ad7e7f12ac 100644 --- a/docs/subsystems/shell.md +++ b/docs/subsystems/shell.md @@ -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 - -/** - * 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 +abstract execute(spec: ShellExecSpec): Promise ``` Source: [`packages/shell/shell/src/index.ts`](../../packages/shell/shell/src/index.ts) diff --git a/docs/subsystems/shell.zh.md b/docs/subsystems/shell.zh.md index bfe455c2e5..f0e9bd6ea0 100644 --- a/docs/subsystems/shell.zh.md +++ b/docs/subsystems/shell.zh.md @@ -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 - -/** - * 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 +abstract execute(spec: ShellExecSpec): Promise ``` Source: [`packages/shell/shell/src/index.ts`](../../packages/shell/shell/src/index.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 8cdff8b24e..a8f481d42c 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -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 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c60d3979e6..104329d7f4 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -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]` — 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]` — 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. @@ -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]` — 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]` — 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": [ diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index dc061b5168..336ac892ad 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -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]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `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]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `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。 @@ -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]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 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]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 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": [ diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml index 31fcbbd285..de0779c23d 100644 --- a/packages/api/job-controller/README.i18n.yaml +++ b/packages/api/job-controller/README.i18n.yaml @@ -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 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md index d3698758cc..ea343a8a20 100644 --- a/packages/api/job-controller/README.md +++ b/packages/api/job-controller/README.md @@ -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) ## 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 diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md index d9744b4d19..ce8f166cec 100644 --- a/packages/api/job-controller/README.zh.md +++ b/packages/api/job-controller/README.zh.md @@ -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" ## 使用本包 -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` 的调用栈上。 ### 配置 diff --git a/packages/api/job-controller/src/client/index.ts b/packages/api/job-controller/src/client/index.ts index 8c4f161ea2..98f9339732 100644 --- a/packages/api/job-controller/src/client/index.ts +++ b/packages/api/job-controller/src/client/index.ts @@ -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. */ diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts index e3eb06a623..f9b900c406 100644 --- a/packages/api/job-controller/src/client/service.ts +++ b/packages/api/job-controller/src/client/service.ts @@ -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 + /** + * 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> } /** 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> } /** 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> { + return this.remote.job.kill({ sessionId, jobId: id }) + } + watchRows(sessionId: SessionId): () => void { return this.acquire( this.rowsEntries, diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts index 3c463dd85c..d056e288e4 100644 --- a/packages/api/job-controller/src/index.ts +++ b/packages/api/job-controller/src/index.ts @@ -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 diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts index aa02d2061f..4e5eceac72 100644 --- a/packages/api/job-controller/src/types.ts +++ b/packages/api/job-controller/src/types.ts @@ -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 { /** diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts index 0dd43b6f41..39db59f0dc 100644 --- a/packages/api/job-controller/tests/client-apply.client.spec.ts +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -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 {})() }, + kill: async (request: unknown) => { + killCalls.push(request) + return { ok: true as const, value: { outcome: 'requested' as const } } + }, } ctx.reflect.provide('remote', { $stream: (options: RemoteStreamOptions) => 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 { @@ -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' }]) + }) }) diff --git a/packages/api/job-controller/tests/kill.host.spec.ts b/packages/api/job-controller/tests/kill.host.spec.ts new file mode 100644 index 0000000000..2a14afac2d --- /dev/null +++ b/packages/api/job-controller/tests/kill.host.spec.ts @@ -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((resolve) => { settle = resolve }), + }), + } + return { spec, cancels, settle: (outcome: JobOutcome) => { settle(outcome) } } +} + +async function registerAgent(ctx: Context, session: Session): Promise { + 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') + }) +}) diff --git a/packages/api/job-controller/tests/model.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts index 1c56724a26..34ea82327f 100644 --- a/packages/api/job-controller/tests/model.client.spec.ts +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -253,6 +253,7 @@ function bench() { const streams: { options: StreamOptions; stream: FakeStream & FakeStream }[] = [] const observeCalls: unknown[] = [] const rowsCalls: unknown[] = [] + const killCalls: unknown[] = [] const remote = { $stream: (options: StreamOptions) => { const stream = new FakeStream() @@ -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(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) diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json index 4e5c8c3f01..7e3b17f92e 100644 --- a/packages/api/job-controller/tsconfig.host.json +++ b/packages/api/job-controller/tsconfig.host.json @@ -15,6 +15,7 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, { "path": "../../core/session" }, { "path": "../../jobs/jobs" }, { "path": "../../typert/protocol" } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 0e8bb05b38..a6f2499759 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 6d8479ea7a..684616e7e6 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -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 diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 2591bbf86f..50b37df21b 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -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 使用它。 ## 目录 diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 0f1cb98168..b91ee92562 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index b6c8f39d40..6f2b856a97 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -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 ## 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. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index 871948194d..b99723e841 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -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" ## 进一步探索 -- [`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 着色是展示层的后续工作。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 4a5d68d808..2777b18ecd 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -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" diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index 8d0cc4440c..c6809102d6 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -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); } diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 95b35d8f53..2bca64cb3b 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -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 } /** 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 }) { const live = isLive(job) @@ -185,15 +207,17 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { {job.label} - {durationCell} - + {job.kind} - {detail ?? status} + {detail !== undefined ? {detail} : null} + {durationCell} {/* A live row is always observable: its output may still arrive. */} - + + + ) : ( @@ -203,28 +227,63 @@ function JobItem({ job, view, expanded, now, onToggle, t }: { {job.label} {detail ?? status} {durationCell} - {observable ? : null} + {observable + ? ( + + + + ) + : 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 (
  • - {observable - ? ( - - ) - : ( - - {body} - - )} +
    + {observable + ? ( + + ) + : ( + + {body} + + )} + {kill !== undefined + ? ( + + ) + : null} +
    {expanded && view !== undefined ? (
    @@ -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(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(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>(() => new Set()) + // One kill affordance advances at a time: arming a row disarms any other. + const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined) const rootRef = useRef(null) const triggerRef = useRef(null) const menuRef = useRef(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): 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 - ?
  • + ? ( +
  • + + +
  • + ) : null} - {settledRows.map(item)} + {settledExpanded ? settledRows.map(item) : null} ) : null} diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 4ee3c3b5de..780b423555 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -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), ) diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts index 1dc263aa19..466288d01e 100644 --- a/packages/client/ui-jobs/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -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 = { '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', diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index c78482198d..8904da41fd 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -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 }> { const ctx = new Context() @@ -48,6 +52,10 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType {} }, + 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 () => { diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index dd50c639c6..26b6bd43a5 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -38,13 +38,14 @@ function props( jobs: readonly JobView[], observe: JobListActionProps['observe'] = () => () => {}, observed: Readonly> = {}, + killJob: JobListActionProps['killJob'] = async () => true, watchRows: JobListActionProps['watchRows'] = () => () => {}, ): JobListActionProps { const jobsState: JobsSnapshot = { rows: jobs.length > 0 ? { [SESSION]: jobs } : {}, observed } function useJobs(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() + const { unmount } = render() expect(watchRows).toHaveBeenCalledWith(SESSION) expect(stop).not.toHaveBeenCalled() unmount() @@ -69,7 +70,7 @@ describe('JobListAction visibility', () => { it('counts live jobs on the trigger', () => { render() expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() @@ -104,21 +105,49 @@ describe('JobListAction rows', () => { render() 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() + 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() 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() - 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() + 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() + 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() 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( { 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() + render() 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() + render() 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() + render() 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() + render() 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( stop)} />) + render( 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() + const { rerender } = render() openList() fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) - rerender() + rerender() 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() + 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() + 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() + 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() + 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() + 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((resolve) => { resolveKill = resolve })) + render() + 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() + openList() + const stop = screen.getByTitle(stopTitle('pnpm run build')) + fireEvent.click(stop) + expect(stop.getAttribute('data-kill-state')).toBe('armed') + rerender() + // 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() + }) +}) diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index 42cee71277..af6d636e93 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -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`) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 28d7b1027d..ae15f20a46 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -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 { + override async execute(spec: ShellExecSpec): Promise { + 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 { - 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), + } } } diff --git a/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts b/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts index 1194f50da0..dc2f998303 100644 --- a/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/sandbox-stack.spec.ts @@ -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 }, spec: ShellExecSpec): Promise { + 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 }, })) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 22b5b1e69f..cc7c901ef3 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -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', - description: 'Run preparation and the foreground command under the resolved timeout.', + signature: 'abstract execute(spec: ShellExecSpec): Promise', + 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', - 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 | 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 | 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 | 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 | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}', + }, + { + name: 'ShellExecution', + declaration: 'export interface ShellExecution extends ShellProcess {\n result(): Promise;\n}', + }, + { + name: 'ShellExpiryPolicy', + declaration: 'export type ShellExpiryPolicy = \'kill\' | \'none\';', }, { name: 'ShellObservedStreams', diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index c4973ed721..197a743fc1 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -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, 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). diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts index c08cf369ad..0aa27fb748 100644 --- a/packages/hooks/hook-protocol/tests/runner.spec.ts +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -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): { - bash: ShellExecutor + bash: Pick specs: ShellExecSpec[] } { const specs: ShellExecSpec[] = [] - const bash = { + const reader = { readFrom: (from: number) => ({ text: '', nextOffset: from, lossy: false }) } + const bash: Pick = { 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): { 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): { sandboxPolicy: request.sandboxPolicy, } }, - async run(spec: ShellExecSpec): Promise { + async execute(spec: ShellExecSpec): Promise { 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 } } diff --git a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts index 8a5a068efa..45aa17b617 100644 --- a/packages/hooks/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude-code/tests/coverage-cases.ts @@ -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' } })) diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 80c5e42bd3..d82322e314 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -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) diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 54ba6905d2..ea68015158 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -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() diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 2b1fc0c08f..8687f18ef9 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -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() diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index a3a87e082e..c12feed8d4 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -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 diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index d5c67eccec..1c7a933d6f 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -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. @@ -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. ### Dev Note diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 41899dcd01..a36924fe3f 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -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 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。 @@ -123,7 +123,7 @@ kind: "package-reference" - **约定是进程内的**——`JobSpec.run()` 传入回调,注册表解析拥有者会话背后的活体 `Agent`;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 - **模型游标是唯一的消耗式读取**——独立观察者使用非消耗的 `readAt`,从不移动它。 -- **前台工作无法转为后台**——生产方在启动前选择前台或后台。 +- **已结算记录会一直列出直到被移除**——由持有者释放、服务释放,或收走它的调用方显式 `remove`;注册表不对已结算 job 保留任何数量上限。 ### 开发备注 diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 57aebe0d17..deb6e2d59f 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -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 + /** + * 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 diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index e607ee99f9..3858cf5085 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -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' diff --git a/packages/jobs/jobs/tests/invariant.spec.ts b/packages/jobs/jobs/tests/invariant.spec.ts index df52f9780f..0f4defcbc9 100644 --- a/packages/jobs/jobs/tests/invariant.spec.ts +++ b/packages/jobs/jobs/tests/invariant.spec.ts @@ -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 }) => { diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 3b87863c70..25cf2e292f 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -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 () => {} } diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml index 6185c49fd5..f6b8dc12ed 100644 --- a/packages/jobs/tool-jobs/README.i18n.yaml +++ b/packages/jobs/tool-jobs/README.i18n.yaml @@ -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 diff --git a/packages/jobs/tool-jobs/README.md b/packages/jobs/tool-jobs/README.md index 1c4c60a32e..38cf89961d 100644 --- a/packages/jobs/tool-jobs/README.md +++ b/packages/jobs/tool-jobs/README.md @@ -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 (: