feat(jobs): human job kill with an unclaimed terminal report

The web task list gains a two-press stop control on running job rows, built on
one seam change: JobRegistry.kill now takes { reason?, reported? } instead of a
positional reason. reported defaults to true (the model's job_kill claims the
terminal report exactly as before); the web path passes reported: false because
a human kill has no model-visible channel, so the settlement flows through the
existing tool-jobs completion notice (wakeup/inject, budgets, truncation all
unchanged). A recorded kill reason merges into a killed settlement's detail
(producer facts first: 'signal: SIGTERM; cancelled by the user'), so the notice
and the web row both say who stopped the work.

- session-controller: session.killJob Remote beside cancel, live-only Agent
  lookup, job-not-found/jobs-unavailable rejections joined to the closed RPC
  error table; ClientSession.killJob and the ISession face carry it to the UI.
- ui-activity: arm/confirm stop button (3s disarm, pending disable, failure
  hint), rendered only on running job rows; standalone activities keep no kill
  handle and the README limitation now says so.
- The background-job-list web e2e now drives the whole human path through the
  button; goldens show the merged killed detail end to end.
- Agent Note 2026-08-26-human-job-kill records the decision against Kimi's
  suppress-own-notification precedent and Claude Code's silent-kill failure;
  the 2026-08-08 blocker note and the unified-task-list note point at it.
This commit is contained in:
Yichen Jiang committed 2026-08-26 14:15:44 +08:00
1 parent 47ee13dd74
commit 51f74910ae
67 files changed
+948 -107

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
2026-08-08-web-background-job-display.md: 23074b5d38cdab8c45925c75a7d0b851f7be2ece
2026-08-08-web-background-job-display.zh.md: 06645267ee8afadf284b7654b0cd4a672fe7e4b4
2026-08-08-web-background-job-display.md: 218bef290094856112dc64cae66c4b3829980029
2026-08-08-web-background-job-display.zh.md: 6aad4daad53a1ca9863df23305c4ca5e935b73e5
@@ -95,7 +95,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.
@@ -129,7 +129,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.
@@ -95,7 +95,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`,完成通知因此仍会投递。
**帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。
@@ -129,7 +129,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
**终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。
**`stopping` 很少可见。** 只有模型的 `job_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。
**`stopping` 在每条 kill 路径上都可见。** 模型的 `job_kill` 与 Web 停止控件都会产生它;从一开始就把它放进联合类型,正是人工 kill 这一期不必动线路格式的原因。
**一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。
@@ -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-25-unified-task-list.md
2026-08-25-unified-task-list.md: e67ca84d96424e723f6a5660cbb2b02733e42892
2026-08-25-unified-task-list.zh.md: 2a71b04ec33b635478bd4897e8777abb3081a8df
2026-08-25-unified-task-list.md: e4ba43e9e599df7f5d7c4557986fbe0e693bedc3
2026-08-25-unified-task-list.zh.md: fde7acbec5ad037722b00551b0e75fc1557dc705
@@ -42,5 +42,5 @@ Component suites cover the join (job lifecycle wins, activity supplies the panel
## Consequences
- The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end.
- A human kill control on live rows is still blocked on the jobs `reported` contract question recorded in the job-display note.
- The human kill control on live rows shipped later, once [the human job kill note](2026-08-26-human-job-kill.md) resolved the jobs `reported` contract question.
- The `job` locale namespace is gone with its package; `activity` owns the merged copy including the duration vocabulary.
@@ -42,5 +42,5 @@
## 后果
- 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。
- live 行上的人工 kill 控件仍被 job-display note 记录的 jobs `reported` 契约问题阻塞。
- live 行上的人工 kill 控件在 [人工任务终止 Note](2026-08-26-human-job-kill.zh.md) 解决 jobs `reported` 契约问题后随即上线。
- `job` 词典命名空间随包一起消失;`activity` 拥有合并后的全部文案,包括时长词汇。
@@ -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: 9ef2d66e129ea70fa927528717f87e4cfec66a1b
2026-08-26-human-job-kill.zh.md: 1b9933b5af48b736fce4a6562338ff9f1d75c6d4
@@ -0,0 +1,34 @@
# Agent Note: Human job kill — an unclaimed terminal report instead of a second cancellation path
Status: implemented
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
The `reported` bit means "the terminal state has a committed delivery path to the model", so the fix is to stop conflating cancellation with claiming that delivery.
- **`kill(id, caller?, options?)` takes `{ reason?, reported? }`.** `reported` defaults to `true` — the exact previous semantics for the model's `job_kill`. `reported: false` withholds this kill's claim, so the settlement flows through the existing completion reporter (wakeup/inject, wake budget, truncation all unchanged). It never clears an existing claim: a job the model already killed stays reported, 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.
- **`session.killJob` is a Session Controller Remote** beside `cancel`, with the same live-only Agent lookup (`ctx.agents.get`): 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; a composition without `ctx.jobs` rejects `jobs-unavailable`. 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; the row's own `stopping` flip from the jobs frames removes it, and 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); `reported` names the fact the registry actually controls, in the vocabulary `JobSnapshot.reported` already documents.
- **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
`jobs-local` pins the unreporting kill (notice stays due, existing claims survive, terminal records untouched), the detail merge on `killed`, and the outran-kill case; `tool-jobs` pins the delivered notice text verbatim, reason included. `kill-job.host.spec.ts` pins the Remote command: admission, unowned-job kill from an agentless session, `job-not-found` for unknown/foreign, `jobs-unavailable` without the registry. 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 `JobSnapshot.detail` always declared it to be.
@@ -0,0 +1,34 @@
# Agent Note:人工任务终止——用「未认领的终态报告」而非第二条取消路径
状态:已实现
[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 投递一条)。
## 决策
`reported` 位的本义是「终态已有一条向模型送达的承诺路径」,所以修法是不再把「取消」与「认领这次送达」混为一谈。
- **`kill(id, caller?, options?)` 接受 `{ reason?, reported? }`。** `reported` 默认 `true`——即模型 `job_kill` 的原有语义。`reported: false` 只是不提出本次 kill 的认领,结算随即照常走完成播报员(唤醒/注入、唤醒预算、截断全部不变)。它绝不清除已有认领:模型已 kill 过的任务保持 reported,终态记录也保持结算时的原样。
- **记录下来的 kill reason 合入 `killed` 结算的 detail**——生产者事实在前(`signal: SIGTERM; cancelled by the user`)——完成通知与 Web 行因此都能说清是谁停的工作,无需新快照字段或新通知模板。跑赢 kill 的任务(结算为 `completed`/`failed`)只保留生产者 detail。这正是 Codex 在 `<turn_aborted>` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。
- **`session.killJob` 是 Session Controller 的 Remote**,与 `cancel` 并列,同样只查活体 Agent(`ctx.agents.get`):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个 `job-not-found` 拒绝;没有 `ctx.jobs` 的组合拒绝 `jobs-unavailable`。不加 approval 交互:这个单用户本地 BFF 把它视为与停轮按钮同级的操作。
- **任务列表的停止控件为两击式**(先武装、3 秒内确认,对齐 Kimi 的 `s`+`y` 与 OpenCode 的双 Esc),只渲染在运行中的 job 行——即带 `jobId` 的行。独立 activity 行(workflow 运行)没有 kill 句柄也就没有按钮。请求进行中按钮禁用;jobs 帧把行翻成 `stopping` 即移除按钮,被拒绝的 kill 短暂提示。由于 kill 按 id 寻址 `ctx.jobs`,所有任务 kind 一次性获得该控件——bash、pwsh、pty 与一次性后台 subagent。
## 备选方案
- **`notify: boolean` 选项**——否决:注册表承诺不了通知(quiet 投递、无主任务、owner 拆除都会正当地丢弃一条);`reported` 命名的是注册表真正控制的事实,用的是 `JobSnapshot.reported` 已有的词汇。
- **独立的 `interrupt()`/`stopByUser()` 方法**——一条取消路径带显式认领胜过两个只差一个布尔的方法;Kimi 的拆分(`stop` 与 `stopByUser`)上线时 TUI 就调错了那个。
- **对模型保持沉默(Claude Code 的形状)**——否决;他们自己的源码注释都在质疑它,而 DSH 的阻塞 Note 正是把过期认知失败记为该控件此前不能上线的原因。
## 测试
`jobs-local` 钉住不认领 kill(通知仍欠、既有认领存续、终态记录不动)、`killed` 的 detail 合并与跑赢 kill 的情形;`tool-jobs` 逐字钉住带原因的投递通知文本。`kill-job.host.spec.ts` 钉住 Remote 命令:受理、无 agent 会话 kill 无主任务、未知/他人的 `job-not-found`、无注册表的 `jobs-unavailable`。客户端套件钉住 RPC 透传与两击控件(武装、确认、解除计时、失败提示、单一武装行、过期相位清理);无密钥 web e2e 端到端驱动按钮。
## 后果
- 人工 kill 的完成通知会唤醒空闲 owner(默认 `wakeup` 投递)——这是刻意的代价:模型永远学不到的未认领完成,正是本 Note 要修的失败。
- `kill` 的位置参数 `reason` 已删除(pre-release,无垫片);options 对象是唯一形式。
- 被 kill 任务的 `detail` 可能携带以 `; ` 连接的两个子句。任何把 `detail` 当单一生产者事实解析的代码都必须把它当不透明文本——`JobSnapshot.detail` 一直如此声明。
+16 -3
View File
@@ -107,12 +107,25 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('flips the open list to the cancelled outcome when the registry settles it', async () => {
it('kills the running job from the two-press stop control and settles the row', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled'))
expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested')
// The whole human path: arm, confirm, session.killJob, 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()
const idle = page.getByRole('button', { name: '1 task' })
// Exact: the running trigger's name ('1 task running') contains this label.
const idle = page.getByRole('button', { name: '1 task', 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: 'Tasks' }).textContent(),
{ timeout: 15_000 },
).toContain('cancelled by the user')
expect(scaffold.ctx.jobs.get(jobId, agent).status).toBe('killed')
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
+1 -1
View File
@@ -114,7 +114,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => {
it('settles the open panel when the job is killed, keeping the streamed tail', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-settled'))
expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested')
expect(scaffold.ctx.jobs.kill(jobId, agent, { reason: 'web e2e cancellation' })).toBe('requested')
// Settlement arrives on the observation stream itself, so the open panel
// flips without any further interaction; the roster trigger follows.
@@ -1,7 +1,7 @@
- list "Tasks":
- listitem:
- button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]:
- text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM {{duration}}"
- text: "bash printf 'streamed-%s\\n' marker-line; sleep 45 signal: SIGTERM; web e2e cancellation {{duration}}"
- img
- text: $ printf 'streamed-%s\n' marker-line; sleep 45
- button "Copy"
@@ -3,6 +3,8 @@
- button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]:
- text: printf 'streamed-%s\n' marker-line; sleep 45 {{duration}} bash running
- img
- button "Stop task printf 'streamed-%s\\n' marker-line; sleep 45":
- img
- text: $ printf 'streamed-%s\n' marker-line; sleep 45
- button "Copy"
- text: streamed-marker-line
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: d1bb632bd0a46e00617efb797ae916264fa4185c
config-catalog.zh.md: 789b61eeca37af88376841c53efb254f2d6be67d
config-catalog.md: 3e49f1503d25b8c05b03fa38d23ae279d0616738
config-catalog.zh.md: d5b089c96e6c896e2a056585c81c87de40698cd2
+1 -1
View File
@@ -322,7 +322,7 @@ export interface Config {
}
```
Source: [`packages/api/session-controller/src/index.ts:58`](../packages/api/session-controller/src/index.ts)
Source: [`packages/api/session-controller/src/index.ts:60`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-attachment-local"></a>
+1 -1
View File
@@ -324,7 +324,7 @@ export interface Config {
}
```
来源:[`packages/api/session-controller/src/index.ts:58`](../packages/api/session-controller/src/index.ts)
来源:[`packages/api/session-controller/src/index.ts:60`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-attachment-local"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 4c8e04139271c4234747594246b00831d396076f
event-producer-consumer.zh.md: dba5f4b86fedde89a5b6816a8a8e078cb642c0a7
event-producer-consumer.md: d8306504afd157e8cafd5449b700eaa6a7f8ae21
event-producer-consumer.zh.md: cfaf4329bff34c181561b9bd7250215bc36a2718
+5 -5
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:515`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:501`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:508`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+5 -5
View File
@@ -23,11 +23,11 @@
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:515`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:501`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:508`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/jobs.md
jobs.md: 092da7b6f7e10c5713d3d6c9c5c46f925084f86a
jobs.zh.md: 9dc390eb30901664edb47c6727a54ddd83839fa7
jobs.md: 5aa33d0433f5f43237353b686a9528d8266de260
jobs.zh.md: 888870464757c3296f188892d17c9c5954ebf8fe
+8 -5
View File
@@ -217,15 +217,18 @@ abstract get(id: JobId, caller?: Agent): JobSnapshot
abstract read(id: JobId, caller?: Agent): JobRead
/**
* Request cancellation, then mark the job stopping and reported. A producer
* throw propagates without changing job state. Throws for an unknown or
* foreign job.
* Request cancellation, then mark the job stopping. By default the kill also
* claims the terminal report ({@link JobKillOptions.reported}); a kill with
* `reported: false` leaves the settlement notice due instead. A recorded
* {@link JobKillOptions.reason} merges into the terminal `detail` when the
* job settles `killed`. A producer throw propagates without changing job
* state. Throws for an unknown or foreign job.
* @param id - job to cancel.
* @param caller - killing agent checked against the owner.
* @param reason - logged reason forwarded to the producer.
* @param options - cancellation reason and terminal-report claim.
* @returns `requested` for live work, otherwise `already-finished`.
*/
abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
abstract kill(id: JobId, caller?: Agent, options?: JobKillOptions): 'requested' | 'already-finished'
/**
* Wait for settlement or timeout without cancelling the job. Caller abort
+8 -5
View File
@@ -217,15 +217,18 @@ abstract get(id: JobId, caller?: Agent): JobSnapshot
abstract read(id: JobId, caller?: Agent): JobRead
/**
* Request cancellation, then mark the job stopping and reported. A producer
* throw propagates without changing job state. Throws for an unknown or
* foreign job.
* Request cancellation, then mark the job stopping. By default the kill also
* claims the terminal report ({@link JobKillOptions.reported}); a kill with
* `reported: false` leaves the settlement notice due instead. A recorded
* {@link JobKillOptions.reason} merges into the terminal `detail` when the
* job settles `killed`. A producer throw propagates without changing job
* state. Throws for an unknown or foreign job.
* @param id - job to cancel.
* @param caller - killing agent checked against the owner.
* @param reason - logged reason forwarded to the producer.
* @param options - cancellation reason and terminal-report claim.
* @returns `requested` for live work, otherwise `already-finished`.
*/
abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
abstract kill(id: JobId, caller?: Agent, options?: JobKillOptions): 'requested' | 'already-finished'
/**
* Wait for settlement or timeout without cancelling the job. Caller abort
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
session.md: ef24802d7ea567681f935fea86efaab2eb2b80c0
session.zh.md: bee235657d1c1d1a6009eb23d51b8c88482c0748
session.md: 867dd555db54a1c01a95d1b1a5f3b94da7c01ac9
session.zh.md: 46a7d8bb7b7b87f724606b274c1ea749be8a1787
+8
View File
@@ -680,6 +680,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
*/
@Remote('cancel') cancel(request: SessionCancelRequest): SessionCancelValue
/**
* Kill one background job on a human's behalf, leaving the terminal report
* unclaimed so the owning agent still receives the completion notice.
* @param request - Session whose task list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('killJob') killJob(request: SessionKillJobRequest): SessionKillJobValue
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
+8
View File
@@ -684,6 +684,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
*/
@Remote('cancel') cancel(request: SessionCancelRequest): SessionCancelValue
/**
* Kill one background job on a human's behalf, leaving the terminal report
* unclaimed so the owning agent still receives the completion notice.
* @param request - Session whose task list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('killJob') killJob(request: SessionKillJobRequest): SessionKillJobValue
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
@@ -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: 815276847f538f99352e0f0e55fc19af5da67470
README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b
README.md: 2b02b394c3e47398b15db62014864a227ad4f90f
README.zh.md: 524b8c67fad7317190ed1045eaa8267a88945f72
+2 -2
View File
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
## Summary
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state. Use it through API Gateway when a Client needs these Session operations.
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, human background-job kill, message-aligned history, live log following, and Host-wide control state. Use it through API Gateway when a Client needs these Session operations.
## Table of Contents
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation, cancellation, and background-job kill require the corresponding live state (the job kill leaves the terminal report unclaimed so the owning agent still receives its completion notice); model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
+2 -2
View File
@@ -8,7 +8,7 @@ kind: "package-reference"
## 概述
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。当 Client 需要这些 Session 操作时,请通过 API Gateway 使用它。
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、人工后台任务终止、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。当 Client 需要这些 Session 操作时,请通过 API Gateway 使用它。
## 目录
@@ -25,7 +25,7 @@ kind: "package-reference"
历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更、取消和后台任务终止要求对应 live 状态仍然存在(任务终止不认领终态报告,owner agent 因此照常收到完成通知);模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
@@ -65,6 +65,14 @@ export interface ISession {
* @returns acceptance, or the business error.
*/
cancel(): Promise<ClientResult<{ accepted: true }>>
/**
* Kill one background job from this session's task list. The owning agent
* still receives the standard completion notice, because a human kill has no
* other model-visible channel; row state converges through the jobs frames.
* @param jobId - the job row's registry id.
* @returns the registry's admission, or the business/transport error.
*/
killJob(jobId: string): Promise<ClientResult<{ outcome: 'requested' | 'already-finished' }>>
/**
* Rename this session (explicit user title; pins it against automatic
* regeneration).
@@ -6,6 +6,7 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
import type {
IApiClient, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
@@ -326,6 +327,23 @@ export class Session implements SessionFace {
return result
}
/**
* Kill one background job from this session's task list. Pure RPC passthrough:
* row state converges through the jobs control frames, and the caller (the
* task-list control) owns error presentation, so no snapshot state changes here.
* @param jobId - the job row's registry id.
* @returns the registry's admission of the kill request.
*/
async killJob(jobId: string): Promise<ClientResult<{ outcome: 'requested' | 'already-finished' }>> {
try {
// The brand is nominal typing only; a client program stamps it at the
// wire boundary rather than value-importing the host-side constructor.
return toSessionResult(await this.remote.session.killJob({ sessionId: this.sessionId, jobId: jobId as JobId }))
} catch (error) {
return transportResult(error)
}
}
/**
* Rename: contract session.rename 1:1. On success settle the 'title'
* projection cell from the response's `{title, seq}` under the store's
@@ -10,6 +10,7 @@ import {
ReasoningEffortId, createUserMessage, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-jobs'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -35,6 +36,8 @@ import type {
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionKillJobRequest,
SessionKillJobValue,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
@@ -442,6 +445,38 @@ export class SessionCommandController {
return { accepted: true }
}
/**
* Kill one background job on a human's behalf. The kill leaves the terminal
* report unclaimed (`reported: false`), so the owning agent still receives
* the standard completion notice — a human cancellation has no other
* model-visible channel. The Agent lookup is live-only: a running job's
* owner is alive by the registry's ownership contract, and listing never
* revives a Session, so neither does killing from its list.
* @param request - Session whose task list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
killJob(request: SessionKillJobRequest): SessionKillJobValue {
const jobs = this.ctx.get('jobs')
if (jobs === undefined) {
reject('jobs-unavailable', 'background jobs are not available in this composition', {})
}
const agent = this.ctx.agents.get(request.sessionId)
try {
const outcome = jobs.kill(request.jobId, agent, {
reason: 'cancelled by the user',
reported: false,
})
return { outcome }
} 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.
reject('job-not-found', String(error), {
sessionId: request.sessionId,
jobId: request.jobId,
})
}
}
private async resolveAgent(sessionId: SessionId): Promise<Agent> {
const found = await this.agents.resolveAgent(sessionId)
if ('error' in found) rejectFailure(found.error)
@@ -28,6 +28,8 @@ import type {
SessionFollowRequest,
SessionForkRequest,
SessionForkValue,
SessionKillJobRequest,
SessionKillJobValue,
SessionListRequest,
SessionListValue,
SessionPage,
@@ -276,6 +278,17 @@ export class SessionController extends TypertRemoteService {
return this.commands.cancel(request)
}
/**
* Kill one background job on a human's behalf, leaving the terminal report
* unclaimed so the owning agent still receives the completion notice.
* @param request - Session whose task list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('killJob')
killJob(request: SessionKillJobRequest): SessionKillJobValue {
return this.commands.killJob(request)
}
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
@@ -199,6 +199,8 @@ export interface SessionErrorDetailsMap {
'attachment-error': { readonly reason: string }
'queue-item-not-found': { readonly itemId: MessageId }
'steer-unavailable': { readonly itemId: MessageId }
'job-not-found': { readonly sessionId: SessionId; readonly jobId: JobId }
'jobs-unavailable': Record<never, never>
'title-invalid': { readonly sessionId: SessionId }
'fork-unavailable': { readonly sessionId: SessionId }
'subagent-not-found': {
@@ -340,6 +342,17 @@ export interface SessionCancelValue {
readonly accepted: true
}
/** Human-initiated cancellation of one background job visible to a Session. */
export interface SessionKillJobRequest {
readonly sessionId: SessionId
readonly jobId: JobId
}
/** Receipt after the registry accepted the human kill request. */
export interface SessionKillJobValue {
readonly outcome: 'requested' | 'already-finished'
}
/** Client-minted prompt identity used to reconcile optimistic and durable messages. */
export type SessionRequestId = Branded<'session-request-id'>
@@ -134,13 +134,14 @@ describe('Session control jobs updates', () => {
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent })
ctx.jobs.kill(id, agent, 'test')
ctx.jobs.kill(id, agent, { reason: 'test' })
task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
const frames = await collected
expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id])
expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed'])
expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM')
// The registry merges the recorded kill reason into the killed detail.
expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM; test')
expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number')
})
@@ -205,7 +206,7 @@ describe('Session control jobs updates', () => {
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent })
ctx.jobs.kill(id, agent, 'test')
ctx.jobs.kill(id, agent, { reason: 'test' })
task.settle({ status: 'killed', detail: 'signal: SIGTERM' })
await collected
@@ -1,6 +1,7 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
@@ -100,6 +101,32 @@ describe('SessionController facade', () => {
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
})
it('delegates killJob to the command controller', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('controller-facade-test')
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([]),
inspect: () => Promise.resolve(undefined),
}) as never)
const controller = createSessionTestController(ctx, defaults)
const session = ctx.sessions.create()
// An unowned job is killable from any session, live agent or not.
let settle!: (outcome: { status: 'killed' }) => void
const id = ctx.jobs.start({
kind: 'bash',
label: 'facade kill',
run: () => ({
cancel: () => { settle({ status: 'killed' }) },
done: new Promise((resolve) => { settle = resolve }),
}),
})
expect(controller.killJob({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'requested' })
await ctx.fiber.dispose()
})
it.each(['success', 'domain-error', 'throw'] as const)(
'promotes a prepared follow observation in the background: %s',
async (outcome) => {
@@ -145,6 +145,7 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' }))
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onKillJob: (payload: unknown) => Promise<RpcResponse<{ outcome: 'requested' | 'already-finished' }>> = () => Promise.resolve(ok({ outcome: 'requested' as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{
version: string
@@ -308,6 +309,7 @@ export class FakeApiClient implements IApiClient {
attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)),
updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)),
cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)),
killJob: payload => this.remoteResult('session.killJob', payload, this.onKillJob(payload)),
page: request => this.page(request),
follow: (request, signal) => this.openFollow(request, signal),
control: signal => this.openControl(signal),
@@ -0,0 +1,120 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
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 { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { describe, expect, it } from 'vitest'
import type { ApiSessionAgentController } from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
function producer(label = 'sleep 60') {
let settle!: (outcome: JobOutcome) => void
const cancels: (string | undefined)[] = []
const spec = {
kind: 'bash' as const,
label,
run: () => ({
cancel: (reason?: string) => { cancels.push(reason) },
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
}),
}
return { spec, cancels, settle: (outcome: JobOutcome) => { settle(outcome) } }
}
async function harness(withRegistry = true): Promise<{
ctx: Context
session: Session
agent: Agent
commands: SessionCommandController
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
if (withRegistry) {
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('kill-job-test')
}
const session = ctx.sessions.create()
const agent = {
id: session.id,
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx,
} as Agent
ctx.agents.register(agent)
// killJob touches only the registry and the live-agent lookup, so the
// resolver-owning agents controller stays out of these commands.
const commands = new SessionCommandController(ctx, {} as ApiSessionAgentController, process.cwd())
return { ctx, session, agent, commands }
}
function failureCode(run: () => unknown): string {
try {
run()
} catch (error) {
if (error instanceof TypertRemoteFailure) return error.failure.code
throw error
}
throw new Error('expected a TypertRemoteFailure')
}
describe('session.killJob', () => {
it('kills an owned running job without claiming the terminal report', async () => {
const { ctx, session, agent, commands } = await harness()
const task = producer('pnpm run watch')
const id = ctx.jobs.start({ ...task.spec, owner: agent })
expect(commands.killJob({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'requested' })
expect(task.cancels).toEqual(['cancelled by the user'])
// The unclaimed report is the whole point: the completion notice stays due.
expect(ctx.jobs.get(id, agent)).toMatchObject({ status: 'stopping', reported: false })
})
it('reports an already-finished job instead of failing', async () => {
const { ctx, session, agent, commands } = await harness()
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent })
task.settle({ status: 'completed', detail: 'exit code: 0' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(commands.killJob({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'already-finished' })
expect(ctx.jobs.get(id, agent).reported).toBe(false)
})
it('kills an unowned job from a session without a live agent', async () => {
const { ctx, commands } = await harness()
const task = producer('unowned work')
const id = ctx.jobs.start(task.spec)
const cold = ctx.sessions.create()
expect(commands.killJob({ sessionId: cold.id, jobId: id })).toEqual({ outcome: 'requested' })
})
it('rejects an unknown job id as job-not-found', async () => {
const { session, commands } = await harness()
expect(failureCode(() => commands.killJob({ 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, commands } = await harness()
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent })
// The other session has no live agent, so the owned job is out of reach.
const other = ctx.sessions.create()
expect(failureCode(() => commands.killJob({ sessionId: other.id, jobId: id })))
.toBe('job-not-found')
expect(ctx.jobs.get(id, agent).status).toBe('running')
})
it('rejects when the composition has no job registry', async () => {
const { session, commands } = await harness(false)
expect(failureCode(() => commands.killJob({ sessionId: session.id, jobId: 'bash-1' as JobId })))
.toBe('jobs-unavailable')
})
})
@@ -279,6 +279,29 @@ describe('prompt and cancel errors', () => {
})
})
it('passes a killJob through to the session Remote and returns the admission', async () => {
const api = new FakeApiClient()
const session = new Session(SID, api, fakeRemote(api), {})
await session.open()
const killed = await session.killJob('bash-3')
expect(killed).toEqual({ ok: true, value: { outcome: 'requested' } })
expect(api.callsOf('session.killJob')).toEqual([{ sessionId: SID, jobId: 'bash-3' }])
// Row state converges through the jobs frames; a kill leaves no local error state.
expect(session.getSnapshot().promptError).toBeNull()
})
it('returns a killJob business failure without touching promptError', async () => {
const api = new FakeApiClient()
api.onKillJob = () => Promise.resolve(err({
code: 'job-not-found', message: 'unknown job bash-9', details: { sessionId: SID, jobId: 'bash-9' },
}) as never)
const session = new Session(SID, api, fakeRemote(api), {})
await session.open()
const killed = await session.killJob('bash-9')
expect(killed).toMatchObject({ ok: false, error: { code: 'job-not-found' } })
expect(session.getSnapshot().promptError).toBeNull()
})
it('lands an interrupt business failure in promptError with op=stop', async () => {
const api = new FakeApiClient()
api.onSubagentInterrupt = () => Promise.resolve(err({
@@ -30,6 +30,8 @@ import type {
SessionForkValue,
SessionFollowFrame,
SessionFollowRequest,
SessionKillJobRequest,
SessionKillJobValue,
SessionListRequest,
SessionListValue,
SessionPage,
@@ -58,6 +60,7 @@ export interface TestSessionRemote {
attachment(request: SessionAttachmentRequest): Promise<RemoteResult<SessionAttachmentValue>>
updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
killJob(request: SessionKillJobRequest): Promise<RemoteResult<SessionKillJobValue>>
page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
@@ -239,6 +242,7 @@ export function createSessionTestRemote(
attachment: request => remoteResult(() => direct.attachment(request)),
updateQueue: request => remoteResult(() => direct.updateQueue(request)),
cancel: request => remoteResult(() => direct.cancel(request)),
killJob: request => remoteResult(() => direct.killJob(request)),
page: (request, signal = new AbortController().signal) => remoteResult(
() => direct.page(request, signal),
signal,
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-activity/README.md
README.md: 6630558922cf9f1e2b772bcc6334a21f91591acb
README.zh.md: b02949f2518c85ba931e0adf76946c390b564d1a
README.md: 2739b6f24845539cdb920b5043c372b4c1dc2fb7
README.zh.md: 087ec98af76baacd159babbe674cb33bc1301199
+3 -1
View File
@@ -31,6 +31,8 @@ Load the plugin through the web-app manifest; it renders nothing until the sessi
Jobs and activities stay separate planes ([why](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md)); the join happens per row in this component. A job row keeps the job's lifecycle, duration, and model-visible `detail` from the `jobsBySession` mirror, and gains an expandable output panel when an activity carries its `correlation.jobId`; an activity without a job keeps its own row. Rows without an observable activity render as static rows with no expansion affordance.
Running job rows also carry a two-press stop control: the first press arms it, the confirming press within three seconds calls `session.killJob`, and the row converges through the jobs frames (`stopping`, then the settled section). The kill leaves the terminal report unclaimed, 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 expanded panel
Expanding an observable row opens that activity's observation stream from `ctx.activityFeed` 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 failures render as notices above the panel.
@@ -79,7 +81,7 @@ 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`; a human kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md).
- **Standalone activities offer no stop control** — a workflow run has no job handle, so cancellation reaches only rows with a `jobId`; stopping activity-only work needs its producer to register a job first.
- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up.
<a id="dev-note"></a>
+3 -1
View File
@@ -31,6 +31,8 @@ kind: "package-reference"
jobs 与 activity 保持为两个独立的面([缘由](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md));合并逐行发生在本组件内。job 行保留来自 `jobsBySession` 镜像的生命周期、时长与模型可见 `detail`,当某个 activity 以 `correlation.jobId` 关联到它时获得可展开的输出面板;没有 job 的 activity 保留自己的行。没有可观察 activity 的行渲染为无展开交互的静态行。
运行中的 job 行还带一个两击式停止控件:首击武装、三秒内的确认击调用 `session.killJob`,行状态经 jobs 帧收敛(先 `stopping`,再入已结束分组)。该 kill 不认领终态报告,任务的 owner agent 因此照常收到标准完成通知——模型被明确告知用户停止了它的任务,而不是留给它去猜([决策](../../../.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md))。
### 展开的面板
展开可观察的行即把该 activity 在 `ctx.activityFeed` 上的观察流打开进内嵌终端面板。面板的复制控件复制命令(而非输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不画自己的运行状态点——上方的行已携带状态。保留缺口与流失败以面板上方的提示呈现。
@@ -79,7 +81,7 @@ jobs 与 activity 保持为两个独立的面([缘由](../../../.agents/notes/
这些限制是当前包约束,不是任务积压。
- **没有 kill 控件**——取消仍走模型的 `job_kill`;人工 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。
- **独立 activity 没有停止控件**——workflow 运行没有 job 句柄,取消只触达带 `jobId` 的行;要停 activity-only 的工作,先让其生产者注册 job。
- **不渲染通道标签**——stdout 与 stderr chunk 连接成一条流;分通道着色是后续呈现优化。
<a id="dev-note"></a>
@@ -83,6 +83,53 @@
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: 2px;
align-items: stretch;
}
.rowLine .row {
flex: 1;
min-width: 0;
}
/* Two-press kill affordance on running job rows. */
.stop {
display: inline-flex;
flex: none;
align-items: center;
align-self: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.stop:hover,
.stop:focus-visible {
background: var(--dsw-alias-fill-l1);
color: var(--dsw-alias-label-secondary);
}
.stopArmed,
.stopArmed:hover,
.stopArmed:focus-visible {
background: var(--dsw-alias-fill-l1);
color: var(--dsw-alias-label-error, var(--dsw-alias-label-secondary));
}
.stopFailed {
color: var(--dsw-alias-label-error, var(--dsw-alias-label-secondary));
}
.row {
display: flex;
align-items: center;
@@ -3,8 +3,9 @@ import type {
ActivityFeedSnapshot, ActivityId, ActivityRow, ObservedActivity,
} from '@deepseek-ai/dsh-api-activity-controller/client'
import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconChevronDownOutline14, StateDot, TerminalBlock, useDismissOnOutsidePointer,
IconChevronDownOutline14, IconStopFill16, 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'
@@ -26,6 +27,12 @@ export interface ActivityListInjected {
* Reference-counted by the client feed, so panels can overlap safely.
*/
observe: (id: ActivityId) => () => void
/**
* Kill one background job on the human's behalf. Resolves `true` when the
* registry admitted the request (`requested` or `already-finished`); row
* state itself converges through the jobs control frames.
*/
killJob: (sessionId: SessionId, jobId: string) => Promise<boolean>
}
/** Full props for the session-header task-list action. */
@@ -53,6 +60,8 @@ interface TaskRow {
finishedAt?: number
/** Present when this row's live output can be observed. */
activityId?: ActivityId
/** Present for job rows: the registry id a human kill can address. */
jobId?: string
}
/** Stable empty lists so a session with no work keeps one array identity. */
@@ -62,6 +71,12 @@ const NO_JOBS: readonly SessionJob[] = []
/** 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(row: TaskRow): boolean {
return row.status === 'running' || row.status === 'stopping'
@@ -189,6 +204,7 @@ function mergeRows(
startedAt: job.startedAt,
...job.finishedAt !== undefined ? { finishedAt: job.finishedAt } : {},
...activity !== undefined ? { activityId: activity.id } : {},
jobId: String(job.id),
}
})
for (const activity of byJob.values()) rows.push(activityTask(activity))
@@ -211,14 +227,24 @@ function ordered(rows: readonly TaskRow[]): TaskRow[] {
})
}
/**
* 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 task row plus, when observable and expanded, its live output panel. */
function TaskItem({ row, view, expanded, now, onToggle, t }: {
function TaskItem({ row, view, expanded, now, onToggle, kill, t }: {
row: TaskRow
view: ObservedActivity | undefined
expanded: boolean
/** Clock sample live rows derive their running duration from. */
now: number
onToggle: () => void
/** Present on running job rows: the human-kill button state and press handler. */
kill?: { state: KillState; onPress: () => void }
t: TranslateNS<typeof NS>
}) {
const live = isLive(row)
@@ -262,25 +288,51 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: {
{observable ? <IconChevronDownOutline14 className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} /> : null}
</>
)
const killTitle = kill === undefined
? undefined
: kill.state === 'armed'
? t('kill.confirm')
: kill.state === 'failed' ? t('kill.failed') : t('kill.stop', { label: row.label })
return (
<li className={css.item}>
{observable
? (
<button
type="button"
className={live ? css.row : `${css.row} ${css.rowSettled}`}
aria-expanded={expanded}
aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: row.label })}
onClick={onToggle}
>
{body}
</button>
)
: (
<span className={live ? `${css.row} ${css.rowStatic}` : `${css.row} ${css.rowSettled} ${css.rowStatic}`}>
{body}
</span>
)}
<div className={css.rowLine}>
{observable
? (
<button
type="button"
className={live ? css.row : `${css.row} ${css.rowSettled}`}
aria-expanded={expanded}
aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: row.label })}
onClick={onToggle}
>
{body}
</button>
)
: (
<span className={live ? `${css.row} ${css.rowStatic}` : `${css.row} ${css.rowSettled} ${css.rowStatic}`}>
{body}
</span>
)}
{kill !== undefined
? (
<button
type="button"
className={
kill.state === 'armed'
? `${css.stop} ${css.stopArmed}`
: kill.state === 'failed' ? `${css.stop} ${css.stopFailed}` : css.stop
}
data-kill-state={kill.state}
disabled={kill.state === 'pending'}
aria-label={killTitle}
title={killTitle}
onClick={kill.onPress}
>
<IconStopFill16 size={12} />
</button>
)
: null}
</div>
{expanded && view !== undefined
? (
<div className={css.panel}>
@@ -318,13 +370,15 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: {
* hooks, the observation control, and the namespace translator.
* @returns the trigger and its popover list, or null when there is nothing to show.
*/
export function ActivityListAction({ sessionId, useSessions, useActivity, observe, t }: ActivityListActionProps) {
export function ActivityListAction({ sessionId, useSessions, useActivity, observe, killJob, t }: ActivityListActionProps) {
const jobs = useSessions(state => state.jobsBySession[sessionId]) ?? NO_JOBS
const owned = useActivity(state => state.rowsBySession[sessionId]) ?? NO_ROWS
const unowned = useActivity(state => state.rowsBySession['']) ?? NO_ROWS
const observedViews = useActivity(state => state.observed)
const [open, setOpen] = useState(false)
const [expandedKey, setExpandedKey] = useState<string | undefined>(undefined)
// One kill affordance advances at a time: arming a row disarms any other.
const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude<KillState, 'idle'> } | undefined>(undefined)
const [now, setNow] = useState(() => Date.now())
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
@@ -399,6 +453,46 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
}
}, [rows, expandedKey])
// 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(row => row.key === killPhase.key && row.jobId !== undefined && row.status === 'running')) {
setKillPhase(undefined)
}
}, [rows, killPhase])
const pressKill = (row: TaskRow): void => {
/* v8 ignore next -- the button only renders for job rows. */
if (row.jobId === undefined) return
const jobId = row.jobId
if (killPhase?.key !== row.key || killPhase.state !== 'armed') {
setKillPhase({ key: row.key, state: 'armed' })
return
}
setKillPhase({ key: row.key, state: 'pending' })
void killJob(sessionId, jobId).then((ok) => {
// Success needs no local state: the jobs frame flips the row to
// `stopping`, which removes the button and clears the phase above.
setKillPhase(current => current?.key === row.key
? (ok ? undefined : { key: row.key, state: 'failed' })
: current)
})
}
if (rows.length === 0) return null
const countKey = liveRows.length > 0
@@ -423,6 +517,14 @@ export function ActivityListAction({ sessionId, useSessions, useActivity, observ
onToggle={() => {
setExpandedKey(current => current === row.key ? undefined : row.key)
}}
{...row.jobId !== undefined && row.status === 'running'
? {
kill: {
state: killPhase?.key === row.key ? killPhase.state : 'idle' as const,
onPress: () => { pressKill(row) },
},
}
: {}}
t={t}
/>
)
@@ -44,6 +44,13 @@ export function apply(ctx: ClientContext): void {
inject: (): ActivityListInjected => ({
hooks: { activity: ctx.activityFeed.state },
observe: id => ctx.activityFeed.observe(id),
killJob: async (sessionId, jobId) => {
// The binding exists for every session whose header renders this
// control; the guard covers a row pressed while its scope prunes.
const session = ctx.sessions.binding(sessionId)?.session
if (session === undefined) return false
return (await session.killJob(jobId)).ok
},
}),
}, ActivityListAction),
)
@@ -14,6 +14,9 @@ export const zh = {
'section.settled': '已结束',
'row.expandAria': '展开 {label} 的实时输出',
'row.collapseAria': '收起 {label} 的实时输出',
'kill.stop': '停止任务 {label}',
'kill.confirm': '再次点击确认停止',
'kill.failed': '停止失败',
'status.running': '运行中',
'status.stopping': '正在停止',
'status.completed': '已完成',
@@ -51,6 +54,9 @@ export const en: Record<ActivityKey, string> = {
'section.settled': 'Finished',
'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.failed': 'Stop failed',
'status.running': 'running',
'status.stopping': 'stopping',
'status.completed': 'completed',
@@ -53,6 +53,7 @@ function props(
snapshot: Partial<ActivityFeedSnapshot>,
observe: ActivityListActionProps['observe'] = () => () => {},
jobs: readonly SessionJob[] = [],
killJob: ActivityListActionProps['killJob'] = async () => true,
): ActivityListActionProps {
const state: ActivityFeedSnapshot = {
rowsBySession: {},
@@ -67,7 +68,7 @@ function props(
function useSessions<T>(select: (value: typeof sessionsState) => T): T {
return select(sessionsState)
}
return { sessionId: SESSION, useSessions, useActivity, observe, t } as unknown as ActivityListActionProps
return { sessionId: SESSION, useSessions, useActivity, observe, killJob, t } as unknown as ActivityListActionProps
}
function openList(): void {
@@ -379,3 +380,112 @@ describe('ActivityListAction rows and observation', () => {
expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull()
})
})
describe('ActivityListAction human kill', () => {
const stopTitle = (label: string): string => zh['kill.stop'].replace('{label}', label)
it('offers the stop control only on running job rows', () => {
render(<ActivityListAction {...props({
rowsBySession: { [SESSION]: [row({ id: 'wf-1' as ActivityId, kind: 'workflow', label: 'standalone' })] },
}, undefined, [job(), job({ id: 'bash-2' as SessionJob['id'], label: 'done', status: 'completed', finishedAt: 1_700_000_100_000 })])} />)
openList()
// The running job row offers it; the settled job and the standalone
// activity row (workflow) have no kill handle.
expect(screen.getByTitle(stopTitle('pnpm run build'))).toBeDefined()
expect(screen.queryByTitle(stopTitle('done'))).toBeNull()
expect(screen.queryByTitle(stopTitle('standalone'))).toBeNull()
})
it('arms on the first press and kills on the confirming press', async () => {
const killJob = vi.fn(async () => true)
render(<ActivityListAction {...props({}, undefined, [job()], killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(killJob).not.toHaveBeenCalled()
expect(stop.getAttribute('data-kill-state')).toBe('armed')
expect(screen.getByTitle(zh['kill.confirm'])).toBe(stop)
await act(async () => { fireEvent.click(stop) })
expect(killJob).toHaveBeenCalledWith(SESSION, 'bash-1')
// The accepted kill leaves row convergence to the jobs frames; the local
// phase resets so the control does not stick in pending.
expect(stop.getAttribute('data-kill-state')).toBe('idle')
})
it('an armed press disarms after the confirmation window', () => {
vi.useFakeTimers()
const killJob = vi.fn(async () => true)
render(<ActivityListAction {...props({}, undefined, [job()], killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(stop.getAttribute('data-kill-state')).toBe('armed')
act(() => { vi.advanceTimersByTime(3_000) })
expect(stop.getAttribute('data-kill-state')).toBe('idle')
expect(killJob).not.toHaveBeenCalled()
})
it('a rejected kill shows its hint and then resets', async () => {
// Fake timers from the start so the failed-hint reset arms on the fake clock.
vi.useFakeTimers()
const killJob = vi.fn(async () => false)
render(<ActivityListAction {...props({}, undefined, [job()], killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
await act(async () => { fireEvent.click(stop) })
expect(stop.getAttribute('data-kill-state')).toBe('failed')
expect(screen.getByTitle(zh['kill.failed'])).toBe(stop)
act(() => { vi.advanceTimersByTime(4_000) })
expect(stop.getAttribute('data-kill-state')).toBe('idle')
})
it('arming a second row disarms the first', () => {
render(<ActivityListAction {...props({}, undefined, [
job(),
job({ id: 'bash-2' as SessionJob['id'], label: 'pnpm run watch' }),
])} />)
openList()
const first = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(first)
expect(first.getAttribute('data-kill-state')).toBe('armed')
const second = screen.getByTitle(stopTitle('pnpm run watch'))
fireEvent.click(second)
expect(second.getAttribute('data-kill-state')).toBe('armed')
expect(first.getAttribute('data-kill-state')).toBe('idle')
})
it('a kill resolving after another row armed leaves the newer phase alone', async () => {
let resolveKill!: (ok: boolean) => void
const killJob = vi.fn(() => new Promise<boolean>((resolve) => { resolveKill = resolve }))
render(<ActivityListAction {...props({}, undefined, [
job(),
job({ id: 'bash-2' as SessionJob['id'], label: 'pnpm run watch' }),
], killJob)} />)
openList()
const first = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(first)
fireEvent.click(first)
expect(killJob).toHaveBeenCalledTimes(1)
// While the first kill is in flight, the user arms the second row; the
// late resolution must not clobber that newer phase.
const second = screen.getByTitle(stopTitle('pnpm run watch'))
fireEvent.click(second)
expect(second.getAttribute('data-kill-state')).toBe('armed')
await act(async () => { resolveKill(false) })
expect(second.getAttribute('data-kill-state')).toBe('armed')
expect(first.getAttribute('data-kill-state')).toBe('idle')
})
it('clears an armed phase whose row stopped being killable', () => {
const { rerender } = render(<ActivityListAction {...props({}, undefined, [job()])} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(stop.getAttribute('data-kill-state')).toBe('armed')
rerender(<ActivityListAction {...props({}, undefined, [job({ status: 'stopping' })])} />)
// The stopping row offers no kill control any more, and the stale phase is gone.
expect(screen.queryByTitle(stopTitle('pnpm run build'))).toBeNull()
expect(document.querySelector('[data-kill-state]')).toBeNull()
})
})
@@ -25,6 +25,10 @@ function headerEntryIds(ctx: Context): (string | undefined)[] {
/** Ids handed to the stubbed feed's observe control. */
const observed: string[] = []
/** Job ids the stubbed bound session was asked to kill, and its scripted result. */
const kills: string[] = []
let killResult: { ok: boolean } = { ok: true }
/** Boot the browser half over a real slot tree that declares the header list. */
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
const ctx = new Context()
@@ -35,7 +39,11 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
} as never, () => null)
ctx.provide('sessions', {} as never)
ctx.provide('sessions', {
binding: (id: string) => id === 'sess-live'
? { session: { killJob: async (jobId: string) => { kills.push(jobId); return killResult } } }
: undefined,
} as never)
ctx.provide('activityFeed', {
state: { getSnapshot: () => ({ rowsBySession: {}, observed: {}, phase: 'pending' }), subscribe: () => () => {} },
observe: (id: string) => { observed.push(id); return () => {} },
@@ -66,12 +74,26 @@ describe('ui-activity browser half', () => {
.entries('conversation.session.header.actions')
.find(candidate => candidate.options.id === 'activity-list')
const face = (entry as unknown as {
inject: () => { hooks: { activity: unknown }; observe: (id: string) => () => void }
inject: () => {
hooks: { activity: unknown }
observe: (id: string) => () => void
killJob: (sessionId: string, jobId: string) => Promise<boolean>
}
}).inject()
expect(face.hooks.activity).toBeDefined()
const stopper = face.observe('bash-1')
expect(observed).toEqual(['bash-1'])
stopper()
// The kill control routes through the bound session and reports admission…
killResult = { ok: true }
await expect(face.killJob('sess-live', 'bash-3')).resolves.toBe(true)
expect(kills).toEqual(['bash-3'])
killResult = { ok: false }
await expect(face.killJob('sess-live', 'bash-4')).resolves.toBe(false)
// …and degrades to a plain failure when the session's scope is gone.
await expect(face.killJob('sess-gone', 'bash-5')).resolves.toBe(false)
expect(kills).toEqual(['bash-3', 'bash-4'])
})
it('registers the header action, and fiber teardown removes it (HMR safety)', async () => {
@@ -47,6 +47,7 @@ function fakeSession(): SessionFace {
readAttachment: () => Promise.reject(new Error('unused fake Session operation')),
updateQueue: () => Promise.reject(new Error('unused fake Session operation')),
cancel: () => Promise.reject(new Error('unused fake Session operation')),
killJob: () => Promise.reject(new Error('unused fake Session operation')),
rename: () => Promise.reject(new Error('unused fake Session operation')),
loadOlder: () => Promise.reject(new Error('unused fake Session operation')),
command: () => Promise.reject(new Error('unused fake Session operation')),
@@ -548,7 +548,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ISession',
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal): Promise<ClientResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<ClientResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{\n accepted: true;\n }>>;\n cancel(): Promise<ClientResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<ClientResult<{\n title: string;\n seq: number;\n }>>;\n loadOlder(): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal): Promise<ClientResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<ClientResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{\n accepted: true;\n }>>;\n cancel(): Promise<ClientResult<{\n accepted: true;\n }>>;\n killJob(jobId: string): Promise<ClientResult<{\n outcome: \'requested\' | \'already-finished\';\n }>>;\n rename(title: string): Promise<ClientResult<{\n title: string;\n seq: number;\n }>>;\n loadOlder(): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
},
{
name: 'KeyPropsOf',
@@ -1017,9 +1017,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'output text and the post-read snapshot.',
},
{
signature: 'abstract kill(id: JobId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'',
description: 'Request cancellation, then mark the job stopping and reported. A producer throw propagates without changing job state. Throws for an unknown or foreign job.',
parameters: [{ name: 'id', description: 'job to cancel.' }, { name: 'caller', description: 'killing agent checked against the owner.' }, { name: 'reason', description: 'logged reason forwarded to the producer.' }],
signature: 'abstract kill(id: JobId, caller?: Agent, options?: JobKillOptions): \'requested\' | \'already-finished\'',
description: 'Request cancellation, then mark the job stopping. By default the kill also claims the terminal report (JobKillOptions.reported); a kill with `reported: false` leaves the settlement notice due instead. A recorded JobKillOptions.reason merges into the terminal `detail` when the job settles `killed`. A producer throw propagates without changing job state. Throws for an unknown or foreign job.',
parameters: [{ name: 'id', description: 'job to cancel.' }, { name: 'caller', description: 'killing agent checked against the owner.' }, { name: 'options', description: 'cancellation reason and terminal-report claim.' }],
returns: '`requested` for live work, otherwise `already-finished`.',
},
{
@@ -1353,6 +1353,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'request', description: 'Session whose active Agent turn is cancelled.' }],
returns: 'acknowledgement that cancellation was requested.',
},
{
signature: '@Remote(\'killJob\') killJob(request: SessionKillJobRequest): SessionKillJobValue',
description: 'Kill one background job on a human\'s behalf, leaving the terminal report unclaimed so the owning agent still receives the completion notice.',
parameters: [{ name: 'request', description: 'Session whose task list carries the job, and the job id.' }],
returns: 'the registry\'s admission of the kill request.',
},
{
signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>',
description: 'Read one cold-safe, message-aligned Session history page.',
@@ -4013,6 +4019,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'JobId',
declaration: 'export type JobId = Branded<\'JobId\'>;',
},
{
name: 'JobKillOptions',
declaration: 'export interface JobKillOptions {\n reason?: string;\n reported?: boolean;\n}',
},
{
name: 'JobKind',
declaration: 'export type JobKind = JobKindMap[keyof JobKindMap];',
@@ -4487,7 +4497,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'RpcErrorDetailsMap',
declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'subagent-parent-unavailable\': {\n parentSessionId: SessionId;\n };\n \'subagent-not-found\': {\n parentSessionId: SessionId;\n childSessionId: SessionId;\n };\n \'subagent-catalog-diagnostic\': {\n parentSessionId: SessionId;\n childS /* …truncated — full shape in source */',
declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'directory-unreadable\': {\n path: string;\n };\n \'directory-exists\': {\n path: string;\n };\n \'directory-create-failed\': {\n path: string;\n };\n \'directory-picker-unavailable\': {\n capability: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'job-not-found\': {\n sessionId: SessionId;\n jobId: string;\n };\n \'jobs-unavailable\': {};\n \'settings-rejected\': {\n ns: string;\n };\n \'settings-conflict\': {\n ns: string;\n expected: number;\n actual: number;\n };\n \'credential-rejected\': {\n ref: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'subagent-parent-unavailable\': {\n parentSessionId: SessionId;\n };\n \'subagent-not-found\': {\n parentSessionId: SessionId;\n childSessi /* …truncated — full shape in source */',
},
{
name: 'RpcId',
@@ -4631,7 +4641,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionErrorDetailsMap',
declaration: 'export interface SessionErrorDetailsMap {\n \'bad-request\': Record<never, never>;\n cancelled: Record<never, never>;\n \'session-not-found\': {\n readonly sessionId: SessionId;\n };\n \'model-unavailable\': {\n readonly provider: string;\n readonly model: string;\n };\n \'session-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedCwd: string;\n readonly existingCwd?: string;\n };\n \'invalid-time-zone\': {\n readonly value: string;\n };\n \'workspace-attach-failed\': {\n readonly sessionId: SessionId;\n readonly workspaceId: string;\n };\n \'workspace-not-found\': {\n readonly workspaceId: string;\n };\n \'agent-preset-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedPreset: string;\n readonly existingPreset?: string;\n };\n \'agent-preset-not-found\': {\n readonly agentPreset: string;\n readonly available: readonly string[];\n };\n \'agent-preset-invalid\': {\n readonly agentPreset: string;\n readonly reason: string;\n };\n \'agent-busy\': {\n readonly reason: string;\n };\n \'attachment-error\': {\n readonly reason: string;\n };\n \'queue-item-not-found\': {\n readonly itemId: MessageId;\n };\n \'steer-unavailable\': {\n readonly itemId: MessageId;\n };\n \'title-invalid\': {\n readonly sessionId: SessionId;\n };\n \'fork-unavailable\': {\n readonly sessionId: SessionId;\n /* …truncated — full shape in source */',
declaration: 'export interface SessionErrorDetailsMap {\n \'bad-request\': Record<never, never>;\n cancelled: Record<never, never>;\n \'session-not-found\': {\n readonly sessionId: SessionId;\n };\n \'model-unavailable\': {\n readonly provider: string;\n readonly model: string;\n };\n \'session-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedCwd: string;\n readonly existingCwd?: string;\n };\n \'invalid-time-zone\': {\n readonly value: string;\n };\n \'workspace-attach-failed\': {\n readonly sessionId: SessionId;\n readonly workspaceId: string;\n };\n \'workspace-not-found\': {\n readonly workspaceId: string;\n };\n \'agent-preset-conflict\': {\n readonly sessionId: SessionId;\n readonly requestedPreset: string;\n readonly existingPreset?: string;\n };\n \'agent-preset-not-found\': {\n readonly agentPreset: string;\n readonly available: readonly string[];\n };\n \'agent-preset-invalid\': {\n readonly agentPreset: string;\n readonly reason: string;\n };\n \'agent-busy\': {\n readonly reason: string;\n };\n \'attachment-error\': {\n readonly reason: string;\n };\n \'queue-item-not-found\': {\n readonly itemId: MessageId;\n };\n \'steer-unavailable\': {\n readonly itemId: MessageId;\n };\n \'job-not-found\': {\n readonly sessionId: SessionId;\n readonly jobId: JobId;\n };\n \'jobs-unavailable\': Record<never, /* …truncated — full shape in source */',
},
{
name: 'SessionEvent',
@@ -4741,6 +4751,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionJob',
declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n}',
},
{
name: 'SessionKillJobRequest',
declaration: 'export interface SessionKillJobRequest {\n readonly sessionId: SessionId;\n readonly jobId: JobId;\n}',
},
{
name: 'SessionKillJobValue',
declaration: 'export interface SessionKillJobValue {\n readonly outcome: \'requested\' | \'already-finished\';\n}',
},
{
name: 'SessionLineageNode',
declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}',
@@ -45,6 +45,8 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('job-not-found'), message: z.string(), details: z.object({ sessionId: z.string(), jobId: z.string() }) }),
z.object({ code: z.literal('jobs-unavailable'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
+2
View File
@@ -40,6 +40,8 @@ export interface RpcErrorDetailsMap {
'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
'agent-preset-invalid': { agentPreset: string; reason: string }
'agent-busy': { reason: string }
'job-not-found': { sessionId: SessionId; jobId: string }
'jobs-unavailable': {}
/**
* A settings write was refused (schema validation, unknown namespace,
* read-only provider, or storage failure); the message is the seam's text.
+19 -6
View File
@@ -17,7 +17,7 @@ import type { ScopeLayer } from '@deepseek-ai/dsh-scope'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs'
import type {
JobDoneListener, JobKind, JobOutcome, JobRead, JobSnapshot, JobStart, JobStatus,
JobDoneListener, JobKillOptions, JobKind, JobOutcome, JobRead, JobSnapshot, JobStart, JobStatus,
JobsChangedListener,
} from '@deepseek-ai/dsh-jobs'
@@ -52,6 +52,8 @@ interface TrackedTask {
startedAt: number
finishedAt: number | undefined
reported: boolean
/** Reason recorded by {@link LocalJobRegistry.kill}, merged into a `killed` settlement's detail. */
killReason: string | undefined
/** Resolves once the terminal snapshot is recorded and listeners notified. */
settled: Promise<void>
/** Resolver for {@link settled}, called by the first effective settlement. */
@@ -168,6 +170,7 @@ export class LocalJobRegistry extends JobRegistry {
startedAt: Date.now(),
finishedAt: undefined,
reported: false,
killReason: undefined,
settled,
markSettled,
waiters: 0,
@@ -212,17 +215,21 @@ export class LocalJobRegistry extends JobRegistry {
return { text, snapshot: this.snapshot(job) }
}
kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' {
kill(id: JobId, caller?: Agent, options?: JobKillOptions): 'requested' | 'already-finished' {
const job = this.expect(id)
this.assertAccess(job, caller)
// An unreporting kill never clears an existing claim, so `false` only
// withholds this call's own claim rather than resurrecting a suppressed notice.
const claims = options?.reported ?? true
if (isTerminal(job.status)) {
job.reported = true
if (claims) job.reported = true
return 'already-finished'
}
// Cancel first so a throw leaves both lifecycle and notice state unchanged.
job.cancel(reason)
job.cancel(options?.reason)
job.status = 'stopping'
job.reported = true
if (options?.reason !== undefined) job.killReason = options.reason
if (claims) job.reported = true
this.notifyChanged(job.owner)
return 'requested'
}
@@ -416,7 +423,13 @@ export class LocalJobRegistry extends JobRegistry {
private settle(job: TrackedTask, outcome: JobOutcome): void {
if (isTerminal(job.status)) return
job.status = outcome.status
job.detail = outcome.detail
// A killed settlement carries the recorded kill reason in its detail:
// producer facts first (`signal: SIGTERM; cancelled by the user`). A job
// that outran its kill request (settled `completed`/`failed`) keeps the
// producer detail alone — the reason describes a kill that never landed.
job.detail = outcome.status === 'killed' && job.killReason !== undefined
? (outcome.detail !== undefined ? `${outcome.detail}; ${job.killReason}` : job.killReason)
: outcome.detail
job.output = outcome.output
job.finishedAt = Date.now()
if (job.waiters > 0) job.reported = true
+74 -1
View File
@@ -398,7 +398,7 @@ describe('LocalJobRegistry.kill', () => {
const p = producer()
const id = ctx.jobs.start(p.spec)
expect(ctx.jobs.kill(id, undefined, 'no longer needed')).toBe('requested')
expect(ctx.jobs.kill(id, undefined, { reason: 'no longer needed' })).toBe('requested')
expect(p.cancels).toEqual(['no longer needed'])
expect(ctx.jobs.list()[0]).toMatchObject({ status: 'stopping', reported: true })
@@ -443,6 +443,79 @@ describe('LocalJobRegistry.kill', () => {
broken = false
expect(ctx.jobs.kill(id)).toBe('already-finished')
})
it('an unreporting kill leaves the notice due and stamps its reason into the killed detail', async () => {
const ctx = await harness()
const seen: JobSnapshot[] = []
ctx.jobs.onJobDone(snapshot => void seen.push(snapshot))
const p = producer()
const id = ctx.jobs.start(p.spec)
expect(ctx.jobs.kill(id, undefined, { reason: 'cancelled by the user', reported: false })).toBe('requested')
expect(p.cancels).toEqual(['cancelled by the user'])
expect(ctx.jobs.list()[0]).toMatchObject({ status: 'stopping', reported: false })
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
await tick()
// Unreported settlement: the completion notice stays due, and the killed
// detail carries producer facts first, then the recorded reason.
expect(seen[0]).toMatchObject({
id,
status: 'killed',
reported: false,
detail: 'signal: SIGTERM; cancelled by the user',
})
})
it('a kill reason stands alone as detail when the killed outcome has none', async () => {
const ctx = await harness()
const p = producer()
const id = ctx.jobs.start(p.spec)
ctx.jobs.kill(id, undefined, { reason: 'cancelled by the user', reported: false })
p.settle({ status: 'killed' })
await tick()
expect(ctx.jobs.get(id).detail).toBe('cancelled by the user')
})
it('a job that outruns its kill keeps the producer detail alone', async () => {
const ctx = await harness()
const p = producer()
const id = ctx.jobs.start(p.spec)
ctx.jobs.kill(id, undefined, { reason: 'cancelled by the user', reported: false })
// The process won the race and exited normally before the kill landed.
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(ctx.jobs.get(id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' })
})
it('an unreporting kill never clears an existing claim', async () => {
const ctx = await harness()
const seen: JobSnapshot[] = []
ctx.jobs.onJobDone(snapshot => void seen.push(snapshot))
const p = producer()
const id = ctx.jobs.start(p.spec)
// The model killed first (its tool result is the delivery)…
expect(ctx.jobs.kill(id, undefined, { reason: 'superseded' })).toBe('requested')
// …then a human kill arrives; withholding its own claim must not
// resurrect the notice the model already has.
expect(ctx.jobs.kill(id, undefined, { reason: 'cancelled by the user', reported: false })).toBe('requested')
expect(ctx.jobs.list()[0]).toMatchObject({ status: 'stopping', reported: true })
p.settle({ status: 'killed' })
await tick()
expect(seen[0]).toMatchObject({ id, reported: true })
})
it('an unreporting kill of a settled job does not claim the pending notice bit', async () => {
const ctx = await harness()
const p = producer()
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(ctx.jobs.kill(id, undefined, { reported: false })).toBe('already-finished')
// The default kill marks a terminal record reported; the unreporting one
// leaves the record exactly as the settlement left it.
expect(ctx.jobs.get(id).reported).toBe(false)
})
})
describe('LocalJobRegistry.wait', () => {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md
README.md: 7625c3672d24edff908c7be01723e970bd44d1f7
README.zh.md: d62953d325ad14479539ccb61fb51378d21e3b52
README.md: bc607404b9dcb637115bcdced761177f187de3fa
README.zh.md: 0a1cbb924179bdcb790adc3a955106c858fc317e
+1 -1
View File
@@ -81,7 +81,7 @@ This section explains the design decisions behind the contract and points at the
### Service operations
Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `kill` invokes producer cancellation before changing status, `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; listeners observe terminal records and visible-set changes at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md).
Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `kill` invokes producer cancellation before changing status (claiming the terminal report by default; a caller with no model-visible channel passes `reported: false` so the completion notice stays due, and its recorded reason merges into a `killed` settlement's 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; listeners observe terminal records and visible-set changes at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md).
</details>
+1 -1
View File
@@ -81,7 +81,7 @@ kind: "package-reference"
### 服务操作
每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录与可见集变化,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。
每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`kill` 在改变状态前调用生产方取消(默认认领终态报告;没有模型可见渠道的调用方传 `reported: false`,完成通知因此仍欠着投递,其记录的原因会合入 `killed` 结算的 detail),`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录与可见集变化,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。
</details>
+10 -6
View File
@@ -9,13 +9,14 @@
import { Context, Service } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
JobDoneListener, JobId, JobRead, JobSnapshot, JobStart, JobsChangedListener,
JobDoneListener, JobId, JobKillOptions, JobRead, JobSnapshot, JobStart, JobsChangedListener,
} from './types.ts'
export { JobId } from './types.ts'
export type {
JobDoneListener,
JobHooks,
JobKillOptions,
JobKind,
JobKindMap,
JobOutcome,
@@ -109,15 +110,18 @@ export abstract class JobRegistry extends Service {
abstract read(id: JobId, caller?: Agent): JobRead
/**
* Request cancellation, then mark the job stopping and reported. A producer
* throw propagates without changing job state. Throws for an unknown or
* foreign job.
* Request cancellation, then mark the job stopping. By default the kill also
* claims the terminal report ({@link JobKillOptions.reported}); a kill with
* `reported: false` leaves the settlement notice due instead. A recorded
* {@link JobKillOptions.reason} merges into the terminal `detail` when the
* job settles `killed`. A producer throw propagates without changing job
* state. Throws for an unknown or foreign job.
* @param id - job to cancel.
* @param caller - killing agent checked against the owner.
* @param reason - logged reason forwarded to the producer.
* @param options - cancellation reason and terminal-report claim.
* @returns `requested` for live work, otherwise `already-finished`.
*/
abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
abstract kill(id: JobId, caller?: Agent, options?: JobKillOptions): 'requested' | 'already-finished'
/**
* Wait for settlement or timeout without cancelling the job. Caller abort
+20
View File
@@ -127,6 +127,26 @@ export interface JobSnapshot {
reported: boolean
}
/** Options for {@link JobRegistry.kill}. */
export interface JobKillOptions {
/**
* Cancellation reason: forwarded verbatim to the producer's cancel hook, and
* merged into the terminal `detail` when the job settles `killed`, so both
* the model and observers can see why the work stopped.
*/
reason?: string
/**
* Whether this kill itself reports the terminal state to the model (default
* `true`): the killer's own result is the delivery, so the settlement notice
* is suppressed exactly as before. Pass `false` for a killer with no
* model-visible channel (a human cancellation from a client UI) — the job
* settles unreported and the standard completion notice stays due under the
* completion reporter's own delivery rules. `false` never clears an existing
* claim: a job the model already killed stays reported.
*/
reported?: boolean
}
/** Output and post-read state returned by {@link JobRegistry.read}. */
export interface JobRead {
/**
+3 -1
View File
@@ -389,7 +389,9 @@ export function apply(ctx: Context, config: Config): void {
},
execute(args, exec) {
const id = validateJobId(args.job_id)
const result = ctx.jobs.kill(id, exec.agent, args.reason)
// The model's own kill claims the terminal report: this tool result is
// its delivery, so the settlement notice stays suppressed (the default).
const result = ctx.jobs.kill(id, exec.agent, args.reason === undefined ? {} : { reason: args.reason })
// A snapshot describes current state without consuming pending output.
const snapshot = publicJob(ctx.jobs.get(id, exec.agent))
return Promise.resolve({
@@ -568,6 +568,26 @@ describe('completion notice delivery', () => {
expect(inject).not.toHaveBeenCalled()
})
it('delivers the completion notice for a human kill, reason included', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = fakeAgent(ctx, 'sess-1', { inject })
const p = producer({ owner, label: 'pnpm run watch' })
const id = ctx.jobs.start(p.spec)
// A kill with an unclaimed report (the web client's stop button) leaves
// the notice due; the model's own job_kill would have claimed it instead.
ctx.jobs.kill(id, owner, { reason: 'cancelled by the user', reported: false })
p.settle({ status: 'killed', detail: 'signal: SIGTERM' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
const message = inject.mock.calls[0]![0] as { content: readonly { type: string; text: string }[] }
expect(message.content[0]!.text).toBe(
'background job bash-1 (bash: pnpm run watch) finished '
+ '[status: killed, signal: SIGTERM; cancelled by the user]. Read its output with job_output.',
)
})
it('never wakes an idle owner under quiet delivery', async () => {
const { ctx } = await setup({ completionDelivery: 'quiet' })
const inject = vi.fn()
@@ -105,7 +105,7 @@ describe('background bash observation', () => {
const job = ctx.jobs.list()[0]
const snapshot = await until(() =>
ctx.activities.list().find(row => row.correlation?.jobId === job!.id))
ctx.jobs.kill(job!.id, undefined, 'test cleanup')
ctx.jobs.kill(job!.id, undefined, { reason: 'test cleanup' })
await until(() => ctx.activities.get(snapshot.id).status === 'killed' ? true : undefined)
expect(ctx.activities.get(snapshot.id).detail).toMatch(/signal|killed before exit/)
})
@@ -119,6 +119,14 @@ export class FixtureSession implements SessionFace {
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `killJob` on the fixture's session face to exercise it.
* @returns never — always throws.
*/
killJob(): never {
throw new Error(`test session "${this.sessionId}": killJob is not stubbed — supply it on the fixture's session face`)
}
/**
* Fail-loud stub; supply `command` on the fixture's session face to exercise it.
* @returns never — always throws.
+3
View File
@@ -303,6 +303,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
SessionAttachmentValue: 'session.md',
SessionCancelRequest: 'session.md',
SessionCancelValue: 'session.md',
SessionKillJobRequest: 'session.md',
SessionKillJobValue: 'session.md',
SessionControlFrame: 'session.md',
SessionCreateRequest: 'session.md',
SessionCreateValue: 'session.md',
@@ -496,6 +498,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ActivityObserveRequest: 'activity.md',
JobDoneListener: 'jobs.md',
JobId: 'jobs.md',
JobKillOptions: 'jobs.md',
JobRead: 'jobs.md',
JobSnapshot: 'jobs.md',
JobStart: 'jobs.md',
@@ -3,3 +3,5 @@
- button "Show live output of sleep 45":
- text: sleep 45 {{duration}} bash running
- img
- button "Stop task sleep 45":
- img
@@ -1,5 +1,5 @@
- list "Tasks":
- listitem:
- button "Show live output of sleep 45":
- text: "bash sleep 45 signal: SIGTERM {{duration}}"
- text: "bash sleep 45 signal: SIGTERM; cancelled by the user {{duration}}"
- img