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 indsh-tool-jobs, which claims a job for the model's ownjob_killand for its waits, so ajob.killfrom 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
killedsettlement'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 (settlescompleted/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.killis a Job Controller Remote (@deepseek-ai/dsh-api-job-controller, besidejob.follow; it began life assession.killJobon the Session Controller and moved with the observation stream when that package split out) with the same live-only Agent lookup (ctx.agents.get) assession.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 onejob/not-foundrejection scoped to the lookup — a producer-cancel throw propagates per the registry contract instead of masquerading as a lookup failure; the controller requiresctx.jobsto 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+yand OpenCode double-Esc shape), rendered only on running job rows — rows with ajobId. 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 addressesctx.jobsby id, every job kind gains the control at once — bash, pwsh, pty, and one-shot background subagents.
Alternatives considered
- A
notify: booleanoption — 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 (stopvsstopByUser) 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
wakeupdelivery) — a deliberate cost: an unclaimed completion the model never learns about is the failure this note exists to fix. kill's positionalreasonparameter is gone (pre-release, no shim); the options object is the only form.- A killed job's
detailmay now carry two clauses joined by;. Anything parsingdetailas a single producer fact must treat it as opaque text, whichJobView.detailalways 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一直如此声明。