diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml
index e895f7c65b..491a988b1f 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.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/architecture/2026-09-01-jobs-absorb-activity-record.md
-2026-09-01-jobs-absorb-activity-record.md: 2f2bf7a65b5c26c80ec7ddc307c0f31a87f4de54
-2026-09-01-jobs-absorb-activity-record.zh.md: ed62a04e8f9da9de35bd721cceee3a2e56644cdb
+2026-09-01-jobs-absorb-activity-record.md: a1da1ba6f47dd0f5d557542d6b9668d6723fd683
+2026-09-01-jobs-absorb-activity-record.zh.md: fd735f1d9764dc83c0469209e241d47176584f5a
diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md
index 2f2bf7a65b..a1da1ba6f4 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md
@@ -2,6 +2,8 @@
Status: implemented
+Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring, `VisibleJobs`, and `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds.
+
English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md)
## Problem
diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md
index ed62a04e8f..fd735f1d97 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md
@@ -2,6 +2,8 @@
Status: implemented
+Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环、`VisibleJobs` 与 `ctx.jobs`——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。
+
[English](2026-09-01-jobs-absorb-activity-record.md) | 中文
## 问题
diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml
new file mode 100644
index 0000000000..c8e9b6a942
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md
+2026-09-03-jobs-seam-consolidation.md: cae6463cee7a2e2a07f66baf759247aaef2afef9
+2026-09-03-jobs-seam-consolidation.zh.md: e87cf61d32e49f74b33e53bf246ccbc64b403baa
diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md
new file mode 100644
index 0000000000..cae6463cee
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md
@@ -0,0 +1,33 @@
+# Agent Note: One output ring, one projection, one event stream for background jobs
+
+Status: implemented
+
+English | [中文](2026-09-03-jobs-seam-consolidation.zh.md)
+
+## Problem
+
+The job seam had grown by accretion. Producers declared their output twice — a consuming `readOutput` hook the model read and an optional `record: true` ring observers read — and the registry kept two vocabularies for one job (`JobSnapshot` for the model, `SessionJob` on the session control stream, `JobWireChunk` on the observation wire). Three listener families (`onJobDone`, `onJobsChanged`, `onOutput`) delivered overlapping facts with different owner filters, and the registry itself tracked `reported`, a bit that only the model-facing tool could interpret. Review of the merged design called the interface layer the weakest part: a reader could not tell which of `read`, `readRecord`, `JobStart`, `RecordingJob`, and `updateDetail` was the contract and which was an accident of history.
+
+## Decision
+
+- **One ring per job.** `JobSpec` replaces `JobStart`: a spec names optional pull `output` sources (`JobOutputSource`, the subprocess `readFrom` family) that the registry pumps at its own cadence (`pumpPollMs` on `dsh-jobs-local`), and every producer receives a `JobHandle` whose `append` pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; `readOutput` and the `record` discriminator are gone. Shell producers hand the registry their `observed` offset readers and fold sandbox facts into the terminal `detail`; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as `JobOutcome.result`, which the first read after settlement carries once.
+- **Two cursors, one storage.** `VisibleJobs.read(id)` is the stateful model cursor; `VisibleJobs.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did.
+- **`updateProgress` replaces `updateDetail`.** `JobView.progress` is the live line and is cleared at settlement; `JobView.detail` is the terminal reason, with the model's `job_kill` reason merged in.
+- **One projection.** `JobView` (client-safe leaf `@deepseek-ai/dsh-jobs/view`) is what the model tools, the browser roster, and the observation frames consume; `owner` is the session id, never the `Agent`.
+- **One caller-bound view.** `JobRegistry.visibleTo(caller?)` binds a session once and returns `list`, `get`, `read`, `readAt`, `kill`, and `wait`; every method throws for an id outside the caller's set without distinguishing unknown from foreign.
+- **One event stream.** `events.subscribe(filter, listener)` with `{ owner }`, `{ owners: 'scope' }`, or `{ owners: 'all' }` replaces the three listener families. `settled` names its cause (`producer`, `kill`, `teardown`); `output` carries only the id and the new total.
+- **The notice ledger moves to `dsh-tool-jobs`.** The registry no longer tracks `reported`. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when `job_kill` is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement.
+- **The roster leaves the session control stream.** `dsh-api-job-controller` owns `job.rows({ sessionId })` — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside `job.observe`, and its client installs `ctx.jobs` (`watchRows`, `observe`, one snapshot). `dsh-api-session-controller` carries queues and projections only; `ui-jobs` watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind.
+
+`attachController` and the owner-relative `servesOwner` gate stay as they were; whether a producer may offer `run_in_background` without a mounted controller is deferred, since the gap predates this change.
+
+## Alternatives considered
+
+- **Keep three listeners with a shared owner filter.** It preserves the accreted names but not the property reviewers asked for — one place to learn what the registry announces — and `onJobDone`'s exact-owner delivery is what forced the registry to keep an `Agent` in every projection.
+- **Keep `reported` in the registry.** Only the tool knows which deliveries reach the model (a wait's tool result, a kill's acknowledgement); the registry could only approximate it, and the approximation was the source of the double-notice and missed-notice bugs the review listed.
+- **Keep the roster on the session control stream.** It saved one Remote stream but coupled the session controller to the job registry and mirrored a `record` flag the ring's byte count now answers directly (`output.total`).
+- **Serve `log` chunks to the model too.** Producer narration is for observers; the model already receives the same facts through `progress` and the terminal `detail`.
+
+## Consequences
+
+The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it.
diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md
new file mode 100644
index 0000000000..e87cf61d32
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md
@@ -0,0 +1,33 @@
+# Agent Note:后台 job 的一个输出环、一个投影、一条事件流
+
+Status: implemented
+
+[English](2026-09-03-jobs-seam-consolidation.md) | 中文
+
+## 问题
+
+job seam 是逐步堆出来的。生产者要把输出声明两次——模型读取的消耗式 `readOutput` 钩子,以及观察者读取的可选 `record: true` 环——注册表也为同一个 job 保留了两套词汇(模型侧的 `JobSnapshot`、会话控制流上的 `SessionJob`、观测线路上的 `JobWireChunk`)。三个监听器家族(`onJobDone`、`onJobsChanged`、`onOutput`)用不同的拥有者过滤投递彼此重叠的事实,注册表自己还跟踪着只有模型侧工具才解释得了的 `reported` 位。合并后设计的评审把接口层称作最薄弱的部分:读者分不清 `read`、`readRecord`、`JobStart`、`RecordingJob` 与 `updateDetail` 里哪些是约定、哪些是历史遗留。
+
+## 决策
+
+- **每个 job 一个环。** `JobSpec` 取代 `JobStart`:spec 声明可选的拉取式 `output` 源(`JobOutputSource`,子进程的 `readFrom` 家族),由注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送;每个生产者都收到一个 `JobHandle`,其 `append` 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;`readOutput` 与 `record` 判别字段被移除。shell 生产者把它们的 `observed` 偏移读取器交给注册表,并把沙箱事实折进终态 `detail`;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 `JobOutcome.result` 返回,结算后的第一次读取携带它一次。
+- **两个游标,一份存储。** `VisibleJobs.read(id)` 是有状态的模型游标;`VisibleJobs.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。
+- **`updateProgress` 取代 `updateDetail`。** `JobView.progress` 是实时行,结算时清除;`JobView.detail` 是终态原因,合并了模型 `job_kill` 给出的原因。
+- **一个投影。** `JobView`(客户端安全叶子 `@deepseek-ai/dsh-jobs/view`)是模型工具、浏览器名册与观测帧共同消费的形状;`owner` 是会话 id,从不是 `Agent`。
+- **一个绑定调用方的视图。** `JobRegistry.visibleTo(caller?)` 绑定一次会话并返回 `list`、`get`、`read`、`readAt`、`kill` 与 `wait`;每个方法对调用方集合之外的 id 抛出,不区分未知与他人。
+- **一条事件流。** 带 `{ owner }`、`{ owners: 'scope' }` 或 `{ owners: 'all' }` 过滤的 `events.subscribe(filter, listener)` 取代三个监听器家族。`settled` 标出原因(`producer`、`kill`、`teardown`);`output` 只携带 id 与新的 total。
+- **播报台账移到 `dsh-tool-jobs`。** 注册表不再跟踪 `reported`。工具在等待开始时认领 job(超时或中止则撤回),在 `job_kill` 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。
+- **名册离开会话控制流。** `dsh-api-job-controller` 在 `job.observe` 旁拥有 `job.rows({ sessionId })`——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 `ctx.jobs`(`watchRows`、`observe`、一份快照)。`dsh-api-session-controller` 只承载队列与投影;`ui-jobs` 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。
+
+`attachController` 与相对拥有者的 `servesOwner` 门保持原样;没有挂载控制器的生产者是否可以提供 `run_in_background`,因为这个缺口早于本次变更,暂不处理。
+
+## 考虑过的替代方案
+
+- **保留三个监听器并共享一个拥有者过滤。** 它保住了堆出来的名字,却保不住评审要的性质——一个地方就能学会注册表宣布什么——而且 `onJobDone` 的精确拥有者投递正是迫使注册表在每个投影里保留 `Agent` 的原因。
+- **把 `reported`留在注册表。** 只有工具知道哪些投递到达了模型(等待的工具结果、kill 的确认);注册表只能近似,而这个近似正是评审列出的重复播报与漏播报 bug 的来源。
+- **把名册留在会话控制流上。** 省了一条 Remote 流,却把会话控制器耦合到 job 注册表,还镜像了一个环的字节数如今直接回答的 `record` 标志(`output.total`)。
+- **也把 `log` 块交给模型。** 生产者叙述是给观察者的;模型已经通过 `progress` 与终态 `detail` 收到同样的事实。
+
+## 后果
+
+模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。
diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml
index ee733d059e..8e24662bc9 100644
--- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
-2026-08-08-web-background-job-display.md: 7787466154d65ba0970010c3fdaf964ae6d8507f
-2026-08-08-web-background-job-display.zh.md: 478acdf00936baed1700d18db2f63b1da1749e37
+2026-08-08-web-background-job-display.md: d2440ea63fc1d9c7f1829fa18b3054a8ea3768a5
+2026-08-08-web-background-job-display.zh.md: e5e55c95ce94f211ee6d1ac7f3391ab9a453c1a6
diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
index 7787466154..d2440ea63f 100644
--- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
+++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
@@ -2,6 +2,8 @@
Status: implemented
+Superseded: the roster this note put on the session control stream (`jobsBySession`, `onJobsChanged`) now streams from the job controller's `job.rows` into `ctx.jobs` — see [the jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The display decisions below (one roster, sections, durations, no kill control) still hold.
+
Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md).
English | [中文](2026-08-08-web-background-job-display.zh.md)
@@ -119,7 +121,7 @@ A running one-shot background subagent therefore appears both there and in the s
The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer.
-Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior.
+Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) pins the roster stream that replaced the control-stream fan-out: the complete visible set on open, a refresh after each lifecycle commit and none per append, owner removal, and a clean abort; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior.
## Consequences
diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md
index 478acdf009..e5e55c95ce 100644
--- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md
@@ -2,6 +2,8 @@
Status: implemented
+Superseded:本文放在会话控制流上的名册(`jobsBySession`、`onJobsChanged`)现在从 job 控制器的 `job.rows` 流入 `ctx.jobs`——见 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)。下文的展示决策(单一名册、分组、时长、没有 kill 控件)仍然成立。
+
Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。
[English](2026-08-08-web-background-job-display.md) | 中文
@@ -119,7 +121,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
[web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。
-在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。
+在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) 钉住取代控制流扇出的名册流:打开时的完整可见集、每次生命周期提交后刷新一次且追加不刷新、拥有者移除,以及干净的中止;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。
## 影响
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 01617f6564..da0b495dee 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.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/architecture.md
-architecture.md: 825dac9cf44bd39f7297d2323b63b9f2bca6aa46
-architecture.zh.md: ca929d52cb20b97b522bf1ca04ff8046da208583
+architecture.md: 07bb03abca22bcef018aefcde9515d42df9f1b7e
+architecture.zh.md: 214927d0ebcf8e76a98fc10911b097827e86c7a4
diff --git a/docs/architecture.md b/docs/architecture.md
index 825dac9cf4..07bb03abca 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -129,7 +129,7 @@ New behavior attaches to a documented extension point. Changing the loop itself
| Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` |
| Add a human command | register on `ctx.commands`; it dispatches without a model turn |
| Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it |
-| Stream live output to observers | declare `record: true` on the `ctx.jobs` start; the Web client renders it |
+| Stream live output to observers | name pull `output` sources on the `ctx.jobs` spec or push through its `JobHandle`; the Web client renders the ring |
| Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter |
| Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events |
| Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning |
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index ca929d52cb..214927d0eb 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -133,7 +133,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统
| 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` |
| 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 |
| 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 |
-| 向观察者流式输出 | 在 `ctx.jobs` 启动时声明 `record: true`;Web 客户端负责渲染 |
+| 向观察者流式输出 | 在 `ctx.jobs` 的 spec 上声明拉取式 `output` 源,或经其 `JobHandle` 推送;Web 客户端负责渲染输出环 |
| 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 |
| 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 |
| 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv |
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 45eda97034..e1becf5027 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: bac3a232db4daaf8f3d251e8bf47e3add1e3e640
-config-catalog.zh.md: 8ad8906e2b558eec8e092971f4ab2aa1d3aec020
+config-catalog.md: 9b6f869107a7a04e282f45af757cdacf50e729b7
+config-catalog.zh.md: e57226dd1ed37f070078894d10c277d914acf437
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index bac3a232db..9b6f869107 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -203,12 +203,12 @@ Source: [`packages/api/gateway/src/index.ts:119`](../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. */
export interface Config {
- /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */
+ /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
@@ -948,14 +948,16 @@ export interface Config {
* omission defaults to 10.
*/
maxConcurrentJobsPerOwner?: number
- /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */
+ /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
- /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */
+ /** Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */
settledRetainBytes?: number
+ /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */
+ pumpPollMs?: number
}
```
-Source: [`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts)
+Source: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts)
@@ -2645,12 +2647,10 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv`
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
- /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
- recordPollMs?: number
}
```
-Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts)
+Source: [`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts)
@@ -2751,7 +2751,7 @@ Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/s
## `@deepseek-ai/dsh-tool-jobs`
-Requires: `tools` · `jobs` · `systemPrompt`
+Requires: `tools` · `jobs` · `agents` · `systemPrompt`
```ts config-catalog
/** Configures bounded `job_output` waits and completion-notice delivery. */
@@ -2772,14 +2772,14 @@ export interface Config {
}
/**
- * How an unreported completion reaches an owner that is already idle: `wakeup`
+ * How an uncollected completion reaches an owner that is already idle: `wakeup`
* opens a turn for it, `quiet` leaves it pending until something else wakes the
* owner. A busy owner is injected either way.
*/
export type CompletionDelivery = 'quiet' | 'wakeup'
```
-Source: [`packages/jobs/tool-jobs/src/index.ts:31`](../packages/jobs/tool-jobs/src/index.ts)
+Source: [`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts)
@@ -2812,12 +2812,10 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv`
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
- /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
- recordPollMs?: number
}
```
-Source: [`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts)
+Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
@@ -3001,7 +2999,7 @@ export interface Config {
}
```
-Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts)
+Source: [`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 8ad8906e2b..e57226dd1e 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -205,12 +205,12 @@ export interface Config {
## `@deepseek-ai/dsh-api-job-controller`
-需要:`agents` · `jobs` · `typert`
+需要:`jobs` · `typert`
```ts config-catalog
/** Job Controller deployment policy. */
export interface Config {
- /** Coalescing window after new record output before an observation read, in milliseconds (default 100). */
+ /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
@@ -952,14 +952,16 @@ export interface Config {
* omission defaults to 10.
*/
maxConcurrentJobsPerOwner?: number
- /** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */
+ /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
- /** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */
+ /** Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */
settledRetainBytes?: number
+ /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */
+ pumpPollMs?: number
}
```
-来源:[`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts)
+来源:[`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts)
@@ -2649,12 +2651,10 @@ export type TokenMeterConfig = Record
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
- /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
- recordPollMs?: number
}
```
-来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts)
+来源:[`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts)
@@ -2755,7 +2755,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-jobs`
-需要:`tools` · `jobs` · `systemPrompt`
+需要:`tools` · `jobs` · `agents` · `systemPrompt`
```ts config-catalog
/** Configures bounded `job_output` waits and completion-notice delivery. */
@@ -2776,14 +2776,14 @@ export interface Config {
}
/**
- * How an unreported completion reaches an owner that is already idle: `wakeup`
+ * How an uncollected completion reaches an owner that is already idle: `wakeup`
* opens a turn for it, `quiet` leaves it pending until something else wakes the
* owner. A busy owner is injected either way.
*/
export type CompletionDelivery = 'quiet' | 'wakeup'
```
-来源:[`packages/jobs/tool-jobs/src/index.ts:31`](../packages/jobs/tool-jobs/src/index.ts)
+来源:[`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts)
@@ -2816,12 +2816,10 @@ export interface Config {
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
- /** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
- recordPollMs?: number
}
```
-来源:[`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts)
+来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
@@ -3005,7 +3003,7 @@ export interface Config {
}
```
-来源:[`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts)
+来源:[`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts)
diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml
index 9d02664a90..b7d90c82c1 100644
--- a/docs/cookbook/adding-a-tool.i18n.yaml
+++ b/docs/cookbook/adding-a-tool.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/cookbook/adding-a-tool.md
-adding-a-tool.md: f6b2703f3db69a6abc04dbd0fd9893555eed0602
-adding-a-tool.zh.md: d4831e2be061c2565e88004062cbb1558491bb9d
+adding-a-tool.md: e6d1a9702d21420eeb42d743bd26f7738be2c217
+adding-a-tool.zh.md: a0d35f0b2c821333d82170945aa14f913fa7e034
diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md
index f6b2703f3d..e6d1a9702d 100644
--- a/docs/cookbook/adding-a-tool.md
+++ b/docs/cookbook/adding-a-tool.md
@@ -52,7 +52,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S
Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but PTC mode must never parse that prose to recover the id.
-The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer.
+The spec supplies synchronous `cancel`, a non-rejecting `done` that settles after resource cleanup, and either pull `output` sources the registry pumps into the job's output ring or pushes through the `JobHandle` its starter receives; `dsh-tool-jobs` renders the model's consuming read from that ring. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer.
## Execution policy and observation
diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md
index d4831e2be0..a0d35f0b2c 100644
--- a/docs/cookbook/adding-a-tool.zh.md
+++ b/docs/cookbook/adding-a-tool.zh.md
@@ -52,7 +52,7 @@ export function apply(ctx: Context) {
通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 PTC mode 绝不能通过解析该文本取得 id。
-producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。
+spec 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及由注册表泵入 job 输出环的拉取式 `output` 源或经 starter 收到的 `JobHandle` 推送的追加;模型的消费式读取由 `dsh-tool-jobs` 从该环渲染。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index 9d71df140c..fc09aa63be 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: e0b4709875723bd96f9910b1bf518600612f0a12
-event-producer-consumer.zh.md: 0f0fa1317eb5a017daf435648f5c3e36208626e0
+event-producer-consumer.md: 53f524875794bd6f088adc093e68d47816512098
+event-producer-consumer.zh.md: b07570110077da6c3c775d6c1381cf69ef534c70
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index e0b4709875..53f5248757 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 0f0fa1317e..b075701100 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -23,11 +23,11 @@
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:551`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index 77ffa1744e..294e9032a4 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.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/module-graph.md
-module-graph.md: 7d3db9fbf2c078bfcf8ad542cc02cd459c303730
-module-graph.zh.md: 726326b378dccc3b5208b67bf94117fb2f2e3d57
+module-graph.md: ad0a401fe90a7eb7f58b0ca0d81ff311667b0f61
+module-graph.zh.md: a16f710ea2476ef28467e5f6e0a1417c095b80df
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 7d3db9fbf2..ad0a401fe9 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -1048,7 +1048,6 @@ flowchart TD
pkg_api_session_controller --> pkg_attachment
pkg_api_session_controller --> pkg_client_connection
pkg_api_session_controller --> pkg_file_reference
- pkg_api_session_controller --> pkg_jobs
pkg_api_session_controller --> pkg_llm
pkg_api_session_controller --> pkg_native_command
pkg_api_session_controller --> pkg_scope
@@ -1383,7 +1382,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 726326b378..a16f710ea2 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -1050,7 +1050,6 @@ flowchart TD
pkg_api_session_controller --> pkg_attachment
pkg_api_session_controller --> pkg_client_connection
pkg_api_session_controller --> pkg_file_reference
- pkg_api_session_controller --> pkg_jobs
pkg_api_session_controller --> pkg_llm
pkg_api_session_controller --> pkg_native_command
pkg_api_session_controller --> pkg_scope
@@ -1385,7 +1384,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index b6e2f8ac7f..32aec02eef 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-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/persistence-catalog.md
-persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c
-persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a
+persistence-catalog.md: 55a5596b3f37f31824eac77141abd0f6662ffa33
+persistence-catalog.zh.md: 2971549e08886fe7e39e77daf0785ea9060f9139
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 1c0c691998..55a5596b3f 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -512,7 +512,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
'model/selection': ModelSelection
```
-Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts)
+Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts)
### `permission/*`
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index 4cc8ba5b7f..2971549e08 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -514,7 +514,7 @@ export type SessionEvent = {
'model/selection': ModelSelection
```
-来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts)
+来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts)
### `permission/*`
diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml
index 8e4c26b4bb..f71f76aa0e 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: 639faa8da3836cb3efc865c494cd8ca8c60f49fb
-jobs.zh.md: 3d008671657847520eccd41b8a3bd75014777680
+jobs.md: cdbeb6df0c0f69458bfae89981c8f2b6d3dfc381
+jobs.zh.md: a502fff316a0c2ebccf7888d4eb5aee10de07576
diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md
index 639faa8da3..cdbeb6df0c 100644
--- a/docs/subsystems/jobs.md
+++ b/docs/subsystems/jobs.md
@@ -2,7 +2,7 @@
English | [中文](jobs.zh.md)
-Types shared by long-running producers, `ctx.jobs`, and job controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts).
+Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam consolidation Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) owns the current design and the [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) its origin; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) and the client-safe [`view.ts`](../../packages/jobs/jobs/src/view.ts) leaf.
## Ids and status
@@ -19,63 +19,83 @@ interface JobKindMap {
}
```
-`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobSnapshot.detail`.
+`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled.
## Producer contract
-`JobStart` declares identity and a starter, discriminated by `record`: a `RecordingJobStart` receives the `RecordingJob` face, a `PlainJobStart` the `RunningJob` face without `append`. The runtime finishes preflight before calling `run()` with that face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state.
+A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring.
```ts type-equiv
/**
- * A start with the model-facing surfaces only. Its producer face carries no
- * `append`, so output the registry would have nowhere to keep is
- * unrepresentable rather than dropped.
+ * Producer declaration passed to {@link JobRegistry.start}. The runtime
+ * preflights access and cleanup before invoking {@link run}; the producer owns
+ * execution resources while the runtime owns identity, lifecycle state, and
+ * the output ring.
*/
-interface PlainJobStart extends JobStartBase {
- record?: undefined
+interface JobSpec {
+ /** Producer kind — also the id prefix (`bash`, `subagent`, …). */
+ kind: JobKind
+ /** One-line model-facing label (the command; the delegation description). */
+ label: string
+ /**
+ * Owning session. Access is fenced by it, and the owner's live Agent must be
+ * the one currently registered under that id: its disposal cancels and
+ * awaits the job. Omitting the owner creates an unowned job, open to any
+ * caller until service disposal.
+ */
+ owner?: SessionId
+ /**
+ * Optional UTF-8 byte cap for each complete model-facing completion notice or
+ * output read, including controller status metadata. Independent of ring
+ * retention: it bounds the consuming model surface, never observers.
+ */
+ outputLimitBytes?: number
+ /**
+ * Pull sources the registry pumps into the ring at its own cadence.
+ * Producers that narrate their own progress use {@link JobHandle.append}
+ * instead; a job may use both.
+ */
+ output?: readonly JobOutputSource[]
/**
* Start the work after preflight and synchronously return its hooks. Called
* once with the job's producer face; a throw leaves nothing registered (the
* spent ordinal is skipped), and the producer must clean up any partially
* started resources.
- * @param job - the issued id plus the live-detail writer.
+ * @param job - the issued id plus the ring append and progress writers.
*/
- run(job: RunningJob): JobHooks
+ run(job: JobHandle): JobHooks
}
```
```ts type-equiv
/**
- * A start that declares an observable output record beside the model-facing
- * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that
- * any number of observers read at absolute byte offsets through
- * {@link JobRegistry.readRecord}, and snapshots carry
- * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}.
+ * Producer face of one registered job, handed to {@link JobSpec.run} and
+ * valid for the job's whole life. All methods are synchronous. Writes staged
+ * inside the starter call are retained and become visible with the
+ * registration commit; after settlement — the producer's own outcome, a kill,
+ * or a registry-forced teardown end — writes log and drop instead of
+ * throwing, so a producer's trailing flush cannot break its own teardown path.
*/
-interface RecordingJobStart extends JobStartBase {
- record: true
+interface JobHandle {
+ /** The registry-issued id (`-N`). */
+ readonly id: JobId
/**
- * Start the work after preflight and synchronously return its hooks. Called
- * once with the job's producer face; a throw leaves nothing registered (the
- * spent ordinal is skipped), and the producer must clean up any partially
- * started resources.
- * @param job - the issued id plus the record append and live-detail writers.
+ * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8
+ * byte length; an empty chunk is dropped without waking observers.
+ * @param text - the chunk text, exactly as produced.
+ * @param options - stream label and gap marker.
*/
- run(job: RecordingJob): JobHooks
+ append(text: string, options?: JobAppendOptions): void
+ /**
+ * Replace the live progress line (`3/10`, the current phase). Settlement
+ * clears it; the terminal reason travels in {@link JobOutcome.detail}.
+ * @param line - the new progress line.
+ */
+ updateProgress(line: string): void
}
```
-```ts type-equiv
-/**
- * Producer declaration passed to {@link JobRegistry.start}, discriminated by
- * `record`. The runtime preflights access and cleanup before invoking `run`;
- * the producer owns execution resources while the runtime owns identity and
- * lifecycle state.
- */
-type JobStart = PlainJobStart | RecordingJobStart
-```
-
-`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream jobs from final-output-only jobs.
+`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. A job whose result is a value rather than a stream — a subagent's report, a workflow's rendered result — returns it as `JobOutcome.result`; the model's first read after settlement carries it once.
```ts type-equiv
/** Hooks through which the runtime controls and observes producer work. */
@@ -89,18 +109,9 @@ interface JobHooks {
* Resolves after the producer releases its resources, not merely when work
* finishes. Must not reject; the runtime converts a rejection to `failed`.
* If teardown cancellation throws, the runtime may force-fail only the
- * registry record without claiming that the work stopped. Settlement also
- * ends the record: fold any record-filling pump into this promise so the
- * final drain lands before the registry trims and closes the stream.
+ * registry record without claiming that the work stopped.
*/
done: Promise
- /**
- * Consume output produced since the previous call. The producer formats
- * truncation and spill notices. Absence marks a final-output-only job; each
- * job has one consuming cursor. Independent of the record: this is the
- * model-facing projection, {@link JobRegistry.readRecord} the observer one.
- */
- readOutput?(): string
}
```
@@ -109,74 +120,82 @@ interface JobHooks {
interface JobOutcome {
/** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
status: 'completed' | 'killed' | 'failed'
- /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
+ /**
+ * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`).
+ * When the job settles `killed` after a {@link VisibleJobs.kill} with a
+ * reason, the registry appends that reason.
+ */
detail?: string
- /** Final output for jobs without `readOutput`; stream jobs leave it unset. */
- output?: string
+ /**
+ * Return value for jobs whose result is a value rather than a stream (a
+ * workflow's rendered result, a subagent's report). The output ring carries
+ * the stream; this is handed out once by the model's next {@link VisibleJobs.read}.
+ */
+ result?: string
}
```
-## Observation record
-
-A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RecordingJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below.
+A pull source hands the registry a non-consuming offset reader — the subprocess `readFrom` family. The registry pumps every source at its own cadence (`pumpPollMs` on `dsh-jobs-local`) and drains it once more before settlement closes the ring, so producers fold nothing into `done`.
```ts type-equiv
/**
- * Producer face of one registered job, handed to `run` and valid for the
- * job's whole life. All methods are synchronous. Writes staged inside the
- * starter call are retained and become visible with the registration commit;
- * after settlement — the producer's own outcome, a kill, or a registry-forced
- * teardown end — writes log and drop instead of throwing, so a producer's
- * trailing flush cannot break its own teardown path.
+ * A pull source the registry pumps into the job's output ring — the subprocess
+ * `readFrom` family. The registry owns the cadence and drains every source one
+ * last time before settlement closes the ring, so a producer folds nothing
+ * into its `done`.
*/
-interface RunningJob {
- /** The registry-issued id (`-N`). */
- readonly id: JobId
- /**
- * Replace the snapshot's status detail with a live progress line (`3/10`).
- * @param detail - the new detail line.
- */
- updateDetail(detail: string): void
-}
-```
-
-```ts type-equiv
-/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */
-interface RecordingJob extends RunningJob {
- /**
- * Append one record chunk. Offsets advance by the chunk's UTF-8 byte
- * length; an empty chunk is dropped without waking observers.
- * @param text - the chunk text, exactly as produced.
- * @param options - stream label and gap marker.
- */
- append(text: string, options?: JobAppendOptions): void
-}
-```
-
-```ts type-equiv
-/** One retained record chunk returned by {@link JobRegistry.readRecord}. */
-interface JobRecordChunk {
- /** Absolute offset of the chunk's first byte. */
- at: number
- /** Chunk text exactly as appended (possibly tail-trimmed by retention). */
- text: string
- /** Stream label, when the producer supplied one. */
+interface JobOutputSource {
+ /** Stream label attached to every chunk this source yields. */
channel?: JobChannel
- /** Producer-reported loss immediately before this chunk. */
- gapBefore?: true
+ /**
+ * Read everything captured since `fromByte` without consuming it.
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first).
+ * @returns the delta text, the next offset, and the lossy flag.
+ */
+ read(fromByte: number): JobSourceRead
}
```
```ts type-equiv
-/** Result of one non-consuming {@link JobRegistry.readRecord}. */
-interface JobRecordRead {
+/** One incremental read from a {@link JobOutputSource}. */
+interface JobSourceRead {
+ /** Text captured since the requested offset (the whole retained tail when lossy). */
+ text: string
+ /** Whole-stream offset to resume from on the next read. */
+ nextOffset: number
+ /** True when the requested offset slid out of the source's retained window. */
+ lossy: boolean
+}
+```
+
+## The output ring
+
+Every job owns one bounded ring. Pull sources are pumped into it and `JobHandle.append` pushes land whole; the model consumes the ring through a registry-kept cursor (`VisibleJobs.read`), any number of observers read it at absolute byte offsets (`VisibleJobs.readAt`), and neither disturbs the other. `JobChannel` labels `stdout`, `stderr`, and `log`; `log` is producer narration that reaches observers only, never the model's consuming read. Settlement ends the stream and trims retention to the settled cap — the ring has no separate lifecycle. Browsers reach the roster and the ring through `job.rows` and `job.observe`, the Remote streams of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below.
+
+```ts type-equiv
+/** One chunk of a job's output ring: absolute offset, text, and its provenance. */
+interface JobChunk {
+ /** Absolute offset of the chunk's first byte; offsets never move once assigned. */
+ readonly at: number
+ /** Chunk text exactly as appended (possibly tail-trimmed by retention). */
+ readonly text: string
+ /** Stream label, when the producer supplied one. */
+ readonly channel?: JobChannel
+ /** Bytes immediately before this chunk were lost, at the producer or to retention. */
+ readonly gapBefore?: true
+}
+```
+
+```ts type-equiv
+/** Result of one non-consuming {@link VisibleJobs.readAt}. */
+interface JobOutputRead {
/** Retained chunks overlapping `[from, total)`, in offset order. */
- chunks: readonly JobRecordChunk[]
+ chunks: readonly JobChunk[]
/**
- * Offset to resume from — the record's current `outputTotal`. Always a
- * chunk boundary: appends land whole and trimming only advances chunk
- * starts, and consumers concatenate `chunks` under that assumption, so a
- * provider serving partial chunks would silently duplicate text.
+ * Offset to resume from — the ring's current `total`. Always a chunk
+ * boundary: appends land whole and trimming only advances chunk starts, and
+ * consumers concatenate `chunks` under that assumption, so a provider
+ * serving partial chunks would silently duplicate text.
*/
next: number
/** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */
@@ -186,78 +205,167 @@ interface JobRecordRead {
## Consumer views
-Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service.
+`JobView` is the one projection every reader consumes: the model tools, the browser roster, and the observation stream. `owner` carries the session id that fences access; the registry resolves the live `Agent` behind it for lifecycle cleanup and never hands the object out. `progress` is the producer's live line and is cleared at settlement; `detail` is the terminal reason, with a recorded kill reason merged in.
```ts type-equiv
/**
- * A read-only projection of one job, safe to hand to listeners and tools —
- * a fresh object per call, never live registry state.
+ * Read-only projection of one job — a fresh object per call, never live
+ * registry state. The model tools, the browser roster, and the observation
+ * stream all consume this one shape.
*/
-interface JobSnapshot {
+interface JobView {
/** The registry-issued id (`-N`). */
- id: JobId
+ readonly id: JobId
/** The producer kind the job was registered with. */
- kind: JobKind
+ readonly kind: JobKind
/** The producer-supplied one-line label. */
- label: string
- /** Producer-owned cap for complete model-facing notices and output reads. */
- outputLimitBytes?: number
- /**
- * Owner session id used for authorization and correlation; absent for
- * unowned jobs. Completion listeners receive the exact {@link Agent}
- * separately through {@link JobDoneListener}.
- */
- ownerSession?: SessionId
+ readonly label: string
+ /** Owning session; absent for an unowned job, which every caller can see. */
+ readonly owner?: SessionId
+ /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */
+ readonly outputLimitBytes?: number
/** Current lifecycle state. */
- status: JobStatus
- /**
- * Kind-specific status detail: live progress while the producer updates it
- * through {@link RunningJob.updateDetail}, the terminal detail once settled.
- */
- detail?: string
+ readonly status: JobStatus
+ /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */
+ readonly progress?: string
+ /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */
+ readonly detail?: string
/** Epoch ms when the job was registered. */
- startedAt: number
- /** Epoch ms when the job settled; absent while `running`/`stopping`. */
- finishedAt?: number
+ readonly startedAt: number
+ /** Epoch ms when the job settled; absent while live. */
+ readonly finishedAt?: number
/**
- * True when a kill, read, wait, or teardown cancel has reported or committed
- * to report the terminal state. Completion reporters suppress redundant
- * notices when set. Teardown claims it because the owner or service being
- * destroyed leaves no reader: a reporter that opens a turn on notice would
- * otherwise spend a model request per teardown layer.
+ * The output ring's absolute coordinates. `total` is the offset the next
+ * chunk starts at (0 while nothing was written); `earliest` is the oldest
+ * retained byte, greater than zero exactly when retention dropped the head.
*/
- reported: boolean
- /**
- * Total UTF-8 bytes ever appended to the record — the offset the next chunk
- * starts at. Present exactly when the job declared {@link JobStart.record}.
- */
- outputTotal?: number
- /**
- * Offset of the oldest retained record byte. Greater than zero exactly when
- * retention dropped the head; a reader starting below it gets a lossy read.
- * Present exactly when the job declared {@link JobStart.record}.
- */
- outputEarliest?: number
+ readonly output: { readonly total: number; readonly earliest: number }
+}
+```
+
+`JobRegistry.visibleTo(caller)` binds one caller's identity and returns its operations; a caller sees its own jobs and every unowned job, and every operation throws for an id outside that set.
+
+```ts type-equiv
+/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */
+interface JobRead {
+ /** Ring chunks appended since the model cursor, in offset order; every channel included. */
+ chunks: readonly JobChunk[]
+ /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */
+ lossy: boolean
+ /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */
+ result?: string
+ /** The job's state at read time. */
+ job: JobView
}
```
```ts type-equiv
-/** Output and post-read state returned by {@link JobRegistry.read}. */
-interface JobRead {
+/**
+ * The operations one caller may perform, bound by {@link JobRegistry.visibleTo}.
+ * A caller sees its own jobs and every unowned job; every method throws for
+ * an id outside that set, without distinguishing unknown from foreign.
+ */
+interface VisibleJobs {
/**
- * Stream kinds: the consuming delta since the previous read. Final-output
- * kinds: empty while live, the terminal {@link JobOutcome.output} (or
- * empty) once settled — idempotent, never consumed.
+ * List the visible jobs in registration order.
+ * @returns fresh projections.
*/
- text: string
- /** The job's state at read time. */
- snapshot: JobSnapshot
+ list(): JobView[]
+ /**
+ * Project one job without touching its cursor.
+ * @param id - job to look up.
+ * @returns a fresh projection.
+ */
+ get(id: JobId): JobView
+ /**
+ * Consume the ring from the model cursor and advance it to the current
+ * total. After settlement the first read also carries the producer's
+ * result; later reads return only what was appended since.
+ * @param id - job to read.
+ * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection.
+ */
+ read(id: JobId): JobRead
+ /**
+ * Read retained ring output from an absolute byte offset without consuming
+ * it. Resume by passing a previous read's `next`; an offset inside a
+ * retained chunk returns that whole chunk (its `at` may precede `from`).
+ * Never moves the model cursor. Throws for a negative or non-integer offset.
+ * @param id - job to read.
+ * @param from - absolute byte offset to read from (0 for the retained head).
+ * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
+ */
+ readAt(id: JobId, from: number): JobOutputRead
+ /**
+ * Request cancellation, then mark the job stopping. A recorded
+ * {@link JobKillOptions.reason} merges into the terminal `detail` when the
+ * job settles `killed`. A producer throw propagates without changing job
+ * state.
+ * @param id - job to cancel.
+ * @param options - cancellation reason.
+ * @returns `requested` for live work, otherwise `already-finished`.
+ */
+ kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished'
+ /**
+ * Wait for settlement or timeout without cancelling the job. Caller abort
+ * rejects only while the job is live; after settlement the terminal
+ * projection wins. Throws for an invalid timeout.
+ * @param id - job to wait for.
+ * @param timeoutMs - positive finite wait bound in milliseconds.
+ * @param signal - optional cancellation of the wait itself.
+ * @returns projection at settlement or timeout.
+ */
+ wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise
}
```
+## Events
+
+The registry announces every commit through one filtered stream. Lifecycle events carry the projection after the commit they announce; `settled` names its cause so a completion reporter can skip a teardown; `output` carries only the id and the new total, so observers read from their own cursor and the registry never pushes payloads.
+
+```ts type-equiv
+/**
+ * One lifecycle or output event. Lifecycle events carry the job's projection
+ * after the commit they announce; `output` carries only the id and the new
+ * total, so an observer schedules a {@link VisibleJobs.readAt} from its own
+ * cursor and the registry never pushes payloads.
+ */
+type JobEvent =
+ | {
+ /** Registration commit, progress line change, stopping transition, or removal from the visible set. */
+ readonly type: 'registered' | 'progress' | 'stopping' | 'removed'
+ readonly job: JobView
+ }
+ | {
+ readonly type: 'settled'
+ readonly job: JobView
+ readonly cause: JobSettleCause
+ }
+ | {
+ readonly type: 'output'
+ readonly id: JobId
+ /** Owning session, absent for an unowned job. */
+ readonly owner?: SessionId
+ /** The ring's total after the append (or at settlement, which ends the stream). */
+ readonly total: number
+ }
+```
+
+```ts type-equiv
+/**
+ * Who a subscription hears about. `{ owner }` delivers that session's jobs
+ * plus every unowned job (the set that session can see). `{ owners: 'scope' }`
+ * delivers the owners composed under the subscribing context — one registry
+ * serves every composition in the process, and a mount under one preset must
+ * not hear another preset's agents. `{ owners: 'all' }` delivers everything.
+ */
+type JobEventFilter =
+ | { readonly owner: SessionId }
+ | { readonly owners: 'all' | 'scope' }
+```
+
## Service behavior
-The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, non-consuming `readRecord`, `kill`, bounded `wait`, failure-isolated `onJobDone`, `onJobsChanged`, and `onOutput` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity, and `retainBytes` (default 262144) with `settledRetainBytes` (default 16384) bound each declared record's live and settled retention. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer.
+The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-bound `visibleTo` (`list`, `get`, the consuming `read`, the non-consuming `readAt`, `kill`, and bounded `wait`), the filtered `events` stream, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the live `Agent` registered under the owner session when the job starts. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity; `retainBytes` (default 262144) and `settledRetainBytes` (default 16384) bound each ring's live and settled retention, and `pumpPollMs` (default 150) is the pull cadence. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer.
@@ -275,11 +383,21 @@ Host service backing the generated `ctx.remote.job` namespace.
```ts cordis-catalog
/**
- * Stream one job's retained record output from an absolute byte offset,
- * then its terminal status once settled and drained. Non-consuming: the
+ * Stream the jobs one session can see — its own plus every unowned job —
+ * as whole-set frames: one on open, then one after each coalesced burst of
+ * lifecycle commits. The stream has no natural end; the carrier closes it.
+ * @param request - the session whose visible set to mirror.
+ * @param signal - cancellation owned by the Remote stream carrier.
+ * @returns the roster frames.
+ */
+@Remote({ mode: 'stream' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable
+
+/**
+ * Stream one job's retained output from an absolute byte offset, then its
+ * terminal projection once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
- * request's session resolves the fenced-read caller; the registry rejects a
- * job the session does not own, a job without a record, or an unknown job.
+ * request's session is the fenced read's caller; the registry rejects a
+ * job the session cannot see and an unknown job.
* @param request - target job, owning session, and optional resume offset.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns anchor, coalesced output frames, and the terminal status.
@@ -293,16 +411,16 @@ Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-cont
### `ctx.jobs` — `JobRegistry` (abstract seam)
-Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
+Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Implementations must honor these semantics:
-- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.
+- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left.
- Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
-- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.
-- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
-- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.
-- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.
+- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.
+- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
+- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other.
+- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.
```ts cordis-catalog
/**
@@ -310,120 +428,20 @@ Implementations must honor these semantics:
* admission before starting and atomically registering work. Any preflight
* rejection leaves no job id or execution resource. A throwing starter
* leaves nothing registered; after it returns, registration cannot fail.
- * Settlement records the outcome, notifies listeners, and releases waiters.
- * @param spec - job identity, owner, and synchronous starter.
+ * @param spec - job identity, owner, output sources, and synchronous starter.
* @returns the registry-issued `-N` id.
*/
-abstract start(spec: JobStart): JobId
+abstract start(spec: JobSpec): JobId
/**
- * List caller-owned and unowned jobs in registration order without exposing
- * another session's labels.
- * @param caller - reading agent; a non-agent caller sees only unowned jobs.
- * @returns fresh snapshots.
+ * Bind the caller's identity once and return its operations. A caller sees
+ * its own jobs and every unowned job; a caller-less view sees unowned jobs
+ * only. The view resolves visibility on every call, so it stays valid as
+ * jobs come and go.
+ * @param caller - the reading session, or undefined for an anonymous caller.
+ * @returns the operations available to that caller.
*/
-abstract list(caller?: Agent): JobSnapshot[]
-
-/**
- * Return a non-consuming snapshot without changing its read cursor or notice
- * state. Throws for an unknown or foreign job.
- * @param id - job to look up.
- * @param caller - reading agent checked against the owner.
- * @returns a fresh snapshot.
- */
-abstract get(id: JobId, caller?: Agent): JobSnapshot
-
-/**
- * Read the next stream delta, or the idempotent final output after settlement.
- * A terminal read marks the job reported. Throws for an unknown or foreign
- * job.
- * @param id - job to read.
- * @param caller - reading agent checked against the owner.
- * @returns output text and the post-read snapshot.
- */
-abstract read(id: JobId, caller?: Agent): JobRead
-
-/**
- * Request cancellation, then mark the job stopping and reported. A producer
- * throw propagates without changing job state. Throws for an unknown or
- * foreign job.
- * @param id - job to cancel.
- * @param caller - killing agent checked against the owner.
- * @param reason - logged reason forwarded to the producer.
- * @returns `requested` for live work, otherwise `already-finished`.
- */
-abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
-
-/**
- * Wait for settlement or timeout without cancelling the job. Caller abort
- * rejects only while the job is live; after settlement the terminal
- * snapshot wins so a notice suppressed for this waiter is still delivered.
- * Throws for invalid, unknown, or foreign input.
- * @param id - job to wait for.
- * @param timeoutMs - positive finite wait bound in milliseconds.
- * @param caller - waiting agent checked against the owner.
- * @param signal - optional cancellation of the wait itself.
- * @returns snapshot at settlement or timeout.
- */
-abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise
-
-/**
- * Register an effect-scoped completion listener. It receives the settlements
- * of the owners its registering context's scope covers; each listener is
- * contained; returned promises are observed but not awaited. No listener runs
- * after service disposal.
- * @param listener - receives each terminal snapshot and its exact owner.
- * @returns disposer that unregisters the listener.
- */
-abstract onJobDone(listener: JobDoneListener): () => void
-
-/**
- * Register an effect-scoped observer of visible-set changes. It fires after
- * every commit that changes what {@link list} returns for that owner —
- * registration, every stopping transition (including the one teardown
- * performs before it awaits a slow producer), settlement, owner-disposal
- * removal, and the emptying that service disposal commits — so an observer
- * re-reads rather than accumulating deltas.
- *
- * Delivery is owner-relative on the same terms as {@link onJobDone}: an
- * observer registered from an unscoped context — a host composition's own
- * carrier — sees every owner, while one registered under an agent
- * composition's scope sees exactly the agents composed under it.
- *
- * This is not a superset of {@link onJobDone}: that one delivers the terminal
- * record under first-wins semantics a job controller couples to notice
- * delivery, while this one carries no delivery meaning and marks nothing
- * reported. Listeners are contained and never awaited.
- * @param listener - receives the owner whose visible set changed, or
- * `undefined` when an unowned job changed and every caller's set did.
- * @returns disposer that unregisters the listener.
- */
-abstract onJobsChanged(listener: JobsChangedListener): () => void
-
-/**
- * Read retained record output from an absolute byte offset without consuming
- * it. Resume by passing a previous read's `next`; a foreign offset inside a
- * retained chunk returns that whole chunk (its `at` may precede `from`).
- * Never marks the job reported. Throws for an unknown or foreign job, a job
- * without a {@link JobStart.record} declaration, or a negative or
- * non-integer offset.
- * @param id - job to read.
- * @param from - absolute byte offset to read from (0 for the retained head).
- * @param caller - reading agent checked against the owner.
- * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
- */
-abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead
-
-/**
- * Register an effect-scoped observer of record advancement — one signal per
- * committed append and one at settlement, carrying only the job id. A
- * consumer schedules a {@link readRecord} from its own cursor; the registry
- * never pushes payloads. Delivery is owner-relative on the same terms as
- * {@link onJobsChanged}. Listeners are contained and never awaited.
- * @param listener - receives the id whose record advanced.
- * @returns disposer that unregisters the listener.
- */
-abstract onOutput(listener: JobOutputListener): () => void
+abstract visibleTo(caller?: SessionId): VisibleJobs
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
@@ -435,7 +453,7 @@ abstract onOutput(listener: JobOutputListener): () => void
abstract attachController(name: string): () => void
```
-Types: [Agent](core.md)
+Types: [SessionId](core.md)
Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts)
diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md
index 3d00867165..a502fff316 100644
--- a/docs/subsystems/jobs.zh.md
+++ b/docs/subsystems/jobs.zh.md
@@ -2,7 +2,7 @@
[English](jobs.md) | 中文
-长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 负责设计;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 中的确切字段和变体。
+长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[seam 收敛 Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md) 负责当前设计,[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 记录其起源;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 与客户端安全叶子 [`view.ts`](../../packages/jobs/jobs/src/view.ts) 中的确切字段与变体。
## ID 与状态
@@ -19,63 +19,83 @@ interface JobKindMap {
}
```
-`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实归入 `JobSnapshot.detail`。
+`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。
## 生产方约定
-`JobStart` 声明身份和启动器,并按 `record` 判别:`RecordingJobStart` 收到 `RecordingJob` 面,`PlainJobStart` 收到没有 `append` 的 `RunningJob` 面。运行时会在完成预检后携带该面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。
+`JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。
```ts type-equiv
/**
- * A start with the model-facing surfaces only. Its producer face carries no
- * `append`, so output the registry would have nowhere to keep is
- * unrepresentable rather than dropped.
+ * Producer declaration passed to {@link JobRegistry.start}. The runtime
+ * preflights access and cleanup before invoking {@link run}; the producer owns
+ * execution resources while the runtime owns identity, lifecycle state, and
+ * the output ring.
*/
-interface PlainJobStart extends JobStartBase {
- record?: undefined
+interface JobSpec {
+ /** Producer kind — also the id prefix (`bash`, `subagent`, …). */
+ kind: JobKind
+ /** One-line model-facing label (the command; the delegation description). */
+ label: string
+ /**
+ * Owning session. Access is fenced by it, and the owner's live Agent must be
+ * the one currently registered under that id: its disposal cancels and
+ * awaits the job. Omitting the owner creates an unowned job, open to any
+ * caller until service disposal.
+ */
+ owner?: SessionId
+ /**
+ * Optional UTF-8 byte cap for each complete model-facing completion notice or
+ * output read, including controller status metadata. Independent of ring
+ * retention: it bounds the consuming model surface, never observers.
+ */
+ outputLimitBytes?: number
+ /**
+ * Pull sources the registry pumps into the ring at its own cadence.
+ * Producers that narrate their own progress use {@link JobHandle.append}
+ * instead; a job may use both.
+ */
+ output?: readonly JobOutputSource[]
/**
* Start the work after preflight and synchronously return its hooks. Called
* once with the job's producer face; a throw leaves nothing registered (the
* spent ordinal is skipped), and the producer must clean up any partially
* started resources.
- * @param job - the issued id plus the live-detail writer.
+ * @param job - the issued id plus the ring append and progress writers.
*/
- run(job: RunningJob): JobHooks
+ run(job: JobHandle): JobHooks
}
```
```ts type-equiv
/**
- * A start that declares an observable output record beside the model-facing
- * surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that
- * any number of observers read at absolute byte offsets through
- * {@link JobRegistry.readRecord}, and snapshots carry
- * {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}.
+ * Producer face of one registered job, handed to {@link JobSpec.run} and
+ * valid for the job's whole life. All methods are synchronous. Writes staged
+ * inside the starter call are retained and become visible with the
+ * registration commit; after settlement — the producer's own outcome, a kill,
+ * or a registry-forced teardown end — writes log and drop instead of
+ * throwing, so a producer's trailing flush cannot break its own teardown path.
*/
-interface RecordingJobStart extends JobStartBase {
- record: true
+interface JobHandle {
+ /** The registry-issued id (`-N`). */
+ readonly id: JobId
/**
- * Start the work after preflight and synchronously return its hooks. Called
- * once with the job's producer face; a throw leaves nothing registered (the
- * spent ordinal is skipped), and the producer must clean up any partially
- * started resources.
- * @param job - the issued id plus the record append and live-detail writers.
+ * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8
+ * byte length; an empty chunk is dropped without waking observers.
+ * @param text - the chunk text, exactly as produced.
+ * @param options - stream label and gap marker.
*/
- run(job: RecordingJob): JobHooks
+ append(text: string, options?: JobAppendOptions): void
+ /**
+ * Replace the live progress line (`3/10`, the current phase). Settlement
+ * clears it; the terminal reason travels in {@link JobOutcome.detail}.
+ * @param line - the new progress line.
+ */
+ updateProgress(line: string): void
}
```
-```ts type-equiv
-/**
- * Producer declaration passed to {@link JobRegistry.start}, discriminated by
- * `record`. The runtime preflights access and cleanup before invoking `run`;
- * the producer owns execution resources while the runtime owns identity and
- * lifecycle state.
- */
-type JobStart = PlainJobStart | RecordingJobStart
-```
-
-`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。
+`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。结果是一个值而非流的 job——subagent 的报告、workflow 渲染出的结果——把它作为 `JobOutcome.result` 返回;模型在结算后的第一次读取携带它一次。
```ts type-equiv
/** Hooks through which the runtime controls and observes producer work. */
@@ -89,18 +109,9 @@ interface JobHooks {
* Resolves after the producer releases its resources, not merely when work
* finishes. Must not reject; the runtime converts a rejection to `failed`.
* If teardown cancellation throws, the runtime may force-fail only the
- * registry record without claiming that the work stopped. Settlement also
- * ends the record: fold any record-filling pump into this promise so the
- * final drain lands before the registry trims and closes the stream.
+ * registry record without claiming that the work stopped.
*/
done: Promise
- /**
- * Consume output produced since the previous call. The producer formats
- * truncation and spill notices. Absence marks a final-output-only job; each
- * job has one consuming cursor. Independent of the record: this is the
- * model-facing projection, {@link JobRegistry.readRecord} the observer one.
- */
- readOutput?(): string
}
```
@@ -109,74 +120,82 @@ interface JobHooks {
interface JobOutcome {
/** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
status: 'completed' | 'killed' | 'failed'
- /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
+ /**
+ * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`).
+ * When the job settles `killed` after a {@link VisibleJobs.kill} with a
+ * reason, the registry appends that reason.
+ */
detail?: string
- /** Final output for jobs without `readOutput`; stream jobs leave it unset. */
- output?: string
+ /**
+ * Return value for jobs whose result is a value rather than a stream (a
+ * workflow's rendered result, a subagent's report). The output ring carries
+ * the stream; this is handed out once by the model's next {@link VisibleJobs.read}.
+ */
+ result?: string
}
```
-## 观测 record
-
-声明 `record: true` 的生产方通过 starter 收到的 `RecordingJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。
+拉取源把一个非消耗的偏移读取器——子进程的 `readFrom` 家族——交给注册表。注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送每个源,并在结算封环之前再排干一次,因此生产方不需要把任何东西折进 `done`。
```ts type-equiv
/**
- * Producer face of one registered job, handed to `run` and valid for the
- * job's whole life. All methods are synchronous. Writes staged inside the
- * starter call are retained and become visible with the registration commit;
- * after settlement — the producer's own outcome, a kill, or a registry-forced
- * teardown end — writes log and drop instead of throwing, so a producer's
- * trailing flush cannot break its own teardown path.
+ * A pull source the registry pumps into the job's output ring — the subprocess
+ * `readFrom` family. The registry owns the cadence and drains every source one
+ * last time before settlement closes the ring, so a producer folds nothing
+ * into its `done`.
*/
-interface RunningJob {
- /** The registry-issued id (`-N`). */
- readonly id: JobId
- /**
- * Replace the snapshot's status detail with a live progress line (`3/10`).
- * @param detail - the new detail line.
- */
- updateDetail(detail: string): void
-}
-```
-
-```ts type-equiv
-/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */
-interface RecordingJob extends RunningJob {
- /**
- * Append one record chunk. Offsets advance by the chunk's UTF-8 byte
- * length; an empty chunk is dropped without waking observers.
- * @param text - the chunk text, exactly as produced.
- * @param options - stream label and gap marker.
- */
- append(text: string, options?: JobAppendOptions): void
-}
-```
-
-```ts type-equiv
-/** One retained record chunk returned by {@link JobRegistry.readRecord}. */
-interface JobRecordChunk {
- /** Absolute offset of the chunk's first byte. */
- at: number
- /** Chunk text exactly as appended (possibly tail-trimmed by retention). */
- text: string
- /** Stream label, when the producer supplied one. */
+interface JobOutputSource {
+ /** Stream label attached to every chunk this source yields. */
channel?: JobChannel
- /** Producer-reported loss immediately before this chunk. */
- gapBefore?: true
+ /**
+ * Read everything captured since `fromByte` without consuming it.
+ * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first).
+ * @returns the delta text, the next offset, and the lossy flag.
+ */
+ read(fromByte: number): JobSourceRead
}
```
```ts type-equiv
-/** Result of one non-consuming {@link JobRegistry.readRecord}. */
-interface JobRecordRead {
+/** One incremental read from a {@link JobOutputSource}. */
+interface JobSourceRead {
+ /** Text captured since the requested offset (the whole retained tail when lossy). */
+ text: string
+ /** Whole-stream offset to resume from on the next read. */
+ nextOffset: number
+ /** True when the requested offset slid out of the source's retained window. */
+ lossy: boolean
+}
+```
+
+## 输出环
+
+每个 job 拥有一个有界的环。拉取源被泵入其中,`JobHandle.append` 的推送整块落地;模型通过注册表保管的游标(`VisibleJobs.read`)消耗该环,任意数量的观察者按绝对字节偏移读取它(`VisibleJobs.readAt`),二者互不干扰。`JobChannel` 标记 `stdout`、`stderr` 与 `log`;`log` 是只到达观察者、从不进入模型消耗式读取的生产方叙述。结算即封流并把保留量裁剪到结算上限——环没有独立的生命周期。浏览器通过 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.rows` 与 `job.observe` 触达名册与环,其帧列于下文的 Cordis API 一节。
+
+```ts type-equiv
+/** One chunk of a job's output ring: absolute offset, text, and its provenance. */
+interface JobChunk {
+ /** Absolute offset of the chunk's first byte; offsets never move once assigned. */
+ readonly at: number
+ /** Chunk text exactly as appended (possibly tail-trimmed by retention). */
+ readonly text: string
+ /** Stream label, when the producer supplied one. */
+ readonly channel?: JobChannel
+ /** Bytes immediately before this chunk were lost, at the producer or to retention. */
+ readonly gapBefore?: true
+}
+```
+
+```ts type-equiv
+/** Result of one non-consuming {@link VisibleJobs.readAt}. */
+interface JobOutputRead {
/** Retained chunks overlapping `[from, total)`, in offset order. */
- chunks: readonly JobRecordChunk[]
+ chunks: readonly JobChunk[]
/**
- * Offset to resume from — the record's current `outputTotal`. Always a
- * chunk boundary: appends land whole and trimming only advances chunk
- * starts, and consumers concatenate `chunks` under that assumption, so a
- * provider serving partial chunks would silently duplicate text.
+ * Offset to resume from — the ring's current `total`. Always a chunk
+ * boundary: appends land whole and trimming only advances chunk starts, and
+ * consumers concatenate `chunks` under that assumption, so a provider
+ * serving partial chunks would silently duplicate text.
*/
next: number
/** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */
@@ -186,78 +205,167 @@ interface JobRecordRead {
## 消费方视图
-快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入。
+`JobView` 是每个读者都消费的唯一投影:模型工具、浏览器名册与观测流。`owner` 携带划定访问边界的会话 id;注册表为生命周期清理解析其背后的活体 `Agent`,从不把该对象交出去。`progress` 是生产方的实时行,结算时清除;`detail` 是终态原因,合并了记录下来的 kill 原因。
```ts type-equiv
/**
- * A read-only projection of one job, safe to hand to listeners and tools —
- * a fresh object per call, never live registry state.
+ * Read-only projection of one job — a fresh object per call, never live
+ * registry state. The model tools, the browser roster, and the observation
+ * stream all consume this one shape.
*/
-interface JobSnapshot {
+interface JobView {
/** The registry-issued id (`-N`). */
- id: JobId
+ readonly id: JobId
/** The producer kind the job was registered with. */
- kind: JobKind
+ readonly kind: JobKind
/** The producer-supplied one-line label. */
- label: string
- /** Producer-owned cap for complete model-facing notices and output reads. */
- outputLimitBytes?: number
- /**
- * Owner session id used for authorization and correlation; absent for
- * unowned jobs. Completion listeners receive the exact {@link Agent}
- * separately through {@link JobDoneListener}.
- */
- ownerSession?: SessionId
+ readonly label: string
+ /** Owning session; absent for an unowned job, which every caller can see. */
+ readonly owner?: SessionId
+ /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */
+ readonly outputLimitBytes?: number
/** Current lifecycle state. */
- status: JobStatus
- /**
- * Kind-specific status detail: live progress while the producer updates it
- * through {@link RunningJob.updateDetail}, the terminal detail once settled.
- */
- detail?: string
+ readonly status: JobStatus
+ /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */
+ readonly progress?: string
+ /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */
+ readonly detail?: string
/** Epoch ms when the job was registered. */
- startedAt: number
- /** Epoch ms when the job settled; absent while `running`/`stopping`. */
- finishedAt?: number
+ readonly startedAt: number
+ /** Epoch ms when the job settled; absent while live. */
+ readonly finishedAt?: number
/**
- * True when a kill, read, wait, or teardown cancel has reported or committed
- * to report the terminal state. Completion reporters suppress redundant
- * notices when set. Teardown claims it because the owner or service being
- * destroyed leaves no reader: a reporter that opens a turn on notice would
- * otherwise spend a model request per teardown layer.
+ * The output ring's absolute coordinates. `total` is the offset the next
+ * chunk starts at (0 while nothing was written); `earliest` is the oldest
+ * retained byte, greater than zero exactly when retention dropped the head.
*/
- reported: boolean
- /**
- * Total UTF-8 bytes ever appended to the record — the offset the next chunk
- * starts at. Present exactly when the job declared {@link JobStart.record}.
- */
- outputTotal?: number
- /**
- * Offset of the oldest retained record byte. Greater than zero exactly when
- * retention dropped the head; a reader starting below it gets a lossy read.
- * Present exactly when the job declared {@link JobStart.record}.
- */
- outputEarliest?: number
+ readonly output: { readonly total: number; readonly earliest: number }
+}
+```
+
+`JobRegistry.visibleTo(caller)` 绑定一个调用方的身份并返回其操作;调用方看得到自己的 job 与所有无主 job,每个操作对该集合之外的 id 抛出。
+
+```ts type-equiv
+/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */
+interface JobRead {
+ /** Ring chunks appended since the model cursor, in offset order; every channel included. */
+ chunks: readonly JobChunk[]
+ /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */
+ lossy: boolean
+ /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */
+ result?: string
+ /** The job's state at read time. */
+ job: JobView
}
```
```ts type-equiv
-/** Output and post-read state returned by {@link JobRegistry.read}. */
-interface JobRead {
+/**
+ * The operations one caller may perform, bound by {@link JobRegistry.visibleTo}.
+ * A caller sees its own jobs and every unowned job; every method throws for
+ * an id outside that set, without distinguishing unknown from foreign.
+ */
+interface VisibleJobs {
/**
- * Stream kinds: the consuming delta since the previous read. Final-output
- * kinds: empty while live, the terminal {@link JobOutcome.output} (or
- * empty) once settled — idempotent, never consumed.
+ * List the visible jobs in registration order.
+ * @returns fresh projections.
*/
- text: string
- /** The job's state at read time. */
- snapshot: JobSnapshot
+ list(): JobView[]
+ /**
+ * Project one job without touching its cursor.
+ * @param id - job to look up.
+ * @returns a fresh projection.
+ */
+ get(id: JobId): JobView
+ /**
+ * Consume the ring from the model cursor and advance it to the current
+ * total. After settlement the first read also carries the producer's
+ * result; later reads return only what was appended since.
+ * @param id - job to read.
+ * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection.
+ */
+ read(id: JobId): JobRead
+ /**
+ * Read retained ring output from an absolute byte offset without consuming
+ * it. Resume by passing a previous read's `next`; an offset inside a
+ * retained chunk returns that whole chunk (its `at` may precede `from`).
+ * Never moves the model cursor. Throws for a negative or non-integer offset.
+ * @param id - job to read.
+ * @param from - absolute byte offset to read from (0 for the retained head).
+ * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
+ */
+ readAt(id: JobId, from: number): JobOutputRead
+ /**
+ * Request cancellation, then mark the job stopping. A recorded
+ * {@link JobKillOptions.reason} merges into the terminal `detail` when the
+ * job settles `killed`. A producer throw propagates without changing job
+ * state.
+ * @param id - job to cancel.
+ * @param options - cancellation reason.
+ * @returns `requested` for live work, otherwise `already-finished`.
+ */
+ kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished'
+ /**
+ * Wait for settlement or timeout without cancelling the job. Caller abort
+ * rejects only while the job is live; after settlement the terminal
+ * projection wins. Throws for an invalid timeout.
+ * @param id - job to wait for.
+ * @param timeoutMs - positive finite wait bound in milliseconds.
+ * @param signal - optional cancellation of the wait itself.
+ * @returns projection at settlement or timeout.
+ */
+ wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise
}
```
+## 事件
+
+注册表通过一条带过滤的流宣布每次提交。生命周期事件携带其所宣布的提交之后的投影;`settled` 标出原因,完成播报方据此跳过 teardown;`output` 只携带 id 与新的 total,观察者从自己的游标读取,注册表从不推送负载。
+
+```ts type-equiv
+/**
+ * One lifecycle or output event. Lifecycle events carry the job's projection
+ * after the commit they announce; `output` carries only the id and the new
+ * total, so an observer schedules a {@link VisibleJobs.readAt} from its own
+ * cursor and the registry never pushes payloads.
+ */
+type JobEvent =
+ | {
+ /** Registration commit, progress line change, stopping transition, or removal from the visible set. */
+ readonly type: 'registered' | 'progress' | 'stopping' | 'removed'
+ readonly job: JobView
+ }
+ | {
+ readonly type: 'settled'
+ readonly job: JobView
+ readonly cause: JobSettleCause
+ }
+ | {
+ readonly type: 'output'
+ readonly id: JobId
+ /** Owning session, absent for an unowned job. */
+ readonly owner?: SessionId
+ /** The ring's total after the append (or at settlement, which ends the stream). */
+ readonly total: number
+ }
+```
+
+```ts type-equiv
+/**
+ * Who a subscription hears about. `{ owner }` delivers that session's jobs
+ * plus every unowned job (the set that session can see). `{ owners: 'scope' }`
+ * delivers the owners composed under the subscribing context — one registry
+ * serves every composition in the process, and a mount under one preset must
+ * not hear another preset's agents. `{ owners: 'all' }` delivers everything.
+ */
+type JobEventFilter =
+ | { readonly owner: SessionId }
+ | { readonly owners: 'all' | 'scope' }
+```
+
## 服务行为
-抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、按调用方划定的 `get` 与 `list`、`read`、非消耗的 `readRecord`、`kill`、有界的 `wait`、故障隔离的 `onJobDone`、`onJobsChanged` 与 `onOutput` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较 owner 会话;owner 清理与准入使用注册在案的确切 `Agent` 实例。本地 provider 的正安全整数配置 `maxConcurrentJobsPerOwner` 默认 `10`,按确切 owner 统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方终态结算释放容量,`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个已声明 record 的运行期与结算后保留量。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md)(Service Definition 契约)、[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md)(注册表生命周期与准入策略)与 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)(模型侧 Consumer)。
+抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、绑定调用方的 `visibleTo`(`list`、`get`、消耗式 `read`、非消耗的 `readAt`、`kill` 与有界的 `wait`)、带过滤的 `events` 流,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较拥有者会话;拥有者清理与准入使用 job 启动时登记在该拥有者会话下的活体 `Agent`。本地提供方的正安全整数配置 `maxConcurrentJobsPerOwner` 默认为 `10`,按精确拥有者统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方的终态结算释放容量;`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个环的运行期与结算后保留量,`pumpPollMs`(默认 150)是拉取节奏。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md) 了解 Service Definition 约定,[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md) 了解注册表生命周期与准入策略,[`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md) 了解面向模型的 Consumer。
@@ -275,11 +383,21 @@ Host service backing the generated `ctx.remote.job` namespace.
```ts cordis-catalog
/**
- * Stream one job's retained record output from an absolute byte offset,
- * then its terminal status once settled and drained. Non-consuming: the
+ * Stream the jobs one session can see — its own plus every unowned job —
+ * as whole-set frames: one on open, then one after each coalesced burst of
+ * lifecycle commits. The stream has no natural end; the carrier closes it.
+ * @param request - the session whose visible set to mirror.
+ * @param signal - cancellation owned by the Remote stream carrier.
+ * @returns the roster frames.
+ */
+@Remote({ mode: 'stream' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable
+
+/**
+ * Stream one job's retained output from an absolute byte offset, then its
+ * terminal projection once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
- * request's session resolves the fenced-read caller; the registry rejects a
- * job the session does not own, a job without a record, or an unknown job.
+ * request's session is the fenced read's caller; the registry rejects a
+ * job the session cannot see and an unknown job.
* @param request - target job, owning session, and optional resume offset.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns anchor, coalesced output frames, and the terminal status.
@@ -293,16 +411,16 @@ Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-cont
### `ctx.jobs` — `JobRegistry` (abstract seam)
-Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
+Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Implementations must honor these semantics:
-- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.
+- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left.
- Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
-- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.
-- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
-- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.
-- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.
+- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.
+- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
+- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other.
+- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.
```ts cordis-catalog
/**
@@ -310,120 +428,20 @@ Implementations must honor these semantics:
* admission before starting and atomically registering work. Any preflight
* rejection leaves no job id or execution resource. A throwing starter
* leaves nothing registered; after it returns, registration cannot fail.
- * Settlement records the outcome, notifies listeners, and releases waiters.
- * @param spec - job identity, owner, and synchronous starter.
+ * @param spec - job identity, owner, output sources, and synchronous starter.
* @returns the registry-issued `-N` id.
*/
-abstract start(spec: JobStart): JobId
+abstract start(spec: JobSpec): JobId
/**
- * List caller-owned and unowned jobs in registration order without exposing
- * another session's labels.
- * @param caller - reading agent; a non-agent caller sees only unowned jobs.
- * @returns fresh snapshots.
+ * Bind the caller's identity once and return its operations. A caller sees
+ * its own jobs and every unowned job; a caller-less view sees unowned jobs
+ * only. The view resolves visibility on every call, so it stays valid as
+ * jobs come and go.
+ * @param caller - the reading session, or undefined for an anonymous caller.
+ * @returns the operations available to that caller.
*/
-abstract list(caller?: Agent): JobSnapshot[]
-
-/**
- * Return a non-consuming snapshot without changing its read cursor or notice
- * state. Throws for an unknown or foreign job.
- * @param id - job to look up.
- * @param caller - reading agent checked against the owner.
- * @returns a fresh snapshot.
- */
-abstract get(id: JobId, caller?: Agent): JobSnapshot
-
-/**
- * Read the next stream delta, or the idempotent final output after settlement.
- * A terminal read marks the job reported. Throws for an unknown or foreign
- * job.
- * @param id - job to read.
- * @param caller - reading agent checked against the owner.
- * @returns output text and the post-read snapshot.
- */
-abstract read(id: JobId, caller?: Agent): JobRead
-
-/**
- * Request cancellation, then mark the job stopping and reported. A producer
- * throw propagates without changing job state. Throws for an unknown or
- * foreign job.
- * @param id - job to cancel.
- * @param caller - killing agent checked against the owner.
- * @param reason - logged reason forwarded to the producer.
- * @returns `requested` for live work, otherwise `already-finished`.
- */
-abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
-
-/**
- * Wait for settlement or timeout without cancelling the job. Caller abort
- * rejects only while the job is live; after settlement the terminal
- * snapshot wins so a notice suppressed for this waiter is still delivered.
- * Throws for invalid, unknown, or foreign input.
- * @param id - job to wait for.
- * @param timeoutMs - positive finite wait bound in milliseconds.
- * @param caller - waiting agent checked against the owner.
- * @param signal - optional cancellation of the wait itself.
- * @returns snapshot at settlement or timeout.
- */
-abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise
-
-/**
- * Register an effect-scoped completion listener. It receives the settlements
- * of the owners its registering context's scope covers; each listener is
- * contained; returned promises are observed but not awaited. No listener runs
- * after service disposal.
- * @param listener - receives each terminal snapshot and its exact owner.
- * @returns disposer that unregisters the listener.
- */
-abstract onJobDone(listener: JobDoneListener): () => void
-
-/**
- * Register an effect-scoped observer of visible-set changes. It fires after
- * every commit that changes what {@link list} returns for that owner —
- * registration, every stopping transition (including the one teardown
- * performs before it awaits a slow producer), settlement, owner-disposal
- * removal, and the emptying that service disposal commits — so an observer
- * re-reads rather than accumulating deltas.
- *
- * Delivery is owner-relative on the same terms as {@link onJobDone}: an
- * observer registered from an unscoped context — a host composition's own
- * carrier — sees every owner, while one registered under an agent
- * composition's scope sees exactly the agents composed under it.
- *
- * This is not a superset of {@link onJobDone}: that one delivers the terminal
- * record under first-wins semantics a job controller couples to notice
- * delivery, while this one carries no delivery meaning and marks nothing
- * reported. Listeners are contained and never awaited.
- * @param listener - receives the owner whose visible set changed, or
- * `undefined` when an unowned job changed and every caller's set did.
- * @returns disposer that unregisters the listener.
- */
-abstract onJobsChanged(listener: JobsChangedListener): () => void
-
-/**
- * Read retained record output from an absolute byte offset without consuming
- * it. Resume by passing a previous read's `next`; a foreign offset inside a
- * retained chunk returns that whole chunk (its `at` may precede `from`).
- * Never marks the job reported. Throws for an unknown or foreign job, a job
- * without a {@link JobStart.record} declaration, or a negative or
- * non-integer offset.
- * @param id - job to read.
- * @param from - absolute byte offset to read from (0 for the retained head).
- * @param caller - reading agent checked against the owner.
- * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
- */
-abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead
-
-/**
- * Register an effect-scoped observer of record advancement — one signal per
- * committed append and one at settlement, carrying only the job id. A
- * consumer schedules a {@link readRecord} from its own cursor; the registry
- * never pushes payloads. Delivery is owner-relative on the same terms as
- * {@link onJobsChanged}. Listeners are contained and never awaited.
- * @param listener - receives the id whose record advanced.
- * @returns disposer that unregisters the listener.
- */
-abstract onOutput(listener: JobOutputListener): () => void
+abstract visibleTo(caller?: SessionId): VisibleJobs
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
@@ -435,7 +453,7 @@ abstract onOutput(listener: JobOutputListener): () => void
abstract attachController(name: string): () => void
```
-Types: [Agent](core.zh.md)
+Types: [SessionId](core.zh.md)
Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts)
diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml
index a70f3d6e35..d862a8995b 100644
--- a/docs/tool-catalog.i18n.yaml
+++ b/docs/tool-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/tool-catalog.md
-tool-catalog.md: 416585106de28296a3105fafbbe6e4edc0d0f16b
-tool-catalog.zh.md: 89d2e467863c09b78805fd9db8ec785ad7936a9d
+tool-catalog.md: 5bc212bd17256443ba98141ba5952e17abcfd2fe
+tool-catalog.zh.md: fbbc265ac30e461a93fcf2a948797dc0c8ca67ad
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 416585106d..5bc212bd17 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -35,7 +35,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. |
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. |
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). |
-| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. |
+| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.agents`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. |
| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. |
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. |
| `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - |
diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md
index 89d2e46786..fbbc265ac3 100644
--- a/docs/tool-catalog.zh.md
+++ b/docs/tool-catalog.zh.md
@@ -39,7 +39,7 @@
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 |
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 |
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 |
-| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 |
+| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.agents`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 |
| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 |
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 |
| `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - |
diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts
index 2b58208bb0..cdcc884c9d 100644
--- a/packages/api/job-controller/src/client/model.ts
+++ b/packages/api/job-controller/src/client/model.ts
@@ -8,8 +8,9 @@
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
+import type { JobView } from '@deepseek-ai/dsh-jobs/view'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { JobObserveFrame, JobView } from '../types.ts'
+import type { JobObserveFrame } from '../types.ts'
/** Bounded per-job render tail, in UTF-16 code units. */
const RENDER_TAIL_LIMIT = 128 * 1024
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index ff56058008..3537921fee 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1038,9 +1038,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'Host service backing the generated `ctx.remote.job` namespace.',
description: 'Host service backing the generated `ctx.remote.job` namespace.',
methods: [
+ {
+ signature: '@Remote({ mode: \'stream\' }) rows(request: JobRowsRequest, signal: AbortSignal): AsyncIterable',
+ description: 'Stream the jobs one session can see — its own plus every unowned job — as whole-set frames: one on open, then one after each coalesced burst of lifecycle commits. The stream has no natural end; the carrier closes it.',
+ parameters: [{ name: 'request', description: 'the session whose visible set to mirror.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }],
+ returns: 'the roster frames.',
+ },
{
signature: '@Remote({ mode: \'stream\' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable',
- description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.',
+ description: 'Stream one job\'s retained output from an absolute byte offset, then its terminal projection once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session is the fenced read\'s caller; the registry rejects a job the session cannot see and an unknown job.',
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.',
},
@@ -1049,67 +1055,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'jobs',
summary: 'Abstract background job registry.',
- description: 'Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.\n- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.',
+ description: 'Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: \'teardown\'`, because a job whose owner is being destroyed has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: \'scope\' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model\'s consuming cursor and observers\' absolute offsets read the same bytes and never disturb each other.\n- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.',
methods: [
{
- signature: 'abstract start(spec: JobStart): JobId',
- description: 'Preflight access, validation, owner cleanup, and implementation-owned admission before starting and atomically registering work. Any preflight rejection leaves no job id or execution resource. A throwing starter leaves nothing registered; after it returns, registration cannot fail. Settlement records the outcome, notifies listeners, and releases waiters.',
- parameters: [{ name: 'spec', description: 'job identity, owner, and synchronous starter.' }],
+ signature: 'abstract readonly events: JobEvents',
+ description: 'Lifecycle and output events, filtered per subscription.',
+ parameters: [],
+ },
+ {
+ signature: 'abstract start(spec: JobSpec): JobId',
+ description: 'Preflight access, validation, owner cleanup, and implementation-owned admission before starting and atomically registering work. Any preflight rejection leaves no job id or execution resource. A throwing starter leaves nothing registered; after it returns, registration cannot fail.',
+ parameters: [{ name: 'spec', description: 'job identity, owner, output sources, and synchronous starter.' }],
returns: 'the registry-issued `-N` id.',
},
{
- signature: 'abstract list(caller?: Agent): JobSnapshot[]',
- description: 'List caller-owned and unowned jobs in registration order without exposing another session\'s labels.',
- parameters: [{ name: 'caller', description: 'reading agent; a non-agent caller sees only unowned jobs.' }],
- returns: 'fresh snapshots.',
- },
- {
- signature: 'abstract get(id: JobId, caller?: Agent): JobSnapshot',
- description: 'Return a non-consuming snapshot without changing its read cursor or notice state. Throws for an unknown or foreign job.',
- parameters: [{ name: 'id', description: 'job to look up.' }, { name: 'caller', description: 'reading agent checked against the owner.' }],
- returns: 'a fresh snapshot.',
- },
- {
- signature: 'abstract read(id: JobId, caller?: Agent): JobRead',
- description: 'Read the next stream delta, or the idempotent final output after settlement. A terminal read marks the job reported. Throws for an unknown or foreign job.',
- parameters: [{ name: 'id', description: 'job to read.' }, { name: 'caller', description: 'reading agent checked against the owner.' }],
- returns: 'output text and the post-read snapshot.',
- },
- {
- signature: 'abstract kill(id: JobId, caller?: Agent, reason?: string): \'requested\' | \'already-finished\'',
- description: 'Request cancellation, then mark the job stopping and reported. A producer throw propagates without changing job state. Throws for an unknown or foreign job.',
- parameters: [{ name: 'id', description: 'job to cancel.' }, { name: 'caller', description: 'killing agent checked against the owner.' }, { name: 'reason', description: 'logged reason forwarded to the producer.' }],
- returns: '`requested` for live work, otherwise `already-finished`.',
- },
- {
- signature: 'abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise',
- description: 'Wait for settlement or timeout without cancelling the job. Caller abort rejects only while the job is live; after settlement the terminal snapshot wins so a notice suppressed for this waiter is still delivered. Throws for invalid, unknown, or foreign input.',
- parameters: [{ name: 'id', description: 'job to wait for.' }, { name: 'timeoutMs', description: 'positive finite wait bound in milliseconds.' }, { name: 'caller', description: 'waiting agent checked against the owner.' }, { name: 'signal', description: 'optional cancellation of the wait itself.' }],
- returns: 'snapshot at settlement or timeout.',
- },
- {
- signature: 'abstract onJobDone(listener: JobDoneListener): () => void',
- description: 'Register an effect-scoped completion listener. It receives the settlements of the owners its registering context\'s scope covers; each listener is contained; returned promises are observed but not awaited. No listener runs after service disposal.',
- parameters: [{ name: 'listener', description: 'receives each terminal snapshot and its exact owner.' }],
- returns: 'disposer that unregisters the listener.',
- },
- {
- signature: 'abstract onJobsChanged(listener: JobsChangedListener): () => void',
- description: 'Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what list returns for that owner — registration, every stopping transition (including the one teardown performs before it awaits a slow producer), settlement, owner-disposal removal, and the emptying that service disposal commits — so an observer re-reads rather than accumulating deltas.\n\nDelivery is owner-relative on the same terms as onJobDone: an observer registered from an unscoped context — a host composition\'s own carrier — sees every owner, while one registered under an agent composition\'s scope sees exactly the agents composed under it.\n\nThis is not a superset of onJobDone: that one delivers the terminal record under first-wins semantics a job controller couples to notice delivery, while this one carries no delivery meaning and marks nothing reported. Listeners are contained and never awaited.',
- parameters: [{ name: 'listener', description: 'receives the owner whose visible set changed, or `undefined` when an unowned job changed and every caller\'s set did.' }],
- returns: 'disposer that unregisters the listener.',
- },
- {
- signature: 'abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead',
- description: 'Read retained record output from an absolute byte offset without consuming it. Resume by passing a previous read\'s `next`; a foreign offset inside a retained chunk returns that whole chunk (its `at` may precede `from`). Never marks the job reported. Throws for an unknown or foreign job, a job without a JobStart.record declaration, or a negative or non-integer offset.',
- parameters: [{ name: 'id', description: 'job to read.' }, { name: 'from', description: 'absolute byte offset to read from (0 for the retained head).' }, { name: 'caller', description: 'reading agent checked against the owner.' }],
- returns: 'retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.',
- },
- {
- signature: 'abstract onOutput(listener: JobOutputListener): () => void',
- description: 'Register an effect-scoped observer of record advancement — one signal per committed append and one at settlement, carrying only the job id. A consumer schedules a readRecord from its own cursor; the registry never pushes payloads. Delivery is owner-relative on the same terms as onJobsChanged. Listeners are contained and never awaited.',
- parameters: [{ name: 'listener', description: 'receives the id whose record advanced.' }],
- returns: 'disposer that unregisters the listener.',
+ signature: 'abstract visibleTo(caller?: SessionId): VisibleJobs',
+ description: 'Bind the caller\'s identity once and return its operations. A caller sees its own jobs and every unowned job; a caller-less view sees unowned jobs only. The view resolves visibility on every call, so it stays valid as jobs come and go.',
+ parameters: [{ name: 'caller', description: 'the reading session, or undefined for an anonymous caller.' }],
+ returns: 'the operations available to that caller.',
},
{
signature: 'abstract attachController(name: string): () => void',
@@ -4175,20 +4138,44 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'JobChannel',
- declaration: 'export type JobChannel = \'stdout\' | \'stderr\';',
+ declaration: 'export type JobChannel = \'stdout\' | \'stderr\' | \'log\';',
},
{
- name: 'JobDoneListener',
- declaration: 'export type JobDoneListener = (snapshot: JobSnapshot, owner: Agent | undefined) => void | PromiseLike;',
+ name: 'JobChunk',
+ declaration: 'export interface JobChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: JobChannel;\n readonly gapBefore?: true;\n}',
+ },
+ {
+ name: 'JobEvent',
+ declaration: 'export type JobEvent = {\n readonly type: \'registered\' | \'progress\' | \'stopping\' | \'removed\';\n readonly job: JobView;\n} | {\n readonly type: \'settled\';\n readonly job: JobView;\n readonly cause: JobSettleCause;\n} | {\n readonly type: \'output\';\n readonly id: JobId;\n readonly owner?: SessionId;\n readonly total: number;\n};',
+ },
+ {
+ name: 'JobEventFilter',
+ declaration: 'export type JobEventFilter = {\n readonly owner: SessionId;\n} | {\n readonly owners: \'all\' | \'scope\';\n};',
+ },
+ {
+ name: 'JobEventListener',
+ declaration: 'export type JobEventListener = (event: JobEvent) => void;',
+ },
+ {
+ name: 'JobEvents',
+ declaration: 'export interface JobEvents {\n subscribe(filter: JobEventFilter, listener: JobEventListener): () => void;\n}',
+ },
+ {
+ name: 'JobHandle',
+ declaration: 'export interface JobHandle {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateProgress(line: string): void;\n}',
},
{
name: 'JobHooks',
- declaration: 'export interface JobHooks {\n cancel(reason?: string): void;\n done: Promise;\n readOutput?(): string;\n}',
+ declaration: 'export interface JobHooks {\n cancel(reason?: string): void;\n done: Promise;\n}',
},
{
name: 'JobId',
declaration: 'export type JobId = Branded<\'JobId\'>;',
},
+ {
+ name: 'JobKillOptions',
+ declaration: 'export interface JobKillOptions {\n reason?: string;\n}',
+ },
{
name: 'JobKind',
declaration: 'export type JobKind = JobKindMap[keyof JobKindMap];',
@@ -4199,55 +4186,55 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'JobObserveFrame',
- declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: JobObserveStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: JobObserveStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};',
+ declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly job: JobView;\n readonly from: number;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly job: JobView;\n};',
},
{
name: 'JobObserveRequest',
declaration: 'export interface JobObserveRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}',
},
- {
- name: 'JobObserveStatus',
- declaration: 'export type JobObserveStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
- },
{
name: 'JobOutcome',
- declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}',
+ declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n result?: string;\n}',
},
{
- name: 'JobOutputListener',
- declaration: 'export type JobOutputListener = (id: JobId) => void;',
+ name: 'JobOutputRead',
+ declaration: 'export interface JobOutputRead {\n chunks: readonly JobChunk[];\n next: number;\n lossy: boolean;\n}',
+ },
+ {
+ name: 'JobOutputSource',
+ declaration: 'export interface JobOutputSource {\n channel?: JobChannel;\n read(fromByte: number): JobSourceRead;\n}',
},
{
name: 'JobRead',
- declaration: 'export interface JobRead {\n text: string;\n snapshot: JobSnapshot;\n}',
+ declaration: 'export interface JobRead {\n chunks: readonly JobChunk[];\n lossy: boolean;\n result?: string;\n job: JobView;\n}',
},
{
- name: 'JobRecordChunk',
- declaration: 'export interface JobRecordChunk {\n at: number;\n text: string;\n channel?: JobChannel;\n gapBefore?: true;\n}',
+ name: 'JobRowsFrame',
+ declaration: 'export interface JobRowsFrame {\n readonly type: \'rows\';\n readonly jobs: readonly JobView[];\n}',
},
{
- name: 'JobRecordRead',
- declaration: 'export interface JobRecordRead {\n chunks: readonly JobRecordChunk[];\n next: number;\n lossy: boolean;\n}',
+ name: 'JobRowsRequest',
+ declaration: 'export interface JobRowsRequest {\n readonly sessionId: SessionId;\n}',
},
{
- name: 'JobsChangedListener',
- declaration: 'export type JobsChangedListener = (owner: Agent | undefined) => void;',
+ name: 'JobSettleCause',
+ declaration: 'export type JobSettleCause = \'producer\' | \'kill\' | \'teardown\';',
},
{
- name: 'JobSnapshot',
- declaration: 'export interface JobSnapshot {\n id: JobId;\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: JobStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n outputTotal?: number;\n outputEarliest?: number;\n}',
+ name: 'JobSourceRead',
+ declaration: 'export interface JobSourceRead {\n text: string;\n nextOffset: number;\n lossy: boolean;\n}',
},
{
- name: 'JobStart',
- declaration: 'export type JobStart = PlainJobStart | RecordingJobStart;',
+ name: 'JobSpec',
+ declaration: 'export interface JobSpec {\n kind: JobKind;\n label: string;\n owner?: SessionId;\n outputLimitBytes?: number;\n output?: readonly JobOutputSource[];\n run(job: JobHandle): JobHooks;\n}',
},
{
name: 'JobStatus',
declaration: 'export type JobStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
},
{
- name: 'JobWireChunk',
- declaration: 'export interface JobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}',
+ name: 'JobView',
+ declaration: 'export interface JobView {\n readonly id: JobId;\n readonly kind: JobKind;\n readonly label: string;\n readonly owner?: SessionId;\n readonly outputLimitBytes?: number;\n readonly status: JobStatus;\n readonly progress?: string;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly output: {\n readonly total: number;\n readonly earliest: number;\n };\n}',
},
{
name: 'JsonSchemaNode',
@@ -4537,10 +4524,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PermissionSelect',
declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}',
},
- {
- name: 'PlainJobStart',
- declaration: 'export interface PlainJobStart extends JobStartBase {\n record?: undefined;\n run(job: RunningJob): JobHooks;\n}',
- },
{
name: 'PostToolDecision',
declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};',
@@ -4661,14 +4644,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ReasoningEffortId',
declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;',
},
- {
- name: 'RecordingJob',
- declaration: 'export interface RecordingJob extends RunningJob {\n append(text: string, options?: JobAppendOptions): void;\n}',
- },
- {
- name: 'RecordingJobStart',
- declaration: 'export interface RecordingJobStart extends JobStartBase {\n record: true;\n run(job: RecordingJob): JobHooks;\n}',
- },
{
name: 'RedactedSecret',
declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}',
@@ -4749,10 +4724,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'RunnerFailureRule',
declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}',
},
- {
- name: 'RunningJob',
- declaration: 'export interface RunningJob {\n readonly id: JobId;\n updateDetail(detail: string): void;\n}',
- },
{
name: 'SandboxEnforcement',
declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';',
@@ -4863,11 +4834,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionControlBaseline',
- declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly jobs: Readonly>;\n readonly projections: Readonly>;\n}',
+ declaration: 'export interface SessionControlBaseline {\n readonly queues: Readonly>;\n readonly projections: Readonly>;\n}',
},
{
name: 'SessionControlFrame',
- declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'queue\';\n readonly sessionId: SessionId;\n readonly items: readonly SessionQueuedItem[];\n} | {\n readonly type: \'jobs\';\n readonly sessionId: SessionId;\n readonly jobs: readonly SessionJob[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);',
+ declaration: 'export type SessionControlFrame = {\n readonly type: \'baseline\';\n readonly value: SessionControlBaseline;\n} | {\n readonly type: \'queue\';\n readonly sessionId: SessionId;\n readonly items: readonly SessionQueuedItem[];\n} | ({\n readonly type: \'projection\';\n} & SessionProjectionUpdate);',
},
{
name: 'SessionCreateRequest',
@@ -4997,10 +4968,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionInspection',
declaration: 'export interface SessionInspection extends SessionStorageMetadata {\n readonly events: readonly SessionEvent[];\n}',
},
- {
- 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 record?: true;\n}',
- },
{
name: 'SessionLineageNode',
declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}',
@@ -6009,6 +5976,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'VerifiedWebhookDelivery',
declaration: 'export interface VerifiedWebhookDelivery {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf;\n readonly receivedAt: number;\n}',
},
+ {
+ name: 'VisibleJobs',
+ declaration: 'export interface VisibleJobs {\n list(): JobView[];\n get(id: JobId): JobView;\n read(id: JobId): JobRead;\n readAt(id: JobId, from: number): JobOutputRead;\n kill(id: JobId, options?: JobKillOptions): \'requested\' | \'already-finished\';\n wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise;\n}',
+ },
{
name: 'WebBootBatch',
declaration: 'export interface WebBootBatch {\n phase: WebBootBatchPhase;\n url: string;\n rev: string;\n entries: string[];\n}',
diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml
index 9c5a47c63b..1405d2ee15 100644
--- a/packages/jobs/jobs-local/README.i18n.yaml
+++ b/packages/jobs/jobs-local/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/jobs-local/README.md
-README.md: 07772f3a8de79f4e812e5217843eb770925050fb
-README.zh.md: bf728c5b935941ee3bb783866a9dbf09f7865d76
+README.md: 58a35e813af03a00cf978d001a12f26bbb848ff4
+README.zh.md: 486a9cef3813d4d07c328339ee179e08e97c98b3
diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md
index 07772f3a8d..58a35e813a 100644
--- a/packages/jobs/jobs-local/README.md
+++ b/packages/jobs/jobs-local/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and a job that declares a record keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement by default) that observers read at absolute offsets; jobs die with the harness process and are not durable across restarts.
+`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh projections, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once, and every job keeps a bounded in-memory output ring (256 KiB live, trimmed to 16 KiB at settlement) that the registry fills from the producer's pull sources at `pumpPollMs` (default 150 ms) and from pushed appends.
## Table of Contents
@@ -33,7 +33,7 @@ Choose it when jobs should live in the harness process and die with it. Avoid it
### Minimal configuration
-Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional and defaults to `10`.
+Loading the plugin registers `ctx.jobs`; every field is optional.
```yaml
- name: '@deepseek-ai/dsh-jobs-local'
@@ -42,8 +42,9 @@ Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional
| Field | Default | Meaning |
|---|---|---|
| `maxConcurrentJobsPerOwner` | `10` | Maximum `running` plus `stopping` jobs per exact owner, or in the shared unowned bucket |
-| `retainBytes` | `262144` | Live record retention per job, in UTF-8 bytes |
-| `settledRetainBytes` | `16384` | Record retention kept after a job settles, in UTF-8 bytes |
+| `retainBytes` | `262144` | Live ring retention per job, in UTF-8 bytes |
+| `settledRetainBytes` | `16384` | Ring retention kept after a job settles, in UTF-8 bytes |
+| `pumpPollMs` | `150` | Poll interval for a job's pull sources, in milliseconds |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted fields.
@@ -71,11 +72,11 @@ This section explains the design decisions behind the registry and points at the
### Design philosophy
-- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job — lifecycle state plus the optional record ring — and projects a new read-only snapshot or chunk copy per call; callers never receive live state.
-- **Retention is bounded per record.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap.
-- **Owner-relative layers, one process-wide registry.** Controllers, completion listeners, and change observers are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a settlement reaches only the listeners its owner's composition registered.
+- **In-memory records, fresh projections.** `LocalJobRegistry` keeps one `TrackedJob` per job — lifecycle state, the output ring, and the model cursor — and projects a new read-only view or chunk copy per call; callers never receive live state.
+- **Retention is bounded per ring.** Appends past the live cap drop the oldest retained chunks (a single oversized chunk keeps its UTF-8-safe tail); a reader below the retained window gets a lossy read, never an error, and settlement trims to the settled cap.
+- **Owner-relative layers, one process-wide registry.** Controllers and `{ owners: 'scope' }` subscriptions are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a scoped settlement reaches only the listeners its owner's composition registered.
- **Preflight before start.** `start()` checks controller service, spec validity, live ownership, and capacity before invoking the producer, so a rejection leaves no job id or execution resource; registration commits without a later failable step.
-- **First-wins settlement, completion last.** The earliest terminal outcome records once, releases waiters, and notifies listeners once with per-listener containment; completion is announced after the record is committed and the visible-set change published, because a reporter may open a model turn synchronously.
+- **First-wins settlement, event last.** The earliest terminal outcome records once, waits for the pump's final drain, trims the ring, releases waiters, and then delivers one `settled` event with per-listener containment, followed by the ring's final `output` signal.
- **Teardown never deadlocks.** A throwing cancel force-fails the record and reports a possible orphan instead of stalling disposal.
### Source map
@@ -83,19 +84,22 @@ This section explains the design decisions behind the registry and points at the
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `LocalJobRegistry`, admission, lifecycle, teardown |
+| [`src/events.ts`](src/events.ts) | Scope-layered event routing: `{ owner }`, `{ owners: 'all' }`, and `{ owners: 'scope' }` subscriptions |
+| [`src/ring.ts`](src/ring.ts) | The bounded per-job output ring: append, retention trim, offset reads |
+| [`src/pump.ts`](src/pump.ts) | The registry-owned pull pump: one timer per job, final drain before settlement |
| — | No runtime invariant companion is published; `@deepseek-ai/dsh-jobs/invariant` owns per-snapshot identity, status, timestamp, and owner checks. This provider's admission decision uses private configuration and must fail before a backend starter runs; `LocalJobRegistry.start()` enforces it synchronously for current producers. Repeating an aggregate after publication would expose private configuration solely to this companion and would not verify the fail-closed pre-start guarantee. |
### Scope layers
-`attachController`, `onJobDone`, `onJobsChanged`, and `onOutput` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`, `outputFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable.
+`attachController` and `{ owners: 'scope' }` subscriptions register into the calling context's scope layer; `{ owner }` and `{ owners: 'all' }` subscriptions are unscoped. The controller question (`servesOwner`) and scoped delivery walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable.
### Admission and settlement
-`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, trims a declared record to the settled cap, resolves every waiter, records the terminal snapshot, announces the visible-set change and the record's final output signal, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed.
+`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` records the terminal outcome once (merging a recorded kill reason into a `killed` detail), clears the progress line, trims the ring to the settled cap, resolves every waiter, then emits `settled` with its cause and the ring's final `output` signal. The cause is `kill` after `VisibleJobs.kill`, `teardown` after an owner or service cancel, and `producer` otherwise; `dsh-tool-jobs` uses it to skip notices nobody can read.
### Teardown
-Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlement, removes their records, and announces the removal — the one visible-set change no per-job record carries. Service disposal (`disposeAll`) closes listeners, cancels all live jobs, awaits settlement, clears the store, announces the emptying to the distinct owners, then detaches the cross-fiber owner-cleanup effects.
+Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlement, removes their records, and announces each removal — the one visible-set change no per-job record carries. Service disposal (`disposeAll`) cancels all live jobs, awaits settlement, removes every record (announcing each removal, so a subscriber mounted outside the service drops its rows), then detaches the cross-fiber owner-cleanup effects.
@@ -106,7 +110,7 @@ Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlemen
Read these pages when the package-level contract is not enough. They move from the registry contract to the model-facing controls and the design records.
-- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, snapshot fields, and `ctx.jobs` cordis surface.
+- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, projection fields, and `ctx.jobs` cordis surface.
- [jobs group map](../README.md) — the sibling group page and its package table.
- [Registry contract](../jobs/README.md) — the abstract `ctx.jobs` service this package implements.
- [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices.
diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md
index bf728c5b93..486a9cef38 100644
--- a/packages/jobs/jobs-local/README.zh.md
+++ b/packages/jobs/jobs-local/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量,声明了 record 的任务另持有一个有界的内存输出环(默认运行期 256 KiB、结算时裁剪到 16 KiB),供观察者按绝对偏移读取;任务会随 harness 进程终止而消失,无法跨重启持久。
+`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新投影,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量;每个 job 保有一个有界的内存输出环(运行期 256 KiB,结算时裁剪到 16 KiB),注册表按 `pumpPollMs`(默认 150 毫秒)从生产方的拉取源以及推送的追加填充它。
## 目录
@@ -33,7 +33,7 @@ kind: "package-reference"
### 最小配置
-加载插件即注册 `ctx.jobs`;`maxConcurrentJobsPerOwner` 可选,默认为 `10`。
+加载插件即注册 `ctx.jobs`;每个字段都是可选的。
```yaml
- name: '@deepseek-ai/dsh-jobs-local'
@@ -42,8 +42,9 @@ kind: "package-reference"
| 字段 | 默认值 | 含义 |
|---|---|---|
| `maxConcurrentJobsPerOwner` | `10` | 每个精确所有者,或共享的无主桶中,`running` 加 `stopping` 任务的最大数量 |
-| `retainBytes` | `262144` | 每个任务 record 的运行期保留量,UTF-8 字节 |
-| `settledRetainBytes` | `16384` | 任务结算后保留的 record 量,UTF-8 字节 |
+| `retainBytes` | `262144` | 每个任务输出环的运行期保留量,UTF-8 字节 |
+| `settledRetainBytes` | `16384` | 任务结算后保留的环容量,UTF-8 字节 |
+| `pumpPollMs` | `150` | 任务拉取源的轮询间隔,毫秒 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-jobs-local)是每个受支持字段的穷尽式真源。
@@ -71,11 +72,11 @@ kind: "package-reference"
### 设计理念
-- **内存记录、全新快照。** `LocalJobRegistry` 为每个任务保存一份 `TrackedTask`——生命周期状态加可选的 record 环——每次调用都投影新的只读快照或块副本;调用方永远拿不到实时状态。
-- **record 保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。
-- **按所有者分层,一个进程级注册表。** 控制器、完成监听器与变更观察者归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次结算也只会抵达其所有者所属组合注册的监听器。
+- **内存记录、全新投影。** `LocalJobRegistry` 为每个任务保存一份 `TrackedJob`——生命周期状态、输出环与模型游标——每次调用都投影新的只读视图或块副本;调用方永远拿不到实时状态。
+- **环的保留量有界。** 追加超出运行期上限时丢弃最老的保留块(单个超限块保留其 UTF-8 安全尾部);低于保留窗口的读取得到 lossy 结果而非错误,结算时裁剪到结算上限。
+- **按所有者分层,一个进程级注册表。** 控制器与 `{ owners: 'scope' }` 订阅归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次带 scope 的结算也只会抵达其所有者所属组合注册的监听器。
- **启动前先预检。** `start()` 在调用生产方之前检查控制器服务、spec 有效性、仍存活的所有权与容量,因此拒绝不会留下 job id 或执行资源;注册一旦提交,后续不再有可失败步骤。
-- **结算首次优先,完成最后。** 最早的终止结果只记录一次,释放等待方,并只通知监听器一次,各监听器故障单独隔离;完成在记录提交且可见集变更发布之后才宣布,因为报告方可能同步开启一个模型轮次。
+- **结算首次优先,事件最后。** 最早的终止结果只记录一次,等待泵的最后一次排干,裁剪环,释放等待方,然后投递一次带逐监听器隔离的 `settled` 事件,再跟上环的最终 `output` 信号。
- **销毁永不死锁。** 抛出的取消会强制失败记录并报告可能的孤立工作,而不是让释放停滞。
### 源码地图
@@ -83,19 +84,22 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`LocalJobRegistry`、准入、生命周期、销毁 |
+| [`src/events.ts`](src/events.ts) | 按 scope 分层的事件路由:`{ owner }`、`{ owners: 'all' }` 与 `{ owners: 'scope' }` 订阅 |
+| [`src/ring.ts`](src/ring.ts) | 每个任务的有界输出环:追加、保留裁剪、按偏移读取 |
+| [`src/pump.ts`](src/pump.ts) | 注册表拥有的拉取泵:每个任务一个定时器,结算前最后一次排干 |
| — | 不发布运行时不变式伴生入口;快照检查位于 `dsh-jobs/invariant`。 |
### scope 分层
-`attachController`、`onJobDone`、`onJobsChanged` 与 `onOutput` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`、`outputFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。
+`attachController` 与 `{ owners: 'scope' }` 订阅注册到调用上下文所在的 scope 层;`{ owner }` 与 `{ owners: 'all' }` 订阅不带 scope。控制器问题(`servesOwner`)与带 scope 的投递走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。
### 准入与结算
-`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,把已声明的 record 裁剪到结算保留量,解析每个等待方,记录终止快照,宣布可见集变更与 record 的最终输出信号,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。
+`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 只记录一次终止结果(把记录下来的 kill 原因合并进 `killed` 的 detail),清除进度行,把环裁剪到结算保留量,解析每个等待方,然后发出带原因的 `settled` 与环的最终 `output` 信号。原因在 `VisibleJobs.kill` 之后为 `kill`,在所有者或服务取消之后为 `teardown`,否则为 `producer`;`dsh-tool-jobs` 据此跳过没人能读的通知。
### 销毁
-所有者释放(`disposeOwned`)会取消该所有者的任务、等待其结算、移除其记录,并宣布移除——这是任何逐任务记录都无法表达的可见集变更。服务释放(`disposeAll`)会关闭监听器、取消所有存活任务、等待结算、清空存储、向不同的所有者宣布清空,然后分离跨 fiber 的所有者清理 effect。
+所有者释放(`disposeOwned`)会取消该所有者的任务、等待其结算、移除其记录,并逐条宣布移除——这是任何逐任务记录都无法表达的可见集变更。服务释放(`disposeAll`)会取消所有存活任务、等待结算、移除每条记录(逐条宣布移除,让挂载在服务之外的订阅者丢掉它的行),然后分离跨 fiber 的所有者清理 effect。
@@ -106,7 +110,7 @@ kind: "package-reference"
当包级约定不够用时阅读以下页面。它们从注册表约定逐步进入模型侧控制与设计记录。
-- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` 的 cordis 接口面。
+- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、投影字段与 `ctx.jobs` 的 cordis 接口面。
- [jobs 组映射](../README.zh.md)——同级组页面及其包表格。
- [注册表约定](../jobs/README.zh.md)——本包实现的抽象 `ctx.jobs` 服务。
- [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。
diff --git a/packages/jobs/jobs-local/src/events.ts b/packages/jobs/jobs-local/src/events.ts
index 333d5db5fc..8c13e4dd9c 100644
--- a/packages/jobs/jobs-local/src/events.ts
+++ b/packages/jobs/jobs-local/src/events.ts
@@ -23,7 +23,9 @@ interface Subscription {
* by a name a second registrant could shadow.
*/
export class JobLayer implements ScopeLayer {
+ /** Tokens of the job controllers attached from this scope. */
readonly controllers = new AnonymousEntries()
+ /** The `{ owners: 'scope' }` subscriptions registered from this scope. */
readonly scoped = new AnonymousEntries()
isEmpty(): boolean {
diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml
index 54d4ecc8e9..ca8c2269ef 100644
--- a/packages/jobs/jobs/README.i18n.yaml
+++ b/packages/jobs/jobs/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md
-README.md: 5d4e10de9fa852dc4a38c521321bae53c744909b
-README.zh.md: b584191a6b94e79ba8908875e7a0706a525edf4f
+README.md: 4a82606f7988827ce8279c649f0fe1caeb34745e
+README.zh.md: 56e4711acd550b7cb1278739cb60415c9e7ad065
diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md
index 5d4e10de9f..4a82606f79 100644
--- a/packages/jobs/jobs/README.md
+++ b/packages/jobs/jobs/README.md
@@ -1,5 +1,5 @@
---
-description: "The background-job registry contract for users and maintainers composing, implementing, or debugging background work: ids, ownership, lifecycle, and completion listeners."
+description: "The background-job registry contract for users and maintainers composing, implementing, or debugging background work: ids, ownership, lifecycle, the output ring, and the event stream."
kind: "package-reference"
---
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. A producer can also declare a per-job observation record: an append-only bounded output stream that any number of observers (the Web client) read at absolute byte offsets without touching the model-facing consuming cursor. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run.
+`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. Every job owns one bounded output ring: the registry pumps the producer's pull sources into it and accepts pushed appends, the model consumes it through a registry-kept cursor, and any number of observers (the Web client) read it at absolute byte offsets without touching that cursor.
## Table of Contents
@@ -29,9 +29,9 @@ Use this package when you are composing a background-job capability or writing a
### What a background job gives you
-A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded.
+A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh projection of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the registry's event stream announces it and `dsh-tool-jobs` turns the settlement into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing read or notice stays bounded.
-A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RecordingJob` face its starter receives — a start without the declaration receives the plain `RunningJob` face, which has no `append`: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state.
+A producer streams output by naming pull sources on its spec — non-consuming offset readers the registry pumps at its own cadence — or by pushing chunks through the `JobHandle` its starter receives; both land in the job's bounded ring, where `stdout` and `stderr` chunks reach the model and `log` chunks reach observers only. Observers read retained chunks at absolute byte offsets and are signaled on advancement; the settlement that ends the job also ends the stream, and `updateProgress` publishes a live progress line into every projection until then. Observation is invisible to the model: `readAt` consumes nothing and never touches notice state.
### The ownership boundary
@@ -69,7 +69,7 @@ This section explains the design decisions behind the contract and points at the
- **Contract and implementation are separate packages.** `JobRegistry` is an abstract Cordis service; loading the class directly throws, so a misconfigured composition fails at load instead of registering an empty `ctx.jobs`.
- **One registry per process, owner-relative answers.** One instance serves every composition in the process, so registrations and deliveries are relative to the registering scope: a controller or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it.
- **Access is fenced by the owner's session id.** Ids are predictable, so authorization — not secrecy — is the boundary.
-- **Settlement is first-wins, and completion is announced last.** One terminal record, released waiters, and one round of contained listener notification; completion is announced after the record is committed and every other observer has seen it, because a reporter may open a model turn synchronously.
+- **Settlement is first-wins, and its event follows every released waiter.** One terminal record, released waiters, then one round of contained event delivery; a consumer that claims a settlement while waiting therefore always claims before the event, so `dsh-tool-jobs` never announces a completion the model already collected.
- **Registrations outlive producer and controller fibers.** Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record.
### Source map
@@ -77,14 +77,14 @@ This section explains the design decisions behind the contract and points at the
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract |
-| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `RecordingJob`, `JobSnapshot`, listener types |
+| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `VisibleJobs`, `JobEvent`, and the read results |
+| [`src/view.ts`](src/view.ts) | Client-safe leaf: `JobView`, `JobChunk`, `JobStatus`, and the merge-extensible `JobKindMap` |
| [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency |
-| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record |
-| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields |
+| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates every projection the registry hands out — identity, status, timestamps, owner, and ring offsets |
### Service operations
-Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `readRecord` reads retained record chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records, visible-set changes, and record advancement at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md).
+Every operation is a thin projection over the registered jobs, bound to one caller by `visibleTo`: `list` and `get` return fresh projections, `read` advances the model's cursor and hands out the producer's result once after settlement, `readAt` reads retained chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status and records the reason for the terminal `detail`, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; `events.subscribe` delivers registration, progress, stopping, settlement, removal, and output commits at owner, scope, or process granularity.
@@ -95,12 +95,13 @@ Every operation is a thin projection over the registered jobs: `get` and `list`
Read these pages when the package-level contract is not enough. They move from the job types to the shipped implementation, the model-facing controls, and the design records.
-- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, snapshot fields, and `ctx.jobs` cordis surface.
+- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, projection fields, and `ctx.jobs` cordis surface.
- [jobs group map](../README.md) — the sibling group page and its package table.
- [Process-local registry](../jobs-local/README.md) — the shipped implementation that runs jobs in this process.
- [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices.
- [Generic long-running tool runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) — the design behind the background-job runtime.
- [job-registry seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) — the owner-fenced registry contract and its rationale.
+- [Jobs seam consolidation Agent Note](../../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) — one output ring, one projection, one event stream.
-----
@@ -120,8 +121,8 @@ No direct invalidation; the named consumers own any request-prefix changes.
These limits define when the contract is a poor fit. They are current package constraints, not a task backlog.
-- **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam.
-- **Stream output has one consuming cursor** — the model owns it; independent observers read the job's declared record through the non-consuming `readRecord` instead.
+- **The contract is in-process** — `JobSpec.run()` passes callbacks and the registry resolves the live `Agent` behind the owner session; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam.
+- **The model's cursor is the only consuming read** — independent observers use the non-consuming `readAt` and never move it.
- **Foreground work cannot be promoted** — producers choose foreground or background before starting.
diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md
index b584191a6b..56e4711acd 100644
--- a/packages/jobs/jobs/README.zh.md
+++ b/packages/jobs/jobs/README.zh.md
@@ -1,5 +1,5 @@
---
-description: "后台任务注册表约定,供组合、实现或排查后台工作的用户与维护者阅读:id、归属、生命周期与完成监听器。"
+description: "后台任务注册表约定,供组合、实现或排查后台工作的用户与维护者阅读:id、归属、生命周期、输出环与事件流。"
kind: "package-reference"
---
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。生产方还可以声明按 job 划分的观测 record:一条追加式的有界输出流,任意数量的观察者(Web 客户端)按绝对字节偏移读取,不触碰模型侧的消耗型游标。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。
+`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。每个 job 拥有一个有界的输出环:注册表把生产方的拉取源泵入其中并接受推送的追加,模型通过注册表保管的游标消耗它,任意数量的观察者(Web 客户端)按绝对字节偏移读取它而不触碰该游标。
## 目录
@@ -29,9 +29,9 @@ kind: "package-reference"
### 后台任务提供什么
-生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。
+生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新投影,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,注册表的事件流宣布它,`dsh-tool-jobs` 把结算变成会话内通知,因此无需轮询。生产方可以附加一个可选的字节上限,让每次完整的模型侧读取或通知保持有界。
-声明 `record: true` 的生产方还会通过 starter 收到的 `RecordingJob` 面(未声明的 start 收到的是没有 `append` 的普通 `RunningJob` 面)把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。
+生产方通过在 spec 上声明拉取源——由注册表按自己的节奏泵送的非消耗偏移读取器——或经 starter 收到的 `JobHandle` 推送块来流式输出;二者都落入该 job 的有界环,其中 `stdout` 与 `stderr` 块到达模型,`log` 块只到达观察者。观察者按绝对字节偏移读取保留块并在推进时收到信号;结束 job 的结算同时封流,在此之前 `updateProgress` 把实时进度行发布进每个投影。观测对模型不可见:`readAt` 不消耗任何东西,也从不触碰通知状态。
### 归属边界
@@ -69,7 +69,7 @@ kind: "package-reference"
- **约定与实现分属不同包。** `JobRegistry` 是抽象 Cordis 服务;直接加载该类会抛出异常,因此错误配置的组合会在加载时失败,而不是注册一个空的 `ctx.jobs`。
- **每进程一个注册表,按所有者给出答案。** 一个实例服务进程内的每套组合,因此注册与投递都相对注册方所在 scope:从不带 scope 的上下文注册的控制器或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,恰好服务于该组合下组合出的 agent。
- **访问以所有者的会话 id 为界。** id 可预测,因此是授权——而非保密——构成边界。
-- **结算首次优先,完成最后宣布。** 一条终止记录、释放的等待方,以及一轮受到隔离的监听器通知;完成在记录提交且该结算的所有其他观察者都已看到之后才宣布,因为报告方可能同步开启一个模型轮次。
+- **结算首次优先,其事件跟在每个被释放的等待方之后。** 一条终止记录、释放的等待方,然后是一轮受到隔离的事件投递;在等待中认领结算的消费方因此总是在事件之前认领,`dsh-tool-jobs` 永远不会播报模型已经收走的完成。
- **注册的存续期长于生产方与控制器 fiber。** 所有者与服务释放会取消正在运行的工作并等待守约的生产方;抛出异常的销毁取消只强制失败记录。
### 源码地图
@@ -77,14 +77,14 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 |
-| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`RecordingJob`、`JobSnapshot`、监听器类型 |
+| [`src/types.ts`](src/types.ts) | 共享词汇:`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`VisibleJobs`、`JobEvent` 与读取结果 |
+| [`src/view.ts`](src/view.ts) | 客户端安全叶子:`JobView`、`JobChunk`、`JobStatus` 与可合并扩展的 `JobKindMap` |
| [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 |
-| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 |
-| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 |
+| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验注册表交出的每个投影——标识、状态、时间戳、所有者与环偏移 |
### 服务操作
-每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`readRecord` 按绝对偏移读取保留的 record 块且不消耗任何东西,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录、可见集变化与 record 推进,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。
+每个操作都是已注册任务之上的薄投影,由 `visibleTo` 绑定到一个调用方:`list` 与 `get` 返回全新投影,`read` 推进模型游标并在结算后把生产方的 result 交出一次,`readAt` 按绝对偏移读取保留块且不消耗任何东西,`kill` 在改变状态前调用生产方取消并为终态 `detail` 记录原因,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;`events.subscribe` 按所有者、scope 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。
@@ -95,12 +95,13 @@ kind: "package-reference"
当包级约定不够用时阅读以下页面。它们从任务类型逐步进入随附实现、模型侧控制与设计记录。
-- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` 的 cordis 接口面。
+- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、投影字段与 `ctx.jobs` 的 cordis 接口面。
- [jobs 组映射](../README.zh.md)——同级组页面及其包表格。
- [进程本地注册表](../jobs-local/README.zh.md)——在本进程中运行任务的随附实现。
- [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。
- [通用长时间运行工具运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)——后台任务运行时背后的设计。
- [任务注册表 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)——按所有者隔离的注册表约定及其理由。
+- [jobs seam 收敛 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md)——一个输出环、一个投影、一条事件流。
-----
@@ -120,8 +121,8 @@ kind: "package-reference"
这些限制说明约定何时不合适。它们是当前包约束,不是任务积压。
-- **约定是进程内的**——`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。
-- **流输出只有一个消费游标**——它属于模型;独立观察者改用非消耗的 `readRecord` 读取 job 声明的 record。
+- **约定是进程内的**——`JobSpec.run()` 传入回调,注册表解析拥有者会话背后的活体 `Agent`;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。
+- **模型游标是唯一的消耗式读取**——独立观察者使用非消耗的 `readAt`,从不移动它。
- **前台工作无法转为后台**——生产方在启动前选择前台或后台。
diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml
index ca617bc8fb..df53b29100 100644
--- a/packages/jobs/tool-jobs/README.i18n.yaml
+++ b/packages/jobs/tool-jobs/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/tool-jobs/README.md
-README.md: bb9fea89b4aabe38296c01c910f36e99244680db
-README.zh.md: 9820b1536f7f5c8860e7f67b1eee956e6d240462
+README.md: a596aad916f37a0a5778958d7b4cae966221ad3c
+README.zh.md: 72fbe6c2d287649337ee41681b7b05c17ffb377a
diff --git a/packages/jobs/tool-jobs/README.md b/packages/jobs/tool-jobs/README.md
index bb9fea89b4..a596aad916 100644
--- a/packages/jobs/tool-jobs/README.md
+++ b/packages/jobs/tool-jobs/README.md
@@ -25,19 +25,19 @@ English | [中文](README.zh.md)
## Use this package
-Load this plugin in any composition where the agent should start, observe, and stop background jobs: it registers the three tools, attaches the controller producers need, and delivers completion notices. It requires the `ctx.tools`, `ctx.jobs`, and `ctx.systemPrompt` services from the composed harness.
+Load this plugin in any composition where the agent should start, observe, and stop background jobs: it registers the three tools, attaches the controller producers need, and delivers completion notices. It requires the `ctx.tools`, `ctx.jobs`, `ctx.agents`, and `ctx.systemPrompt` services from the composed harness.
### The three tools
-- `job_output(job_id, wait?, timeout_ms?)` — Read a job's output. Stream jobs return only the output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap and leaves a still-running job alive on timeout.
+- `job_output(job_id, wait?, timeout_ms?)` — Read a job's output. Stream jobs return only the output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap and leaves a still-running job alive on timeout. A read renders stdout first and stderr in one `[stderr]` section, notes output that left memory before the read, and carries a job's result (a subagent's answer) exactly once, on the first read after settlement.
- `job_list()` — List your background jobs with their ids, kinds, and statuses, one per line: ` [] —