DSH / Atlas
2026-08-26implementedfeaturearchived 2026-09-30

Human job kill — an unclaimed terminal report instead of a second cancellation path

人工任务终止——用「未认领的终态报告」而非第二条取消路径

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 alr

Source compatibility: Chinese title uses a compatible non-canonical header

English

Problem

The web job display note shipped the task list read-only and recorded why: JobRegistry.kill() marks the job reported, and the dsh-tool-jobs completion reporter suppresses the settlement notice for a reported job. That coupling is correct for the one caller that existed — the model's job_kill, whose own tool result already tells the model what it did — but a human pressing a stop button has no model-visible channel at all. A kill written against that contract would leave the model believing its task is still running, exactly the stale-world-model failure Claude Code ships today (its /tasks kill sets notified: true and the model learns nothing) and Kimi avoids (a model TaskStop suppresses its own notification; a human stop delivers one).

Decision

A delivery claim means "the terminal state has a committed delivery path to the model", so the fix is to stop conflating cancellation with claiming that delivery.

  • A human kill claims no delivery. JobRegistry.kill(id, { reason? }) records the reason only; the delivery ledger lives in dsh-tool-jobs, which claims a job for the model's own job_kill and for its waits, so a job.kill from the task list leaves the settlement to the existing completion reporter (wakeup/inject, wake budget, truncation all unchanged). It never clears a claim the model already holds, and a terminal record stays exactly as settlement left it.
  • A recorded kill reason merges into a killed settlement's detail — producer facts first (signal: SIGTERM; cancelled by the user) — so the completion notice and the web row both say who stopped the work without a new snapshot field or notice template. A job that outruns its kill (settles completed/failed) keeps the producer detail alone. This is the reconciliation Codex spells out in its <turn_aborted> guidance: when the user stops something, the model is told rather than left to infer.
  • job.kill is a Job Controller Remote (@deepseek-ai/dsh-api-job-controller, beside job.follow; it began life as session.killJob on the Session Controller and moved with the observation stream when that package split out) with the same live-only Agent lookup (ctx.agents.get) as session.cancel: a running job's owner is alive by the registry's ownership contract, and killing from a list must not revive a Session any more than listing does. Unknown and foreign jobs collapse to one job/not-found rejection scoped to the lookup — a producer-cancel throw propagates per the registry contract instead of masquerading as a lookup failure; the controller requires ctx.jobs to load, so a composition without a registry has no kill Remote at all. The registry's owner fence is the only access rule: the Session Controller's subagent ownership fence is not applied, so a child session's own jobs are killable from its list (review rejected exporting that fence's helpers from the Session Controller package, and the rule adds nothing for a job the child owns), and the controller depends on neither the agent registry nor the Session Controller. No approval interaction: this single-user local BFF treats it as the same class of action as the turn-cancel button.
  • The task list's stop control is two-press (arm, then confirm within 3s, matching the Kimi s+y and OpenCode double-Esc shape), rendered only on running job rows — rows with a jobId. Standalone activity rows (workflow runs) have no kill handle and no button. The pending press disables the control and stays pending on an admitted kill — the unary response and the jobs frames have no cross-carrier ordering, so only the authoritative frame (the row leaving the killable set) releases it; a rejected kill shows a brief hint. Because the kill addresses ctx.jobs by id, every job kind gains the control at once — bash, pwsh, pty, and one-shot background subagents.

Alternatives considered

  • A notify: boolean option — rejected because the registry cannot promise a notice (quiet delivery, unowned jobs, owner teardown all legitimately drop one); the ledger's claim names the fact the tool actually controls: whether one of its own tool results already delivered the terminal state.
  • A separate interrupt()/stopByUser() method — one cancellation path with an explicit claim beats two methods whose only difference is a boolean, and Kimi's split (stop vs stopByUser) shipped with its TUI calling the wrong one.
  • Saying nothing to the model (Claude Code's shape) — rejected; their own source comments question it, and the DSH blocker note recorded the stale-belief failure as the reason the control did not ship earlier.

Testing

tool-jobs pins the ledger (a kill the model did not request leaves the notice due; its own job_kill and waits claim it) and the delivered notice text verbatim, reason included; jobs-local pins the detail merge on killed and the outran-kill case. kill.host.spec.ts pins the Remote command: admission, unowned-job kill from an agentless session, job/not-found for unknown/foreign. The client suites pin the RPC passthrough and the two-press control (arm, confirm, disarm timer, failure hint, single armed row, stale-phase cleanup); the keyless web e2e drives the button end to end.

Consequences

  • The completion notice for a human kill wakes an idle owner (default wakeup delivery) — a deliberate cost: an unclaimed completion the model never learns about is the failure this note exists to fix.
  • kill's positional reason parameter is gone (pre-release, no shim); the options object is the only form.
  • A killed job's detail may now carry two clauses joined by ; . Anything parsing detail as a single producer fact must treat it as opaque text, which JobView.detail always declared it to be.

中文

问题

Web 任务展示 Note 把任务列表交付为只读并记下了原因:JobRegistry.kill() 会把任务标记为 reported,而 dsh-tool-jobs 的完成播报员对已 reported 的任务抑制结算通知。这个耦合对当时唯一的调用方是正确的——模型的 job_kill 自己的 tool result 已经告诉模型它做了什么——但按下停止按钮的人没有任何模型可见渠道。照那份契约写一个人工 kill,模型会一直以为任务还在跑,正是 Claude Code 今天在售的过期世界模型失败(其 /tasks kill 置 notified: true,模型什么也学不到),也正是 Kimi 避开的(模型 TaskStop 抑制自己的通知;人工 stop 投递一条)。

决策

送达认领的本义是「终态已有一条向模型送达的承诺路径」,所以修法是不再把「取消」与「认领这次送达」混为一谈。

  • 人类的 kill 不认领送达。 JobRegistry.kill(id, { reason? }) 只记录原因;送达台账在 dsh-tool-jobs 里,它只为模型自己的 job_kill 与等待认领任务,因此来自任务列表的 job.kill 让结算照常走完成播报员(唤醒/注入、唤醒预算、截断全部不变)。它绝不清除模型已持有的认领,终态记录也保持结算时的原样。
  • 记录下来的 kill reason 合入 killed 结算的 detail——生产者事实在前(signal: SIGTERM; cancelled by the user)——完成通知与 Web 行因此都能说清是谁停的工作,无需新快照字段或新通知模板。跑赢 kill 的任务(结算为 completed/failed)只保留生产者 detail。这正是 Codex 在 <turn_aborted> 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。
  • job.kill 是 Job Controller 的 Remote(@deepseek-ai/dsh-api-job-controller,与 job.follow 并列;它最初是 Session Controller 上的 session.killJob,该包拆出时随观测流一起迁走),与 session.cancel 一样只查活体 Agent(ctx.agents.get):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个只作用于查找的 job/not-found 拒绝——生产者 cancel 抛错按注册表契约原样传播,绝不伪装成查找失败;控制器加载就要求 ctx.jobs,没有注册表的组合根本没有 kill Remote。注册表的所有者围栏是唯一的访问规则:不施加 Session Controller 的 subagent 所有权栅栏,子会话自己的 job 可以从它的列表里被杀停(评审否决了从 Session Controller 包导出该栅栏的实现函数,而这条规则对子会话自有的 job 也没有额外意义),控制器既不依赖 agent 注册表也不依赖 Session Controller。不加 approval 交互:这个单用户本地 BFF 把它视为与停轮按钮同级的操作。
  • 任务列表的停止控件为两击式(先武装、3 秒内确认,对齐 Kimi 的 s+y 与 OpenCode 的双 Esc),只渲染在运行中的 job 行——即带 jobId 的行。独立 activity 行(workflow 运行)没有 kill 句柄也就没有按钮。请求进行中按钮禁用,且受理成功后保持 pending——unary 响应与 jobs 帧没有跨载体顺序保证,只有权威帧(行离开可杀集合)才释放控件;被拒绝的 kill 短暂提示。由于 kill 按 id 寻址 ctx.jobs,所有任务 kind 一次性获得该控件——bash、pwsh、pty 与一次性后台 subagent。

备选方案

  • notify: boolean 选项——否决:注册表承诺不了通知(quiet 投递、无主任务、owner 拆除都会正当地丢弃一条);台账的认领命名的是工具真正控制的事实:它自己的某个 tool result 是否已经送达了终态。
  • 独立的 interrupt()/stopByUser() 方法——一条取消路径带显式认领胜过两个只差一个布尔的方法;Kimi 的拆分(stop 与 stopByUser)上线时 TUI 就调错了那个。
  • 对模型保持沉默(Claude Code 的形状)——否决;他们自己的源码注释都在质疑它,而 DSH 的阻塞 Note 正是把过期认知失败记为该控件此前不能上线的原因。

测试

tool-jobs 钉住台账(不是模型请求的 kill 让通知保持待送达;它自己的 job_kill 与等待认领它)与逐字的带原因投递通知文本;jobs-local 钉住 killed 的 detail 合并与跑赢 kill 的情形。kill.host.spec.ts 钉住 Remote 命令:受理、无 agent 会话 kill 无主任务、未知/他人的 job/not-found。客户端套件钉住 RPC 透传与两击控件(武装、确认、解除计时、失败提示、单一武装行、过期相位清理);无密钥 web e2e 端到端驱动按钮。

后果

  • 人工 kill 的完成通知会唤醒空闲 owner(默认 wakeup 投递)——这是刻意的代价:模型永远学不到的未认领完成,正是本 Note 要修的失败。
  • kill 的位置参数 reason 已删除(pre-release,无垫片);options 对象是唯一形式。
  • 被 kill 任务的 detail 可能携带以 ; 连接的两个子句。任何把 detail 当单一生产者事实解析的代码都必须把它当不透明文本——JobView.detail 一直如此声明。