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.
This commit is contained in:
1 parent
121db081a4
commit
e8469d6f6c
50 files changed
+282
-324
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-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
|
||||
@@ -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 `<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 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
|
||||
|
||||
@@ -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 在 `<turn_aborted>` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。
|
||||
- **`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。
|
||||
|
||||
## 备选方案
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
<a id="deepseek-aidsh-api-session-controller"></a>
|
||||
|
||||
@@ -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)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
<a id="deepseek-aidsh-api-session-controller"></a>
|
||||
|
||||
@@ -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)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></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: cab6dd95719264a4103cb33df894325b1c5006ea
|
||||
event-producer-consumer.zh.md: 20d5a54c1100cb7bc11f224c91daddb71e37e439
|
||||
event-producer-consumer.md: 94364122879a8b2bef6df663a77faff7fd8c1403
|
||||
event-producer-consumer.zh.md: b10d2291760d7cf0dc31487410c4a15cf257346e
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
@@ -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<JobObserveFrame>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -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<JobObserveFrame>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -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
|
||||
@@ -778,14 +778,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
|
||||
*/
|
||||
@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.
|
||||
|
||||
@@ -782,14 +782,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
|
||||
*/
|
||||
@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/job-controller/README.md
|
||||
README.md: 1af9cac6574b36fb9ee0fe2bd4ff4a529848c2fe
|
||||
README.zh.md: a232d8e8655fea6ccddedb60e222837466705549
|
||||
README.md: 1261dbc7a31bf938f825f11e0819a5f363383ffd
|
||||
README.zh.md: 3baafa4b594cc31bbcfe94f289f397a804425806
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Host and Client job observation: stream one background job's output record to the browser without touching the model's consuming cursor."
|
||||
description: "Host and Client job control: stream one background job's output record to the browser without touching the model's consuming cursor, and stop a job on a human's behalf."
|
||||
kind: "package-reference"
|
||||
---
|
||||
# Job Controller
|
||||
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `outputTotal` says whether this controller has anything to stream for it.
|
||||
`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its Remote stream `job.observe` delivers a record-declaring job's retained output from an absolute byte offset, and its `job.kill` Remote stops one job on a human's behalf; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation and kill service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `outputTotal` says whether this controller has anything to stream for it.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -24,7 +24,9 @@ English | [中文](README.zh.md)
|
||||
|
||||
The Host controller requires the live Agent registry and the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without them. `job.observe({ sessionId?, jobId, from? })` resolves the fenced-read caller from the request's session — an unowned job needs no session — and yields one `opened` anchor, coalesced `output` frames, then one terminal `status` once the job has settled and its record is drained, after which the stream closes normally. Record reads are non-consuming: the model-facing `job_output` cursor and completion-notice state never observe them. A job the session does not own, a job without a record, or an unknown job rejects the stream. Reconnecting callers resume by passing the last frame's `next` as `from`; a `from` below the oldest retained byte gets a `lossy` first frame instead of an error.
|
||||
|
||||
The Client entry provides `ClientJobOutput` (`ctx.jobOutput`) over `ClientJobOutputModel`. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, records the terminal status or a stream failure on the view, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because observation (re)opens run on caller stacks that have not declared `remote.job`.
|
||||
`job.kill({ sessionId, jobId })` cancels one job with the reason `cancelled by the user` and leaves the terminal report unclaimed, so the owning agent's completion notice stays due; it answers `{ outcome: 'requested' }` or `{ outcome: 'already-finished' }`, rejects an unknown or foreign job with `job/not-found`, and applies the Session Controller's subagent ownership fence (`session/agent-busy`) to a subagent-owned live session.
|
||||
|
||||
The Client entry provides `ClientJobOutput` (`ctx.jobOutput`) over `ClientJobOutputModel`. `kill(sessionId, jobId)` forwards to `job.kill` and returns the Remote result for the caller's admission verdict. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, records the terminal status or a stream failure on the view, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because observation (re)opens run on caller stacks that have not declared `remote.job`.
|
||||
|
||||
### Config
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Host 与 Client 的 job 观测:把一个后台任务的输出 record 流式送到浏览器,不触碰模型的消耗型游标。"
|
||||
description: "Host 与 Client 的 job 控制:把一个后台任务的输出 record 流式送到浏览器,不触碰模型的消耗型游标,并替人停止一个 job。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
# Job Controller
|
||||
@@ -8,7 +8,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `outputTotal` 说明本控制器是否有东西可流。
|
||||
`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出,Remote `job.kill` 替人停止一个 job;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测与 kill 服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `outputTotal` 说明本控制器是否有东西可流。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -24,7 +24,9 @@ kind: "package-reference"
|
||||
|
||||
Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.observe({ sessionId?, jobId, from? })` 由请求的 session 解析围栏读取者——无主 job 不需要 session——先产出一个 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且 record 排干后产出一个终态 `status`,随后流正常关闭。record 读取是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。session 不拥有的 job、没有 record 的 job 或未知 job 会拒绝该流。重连方把上一帧的 `next` 作为 `from` 续读;低于最老保留字节的 `from` 得到带 `lossy` 的首帧而非错误。
|
||||
|
||||
Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jobOutput`)。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,把终态或流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为观测的(重)开启跑在未声明 `remote.job` 的调用栈上。
|
||||
`job.kill({ sessionId, jobId })` 以 `cancelled by the user` 为理由取消一个 job,并把终态报告留给 owner 不予认领,因此拥有它的 agent 的完成播报仍然待发;它回答 `{ outcome: 'requested' }` 或 `{ outcome: 'already-finished' }`,未知或他人的 job 以 `job/not-found` 拒绝,subagent 拥有的活体 session 适用 Session Controller 的所有权栅栏(`session/agent-busy`)。
|
||||
|
||||
Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jobOutput`)。`kill(sessionId, jobId)` 转发到 `job.kill` 并把 Remote 结果原样交给调用方判定是否受理。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,把终态或流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为观测的(重)开启跑在未声明 `remote.job` 的调用栈上。
|
||||
|
||||
### 配置
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -16,7 +16,7 @@ export { ClientJobOutputModel } from './model.ts'
|
||||
export type { JobOutputSnapshot, JobOutputSource, ObservedJob } from './model.ts'
|
||||
export { ClientJobOutput } from './service.ts'
|
||||
export type { IJobOutput, JobObserveRemote, JobRemote } from './service.ts'
|
||||
export type { JobObserveFrame, JobObserveRequest, JobObserveStatus, JobWireChunk } from '../types.ts'
|
||||
export type { JobKillRequest, JobKillValue, JobObserveFrame, JobObserveRequest, JobObserveStatus, JobWireChunk } from '../types.ts'
|
||||
|
||||
/** Required Client Remote services. */
|
||||
export const inject = ['remote', 'remote.job']
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/**
|
||||
* The `ctx.jobOutput` client service: reference-counted per-job observation
|
||||
* streams over `job.observe`, so overlapping viewers of one job share one
|
||||
* stream and resume from the model's cursor across reconnects.
|
||||
* stream and resume from the model's cursor across reconnects, plus the
|
||||
* human kill passthrough over `job.kill`.
|
||||
* @module @deepseek-ai/dsh-api-job-controller/client/service
|
||||
*/
|
||||
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { JobObserveFrame, JobObserveRequest } from '../types.ts'
|
||||
import type { JobKillRequest, JobKillValue, JobObserveFrame, JobObserveRequest } from '../types.ts'
|
||||
import type { ClientJobOutputModel, JobOutputSource } from './model.ts'
|
||||
|
||||
/** The generated `job` namespace face the observation runner drives. */
|
||||
@@ -21,6 +23,12 @@ export interface JobRemote {
|
||||
* @returns the frame sequence of one generation.
|
||||
*/
|
||||
observe(request: JobObserveRequest, signal?: AbortSignal): AsyncIterable<JobObserveFrame>
|
||||
/**
|
||||
* 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<RemoteResult<JobKillValue>>
|
||||
}
|
||||
|
||||
/** 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<RemoteResult<JobKillValue>>
|
||||
}
|
||||
|
||||
/** 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<RemoteResult<JobKillValue>> {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<JobObserveFrame> {})()
|
||||
},
|
||||
kill: async (request: unknown) => {
|
||||
killCalls.push(request)
|
||||
return { ok: true as const, value: { outcome: 'requested' as const } }
|
||||
},
|
||||
}
|
||||
ctx.reflect.provide('remote', {
|
||||
$stream: <Item>(options: RemoteStreamOptions<Item>) => 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<void> {
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
+44
-55
@@ -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')
|
||||
})
|
||||
@@ -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<void>(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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -107,14 +107,6 @@ export interface ISession {
|
||||
* @returns acceptance, or the business error.
|
||||
*/
|
||||
cancel(): Promise<RemoteResult<{ 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<RemoteResult<{ outcome: 'requested' | 'already-finished' }>>
|
||||
/**
|
||||
* Rename this session (explicit user title; pins it against automatic
|
||||
* regeneration).
|
||||
|
||||
@@ -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<RemoteResult<{ outcome: 'requested' | 'already-finished' }>> {
|
||||
// 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
|
||||
|
||||
@@ -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<Agent> {
|
||||
const found = await this.agents.resolveAgent(sessionId)
|
||||
if ('error' in found) throw found.error
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<never, never>
|
||||
'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. */
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RemoteResult<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onKillJob: (payload: unknown) => Promise<RemoteResult<{ outcome: 'requested' | 'already-finished' }>> = () => Promise.resolve(ok({ outcome: 'requested' as const }))
|
||||
onOpenWorkspacePath: (payload: unknown) => Promise<RemoteResult<{ opened: true }>> =
|
||||
() => 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,
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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<RemoteResult<SessionAttachmentValue>>
|
||||
updateQueue(request: SessionUpdateQueueRequest): Promise<RemoteResult<SessionUpdateQueueValue>>
|
||||
cancel(request: SessionCancelRequest): Promise<RemoteResult<SessionCancelValue>>
|
||||
killJob(request: SessionKillJobRequest): Promise<RemoteResult<SessionKillJobValue>>
|
||||
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,
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
<a id="further-exploration"></a>
|
||||
## 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.
|
||||
|
||||
@@ -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
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
- [`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` 表面。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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<Context['plugi
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, () => 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<Context['plugi
|
||||
|
||||
describe('ui-jobs browser half', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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<RemoteResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<RemoteResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{\n accepted: true;\n }>>;\n cancel(): Promise<RemoteResult<{\n accepted: true;\n }>>;\n killJob(jobId: string): Promise<RemoteResult<{\n outcome: \'requested\' | \'already-finished\';\n }>>;\n rename(title: string): Promise<RemoteResult<{\n title: string;\n seq: SessionSeq;\n }>>;\n loadOlder(): Promise<void>;\n loadThrough(seq: SessionSeq): 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 beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise<RemoteResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<RemoteResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{\n accepted: true;\n }>>;\n cancel(): Promise<RemoteResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<RemoteResult<{\n title: string;\n seq: SessionSeq;\n }>>;\n loadOlder(): Promise<void>;\n loadThrough(seq: SessionSeq): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'KeyedHooksSources',
|
||||
|
||||
@@ -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<SessionPage>',
|
||||
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}',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -430,7 +430,6 @@ describe('fixture session face', () => {
|
||||
expect(() => bare.readAttachment('att-1' as Parameters<typeof bare.readAttachment>[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/)
|
||||
|
||||
Generated
+6
@@ -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
|
||||
|
||||
@@ -310,8 +310,6 @@ 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',
|
||||
@@ -512,6 +510,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
JobDoneListener: 'jobs.md',
|
||||
JobId: 'jobs.md',
|
||||
JobKillOptions: 'jobs.md',
|
||||
JobKillRequest: 'jobs.md',
|
||||
JobKillValue: 'jobs.md',
|
||||
JobObserveFrame: 'jobs.md',
|
||||
JobObserveRequest: 'jobs.md',
|
||||
JobRead: 'jobs.md',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user