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