From e8469d6f6cf89ba4e095edd4e3ef3d4caf1ab316 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 23:18:08 +0800 Subject: [PATCH] refactor(jobs): move the human kill to the job namespace `session.killJob` becomes `job.kill` on `dsh-api-job-controller`, beside `job.observe`, so the Session Controller carries only session control and the roster while every per-job Remote lives on the job namespace. The client `ctx.jobOutput` gains `kill(sessionId, jobId)`; `dsh-client-ui-jobs` routes its stop control through it and no longer requires `sessions`. Unknown or foreign jobs reject with `job/not-found`; the controller requires `ctx.jobs` to load, so `jobs-unavailable` disappears. The two subagent ownership-fence helpers are classified duplicate-install-safe Host exports of the Session Controller for the cross-controller import. --- .../2026-08-26-human-job-kill.i18n.yaml | 4 +- .../feature/2026-08-26-human-job-kill.md | 2 +- .../feature/2026-08-26-human-job-kill.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 10 ++ docs/subsystems/jobs.zh.md | 10 ++ docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 8 -- docs/subsystems/session.zh.md | 8 -- packages/api/job-controller/README.i18n.yaml | 4 +- packages/api/job-controller/README.md | 8 +- packages/api/job-controller/README.zh.md | 8 +- packages/api/job-controller/package.json | 1 + .../api/job-controller/src/client/index.ts | 2 +- .../api/job-controller/src/client/service.ts | 25 ++++- packages/api/job-controller/src/index.ts | 42 +++++++- packages/api/job-controller/src/types.ts | 19 ++++ .../tests/client-apply.client.spec.ts | 16 ++- .../tests/kill.host.spec.ts} | 99 +++++++++---------- .../job-controller/tests/model.client.spec.ts | 13 ++- .../api/job-controller/tsconfig.host.json | 27 +++-- .../src/client/contract/session.ts | 8 -- .../src/client/sessions/session.ts | 14 --- .../api/session-controller/src/commands.ts | 42 -------- packages/api/session-controller/src/index.ts | 15 +-- packages/api/session-controller/src/types.ts | 13 --- .../tests/controller.host.spec.ts | 27 ----- .../tests/fake-api.client.ts | 2 - .../tests/session.client.spec.ts | 23 ----- .../session-controller/tests/test-remote.ts | 4 - .../conversation-registry.client.spec.ts | 1 - packages/client/ui-jobs/README.i18n.yaml | 4 +- packages/client/ui-jobs/README.md | 4 +- packages/client/ui-jobs/README.zh.md | 4 +- packages/client/ui-jobs/package.json | 1 + packages/client/ui-jobs/src/client/index.ts | 21 ++-- .../tests/browser-plugin.client.spec.ts | 21 ++-- .../src/client/api-catalog.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 28 +++--- .../client-runtime/src/sessions.ts | 8 -- .../tests/runtime.client.spec.tsx | 1 - pnpm-lock.yaml | 6 ++ scripts/gen-cordis-catalog.ts | 4 +- scripts/package-dependency-policy.ts | 1 + 50 files changed, 282 insertions(+), 324 deletions(-) rename packages/api/{session-controller/tests/kill-job.host.spec.ts => job-controller/tests/kill.host.spec.ts} (59%) diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml index df6cf0e7c2..a6b256e5ae 100644 --- a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml @@ -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-26-human-job-kill.md -2026-08-26-human-job-kill.md: 2242ed2cf5612394f6797106decf75d8543fc420 -2026-08-26-human-job-kill.zh.md: 37a2d2b38bfa74aaaef6c5ba34fa8e0c7761178b +2026-08-26-human-job-kill.md: d9352254df1bb12d00ae2a8f1cad7b47f47395f0 +2026-08-26-human-job-kill.zh.md: 51469f4be87bfec7a6c7cff9b7f038fed6bf4aec diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md index 2242ed2cf5..d9352254df 100644 --- a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md @@ -14,7 +14,7 @@ The `reported` bit means "the terminal state has a committed delivery path to th - **`kill(id, caller?, options?)` takes `{ reason?, reported? }`.** `reported` defaults to `true` — the exact previous semantics for the model's `job_kill`. `reported: false` withholds this kill's claim, so the settlement flows through the existing completion reporter (wakeup/inject, wake budget, truncation all unchanged). It never clears an existing claim: a job the model already killed stays reported, and a terminal record stays exactly as settlement left it. - **A recorded kill reason merges into a `killed` settlement's detail** — producer facts first (`signal: SIGTERM; cancelled by the user`) — so the completion notice and the web row both say who stopped the work without a new snapshot field or notice template. A job that outruns its kill (settles `completed`/`failed`) keeps the producer detail alone. This is the reconciliation Codex spells out in its `` guidance: when the user stops something, the model is told rather than left to infer. -- **`session.killJob` is a Session Controller Remote** beside `cancel`, with the same live-only Agent lookup (`ctx.agents.get`): a running job's owner is alive by the registry's ownership contract, and killing from a list must not revive a Session any more than listing does. Unknown and foreign jobs collapse to one `job-not-found` rejection scoped to the lookup — a producer-cancel throw propagates per the registry contract instead of masquerading as a lookup failure; a composition without `ctx.jobs` rejects `jobs-unavailable`, and the service-wide subagent ownership fence applies exactly as it does to `cancel`. No approval interaction: this single-user local BFF treats it as the same class of action as the turn-cancel button. +- **`job.kill` is a Job Controller Remote** (`@deepseek-ai/dsh-api-job-controller`, beside `job.observe`; it began life as `session.killJob` on the Session Controller and moved with the observation stream when that package split out) with the same live-only Agent lookup (`ctx.agents.get`) as `session.cancel`: a running job's owner is alive by the registry's ownership contract, and killing from a list must not revive a Session any more than listing does. Unknown and foreign jobs collapse to one `job/not-found` rejection scoped to the lookup — a producer-cancel throw propagates per the registry contract instead of masquerading as a lookup failure; the controller requires `ctx.jobs` to load, so a composition without a registry has no kill Remote at all, and the Session Controller's subagent ownership fence applies exactly as it does to `cancel`. 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 and stays pending on an admitted kill — the unary response and the jobs frames have no cross-carrier ordering, so only the authoritative frame (the row leaving the killable set) releases it; a rejected kill shows a brief hint. Because the kill addresses `ctx.jobs` by id, every job kind gains the control at once — bash, pwsh, pty, and one-shot background subagents. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md index 37a2d2b38b..51469f4be8 100644 --- a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md @@ -14,7 +14,7 @@ - **`kill(id, caller?, options?)` 接受 `{ reason?, reported? }`。** `reported` 默认 `true`——即模型 `job_kill` 的原有语义。`reported: false` 只是不提出本次 kill 的认领,结算随即照常走完成播报员(唤醒/注入、唤醒预算、截断全部不变)。它绝不清除已有认领:模型已 kill 过的任务保持 reported,终态记录也保持结算时的原样。 - **记录下来的 kill reason 合入 `killed` 结算的 detail**——生产者事实在前(`signal: SIGTERM; cancelled by the user`)——完成通知与 Web 行因此都能说清是谁停的工作,无需新快照字段或新通知模板。跑赢 kill 的任务(结算为 `completed`/`failed`)只保留生产者 detail。这正是 Codex 在 `` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。 -- **`session.killJob` 是 Session Controller 的 Remote**,与 `cancel` 并列,同样只查活体 Agent(`ctx.agents.get`):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个只作用于查找的 `job-not-found` 拒绝——生产者 cancel 抛错按注册表契约原样传播,绝不伪装成查找失败;没有 `ctx.jobs` 的组合拒绝 `jobs-unavailable`,服务级的 subagent 所有权栅栏与 `cancel` 完全一致地适用。不加 approval 交互:这个单用户本地 BFF 把它视为与停轮按钮同级的操作。 +- **`job.kill` 是 Job Controller 的 Remote**(`@deepseek-ai/dsh-api-job-controller`,与 `job.observe` 并列;它最初是 Session Controller 上的 `session.killJob`,该包拆出时随观测流一起迁走),与 `session.cancel` 一样只查活体 Agent(`ctx.agents.get`):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个只作用于查找的 `job/not-found` 拒绝——生产者 cancel 抛错按注册表契约原样传播,绝不伪装成查找失败;控制器加载就要求 `ctx.jobs`,没有注册表的组合根本没有 kill Remote,Session Controller 的 subagent 所有权栅栏与 `cancel` 完全一致地适用。不加 approval 交互:这个单用户本地 BFF 把它视为与停轮按钮同级的操作。 - **任务列表的停止控件为两击式**(先武装、3 秒内确认,对齐 Kimi 的 `s`+`y` 与 OpenCode 的双 Esc),只渲染在运行中的 job 行——即带 `jobId` 的行。独立 activity 行(workflow 运行)没有 kill 句柄也就没有按钮。请求进行中按钮禁用,且受理成功后保持 pending——unary 响应与 jobs 帧没有跨载体顺序保证,只有权威帧(行离开可杀集合)才释放控件;被拒绝的 kill 短暂提示。由于 kill 按 id 寻址 `ctx.jobs`,所有任务 kind 一次性获得该控件——bash、pwsh、pty 与一次性后台 subagent。 ## 备选方案 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d5b3bfc71f..48f96a122e 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 2de36662c7e9cacbc5ab24f0378182b01f6a655b -config-catalog.zh.md: 241702902e3c6e816b442e05caa2170d2cc2398d +config-catalog.md: a58fddac8cecd6c17ca8343cff9c9201e83a9967 +config-catalog.zh.md: 38d3766290535b37ecfb510b1dc5640e96d784f3 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2de36662c7..a58fddac8c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -215,7 +215,7 @@ export interface Config { } ``` -Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) +Source: [`packages/api/job-controller/src/index.ts:34`](../packages/api/job-controller/src/index.ts) @@ -235,7 +235,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:74`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 241702902e..38d3766290 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -217,7 +217,7 @@ export interface Config { } ``` -来源:[`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) +来源:[`packages/api/job-controller/src/index.ts:34`](../packages/api/job-controller/src/index.ts) @@ -239,7 +239,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:74`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e490f6b0b9..d7f59d7c7d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: cab6dd95719264a4103cb33df894325b1c5006ea -event-producer-consumer.zh.md: 20d5a54c1100cb7bc11f224c91daddb71e37e439 +event-producer-consumer.md: 94364122879a8b2bef6df663a77faff7fd8c1403 +event-producer-consumer.zh.md: b10d2291760d7cf0dc31487410c4a15cf257346e diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index cab6dd9571..9436412287 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:563`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:570`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:556`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../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:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 20d5a54c11..b10d229176 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:563`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:570`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:556`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../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:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 8d18432d87..8d23519beb 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/jobs.md -jobs.md: d5d1b560ad82efefe3407038707cef44c8abbd93 -jobs.zh.md: afde8704ee7e9ce7598e1c48bd1e52282aa7fff4 +jobs.md: ad470e66a65fd8a78c93ab638cd6ea9f5dfb5556 +jobs.zh.md: 00ba02352332e9a95f9359e1a1b00cc3516058c1 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index d5d1b560ad..ad470e66a6 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -276,6 +276,16 @@ Host service backing the generated `ctx.remote.job` namespace. * @returns anchor, coalesced output frames, and the terminal status. */ @Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable + +/** + * Kill one background job on a human's behalf, leaving the terminal report + * unclaimed so the owning agent still receives the completion notice. The + * request's session resolves the live agent for the fenced lookup, and the + * subagent ownership fence applies exactly as it does to `session.cancel`. + * @param request - Session whose task list carries the job, and the job id. + * @returns the registry's admission of the kill request. + */ +@Remote('kill') kill(request: JobKillRequest): JobKillValue ``` Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index afde8704ee..00ba023523 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -276,6 +276,16 @@ Host service backing the generated `ctx.remote.job` namespace. * @returns anchor, coalesced output frames, and the terminal status. */ @Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable + +/** + * Kill one background job on a human's behalf, leaving the terminal report + * unclaimed so the owning agent still receives the completion notice. The + * request's session resolves the live agent for the fenced lookup, and the + * subagent ownership fence applies exactly as it does to `session.cancel`. + * @param request - Session whose task list carries the job, and the job id. + * @returns the registry's admission of the kill request. + */ +@Remote('kill') kill(request: JobKillRequest): JobKillValue ``` Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index ed9d062452..f9653cecea 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: ec395926301527c2d13ec8b11eede23e08e7719b -session.zh.md: 3ab442964348ca76b5878f47491467a69f55d67a +session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 +session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index ec39592630..48a80ccb92 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -778,14 +778,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise + /** + * Kill one job on the human's behalf. + * @param request - the session whose list carries the job, and the job id. + * @returns the registry's admission, or the business/transport failure. + */ + kill(request: JobKillRequest): Promise> } /** Remote faces the observation runner drives: the Gateway stream factory and the `job` namespace. */ @@ -41,6 +49,15 @@ export interface IJobOutput { * @returns stop function releasing this observer's reference. */ observe(sessionId: SessionId | undefined, id: JobId): () => void + /** + * Kill one background job from a 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. + * @param sessionId - session whose task list carries the job. + * @param id - the job row's registry id. + * @returns the registry's admission, or the business/transport failure. + */ + kill(sessionId: SessionId, id: JobId): Promise> } /** One reference-counted observation stream. */ @@ -84,6 +101,10 @@ export class ClientJobOutput extends Service implements IJobOutput { }, 'job-controller.client.observations') } + kill(sessionId: SessionId, id: JobId): Promise> { + return this.remote.job.kill({ sessionId, jobId: id }) + } + observe(sessionId: SessionId | undefined, id: JobId): () => void { const key = String(id) const existing = this.entries.get(key) diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts index 27e85a0ed0..0ad9da8eab 100644 --- a/packages/api/job-controller/src/index.ts +++ b/packages/api/job-controller/src/index.ts @@ -2,7 +2,7 @@ * Host job Remote owner: streams one background job's observation record to * browsers over the generated `job` namespace. The roster itself rides the * session control stream (`jobsBySession`, owned by `dsh-api-session-controller`); - * this controller carries only the non-consuming record output. + * this controller carries the non-consuming record output and the human kill. * @module @deepseek-ai/dsh-api-job-controller */ @@ -10,9 +10,10 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-jobs' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner } from '@deepseek-ai/dsh-api-session-controller' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { observeJobRecord } from './observe.ts' -import type { JobObserveFrame, JobObserveRequest } from './types.ts' +import type { JobKillRequest, JobKillValue, JobObserveFrame, JobObserveRequest } from './types.ts' export type * from './types.ts' @@ -81,6 +82,41 @@ export class JobController extends TypertRemoteService { caller, }, signal) } + + /** + * Kill one background job on a human's behalf, leaving the terminal report + * unclaimed so the owning agent still receives the completion notice. The + * request's session resolves the live agent for the fenced lookup, and the + * subagent ownership fence applies exactly as it does to `session.cancel`. + * @param request - Session whose task list carries the job, and the job id. + * @returns the registry's admission of the kill request. + */ + @Remote('kill') + kill(request: JobKillRequest): JobKillValue { + const agent = this.ctx.agents.get(request.sessionId) + if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + throw apiSessionSubagentOwnershipError(request.sessionId) + } + const jobs = this.ctx.jobs + try { + jobs.get(request.jobId, agent) + } 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. + throw new RemoteError('job/not-found', String(error), { + sessionId: request.sessionId, + jobId: request.jobId, + }) + } + // Same synchronous span as the lookup, so nothing can remove the job in + // between — and a producer-cancel throw propagates per the registry + // contract (job state unchanged) instead of masquerading as job-not-found. + const outcome = jobs.kill(request.jobId, agent, { + reason: 'cancelled by the user', + reported: false, + }) + return { outcome } + } } export default JobController diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts index 98df4d8f65..925e56f089 100644 --- a/packages/api/job-controller/src/types.ts +++ b/packages/api/job-controller/src/types.ts @@ -29,6 +29,25 @@ export interface JobObserveRequest { readonly from?: number } +/** Human-initiated cancellation of one background job visible to a session. */ +export interface JobKillRequest { + /** Session whose task list carries the job; resolves the live agent for the fenced lookup. */ + readonly sessionId: SessionId + readonly jobId: JobId +} + +/** Receipt after the registry accepted the human kill request. */ +export interface JobKillValue { + readonly outcome: 'requested' | 'already-finished' +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + /** The session's task list no longer carries a killable row under that id. */ + 'job/not-found': { readonly sessionId: SessionId; readonly jobId: JobId } + } +} + /** One retained record chunk on the wire. `channel` widens to string like a job's `kind`. */ export interface JobWireChunk { /** Absolute offset of the chunk's first byte. */ diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts index ebbe67e98e..e49f8d53b5 100644 --- a/packages/api/job-controller/tests/client-apply.client.spec.ts +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -14,10 +14,11 @@ afterEach(async () => { }) /** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */ -async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { +async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; killCalls: unknown[] }> { const ctx = new Context() contexts.add(ctx) const observeCalls: unknown[] = [] + const killCalls: unknown[] = [] const connection: ConnectionHandle = { isLoopback: true, generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, @@ -33,6 +34,10 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { observeCalls.push(request) return (async function* (): AsyncGenerator {})() }, + kill: async (request: unknown) => { + killCalls.push(request) + return { ok: true as const, value: { outcome: 'requested' as const } } + }, } ctx.reflect.provide('remote', { $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), @@ -40,7 +45,7 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> { }) ctx.reflect.provide('remote.job', job) await ctx.plugin(JobClient) - return { ctx, observeCalls } + return { ctx, observeCalls, killCalls } } async function flush(): Promise { @@ -62,4 +67,11 @@ describe('Job Controller Client apply', () => { await flush() expect(ctx.jobOutput.state.getSnapshot().observed).toEqual({}) }) + + it('forwards a kill to the job namespace with the row\'s session', async () => { + const { ctx, killCalls } = await mount() + await expect(ctx.jobOutput.kill('session-1' as SessionId, 'bash-1' as never)) + .resolves.toEqual({ ok: true, value: { outcome: 'requested' } }) + expect(killCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }]) + }) }) diff --git a/packages/api/session-controller/tests/kill-job.host.spec.ts b/packages/api/job-controller/tests/kill.host.spec.ts similarity index 59% rename from packages/api/session-controller/tests/kill-job.host.spec.ts rename to packages/api/job-controller/tests/kill.host.spec.ts index cdd92f055d..7c259a98a9 100644 --- a/packages/api/session-controller/tests/kill-job.host.spec.ts +++ b/packages/api/job-controller/tests/kill.host.spec.ts @@ -6,9 +6,9 @@ import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import SessionStore from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { describe, expect, it } from 'vitest' -import type { ApiSessionAgentController } from '../src/agent.ts' -import { SessionCommandController } from '../src/commands.ts' +import { JobController } from '../src/index.ts' function producer(label = 'sleep 60') { let settle!: (outcome: JobOutcome) => void @@ -24,20 +24,7 @@ function producer(label = 'sleep 60') { 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() +function registerAgent(ctx: Context, session: Session): Agent { const agent = { id: session.id, session, @@ -46,10 +33,25 @@ async function harness(withRegistry = true): Promise<{ 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 } + return agent +} + +async function harness(): Promise<{ + ctx: Context + session: Session + agent: Agent + controller: JobController +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + ctx.jobs.attachController('kill-test') + await ctx.plugin(TypertRegistry) + await ctx.plugin(JobController, {}) + const session = ctx.sessions.create() + const agent = registerAgent(ctx, session) + return { ctx, session, agent, controller: ctx.jobController } } function failureCode(run: () => unknown): string { @@ -63,64 +65,58 @@ function failureCode(run: () => unknown): string { throw new Error('expected a RemoteError failure') } -describe('session.killJob', () => { +describe('JobController.kill', () => { it('kills an owned running job without claiming the terminal report', async () => { - const { ctx, session, agent, commands } = await harness() + const { ctx, session, agent, controller } = 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(controller.kill({ 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 { ctx, session, agent, controller } = 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(controller.kill({ 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 { ctx, controller } = 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' }) + expect(controller.kill({ 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('session/job-not-found') + it('rejects an unknown job id as job/not-found', async () => { + const { session, controller } = await harness() + expect(failureCode(() => controller.kill({ 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() + it('rejects a foreign session\'s job as job/not-found', async () => { + const { ctx, agent, controller } = 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('session/job-not-found') + expect(failureCode(() => controller.kill({ 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('session/jobs-unavailable') - }) - - it('propagates a producer cancel throw instead of masking it as job-not-found', async () => { - const { ctx, session, agent, commands } = await harness() + it('propagates a producer cancel throw instead of masking it as job/not-found', async () => { + const { ctx, session, agent, controller } = await harness() const id = ctx.jobs.start({ kind: 'bash', label: 'flaky cancel', @@ -131,26 +127,19 @@ describe('session.killJob', () => { }), }) // The registry contract: a producer throw propagates with job state - // unchanged — the command must not rewrite it into a lookup failure. - expect(() => commands.killJob({ sessionId: session.id, jobId: id })).toThrow('cancel boom') + // unchanged — the Remote must not rewrite it into a lookup failure. + expect(() => controller.kill({ sessionId: session.id, jobId: id })).toThrow('cancel boom') expect(ctx.jobs.get(id, agent)).toMatchObject({ status: 'running', reported: false }) }) it('rejects a subagent-owned live session with the ownership fence', async () => { - const { ctx, commands } = await harness() + const { ctx, controller } = await harness() const child = ctx.sessions.create(undefined, { meta: { origin: 'subagent' } }) - const childAgent = { - id: child.id, - session: child, - inbox: new Inbox(child, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), - status: 'idle', - ctx, - } as Agent - ctx.agents.register(childAgent) + const childAgent = registerAgent(ctx, child) const task = producer('child work') const id = ctx.jobs.start({ ...task.spec, owner: childAgent }) - expect(failureCode(() => commands.killJob({ sessionId: child.id, jobId: id }))) + expect(failureCode(() => controller.kill({ sessionId: child.id, jobId: id }))) .toBe('session/agent-busy') expect(ctx.jobs.get(id, childAgent).status).toBe('running') }) diff --git a/packages/api/job-controller/tests/model.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts index 69260ff3fc..6ea1ce2073 100644 --- a/packages/api/job-controller/tests/model.client.spec.ts +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -213,6 +213,7 @@ function bench() { const model = new ClientJobOutputModel() const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = [] const observeCalls: unknown[] = [] + const killCalls: unknown[] = [] const remote = { $stream: (options: { open: (signal: AbortSignal) => unknown }) => { const stream = new FakeStream() @@ -224,15 +225,25 @@ function bench() { observeCalls.push(request) return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } }, + kill: async (request: unknown) => { + killCalls.push(request) + return { ok: false as const, error: { code: 'job/not-found', message: 'gone' } } + }, }, } const output = new ClientJobOutput(ctx, remote as never, model) - return { ctx, model, output, streams, observeCalls } + return { ctx, model, output, streams, observeCalls, killCalls } } const tick = () => new Promise(resolve => setTimeout(resolve, 0)) describe('ClientJobOutput observation streams', () => { + it('forwards a kill to the job namespace and returns the Remote verdict', async () => { + const { output, killCalls } = bench() + await expect(output.kill('session-1' as never, ID)).resolves.toMatchObject({ ok: false }) + expect(killCalls).toEqual([{ sessionId: 'session-1', jobId: ID }]) + }) + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { const { model, output, streams } = bench() output.observe(undefined, ID) diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json index 96de4dede2..833b60c13f 100644 --- a/packages/api/job-controller/tsconfig.host.json +++ b/packages/api/job-controller/tsconfig.host.json @@ -11,11 +11,26 @@ "src/types.ts" ], "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../core/agent" }, - { "path": "../../core/session" }, - { "path": "../../jobs/jobs" }, - { "path": "../../typert/protocol" } + { + "path": "../../../vendor/cordis" + }, + { + "path": "../session-controller/tsconfig.host.json" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../jobs/jobs" + }, + { + "path": "../../typert/protocol" + } ] } diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 61ec3cde32..43c0f2dfd6 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -107,14 +107,6 @@ export interface ISession { * @returns acceptance, or the business error. */ cancel(): Promise> - /** - * Kill one background job from this session's task list. The owning agent - * still receives the standard completion notice, because a human kill has no - * other model-visible channel; row state converges through the jobs frames. - * @param jobId - the job row's registry id. - * @returns the registry's admission, or the business/transport error. - */ - killJob(jobId: string): Promise> /** * Rename this session (explicit user title; pins it against automatic * regeneration). diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 0b0abd3da3..d9d58d41e2 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -4,7 +4,6 @@ import type { Context } from '@deepseek-ai/cordis' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { SessionEventStream } from '../transport.ts' @@ -326,19 +325,6 @@ 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> { - // The brand is nominal typing only; a client program stamps it at the - // wire boundary rather than value-importing the host-side constructor. - return this.remote.session.killJob({ sessionId: this.sessionId, jobId: jobId as JobId }) - } - /** * Rename: contract session.rename 1:1. On success settle the 'title' * projection cell from the response's `{title, seq}` under the store's diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 803aa6c763..423c7d5174 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -37,8 +37,6 @@ import type { SessionCreateValue, SessionForkRequest, SessionForkValue, - SessionKillJobRequest, - SessionKillJobValue, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, @@ -450,46 +448,6 @@ 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. The service-wide - * subagent ownership fence applies exactly as it does to `cancel`. - * @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) { - throw new RemoteError('session/jobs-unavailable', 'background jobs are not available in this composition', {}) - } - const agent = this.ctx.agents.get(request.sessionId) - if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - throw apiSessionSubagentOwnershipError(request.sessionId) - } - try { - jobs.get(request.jobId, agent) - } 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. - throw new RemoteError('session/job-not-found', String(error), { - sessionId: request.sessionId, - jobId: request.jobId, - }) - } - // Same synchronous span as the lookup, so nothing can remove the job in - // between — and a producer-cancel throw now propagates per the registry - // contract (job state unchanged) instead of masquerading as job-not-found. - const outcome = jobs.kill(request.jobId, agent, { - reason: 'cancelled by the user', - reported: false, - }) - return { outcome } - } - private async resolveAgent(sessionId: SessionId): Promise { const found = await this.agents.resolveAgent(sessionId) if ('error' in found) throw found.error diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0d9f7d076c..6346728e76 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -38,8 +38,6 @@ import type { SessionFollowRequest, SessionForkRequest, SessionForkValue, - SessionKillJobRequest, - SessionKillJobValue, SessionListRequest, SessionListValue, SessionOpenWorkspacePathRequest, @@ -59,7 +57,7 @@ import type { } from './types.ts' export type * from './types.ts' -export { ApiSessionNotFound } from './agent.ts' +export { ApiSessionNotFound, apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner } from './agent.ts' export { SessionFileReferences } from './file-references.ts' export { SessionSkillCatalog } from './skill-catalog.ts' @@ -369,17 +367,6 @@ export class SessionController extends TypertRemoteService { return this.commands.cancel(request) } - /** - * Kill one background job on a human's behalf, leaving the terminal report - * unclaimed so the owning agent still receives the completion notice. - * @param request - Session whose task list carries the job, and the job id. - * @returns the registry's admission of the kill request. - */ - @Remote('killJob') - killJob(request: SessionKillJobRequest): SessionKillJobValue { - return this.commands.killJob(request) - } - /** * Read one cold-safe, message-aligned Session history page. * @param request - durable address, backward cursor, and page budget. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index adaf16be6b..c5a6caeacb 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -194,8 +194,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'session/attachment-invalid': { readonly reason: string } 'session/queue-item-not-found': { readonly itemId: MessageId } 'session/steer-unavailable': { readonly itemId: MessageId } - 'session/job-not-found': { readonly sessionId: SessionId; readonly jobId: JobId } - 'session/jobs-unavailable': Record 'session/title-invalid': { readonly sessionId: SessionId } 'session/fork-unavailable': { readonly sessionId: SessionId } 'subagent/not-found': { @@ -349,17 +347,6 @@ 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' -} - /** Request to open one path prepared by a Session-aware caller on the Host desktop. */ export interface SessionOpenWorkspacePathRequest { /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 6d7a771752..ef5001cbe2 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -1,7 +1,6 @@ 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, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' @@ -115,32 +114,6 @@ describe('SessionController facade', () => { await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) - it('delegates killJob to the command controller', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('controller-facade-test') - ctx.provide('sessionPersistence', testSessionPersistence(ctx, { - list: () => Promise.resolve([]), - inspect: () => Promise.resolve(undefined), - }) as never) - const controller = createSessionTestController(ctx, defaults) - const session = ctx.sessions.create() - // An unowned job is killable from any session, live agent or not. - let settle!: (outcome: { status: 'killed' }) => void - const id = ctx.jobs.start({ - kind: 'bash', - label: 'facade kill', - run: () => ({ - cancel: () => { settle({ status: 'killed' }) }, - done: new Promise((resolve) => { settle = resolve }), - }), - }) - expect(controller.killJob({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'requested' }) - await ctx.fiber.dispose() - }) - it.each(['success', 'domain-error', 'throw'] as const)( 'promotes a prepared follow observation in the background: %s', async (outcome) => { diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 4afa4b345c..e3ea48f783 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -145,7 +145,6 @@ export class FakeApiClient { () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onKillJob: (payload: unknown) => Promise> = () => Promise.resolve(ok({ outcome: 'requested' as const })) onOpenWorkspacePath: (payload: unknown) => Promise> = () => Promise.resolve(ok({ opened: true as const })) @@ -228,7 +227,6 @@ export class FakeApiClient { attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)), updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)), - killJob: payload => this.record('session.killJob', payload, this.onKillJob(payload)), openWorkspacePath: payload => this.record( 'session.openWorkspacePath', payload, diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 6cb0fd8988..f91e574c77 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -460,29 +460,6 @@ 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, 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( - new RemoteError('session/job-not-found', 'unknown job bash-9', { sessionId: SID, jobId: 'bash-9' as never }), - ) as never) - const session = new Session(SID, fakeRemote(api), {}) - await session.open() - const killed = await session.killJob('bash-9') - expect(killed).toMatchObject({ ok: false, error: { code: 'session/job-not-found' } }) - expect(session.getSnapshot().promptError).toBeNull() - }) - it('forwards continuation image parts to the subagent prompt Remote unstripped', async () => { const api = new FakeApiClient() const session = new Session(SID, fakeRemote(api), { diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 58c92f4baf..1b5859b509 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -38,8 +38,6 @@ import type { SessionForkValue, SessionFollowFrame, SessionFollowRequest, - SessionKillJobRequest, - SessionKillJobValue, SessionListRequest, SessionListValue, SessionOpenWorkspacePathRequest, @@ -72,7 +70,6 @@ export interface TestSessionRemote { attachment(request: SessionAttachmentRequest): Promise> updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> - killJob(request: SessionKillJobRequest): Promise> openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal?: AbortSignal, @@ -322,7 +319,6 @@ 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)), openWorkspacePath: (request, signal = new AbortController().signal) => remoteResult( () => direct.openWorkspacePath(request, signal), signal, diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index a5a3bfcbca..9805bf189f 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -52,7 +52,6 @@ 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')), loadThrough: () => Promise.reject(new Error('unused fake Session operation')), diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index abe915483f..f56ad2cff3 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md -README.md: 889d91bfaf9ab2b0fbf30bd6dbc10b6b4753397c -README.zh.md: 00f69419e7b1cc0ab5edb99c5e298d3fc8d55877 +README.md: 3c2fdd6d7f07108e8b9231d6d623207c84cee1ee +README.zh.md: 92bff532822cb55b1ba7f60f54fa5081254d2c69 diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 889d91bfaf..3c2fdd6d7f 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -31,7 +31,7 @@ Load the plugin through the web-app manifest; it renders nothing until the sessi The session control stream's `jobsBySession` mirror is the single roster: each `SessionJob` row carries lifecycle, duration, and the model-visible `detail`, and its `outputTotal` field is present exactly when the job declared a record — that presence is what makes a row expandable. There is no second roster to join. -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)). +Running job rows also carry a two-press stop control: the first press arms it, the confirming press within three seconds calls `ctx.jobOutput.kill`, 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 @@ -60,7 +60,7 @@ One slot entry in the header actions band (after the preset label, before the su ## Further Exploration -- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.observe` stream and the `ctx.jobOutput` service behind the panel. +- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.observe` stream, the `job.kill` Remote, and the `ctx.jobOutput` service behind the panel and the stop control. - [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` roster mirror the rows come from. - [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics. - [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index 00f69419e7..92bff53282 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJob` 行携带生命周期、时长与模型可见的 `detail`,其 `outputTotal` 字段恰在 job 声明了 record 时存在——这个存在性就是行可展开的依据。不存在需要 join 的第二份名册。 -运行中的 job 行还带一个两击式停止控件:首击武装、三秒内的确认击调用 `session.killJob`,行状态经 jobs 帧收敛(先 `stopping`,再入已结束分组)。该 kill 不认领终态报告,任务的 owner agent 因此照常收到标准完成通知——模型被明确告知用户停止了它的任务,而不是留给它去猜([决策](../../../.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md))。 +运行中的 job 行还带一个两击式停止控件:首击武装、三秒内的确认击调用 `ctx.jobOutput.kill`,行状态经 jobs 帧收敛(先 `stopping`,再入已结束分组)。该 kill 不认领终态报告,任务的 owner agent 因此照常收到标准完成通知——模型被明确告知用户停止了它的任务,而不是留给它去猜([决策](../../../.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md))。 ### 展开的面板 @@ -60,7 +60,7 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo ## 进一步探索 -- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 面板背后的 `job.observe` 流与 `ctx.jobOutput` 服务。 +- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 面板与停止控件背后的 `job.observe` 流、`job.kill` Remote 与 `ctx.jobOutput` 服务。 - [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 行所来自的 `jobsBySession` 名册镜像。 - [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。 - [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index fb85ed298d..5ad759854b 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index a9a9b752af..b0e0011d0d 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -1,11 +1,12 @@ /** * Background-job plugin, browser half: contributes one session-header action * that renders this session's jobs. Job rows arrive through the - * `jobsBySession` list mirror; per-row observation streams arrive through the - * `jobOutput` client service. This plugin holds no transport state of its - * own. + * `jobsBySession` list mirror; per-row observation streams and the human kill + * go through the `jobOutput` client service. This plugin holds no transport + * state of its own. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import { JobListAction } from './JobListAction.tsx' import type { JobListInjected } from './JobListAction.tsx' import type {} from '@deepseek-ai/dsh-api-job-controller/client' @@ -24,8 +25,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export type { JobListActionProps, JobListInjected } from './JobListAction.tsx' -/** Required services: session state, job observation, the slot registry, and dictionaries. */ -export const inject = ['sessions', 'jobOutput', 'slots', 'locale'] +/** Required services: the job observation/kill service, the slot registry, and dictionaries. */ +export const inject = ['jobOutput', 'slots', 'locale'] /** * Client plugin body: register the dictionaries and the header action. @@ -44,13 +45,9 @@ export function apply(ctx: ClientContext): void { inject: (): JobListInjected => ({ hooks: { jobOutput: ctx.jobOutput.state }, observe: (sessionId, id) => ctx.jobOutput.observe(sessionId, 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 - }, + // The brand is nominal typing only; the row key is the registry id the + // jobs frames delivered, so the wire boundary stamps it back here. + killJob: async (sessionId, jobId) => (await ctx.jobOutput.kill(sessionId, jobId as JobId)).ok, }), }, JobListAction), ) diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index 9d5dcea1af..441358b9e4 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -23,7 +23,7 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { const observed: [string | undefined, string][] = [] /** Job ids the stubbed bound session was asked to kill, and its scripted result. */ -const kills: string[] = [] +const kills: [string, string][] = [] let killResult: { ok: boolean } = { ok: true } /** Boot the browser half over a real slot tree that declares the header list. */ @@ -36,17 +36,16 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) - ctx.provide('sessions', { - binding: (id: string) => id === 'sess-live' - ? { session: { killJob: async (jobId: string) => { kills.push(jobId); return killResult } } } - : undefined, - } as never) ctx.provide('jobOutput', { state: { getSnapshot: () => ({ observed: {} }), subscribe: () => () => {} }, observe: (sessionId: string | undefined, id: string) => { observed.push([sessionId, id]) return () => {} }, + kill: async (sessionId: string, jobId: string) => { + kills.push([sessionId, jobId]) + return killResult + }, } as never) // The locale plugin binds a settings scope, which reads the connection handle // and the forwarded-event port. @@ -65,7 +64,7 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'jobOutput', 'slots', 'locale']) + expect(inject).toEqual(['jobOutput', 'slots', 'locale']) }) it('exposes the output source and observation control through the inject face', async () => { @@ -85,15 +84,13 @@ describe('ui-jobs browser half', () => { expect(observed).toEqual([['session', 'bash-1']]) stopper() - // The kill control routes through the bound session and reports admission… + // The kill control routes through the job service with the row's session + // and reports the admission verdict. 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']) + expect(kills).toEqual([['sess-live', 'bash-3'], ['sess-live', 'bash-4']]) }) it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 01ec296b45..0fc5aded9a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -559,7 +559,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ISession', - declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n killJob(jobId: string): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: SessionSeq): Promise;\n command(line: string): Promise>;\n}', + declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise>;\n readAttachment(attachmentId: AttachmentIdType): Promise>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise>;\n cancel(): Promise>;\n rename(title: string): Promise>;\n loadOlder(): Promise;\n loadThrough(seq: SessionSeq): Promise;\n command(line: string): Promise>;\n}', }, { name: 'KeyedHooksSources', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index aee3932536..2551da34fe 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1044,6 +1044,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'anchor, coalesced output frames, and the terminal status.', }, + { + signature: '@Remote(\'kill\') kill(request: JobKillRequest): JobKillValue', + description: 'Kill one background job on a human\'s behalf, leaving the terminal report unclaimed so the owning agent still receives the completion notice. The request\'s session resolves the live agent for the fenced lookup, and the subagent ownership fence applies exactly as it does to `session.cancel`.', + parameters: [{ name: 'request', description: 'Session whose task list carries the job, and the job id.' }], + returns: 'the registry\'s admission of the kill request.', + }, ], }, { @@ -1450,12 +1456,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session whose active Agent turn is cancelled.' }], returns: 'acknowledgement that cancellation was requested.', }, - { - signature: '@Remote(\'killJob\') killJob(request: SessionKillJobRequest): SessionKillJobValue', - description: 'Kill one background job on a human\'s behalf, leaving the terminal report unclaimed so the owning agent still receives the completion notice.', - parameters: [{ name: 'request', description: 'Session whose task list carries the job, and the job id.' }], - returns: 'the registry\'s admission of the kill request.', - }, { signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise', description: 'Read one cold-safe, message-aligned Session history page.', @@ -4193,6 +4193,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'JobKillOptions', declaration: 'export interface JobKillOptions {\n reason?: string;\n reported?: boolean;\n}', }, + { + name: 'JobKillRequest', + declaration: 'export interface JobKillRequest {\n readonly sessionId: SessionId;\n readonly jobId: JobId;\n}', + }, + { + name: 'JobKillValue', + declaration: 'export interface JobKillValue {\n readonly outcome: \'requested\' | \'already-finished\';\n}', + }, { name: 'JobKind', declaration: 'export type JobKind = JobKindMap[keyof JobKindMap];', @@ -4993,14 +5001,6 @@ 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 readonly outputTotal?: 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}', diff --git a/packages/test-support/client-runtime/src/sessions.ts b/packages/test-support/client-runtime/src/sessions.ts index d0bcd57943..419e155af7 100644 --- a/packages/test-support/client-runtime/src/sessions.ts +++ b/packages/test-support/client-runtime/src/sessions.ts @@ -136,14 +136,6 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`) } - /** - * Fail-loud stub; supply `killJob` on the fixture's session face to exercise it. - * @returns never — always throws. - */ - killJob(): never { - throw new Error(`test session "${this.sessionId}": killJob is not stubbed — supply it on the fixture's session face`) - } - /** * Fail-loud stub; supply `command` on the fixture's session face to exercise it. * @returns never — always throws. diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index cfe1cd4c43..d16175f9b5 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -430,7 +430,6 @@ describe('fixture session face', () => { expect(() => bare.readAttachment('att-1' as Parameters[0])).toThrow(/readAttachment is not stubbed/) expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) - expect(() => bare.killJob()).toThrow(/killJob is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) expect(() => bare.loadThrough()).toThrow(/loadThrough is not stubbed/) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcd9c4d2e5..29ef1d2e17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -667,6 +667,9 @@ importers: packages/api/job-controller: dependencies: + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../session-controller '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -2580,6 +2583,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a6e082175c..3246088180 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -310,8 +310,6 @@ export const LINK_MAP: Readonly> = { SessionAttachmentValue: 'session.md', SessionCancelRequest: 'session.md', SessionCancelValue: 'session.md', - SessionKillJobRequest: 'session.md', - SessionKillJobValue: 'session.md', SessionControlFrame: 'session.md', SessionCreateRequest: 'session.md', SessionCreateValue: 'session.md', @@ -512,6 +510,8 @@ export const LINK_MAP: Readonly> = { JobDoneListener: 'jobs.md', JobId: 'jobs.md', JobKillOptions: 'jobs.md', + JobKillRequest: 'jobs.md', + JobKillValue: 'jobs.md', JobObserveFrame: 'jobs.md', JobObserveRequest: 'jobs.md', JobRead: 'jobs.md', diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 11702c568a..78c34808c2 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -41,6 +41,7 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [ * Runtime exports whose values remain valid when npm installs another package copy. */ const SAFE_HOST_DEPENDENCY_EXPORTS = { + '@deepseek-ai/dsh-api-session-controller': ['apiSessionSubagentOwnershipError', 'hasApiSessionSubagentOwner'], '@deepseek-ai/dsh-credentials': ['credentialKey'], '@deepseek-ai/dsh-deque': ['Deque'], '@deepseek-ai/dsh-llm': ['callConfigEquals'],