The registry's owner fence is the only access rule for a human kill: a job is killable by the session whose list carries it, and a child session's own jobs are no exception. The fence had pulled two implementation helpers out of the Session Controller's package entry (plus a safe-host-export exception for them) so the job controller could import them; both go, and the job controller no longer depends on the agent registry or the Session Controller.
6.3 KiB
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. 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 | 中文
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.