diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 9b64d73885..29e6b05336 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 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 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 23074b5d38..218bef2900 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 06645267ee..6aad4daad5 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -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。 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml index cba3c8b11c..64e8d30257 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-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 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md index e67ca84d96..e4ba43e9e5 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md index 2a71b04ec3..fde7acbec5 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md @@ -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` 拥有合并后的全部文案,包括时长词汇。 diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml new file mode 100644 index 0000000000..7705e75ac0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-human-job-kill.md +2026-08-26-human-job-kill.md: 9ef2d66e129ea70fa927528717f87e4cfec66a1b +2026-08-26-human-job-kill.zh.md: 1b9933b5af48b736fce4a6562338ff9f1d75c6d4 diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md new file mode 100644 index 0000000000..9ef2d66e12 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md @@ -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 `` 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. diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md new file mode 100644 index 0000000000..1b9933b5af --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md @@ -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 在 `` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。 +- **`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` 一直如此声明。 diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index ebecc28f08..25333dc422 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -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) diff --git a/apps/web/tests/live-activity-stream.e2e.ts b/apps/web/tests/live-activity-stream.e2e.ts index a8911746ca..a223d4c4ed 100644 --- a/apps/web/tests/live-activity-stream.e2e.ts +++ b/apps/web/tests/live-activity-stream.e2e.ts @@ -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. diff --git a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md index ecdcd4fea4..7cf217552d 100644 --- a/apps/web/tests/snapshots/live-activity-stream/settled.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/settled.expected.md @@ -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" diff --git a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md index aa23b11410..0f089d0192 100644 --- a/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md +++ b/apps/web/tests/snapshots/live-activity-stream/streaming.expected.md @@ -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 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8c8e2e953a..7fdd40f5c7 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: d1bb632bd0a46e00617efb797ae916264fa4185c -config-catalog.zh.md: 789b61eeca37af88376841c53efb254f2d6be67d +config-catalog.md: 3e49f1503d25b8c05b03fa38d23ae279d0616738 +config-catalog.zh.md: d5b089c96e6c896e2a056585c81c87de40698cd2 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d1bb632bd0..3e49f1503d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 789b61eeca..d5b089c96e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 810743bee1..41777f467e 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 4c8e04139271c4234747594246b00831d396076f -event-producer-consumer.zh.md: dba5f4b86fedde89a5b6816a8a8e078cb642c0a7 +event-producer-consumer.md: d8306504afd157e8cafd5449b700eaa6a7f8ae21 +event-producer-consumer.zh.md: cfaf4329bff34c181561b9bd7250215bc36a2718 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4c8e041392..d8306504af 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index dba5f4b86f..cfaf4329bf 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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` | diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 53ba3397ea..1acc775cf7 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/jobs.md -jobs.md: 092da7b6f7e10c5713d3d6c9c5c46f925084f86a -jobs.zh.md: 9dc390eb30901664edb47c6727a54ddd83839fa7 +jobs.md: 5aa33d0433f5f43237353b686a9528d8266de260 +jobs.zh.md: 888870464757c3296f188892d17c9c5954ebf8fe diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 092da7b6f7..5aa33d0433 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -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 diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 9dc390eb30..8888704647 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -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 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index e5b62d616e..4f6bbfae90 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: ef24802d7ea567681f935fea86efaab2eb2b80c0 -session.zh.md: bee235657d1c1d1a6009eb23d51b8c88482c0748 +session.md: 867dd555db54a1c01a95d1b1a5f3b94da7c01ac9 +session.zh.md: 46a7d8bb7b7b87f724606b274c1ea749be8a1787 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index ef24802d7e..867dd555db 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -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. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index bee235657d..46a7d8bb7b 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -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. diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3325cc86a4..40a91edd94 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 815276847f538f99352e0f0e55fc19af5da67470 -README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b +README.md: 2b02b394c3e47398b15db62014864a227ad4f90f +README.zh.md: 524b8c67fad7317190ed1045eaa8267a88945f72 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 815276847f..2b02b394c3 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `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. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 51bf5a98c6..524b8c67fa 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `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。 diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 6ac4182bb9..981758b9b9 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -65,6 +65,14 @@ export interface ISession { * @returns acceptance, or the business error. */ cancel(): Promise> + /** + * 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> /** * Rename this session (explicit user title; pins it against automatic * regeneration). diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 182d8514d9..7ed2fe754c 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -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> { + 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 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 48c41f3aa1..845a34fb44 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -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 { const found = await this.agents.resolveAgent(sessionId) if ('error' in found) rejectFailure(found.error) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index a3ce201aaf..458c75f503 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -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. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c045e00707..e088d6aa2e 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -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 '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'> diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts index f83fa1b3c8..0af1606b6e 100644 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -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 diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 39a4bf1888..2449ca6024 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -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) => { diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index a08af90309..4c924411c1 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -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> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onKillJob: (payload: unknown) => Promise> = () => Promise.resolve(ok({ outcome: 'requested' as const })) onDescribe: (payload: unknown) => Promise 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), diff --git a/packages/api/session-controller/tests/kill-job.host.spec.ts b/packages/api/session-controller/tests/kill-job.host.spec.ts new file mode 100644 index 0000000000..0cb4c41d01 --- /dev/null +++ b/packages/api/session-controller/tests/kill-job.host.spec.ts @@ -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((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') + }) +}) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 6ee5bd9ba4..bc74f7b31a 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -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({ diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ac131ab628..4e4c1edb13 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -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> updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> + killJob(request: SessionKillJobRequest): Promise> page(request: SessionPageRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable @@ -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, diff --git a/packages/client/ui-activity/README.i18n.yaml b/packages/client/ui-activity/README.i18n.yaml index bae875ee0e..1c05272292 100644 --- a/packages/client/ui-activity/README.i18n.yaml +++ b/packages/client/ui-activity/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-activity/README.md -README.md: 6630558922cf9f1e2b772bcc6334a21f91591acb -README.zh.md: b02949f2518c85ba931e0adf76946c390b564d1a +README.md: 2739b6f24845539cdb920b5043c372b4c1dc2fb7 +README.zh.md: 087ec98af76baacd159babbe674cb33bc1301199 diff --git a/packages/client/ui-activity/README.md b/packages/client/ui-activity/README.md index 6630558922..2739b6f248 100644 --- a/packages/client/ui-activity/README.md +++ b/packages/client/ui-activity/README.md @@ -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. diff --git a/packages/client/ui-activity/README.zh.md b/packages/client/ui-activity/README.zh.md index b02949f251..087ec98af7 100644 --- a/packages/client/ui-activity/README.zh.md +++ b/packages/client/ui-activity/README.zh.md @@ -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 连接成一条流;分通道着色是后续呈现优化。 diff --git a/packages/client/ui-activity/src/client/ActivityListAction.module.css b/packages/client/ui-activity/src/client/ActivityListAction.module.css index b5fde9edd1..404c77e5b9 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.module.css +++ b/packages/client/ui-activity/src/client/ActivityListAction.module.css @@ -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; diff --git a/packages/client/ui-activity/src/client/ActivityListAction.tsx b/packages/client/ui-activity/src/client/ActivityListAction.tsx index 41f4d7a4b1..ea7914d849 100644 --- a/packages/client/ui-activity/src/client/ActivityListAction.tsx +++ b/packages/client/ui-activity/src/client/ActivityListAction.tsx @@ -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 } /** 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 }) { const live = isLive(row) @@ -262,25 +288,51 @@ function TaskItem({ row, view, expanded, now, onToggle, t }: { {observable ? : 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 (
  • - {observable - ? ( - - ) - : ( - - {body} - - )} +
    + {observable + ? ( + + ) + : ( + + {body} + + )} + {kill !== undefined + ? ( + + ) + : null} +
    {expanded && view !== undefined ? (
    @@ -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(undefined) + // One kill affordance advances at a time: arming a row disarms any other. + const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude } | undefined>(undefined) const [now, setNow] = useState(() => Date.now()) const rootRef = useRef(null) const triggerRef = useRef(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} /> ) diff --git a/packages/client/ui-activity/src/client/index.ts b/packages/client/ui-activity/src/client/index.ts index 39f43df9e1..ffa476baa3 100644 --- a/packages/client/ui-activity/src/client/index.ts +++ b/packages/client/ui-activity/src/client/index.ts @@ -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), ) diff --git a/packages/client/ui-activity/src/client/locales.ts b/packages/client/ui-activity/src/client/locales.ts index d4fb08ec31..c798b775a4 100644 --- a/packages/client/ui-activity/src/client/locales.ts +++ b/packages/client/ui-activity/src/client/locales.ts @@ -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 = { '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', diff --git a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx index ffb4ce5492..28276e5fcb 100644 --- a/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx +++ b/packages/client/ui-activity/tests/activity-list-action.client.spec.tsx @@ -53,6 +53,7 @@ function props( snapshot: Partial, observe: ActivityListActionProps['observe'] = () => () => {}, jobs: readonly SessionJob[] = [], + killJob: ActivityListActionProps['killJob'] = async () => true, ): ActivityListActionProps { const state: ActivityFeedSnapshot = { rowsBySession: {}, @@ -67,7 +68,7 @@ function props( function useSessions(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() + 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() + 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() + openList() + const stop = screen.getByTitle(stopTitle('pnpm run build')) + fireEvent.click(stop) + expect(stop.getAttribute('data-kill-state')).toBe('armed') + act(() => { vi.advanceTimersByTime(3_000) }) + expect(stop.getAttribute('data-kill-state')).toBe('idle') + expect(killJob).not.toHaveBeenCalled() + }) + + it('a rejected kill shows its hint and then resets', async () => { + // Fake timers from the start so the failed-hint reset arms on the fake clock. + vi.useFakeTimers() + const killJob = vi.fn(async () => false) + render() + openList() + const stop = screen.getByTitle(stopTitle('pnpm run build')) + fireEvent.click(stop) + await act(async () => { fireEvent.click(stop) }) + expect(stop.getAttribute('data-kill-state')).toBe('failed') + expect(screen.getByTitle(zh['kill.failed'])).toBe(stop) + act(() => { vi.advanceTimersByTime(4_000) }) + expect(stop.getAttribute('data-kill-state')).toBe('idle') + }) + + it('arming a second row disarms the first', () => { + render() + openList() + const first = screen.getByTitle(stopTitle('pnpm run build')) + fireEvent.click(first) + expect(first.getAttribute('data-kill-state')).toBe('armed') + const second = screen.getByTitle(stopTitle('pnpm run watch')) + fireEvent.click(second) + expect(second.getAttribute('data-kill-state')).toBe('armed') + expect(first.getAttribute('data-kill-state')).toBe('idle') + }) + + it('a kill resolving after another row armed leaves the newer phase alone', async () => { + let resolveKill!: (ok: boolean) => void + const killJob = vi.fn(() => new Promise((resolve) => { resolveKill = resolve })) + render() + openList() + const first = screen.getByTitle(stopTitle('pnpm run build')) + fireEvent.click(first) + fireEvent.click(first) + expect(killJob).toHaveBeenCalledTimes(1) + // While the first kill is in flight, the user arms the second row; the + // late resolution must not clobber that newer phase. + const second = screen.getByTitle(stopTitle('pnpm run watch')) + fireEvent.click(second) + expect(second.getAttribute('data-kill-state')).toBe('armed') + await act(async () => { resolveKill(false) }) + expect(second.getAttribute('data-kill-state')).toBe('armed') + expect(first.getAttribute('data-kill-state')).toBe('idle') + }) + + it('clears an armed phase whose row stopped being killable', () => { + const { rerender } = render() + openList() + const stop = screen.getByTitle(stopTitle('pnpm run build')) + fireEvent.click(stop) + expect(stop.getAttribute('data-kill-state')).toBe('armed') + rerender() + // The stopping row offers no kill control any more, and the stale phase is gone. + expect(screen.queryByTitle(stopTitle('pnpm run build'))).toBeNull() + expect(document.querySelector('[data-kill-state]')).toBeNull() + }) +}) diff --git a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts index 3f84c2252d..b520cdb629 100644 --- a/packages/client/ui-activity/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-activity/tests/browser-plugin.client.spec.ts @@ -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 }> { const ctx = new Context() @@ -35,7 +39,11 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType 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 + } }).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 () => { diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 20a7a0f7aa..0e5054fb24 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -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')), diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 7dd28e586b..ea2b8716e5 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -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>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n killJob(jobId: string): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyPropsOf', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e3150fc879..f0b39b11e5 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -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', 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;\n cancelled: Record;\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;\n cancelled: Record;\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 = 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() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index fbbdbe63d0..d9ce294d68 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -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. diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 8c71ae1b79..2db513b520 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -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 /** 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 diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 33ea29ba68..930d2bc8d3 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -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', () => { diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index faeca47c08..7c29aaae8e 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md -README.md: 7625c3672d24edff908c7be01723e970bd44d1f7 -README.zh.md: d62953d325ad14479539ccb61fb51378d21e3b52 +README.md: bc607404b9dcb637115bcdced761177f187de3fa +README.zh.md: 0a1cbb924179bdcb790adc3a955106c858fc317e diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 7625c3672d..bc607404b9 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -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). diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index d62953d325..0a1cbb9241 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -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)。 diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 7d46dfa342..3c36593119 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -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 diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index 2842f6a3a5..c5effe5733 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -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 { /** diff --git a/packages/jobs/tool-jobs/src/index.ts b/packages/jobs/tool-jobs/src/index.ts index 3424abc3ef..5a3c1c7798 100644 --- a/packages/jobs/tool-jobs/src/index.ts +++ b/packages/jobs/tool-jobs/src/index.ts @@ -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({ diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 29fc44a11b..79972578a7 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -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() diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 3ba16a832b..a859ede244 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -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/) }) diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index b269eb9bf4..ff3386c87b 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -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. diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4b1cbda2b1..be821677c4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -303,6 +303,8 @@ export const LINK_MAP: Readonly> = { 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> = { ActivityObserveRequest: 'activity.md', JobDoneListener: 'jobs.md', JobId: 'jobs.md', + JobKillOptions: 'jobs.md', JobRead: 'jobs.md', JobSnapshot: 'jobs.md', JobStart: 'jobs.md', diff --git a/snapshots/web/background-job-list/running.expected.md b/snapshots/web/background-job-list/running.expected.md index 95f226f226..28574ac6f3 100644 --- a/snapshots/web/background-job-list/running.expected.md +++ b/snapshots/web/background-job-list/running.expected.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 diff --git a/snapshots/web/background-job-list/settled.expected.md b/snapshots/web/background-job-list/settled.expected.md index 3343ae521c..34e39bcf26 100644 --- a/snapshots/web/background-job-list/settled.expected.md +++ b/snapshots/web/background-job-list/settled.expected.md @@ -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