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 4cc18ec26a..151857373f 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: b80fdb1e4d4c9b68846b3d63fc1b0de819267aa2 -2026-08-26-human-job-kill.zh.md: 53c16c75ab07957b2e03e171185e9fd64fd8406e +2026-08-26-human-job-kill.md: efffdbd85bf2fa665565ff584a6938cfee827516 +2026-08-26-human-job-kill.zh.md: 206acca008518dd689351e40a99ff1571f3d1a8f 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 b80fdb1e4d..efffdbd85b 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 @@ -16,7 +16,7 @@ A delivery claim means "the terminal state has a committed delivery path to the - **A human kill claims no delivery.** `JobRegistry.kill(id, { reason? })` records the reason only; the delivery ledger lives in `dsh-tool-jobs`, which claims a job for the model's own `job_kill` and for its waits, so a `job.kill` from the task list leaves the settlement to the existing completion reporter (wakeup/inject, wake budget, truncation all unchanged). It never clears a claim the model already holds, 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. -- **`job.kill` is a Job Controller Remote** (`@deepseek-ai/dsh-api-job-controller`, beside `job.follow`; 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. +- **`job.kill` is a Job Controller Remote** (`@deepseek-ai/dsh-api-job-controller`, beside `job.follow`; 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. The registry's owner fence is the only access rule: the Session Controller's subagent ownership fence is not applied, so a child session's own jobs are killable from its list (review rejected exporting that fence's helpers from the Session Controller package, and the rule adds nothing for a job the child owns), and the controller depends on neither the agent registry nor the Session Controller. 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 53c16c75ab..206acca008 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 @@ -16,7 +16,7 @@ Update:本文引入的 `reported` 选项已随 [jobs seam 收敛](../architect - **人类的 kill 不认领送达。** `JobRegistry.kill(id, { reason? })` 只记录原因;送达台账在 `dsh-tool-jobs` 里,它只为模型自己的 `job_kill` 与等待认领任务,因此来自任务列表的 `job.kill` 让结算照常走完成播报员(唤醒/注入、唤醒预算、截断全部不变)。它绝不清除模型已持有的认领,终态记录也保持结算时的原样。 - **记录下来的 kill reason 合入 `killed` 结算的 detail**——生产者事实在前(`signal: SIGTERM; cancelled by the user`)——完成通知与 Web 行因此都能说清是谁停的工作,无需新快照字段或新通知模板。跑赢 kill 的任务(结算为 `completed`/`failed`)只保留生产者 detail。这正是 Codex 在 `` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。 -- **`job.kill` 是 Job Controller 的 Remote**(`@deepseek-ai/dsh-api-job-controller`,与 `job.follow` 并列;它最初是 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 把它视为与停轮按钮同级的操作。 +- **`job.kill` 是 Job Controller 的 Remote**(`@deepseek-ai/dsh-api-job-controller`,与 `job.follow` 并列;它最初是 Session Controller 上的 `session.killJob`,该包拆出时随观测流一起迁走),与 `session.cancel` 一样只查活体 Agent(`ctx.agents.get`):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个只作用于查找的 `job/not-found` 拒绝——生产者 cancel 抛错按注册表契约原样传播,绝不伪装成查找失败;控制器加载就要求 `ctx.jobs`,没有注册表的组合根本没有 kill Remote。注册表的所有者围栏是唯一的访问规则:不施加 Session Controller 的 subagent 所有权栅栏,子会话自己的 job 可以从它的列表里被杀停(评审否决了从 Session Controller 包导出该栅栏的实现函数,而这条规则对子会话自有的 job 也没有额外意义),控制器既不依赖 agent 注册表也不依赖 Session Controller。不加 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 d7a3419e3d..721b24cd70 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: e373bc9536fd7c7f1e9019db589f21d7440efef3 -config-catalog.zh.md: dd7078c8cabc0c1edbd8e1952c2e72cb998d7974 +config-catalog.md: a9f82169c1a0d1ced020a84c56e994be1c963d01 +config-catalog.zh.md: 572131ec9efc2149fb1e81850135d8e9687ccd13 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e373bc9536..a9f82169c1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -205,7 +205,7 @@ Source: [`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/in ## `@deepseek-ai/dsh-api-job-controller` -Requires: `agents` · `jobs` · `typert` +Requires: `jobs` · `typert` ```ts config-catalog /** Job Controller deployment policy. */ @@ -217,7 +217,7 @@ export interface Config { } ``` -Source: [`packages/api/job-controller/src/index.ts:37`](../packages/api/job-controller/src/index.ts) +Source: [`packages/api/job-controller/src/index.ts:35`](../packages/api/job-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dd7078c8ca..572131ec9e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -219,7 +219,7 @@ export interface Config { } ``` -来源:[`packages/api/job-controller/src/index.ts:37`](../packages/api/job-controller/src/index.ts) +来源:[`packages/api/job-controller/src/index.ts:35`](../packages/api/job-controller/src/index.ts) diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index ad4fbe2d83..059d83dd5f 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: 85400f905e1d313f5f903863f572b0bedbcadfd7 -jobs.zh.md: 1397e1b93bbdd2f57ab219122b1c1ad8c0938538 +jobs.md: 0d235c6101ff20c9ef530bc08efc98000ce05fd0 +jobs.zh.md: fbb650bbc10e4c35451b193cdf7317b28980752c diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 85400f905e..0d235c6101 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -369,12 +369,12 @@ Host service backing the generated `ctx.remote.job` namespace. /** * Kill one background job on a human's behalf. The request's session is - * the fenced read's caller, so the job must be one that session can see; - * the subagent ownership fence applies exactly as it does to - * `session.cancel`. The kill records `cancelled by the user` as its reason; - * it is not one the model requested, so the owning agent still receives - * the completion notice, and a shell tool waiting on that job reads the - * reason in its own result. + * the fenced read's caller, so the job must be one that session can see: + * the registry's owner fence is the only access rule, and a child session's + * own jobs are killable from its list like any other. The kill records + * `cancelled by the user` as its reason; it is not one the model requested, + * so the owning agent still receives the completion notice, and a shell + * tool waiting on that job reads the reason in its own result. * @param request - Session whose job list carries the job, and the job id. * @returns the registry's admission of the kill request. */ diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 1397e1b93b..fbb650bbc1 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -369,12 +369,12 @@ Host service backing the generated `ctx.remote.job` namespace. /** * Kill one background job on a human's behalf. The request's session is - * the fenced read's caller, so the job must be one that session can see; - * the subagent ownership fence applies exactly as it does to - * `session.cancel`. The kill records `cancelled by the user` as its reason; - * it is not one the model requested, so the owning agent still receives - * the completion notice, and a shell tool waiting on that job reads the - * reason in its own result. + * the fenced read's caller, so the job must be one that session can see: + * the registry's owner fence is the only access rule, and a child session's + * own jobs are killable from its list like any other. The kill records + * `cancelled by the user` as its reason; it is not one the model requested, + * so the owning agent still receives the completion notice, and a shell + * tool waiting on that job reads the reason in its own result. * @param request - Session whose job list carries the job, and the job id. * @returns the registry's admission of the kill request. */ diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml index e02db7e14e..de0779c23d 100644 --- a/packages/api/job-controller/README.i18n.yaml +++ b/packages/api/job-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/job-controller/README.md -README.md: 891416d050aaad36c2f9926bdf7bbbac7bb90d4b -README.zh.md: 67dca2b58730ce680b2acf257184dace26989077 +README.md: ea343a8a201b50901af0033b39662fab4dd50555 +README.zh.md: ce8f166cec4e180e491cd48377f20db2e7177e75 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md index 891416d050..ea343a8a20 100644 --- a/packages/api/job-controller/README.md +++ b/packages/api/job-controller/README.md @@ -24,7 +24,7 @@ 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.list({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.follow({ sessionId?, jobId, from? })` passes `sessionId` to registry `get` and `readAt` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them. -`job.kill({ sessionId, jobId })` cancels one job the session can see with the reason `cancelled by the user`, which the registry merges into the killed job's detail. It is not a kill the model requested — `dsh-tool-jobs` withholds a notice only for the model's own `job_kill` and for settlements a live wait collected — so the owning agent's completion notice stays due, reason included, and a shell tool still waiting on that job reads the reason as `[stopped: cancelled by the user]` in its own result. It answers `{ outcome: 'requested' }` or `{ outcome: 'already-finished' }`, rejects an id the session cannot see as `job/not-found`, and applies the subagent ownership fence exactly as `session.cancel` does. +`job.kill({ sessionId, jobId })` cancels one job the session can see with the reason `cancelled by the user`, which the registry merges into the killed job's detail. It is not a kill the model requested — `dsh-tool-jobs` withholds a notice only for the model's own `job_kill` and for settlements a live wait collected — so the owning agent's completion notice stays due, reason included, and a shell tool still waiting on that job reads the reason as `[stopped: cancelled by the user]` in its own result. It answers `{ outcome: 'requested' }` or `{ outcome: 'already-finished' }` and rejects an id the session cannot see as `job/not-found`; the registry's owner fence is the only access rule, so a child session's own jobs are killable from its list like any other. The Client entry installs `ctx.jobs` (`IJobs`), backed by the package-internal `ClientJobsModel`. `kill(sessionId, jobId)` forwards to `job.kill` and returns the Remote result for the caller's admission verdict. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `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, closes the view on the terminal frame or records a stream failure on it, 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 stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md index 67dca2b587..ce8f166cec 100644 --- a/packages/api/job-controller/README.zh.md +++ b/packages/api/job-controller/README.zh.md @@ -24,7 +24,7 @@ kind: "package-reference" Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.list({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.follow({ sessionId?, jobId, from? })` 把 `sessionId` 传给注册表的 `get` 与 `readAt`——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。 -`job.kill({ sessionId, jobId })` 以 `cancelled by the user` 为原因取消一个该会话看得到的 job,注册表把该原因合并进被杀 job 的 detail。它不是模型请求的杀停——`dsh-tool-jobs` 只对模型自己的 `job_kill` 与存活等待收走的结算不发通知——因此拥有者 agent 的完成通知照常送达,并带上原因;仍在等待该 job 的 shell 工具则在自己的结果里以 `[stopped: cancelled by the user]` 读到原因。它回答 `{ outcome: 'requested' }` 或 `{ outcome: 'already-finished' }`,对该会话看不到的 id 以 `job/not-found` 拒绝,并与 `session.cancel` 一样施加 subagent 所有权围栏。 +`job.kill({ sessionId, jobId })` 以 `cancelled by the user` 为原因取消一个该会话看得到的 job,注册表把该原因合并进被杀 job 的 detail。它不是模型请求的杀停——`dsh-tool-jobs` 只对模型自己的 `job_kill` 与存活等待收走的结算不发通知——因此拥有者 agent 的完成通知照常送达,并带上原因;仍在等待该 job 的 shell 工具则在自己的结果里以 `[stopped: cancelled by the user]` 读到原因。它回答 `{ outcome: 'requested' }` 或 `{ outcome: 'already-finished' }`,对该会话看不到的 id 以 `job/not-found` 拒绝;注册表的所有者围栏是唯一的访问规则,因此子会话自己的 job 也能像其他 job 一样从它的列表里被杀停。 Client 入口安装 `ctx.jobs`(`IJobs`),由包内部的 `ClientJobsModel` 支撑。`kill(sessionId, jobId)` 转发到 `job.kill` 并把 Remote 结果交给调用方判定准入。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。 diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index 3311eeb278..30fcb07689 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -65,7 +65,6 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts index 9d22318d49..d056e288e4 100644 --- a/packages/api/job-controller/src/index.ts +++ b/packages/api/job-controller/src/index.ts @@ -10,9 +10,7 @@ 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 { apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner } from '@deepseek-ai/dsh-api-session-controller' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { observeJobOutput } from './observe.ts' import { streamJobRows } from './rows.ts' @@ -43,7 +41,7 @@ export interface Config { /** Host service backing the generated `ctx.remote.job` namespace. */ export class JobController extends TypertRemoteService { - static inject = ['agents', 'jobs', 'typert'] + static inject = ['jobs', 'typert'] static Config: z = z.object({ observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), @@ -99,21 +97,17 @@ export class JobController extends TypertRemoteService { /** * Kill one background job on a human's behalf. The request's session is - * the fenced read's caller, so the job must be one that session can see; - * the subagent ownership fence applies exactly as it does to - * `session.cancel`. The kill records `cancelled by the user` as its reason; - * it is not one the model requested, so the owning agent still receives - * the completion notice, and a shell tool waiting on that job reads the - * reason in its own result. + * the fenced read's caller, so the job must be one that session can see: + * the registry's owner fence is the only access rule, and a child session's + * own jobs are killable from its list like any other. The kill records + * `cancelled by the user` as its reason; it is not one the model requested, + * so the owning agent still receives the completion notice, and a shell + * tool waiting on that job reads the reason in its own result. * @param request - Session whose job 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, request.sessionId) diff --git a/packages/api/job-controller/tests/kill.host.spec.ts b/packages/api/job-controller/tests/kill.host.spec.ts index 083515647d..2a14afac2d 100644 --- a/packages/api/job-controller/tests/kill.host.spec.ts +++ b/packages/api/job-controller/tests/kill.host.spec.ts @@ -135,15 +135,15 @@ describe('JobController.kill', () => { expect(ctx.jobs.get(id, agent.id).status).toBe('running') }) - it('rejects a subagent-owned live session with the ownership fence', async () => { + it('kills a child session\'s own job from its list: the owner fence is the only access rule', async () => { const { ctx, controller } = await harness() const child = ctx.sessions.create(undefined, { meta: { origin: 'subagent' } }) const childAgent = await registerAgent(ctx, child) const task = producer('child work') const id = ctx.jobs.start({ ...task.spec, owner: childAgent.id }) - expect(failureCode(() => controller.kill({ sessionId: child.id, jobId: id }))) - .toBe('session/agent-busy') - expect(ctx.jobs.get(id, childAgent.id).status).toBe('running') + expect(controller.kill({ sessionId: child.id, jobId: id })).toEqual({ outcome: 'requested' }) + expect(task.cancels).toEqual(['cancelled by the user']) + expect(ctx.jobs.get(id, childAgent.id).status).toBe('stopping') }) }) diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json index c61b7c5a85..7e3b17f92e 100644 --- a/packages/api/job-controller/tsconfig.host.json +++ b/packages/api/job-controller/tsconfig.host.json @@ -15,7 +15,6 @@ "references": [ { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, - { "path": "../session-controller/tsconfig.host.json" }, { "path": "../../core/agent" }, { "path": "../../core/session" }, { "path": "../../jobs/jobs" }, diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index dc079a931e..d830d2b661 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -63,7 +63,7 @@ import type { } from './types.ts' export type * from './types.ts' -export { ApiSessionNotFound, apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner } from './agent.ts' +export { ApiSessionNotFound } from './agent.ts' export { SessionFileReferences } from './file-references.ts' export { SessionSkillCatalog } from './skill-catalog.ts' diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2abe60f45b..6b2473f9c5 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1220,7 +1220,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'kill\') kill(request: JobKillRequest): JobKillValue', - description: 'Kill one background job on a human\'s behalf. The request\'s session is the fenced read\'s caller, so the job must be one that session can see; the subagent ownership fence applies exactly as it does to `session.cancel`. The kill records `cancelled by the user` as its reason; it is not one the model requested, so the owning agent still receives the completion notice, and a shell tool waiting on that job reads the reason in its own result.', + description: 'Kill one background job on a human\'s behalf. The request\'s session is the fenced read\'s caller, so the job must be one that session can see: the registry\'s owner fence is the only access rule, and a child session\'s own jobs are killable from its list like any other. The kill records `cancelled by the user` as its reason; it is not one the model requested, so the owning agent still receives the completion notice, and a shell tool waiting on that job reads the reason in its own result.', parameters: [{ name: 'request', description: 'Session whose job list carries the job, and the job id.' }], returns: 'the registry\'s admission of the kill request.', }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c260d1b5b8..8ae4c314c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -990,9 +990,6 @@ 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 diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index 40794dc090..57903b0e03 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -45,7 +45,6 @@ const DUPLICATE_SAFE_PACKAGES: readonly string[] = [ * prominent heading in the pull request description. */ 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'],