Merge branch 'worktree/process-plugin-streaming-72f9b3' into worktree/job-kill-delivery

# Conflicts:
#	.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml
#	packages/jobs/jobs-local/src/index.ts
#	snapshots/web/cordis-tool-round/session.jsonl
This commit is contained in:
Yichen Jiang committed 2026-09-03 13:19:22 +08:00
commit d0b9167dba
36 files changed
+332 -218

No files matched your search

@@ -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/archived/feature/2026-08-25-unified-task-list.md
2026-08-25-unified-task-list.md: f2f955e36a78eee8fa8ff8a804293bcc37a44acd
2026-08-25-unified-task-list.zh.md: 5a4c51a6cc3d0f331d2559debb73dc02e9cf12c5
@@ -1,11 +1,12 @@
# Agent Note: One session-header task list — jobs joined with activities in the UI projection only
Status: implemented
Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md).
Archived: 2026-09-03
English | [中文](2026-08-25-unified-task-list.zh.md)
Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md).
## Problem
The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy.
@@ -44,5 +45,5 @@ Component suites cover the join (job lifecycle wins, activity supplies the panel
## Consequences
- The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end.
- The human kill control on live rows shipped later, once [the human job kill note](2026-08-26-human-job-kill.md) resolved the jobs `reported` contract question.
- A human kill control on live rows is still blocked on the jobs `reported` contract question recorded in the job-display note.
- The `job` locale namespace is gone with its package; `activity` owns the merged copy including the duration vocabulary.
@@ -1,11 +1,12 @@
# Agent Note:会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并
# Agent Note: 会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并
状态:implemented
Superseded:本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。
Status: implemented
Archived: 2026-09-03
[English](2026-08-25-unified-task-list.md) | 中文
Superseded: 本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。
## 问题
[activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。
@@ -44,5 +45,5 @@ Superseded:本文描述的双名册 join 已随 activity seam 一并移除—
## 后果
- 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。
- live 行上的人工 kill 控件在 [人工任务终止 Note](2026-08-26-human-job-kill.zh.md) 解决 jobs `reported` 契约问题后随即上线。
- live 行上的人工 kill 控件仍被 job-display note 记录的 jobs `reported` 契约问题阻塞。
- `job` 词典命名空间随包一起消失;`activity` 拥有合并后的全部文案,包括时长词汇。
+3
View File
@@ -328,6 +328,9 @@
"feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml": "sha256:c2cb839ed676040ed62153c2aab65b677fa59739f69253af50246e79a2347620",
"feature/2026-08-19-high-cache-hit-decimal-display.md": "sha256:08cb68bfc379da47a05b816afac26126146d36d6248350d4380f7cb98607d573",
"feature/2026-08-19-high-cache-hit-decimal-display.zh.md": "sha256:9d7afe3e2fc3029fbccc643b432a01bcb3b5671750adb6945ac414ec843ca063",
"feature/2026-08-25-unified-task-list.i18n.yaml": "sha256:fec0d4a3ecf59aafd440bc801c686b14d63152c937f51bebe0da61ff615f6f33",
"feature/2026-08-25-unified-task-list.md": "sha256:30bd4e338a3de2f59af98d512c7878928c61846e6700d6012fc45e51c0bf94df",
"feature/2026-08-25-unified-task-list.zh.md": "sha256:88fce0a52b5387ea98c1f68742c03b2f475c5465c680befb32c587066e52cfce",
"process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d",
"process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e",
"process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b",
@@ -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: c1a668f8a008948957e62b5acf1d9db141441885
2026-09-01-jobs-absorb-activity-record.zh.md: a23f3cda4c8c53663a6aee99469297d6878539d1
2026-09-01-jobs-absorb-activity-record.md: 2f2bf7a65b5c26c80ec7ddc307c0f31a87f4de54
2026-09-01-jobs-absorb-activity-record.zh.md: ed62a04e8f9da9de35bd721cceee3a2e56644cdb
@@ -14,7 +14,7 @@ The one activity without a job was the foreground workflow mirror. Everything el
`ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed.
- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops.
- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` for a `record: true` start, the plain `RunningJob { id, updateDetail(detail) }` otherwise — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops.
- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented.
- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design.
- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it.
@@ -38,6 +38,7 @@ Review of the merged design settled five points that the sections above leave im
- **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR.
- **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor.
- **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need.
- **The declaration types the face.** `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob`, `PlainJobStart.run` receives `RunningJob` without `append`, and the registry builds the plain face without the method — output the registry has nowhere to keep is unrepresentable instead of warned about and dropped.
- **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id.
## Consequences
@@ -14,7 +14,7 @@ Status: implemented
`ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。
- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。
- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`record: true` 的 start 收到 `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`,其余收到普通的 `RunningJob { id, updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。
- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。
- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。
- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。
@@ -38,6 +38,7 @@ Status: implemented
- **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。
- **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。
- **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。
- **声明决定生产者面的类型。** `JobStart` 是按 `record` 判别的联合类型:`RecordingJobStart.run` 收到 `RecordingJob`,`PlainJobStart.run` 收到没有 `append` 的 `RunningJob`,注册表给普通 job 构造的面根本没有这个方法——注册表无处存放的输出成为不可表达,而不是警告后丢弃。
- **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。
## 后果
@@ -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: 82be7df313215ec1b587c5730bc6fae1db2c2a40
2026-08-08-web-background-job-display.zh.md: 0bfd8e83242123e82e2caad40bbd0485ce77c7f7
2026-08-08-web-background-job-display.md: d22fdfe73aa021d48c94de033915a8f17749c054
2026-08-08-web-background-job-display.zh.md: 29f9939fe32bb047ff2141bf4bfab2dc0989aea0
@@ -66,7 +66,7 @@ The listener is owner-granular rather than task-granular. The only consumer push
`onJobDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-jobs` couples to `reported`; `onJobsChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onJobDone`, and each registration is an effect on the calling fiber.
Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set.
Service disposal commits one final emptying and announces it: after the registry has cancelled and awaited its jobs, `onJobsChanged` fires once per owner whose visible set the teardown emptied, so a listener registered on a longer-lived fiber sees the final empty set instead of a stale roster (a listener registered on the registry's own fiber is already gone by then).
### The Session Controller carrier
@@ -89,7 +89,7 @@ Two replacement points keep it honest. Each control-stream generation clears the
### The header action
`@deepseek-ai/dsh-client-ui-jobs` (since merged into `dsh-client-ui-activity`) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible.
`@deepseek-ai/dsh-client-ui-jobs` registers one entry in `conversation.session.header.actions`, ordered between the preset label and the subagent catalog (`order: 20` against the catalog's 30). Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible.
A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks.
@@ -66,7 +66,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
`onJobDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-jobs` 把这套语义与 `reported` 绑在一起;`onJobsChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onJobDone` 一致,每次注册都是调用方 fiber 上的 effect。
服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。
服务销毁会提交最后一次清空并通告它:注册表取消并等完自己的 job 之后,`onJobsChanged` 对每个可见集被 teardown 清空的 owner 触发一次,因此注册在更长命 fiber 上的监听器看到的是最终空集而不是陈旧名册(注册在注册表自身 fiber 上的监听器此时早已消失)。
### Session Controller 载体
@@ -89,7 +89,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
### header 入口
`@deepseek-ai/dsh-client-ui-jobs`(后并入 `dsh-client-ui-activity`) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。
`@deepseek-ai/dsh-client-ui-jobs` 在 `conversation.session.header.actions` 注册一个条目,排在 preset 标签与 subagent 目录之间(`order: 20`,目录为 30)。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。
因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md
2026-08-24-activity-observation-seam.md: 29ee08b1bb7f72a63d2f84278af62587060de3fe
2026-08-24-activity-observation-seam.zh.md: 97ac4f195c83cdd4f16d2f0a108abd2b3338d95d
2026-08-24-activity-observation-seam.md: 21fe08fd21ad95eec7a83f6f29b9b94514358bb9
2026-08-24-activity-observation-seam.zh.md: ffb6766b3e5d226d324ea2894a6e3f758cf1932a
@@ -19,7 +19,7 @@ A new capability seam, `packages/activity/`, is the non-consuming observation pl
- **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump.
- **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown.
- **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail).
- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame.
- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](../../archived/feature/2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame.
Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes:
@@ -50,4 +50,4 @@ Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy re
## Consequences
The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart.
The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](../../archived/feature/2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart.
@@ -19,7 +19,7 @@ bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom(
- **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。
- **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。
- **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。
- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](2026-08-25-unified-task-list.zh.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。
- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](../../archived/feature/2026-08-25-unified-task-list.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。
生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作:
@@ -50,4 +50,4 @@ bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom(
## 后果
Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](2026-08-25-unified-task-list.zh.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。
Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](../../archived/feature/2026-08-25-unified-task-list.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-unified-task-list.md
2026-08-25-unified-task-list.md: 066f63550e19d03edeae1655eb17a0d6086ff388
2026-08-25-unified-task-list.zh.md: 8c63d076fb53da4e5eb00b94268619966a66e7c3
+2 -2
View File
@@ -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: ad470e66a65fd8a78c93ab638cd6ea9f5dfb5556
jobs.zh.md: 00ba02352332e9a95f9359e1a1b00cc3516058c1
jobs.md: 88e6d3f1d14900c908787a37068b2a7491af4437
jobs.zh.md: affc6336b77d2b1d98ae2bd002b7d2e8d55c62be
+55 -47
View File
@@ -23,42 +23,37 @@ interface JobKindMap {
## Producer contract
`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` with the job's producer face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state.
`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.
```ts type-equiv
/**
* 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 and lifecycle state.
* 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.
*/
interface JobStart {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
kind: JobKind
/** One-line model-facing label (the command; the delegation description). */
label: string
interface PlainJobStart extends JobStartBase {
record?: undefined
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including controller status metadata. Independent of record
* retention: it bounds the consuming model surface, never
* {@link JobRegistry.readRecord}.
* 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.
*/
outputLimitBytes?: number
/**
* Owning live agent. Access is fenced by its session id, and agent disposal
* cancels and awaits the job. The instance must be the one currently
* registered under its agent id. Omitting the owner creates an unowned job,
* open to any caller until service disposal.
*/
owner?: Agent
/**
* Declares an observable output record beside the model-facing surfaces:
* {@link RunningJob.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}.
* Without the declaration `append` logs and drops.
*/
record?: true
run(job: RunningJob): 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}.
*/
interface RecordingJobStart extends JobStartBase {
record: true
/**
* Start the work after preflight and synchronously return its hooks. Called
* once with the job's producer face; a throw leaves nothing registered (the
@@ -66,10 +61,20 @@ interface JobStart {
* started resources.
* @param job - the issued id plus the record append and live-detail writers.
*/
run(job: RunningJob): JobHooks
run(job: RecordingJob): JobHooks
}
```
```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.
```ts type-equiv
@@ -113,34 +118,38 @@ interface JobOutcome {
## Observation record
A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` 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 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.
```ts type-equiv
/**
* Producer face of one registered job, handed to {@link JobStart.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 — both methods log and drop instead of
* throwing, so a producer's trailing flush cannot break its own teardown path.
* 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.
*/
interface RunningJob {
/** The registry-issued id (`<kind>-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. Without a
* {@link JobStart.record} declaration the chunk is logged and dropped.
* 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
/**
* Replace the snapshot's status detail with a live progress line (`3/10`).
* Works for every job, with or without a record.
* @param detail - the new detail line.
*/
updateDetail(detail: string): void
}
```
@@ -381,7 +390,6 @@ abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal
*/
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 —
+55 -47
View File
@@ -23,42 +23,37 @@ interface JobKindMap {
## 生产方约定
`JobStart` 声明身份和启动器。运行时会在完成预检后携带该 job 的生产者面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。
`JobStart` 声明身份和启动器,并按 `record` 判别:`RecordingJobStart` 收到 `RecordingJob` 面,`PlainJobStart` 收到没有 `append` 的 `RunningJob` 面。运行时会在完成预检后携带该面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。
```ts type-equiv
/**
* 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 and lifecycle state.
* 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.
*/
interface JobStart {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
kind: JobKind
/** One-line model-facing label (the command; the delegation description). */
label: string
interface PlainJobStart extends JobStartBase {
record?: undefined
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including controller status metadata. Independent of record
* retention: it bounds the consuming model surface, never
* {@link JobRegistry.readRecord}.
* 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.
*/
outputLimitBytes?: number
/**
* Owning live agent. Access is fenced by its session id, and agent disposal
* cancels and awaits the job. The instance must be the one currently
* registered under its agent id. Omitting the owner creates an unowned job,
* open to any caller until service disposal.
*/
owner?: Agent
/**
* Declares an observable output record beside the model-facing surfaces:
* {@link RunningJob.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}.
* Without the declaration `append` logs and drops.
*/
record?: true
run(job: RunningJob): 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}.
*/
interface RecordingJobStart extends JobStartBase {
record: true
/**
* Start the work after preflight and synchronously return its hooks. Called
* once with the job's producer face; a throw leaves nothing registered (the
@@ -66,10 +61,20 @@ interface JobStart {
* started resources.
* @param job - the issued id plus the record append and live-detail writers.
*/
run(job: RunningJob): JobHooks
run(job: RecordingJob): JobHooks
}
```
```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` 用来区分会消费输出的流式任务和仅有最终输出的任务。
```ts type-equiv
@@ -113,34 +118,38 @@ interface JobOutcome {
## 观测 record
声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 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 一节。
声明 `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 一节。
```ts type-equiv
/**
* Producer face of one registered job, handed to {@link JobStart.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 — both methods log and drop instead of
* throwing, so a producer's trailing flush cannot break its own teardown path.
* 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.
*/
interface RunningJob {
/** The registry-issued id (`<kind>-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. Without a
* {@link JobStart.record} declaration the chunk is logged and dropped.
* 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
/**
* Replace the snapshot's status detail with a live progress line (`3/10`).
* Works for every job, with or without a record.
* @param detail - the new detail line.
*/
updateDetail(detail: string): void
}
```
@@ -381,7 +390,6 @@ abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal
*/
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 —
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { JobId } from '@deepseek-ai/dsh-jobs'
import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobOutcome, RecordingJob } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
@@ -38,13 +38,13 @@ function registerAgent(ctx: Context, rawId: string): Agent {
/** Start one record job whose done settles on demand; expose the producer face. */
function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) {
let settle!: (outcome: JobOutcome) => void
let face!: RunningJob
let face!: RecordingJob
const id = ctx.jobs.start({
kind: 'bash',
label: options.label ?? 'echo',
...options.owner !== undefined ? { owner: options.owner } : {},
...options.record === false ? {} : { record: true },
run: (job) => {
run: (job: RecordingJob) => {
face = job
return {
cancel() {},
@@ -54,7 +54,7 @@ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent;
})
return {
id,
append: (text: string, opts?: Parameters<RunningJob['append']>[1]) => { face.append(text, opts) },
append: (text: string, opts?: Parameters<RecordingJob['append']>[1]) => { face.append(text, opts) },
updateDetail: (detail: string) => { face.updateDetail(detail) },
settle: async (outcome: JobOutcome) => {
settle(outcome)
+2 -1
View File
@@ -39,7 +39,8 @@ export function apply(ctx: ClientContext): void {
() => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'job-list',
// After the subagent catalog: session lineage reads before running work.
// Between the preset label and the subagent catalog (order 30): running
// work reads before the session lineage.
order: 20,
locale: NS,
inject: (): JobListInjected => ({
@@ -1101,7 +1101,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
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.',
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.',
},
@@ -4251,7 +4251,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'JobStart',
declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n record?: true;\n run(job: RunningJob): JobHooks;\n}',
declaration: 'export type JobStart = PlainJobStart | RecordingJobStart;',
},
{
name: 'JobStatus',
@@ -4549,6 +4549,10 @@ 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};',
@@ -4669,6 +4673,14 @@ 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}',
@@ -4751,7 +4763,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'RunningJob',
declaration: 'export interface RunningJob {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateDetail(detail: string): void;\n}',
declaration: 'export interface RunningJob {\n readonly id: JobId;\n updateDetail(detail: string): void;\n}',
},
{
name: 'SandboxEnforcement',
+21 -20
View File
@@ -19,8 +19,8 @@ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs'
import type {
JobAppendOptions, JobChannel, JobDoneListener, JobKillOptions, JobKind, JobOutcome,
JobOutputListener, JobRead, JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus,
JobsChangedListener, RunningJob,
JobOutputListener, JobRead, JobRecordChunk, JobRecordRead, JobsChangedListener, JobSnapshot,
JobStart, JobStatus, RecordingJob, RunningJob,
} from '@deepseek-ai/dsh-jobs'
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
@@ -239,17 +239,13 @@ export class LocalJobRegistry extends JobRegistry {
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = JobId(`${spec.kind}-${count}`)
const state: ProducerState = {
record: spec.record === true ? { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } : undefined,
detail: undefined,
job: undefined,
}
const handle: RunningJob = {
const state: ProducerState = { record: undefined, detail: undefined, job: undefined }
const face: RunningJob = {
id,
append: (text, options) => { this.appendRecord(id, state, text, options) },
updateDetail: (detail) => { this.updateDetail(state, detail) },
}
const hooks = spec.run(handle)
// The declaration decides the face: a plain start never receives `append`.
const hooks = spec.record === true ? spec.run(this.recordingFace(face, state)) : spec.run(face)
let markSettled!: () => void
const settled = new Promise<void>((resolve) => { markSettled = resolve })
@@ -566,23 +562,28 @@ export class LocalJobRegistry extends JobRegistry {
}
}
/** Install the job's record and extend the producer face with its append. */
private recordingFace(face: RunningJob, state: ProducerState): RecordingJob {
const record: RecordState = { chunks: [], retainedBytes: 0, total: 0, earliest: 0 }
state.record = record
return {
...face,
append: (text, options) => { this.appendRecord(state, record, text, options) },
}
}
/**
* Append one chunk through a producer face. A chunk against a settled job or
* without a record declaration is logged and dropped; an empty chunk is
* dropped silently. A chunk staged inside the starter call is retained and
* signals no observer — the registration commit publishes it.
* Append one chunk through a recording producer face. A chunk against a
* settled job is logged and dropped; an empty chunk is dropped silently. A
* chunk staged inside the starter call is retained and signals no observer —
* the registration commit publishes it.
*/
private appendRecord(id: JobId, state: ProducerState, text: string, options?: JobAppendOptions): void {
private appendRecord(state: ProducerState, record: RecordState, text: string, options?: JobAppendOptions): void {
const job = state.job
if (job !== undefined && isTerminal(job.status)) {
this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`)
return
}
const record = state.record
if (record === undefined) {
this.selfCtx.logger.warn(`jobs: append to job ${id} without a record declaration dropped`)
return
}
if (text.length === 0) return
const bytes = Buffer.byteLength(text, 'utf8')
record.chunks.push({
+10 -12
View File
@@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { JobId } from '@deepseek-ai/dsh-jobs'
import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RecordingJob } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local'
declare module '@deepseek-ai/dsh-jobs' {
@@ -66,14 +66,14 @@ function producer(overrides: Partial<Omit<JobStart, 'run'> & JobHooks> = {}) {
done: new Promise<JobOutcome>((res, rej) => { settle = res; reject = rej }),
...hookOverrides,
}
let started: RunningJob | undefined
let started: RecordingJob | undefined
const spec: JobStart = {
kind,
label,
...owner !== undefined ? { owner } : {},
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
...record !== undefined ? { record } : {},
run: (job) => {
run: (job: RecordingJob) => {
started = job
return hooks
},
@@ -83,7 +83,7 @@ function producer(overrides: Partial<Omit<JobStart, 'run'> & JobHooks> = {}) {
settle,
reject,
cancels,
job(): RunningJob {
job(): RecordingJob {
if (started === undefined) throw new Error('producer not started')
return started
},
@@ -200,7 +200,7 @@ describe('LocalJobRegistry.start', () => {
for (const job of live) ctx.jobs.start(job.spec)
const blocked = producer()
const run = vi.fn((job: RunningJob) => blocked.spec.run(job))
const run = vi.fn((job: RecordingJob) => blocked.spec.run(job))
expect(() => ctx.jobs.start({ ...blocked.spec, run }))
.toThrow('background job limit reached for this owner (limit: 10)')
expect(run).not.toHaveBeenCalled()
@@ -213,7 +213,7 @@ describe('LocalJobRegistry.start', () => {
expect(ctx.jobs.start(first.spec)).toBe('bash-1')
const blocked = producer()
const run = vi.fn((job: RunningJob) => blocked.spec.run(job))
const run = vi.fn((job: RecordingJob) => blocked.spec.run(job))
expect(() => ctx.jobs.start({ ...blocked.spec, run }))
.toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry')
expect(run).not.toHaveBeenCalled()
@@ -731,7 +731,7 @@ describe('LocalJobRegistry owner isolation', () => {
ctx.jobs.start(current.spec) // Attach the current owner's cleanup first.
const stale = producer({ owner: staleOwner })
const staleRun = vi.fn((job: RunningJob) => stale.spec.run(job))
const staleRun = vi.fn((job: RecordingJob) => stale.spec.run(job))
expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun }))
.toThrow('is not the registered agent instance')
expect(staleRun).not.toHaveBeenCalled()
@@ -1296,14 +1296,12 @@ describe('LocalJobRegistry record', () => {
expect(ctx.jobs.get(id).outputTotal).toBe(0)
})
it('logs and drops an append without a record declaration; updateDetail still works', async () => {
it('hands a start without a record declaration no append; updateDetail still works', async () => {
const ctx = await harness()
const warn = vi.fn()
ctx.logger.warn = warn as never
const p = producer()
const id = ctx.jobs.start(p.spec)
p.job().append('nowhere to land')
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`append to job ${id} without a record declaration dropped`))
// The plain face is built without the method, not with a dropping stub.
expect(p.job()).not.toHaveProperty('append')
expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`)
const snapshot = ctx.jobs.get(id)
expect(snapshot.outputTotal).toBeUndefined()
+2 -2
View File
@@ -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: 6a8576ea482e67ec25ad633a35d1051f6f64e14f
README.zh.md: 7c27eea4b274d1006fa7c4491597604df69c387c
README.md: 30eccf64aa5699a09e27743ffdff6b8f2360a216
README.zh.md: 90174b87774b8bb4803884279bbec91598d75fa5
+2 -2
View File
@@ -31,7 +31,7 @@ Use this package when you are composing a background-job capability or writing a
A producer registers work with a kind and a one-line label; the registry returns a `<kind>-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 that declares `record: true` additionally streams raw output into the job's bounded record through the `RunningJob` face its starter receives: 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 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.
### The ownership boundary
@@ -77,7 +77,7 @@ 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`, `JobSnapshot`, listener types |
| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `RecordingJob`, `JobSnapshot`, listener types |
| [`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 |
+2 -2
View File
@@ -31,7 +31,7 @@ kind: "package-reference"
生产方以 kind 和一行标签注册工作;注册表返回 `<kind>-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。
声明 `record: true` 的生产方还会通过 starter 收到的 `RunningJob` 面把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。
声明 `record: true` 的生产方还会通过 starter 收到的 `RecordingJob` 面(未声明的 start 收到的是没有 `append` 的普通 `RunningJob` 面)把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。
### 归属边界
@@ -77,7 +77,7 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 |
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`JobSnapshot`、监听器类型 |
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`RecordingJob`、`JobSnapshot`、监听器类型 |
| [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 |
| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 |
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 |
+3 -1
View File
@@ -34,6 +34,9 @@ export type {
JobStart,
JobStatus,
JobsChangedListener,
PlainJobStart,
RecordingJob,
RecordingJobStart,
RunningJob,
} from './types.ts'
export { pumpJobOutput } from './pump.ts'
@@ -167,7 +170,6 @@ export abstract class JobRegistry extends Service {
*/
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 —
+2 -2
View File
@@ -5,7 +5,7 @@
* @module
*/
import type { JobChannel, RunningJob } from './types.ts'
import type { JobChannel, RecordingJob } from './types.ts'
/** One non-consuming offset read from a producer-owned stream. */
export interface JobPumpRead {
@@ -74,7 +74,7 @@ export interface JobPumpOptions {
* @returns resolves after the final post-settlement drain.
*/
export async function pumpJobOutput(
job: RunningJob,
job: RecordingJob,
sources: readonly JobPumpSource[],
options: JobPumpOptions,
): Promise<void> {
+53 -28
View File
@@ -56,12 +56,8 @@ export interface JobAppendOptions {
gapBefore?: true
}
/**
* 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 and lifecycle state.
*/
export interface JobStart {
/** Fields shared by every producer declaration passed to {@link JobRegistry.start}. */
interface JobStartBase {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
kind: JobKind
/** One-line model-facing label (the command; the delegation description). */
@@ -80,15 +76,34 @@ export interface JobStart {
* open to any caller until service disposal.
*/
owner?: Agent
}
/**
* 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.
*/
export interface PlainJobStart extends JobStartBase {
record?: undefined
/**
* Declares an observable output record beside the model-facing surfaces:
* {@link RunningJob.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}.
* Without the declaration `append` logs and drops.
* 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.
*/
record?: true
run(job: RunningJob): JobHooks
}
/**
* 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}.
*/
export interface RecordingJobStart extends JobStartBase {
record: true
/**
* Start the work after preflight and synchronously return its hooks. Called
* once with the job's producer face; a throw leaves nothing registered (the
@@ -96,34 +111,44 @@ export interface JobStart {
* started resources.
* @param job - the issued id plus the record append and live-detail writers.
*/
run(job: RunningJob): JobHooks
run(job: RecordingJob): JobHooks
}
/**
* Producer face of one registered job, handed to {@link JobStart.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 — both methods log and drop instead of
* throwing, so a producer's trailing flush cannot break its own teardown path.
* 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.
*/
export type JobStart = PlainJobStart | RecordingJobStart
/**
* 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.
*/
export interface RunningJob {
/** The registry-issued id (`<kind>-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
}
/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */
export 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. Without a
* {@link JobStart.record} declaration the chunk is logged and dropped.
* 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
/**
* Replace the snapshot's status detail with a live progress line (`3/10`).
* Works for every job, with or without a record.
* @param detail - the new detail line.
*/
updateDetail(detail: string): void
}
/** Hooks through which the runtime controls and observes producer work. */
+38 -4
View File
@@ -3,8 +3,8 @@ import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs'
import type {
JobAppendOptions, JobDoneListener, JobOutputListener, JobPumpSource, JobRead, JobRecordRead,
JobSnapshot, JobStart, JobsChangedListener, RunningJob,
JobAppendOptions, JobDoneListener, JobHooks, JobOutputListener, JobPumpSource, JobRead, JobRecordRead,
JobSnapshot, JobStart, JobsChangedListener, PlainJobStart, RecordingJob, RecordingJobStart,
} from '@deepseek-ai/dsh-jobs'
/**
@@ -71,6 +71,40 @@ class StubJobRegistry extends JobRegistry {
}
}
/** Compile-time probe: does the producer face `Face` carry `append`? */
type HasAppend<Face> = 'append' extends keyof Face ? true : false
describe('JobStart producer faces', () => {
it('hands append only to a start that declares a record', () => {
const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) }
// The literal's `record` discriminant selects the face `run` receives.
const plain: JobStart = {
kind: 'bash',
label: 'no record',
run: (job) => {
job.updateDetail('works for every job')
return hooks
},
}
const recording: JobStart = {
kind: 'bash',
label: 'record',
record: true,
run: (job) => {
job.append('lands in the record')
return hooks
},
}
// Negative proof: a plain start's face has no `append` at the type level.
const plainFace: HasAppend<Parameters<PlainJobStart['run']>[0]> = false
const recordingFace: HasAppend<Parameters<RecordingJobStart['run']>[0]> = true
expect(plainFace).toBe(false)
expect(recordingFace).toBe(true)
expect(plain.record).toBeUndefined()
expect(recording.record).toBe(true)
})
})
describe('JobRegistry seam', () => {
it('a concrete subclass registers as ctx.jobs and serves the abstract API', async () => {
const ctx = new Context()
@@ -109,9 +143,9 @@ describe('JobRegistry seam', () => {
})
/** Record every append so pump behavior is observable without a registry. */
function recordingJob(): { job: RunningJob; appends: { text: string; options?: JobAppendOptions }[] } {
function recordingJob(): { job: RecordingJob; appends: { text: string; options?: JobAppendOptions }[] } {
const appends: { text: string; options?: JobAppendOptions }[] = []
const job: RunningJob = {
const job: RecordingJob = {
id: JobId('bash-1'),
append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) },
updateDetail() {},
+2 -2
View File
@@ -15,7 +15,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import { pumpJobOutput } from '@deepseek-ai/dsh-jobs'
import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs'
/**
* Pump the process's non-consuming offset readers into the job record until
@@ -31,7 +31,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
*/
export function observeProcessRecord(
ctx: Context,
job: RunningJob,
job: RecordingJob,
proc: ShellProcess,
pollMs: number,
): Promise<void> {
@@ -10,7 +10,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import type { JobId, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobId, RecordingJob } from '@deepseek-ai/dsh-jobs'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
@@ -122,8 +122,8 @@ describe('background bash record', () => {
})
describe('observeProcessRecord', () => {
/** A RunningJob face recording appends for pump assertions. */
function recordingJob(): { job: RunningJob; appends: string[] } {
/** A RecordingJob face recording appends for pump assertions. */
function recordingJob(): { job: RecordingJob; appends: string[] } {
const appends: string[] = []
return {
job: {
+2 -2
View File
@@ -16,7 +16,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import { pumpJobOutput } from '@deepseek-ai/dsh-jobs'
import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs'
/**
* Pump the process's non-consuming offset readers into the job record until
@@ -32,7 +32,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
*/
export function observeProcessRecord(
ctx: Context,
job: RunningJob,
job: RecordingJob,
proc: ShellProcess,
pollMs: number,
): Promise<void> {
@@ -8,7 +8,7 @@
*/
import type { Context } from '@deepseek-ai/cordis'
import type { RunningJob } from '@deepseek-ai/dsh-jobs'
import type { RecordingJob } from '@deepseek-ai/dsh-jobs'
import type { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
/** Job-record taps for the background runs the tool tracks. */
@@ -19,7 +19,7 @@ export interface WorkflowRecordMirror {
* @param runId - the started run.
* @param job - the owning job's producer face.
*/
start(runId: WorkflowRunId, job: RunningJob): void
start(runId: WorkflowRunId, job: RecordingJob): void
/**
* Stop routing a settled or abandoned run. Idempotent.
* @param runId - the run to drop.
@@ -34,7 +34,7 @@ export interface WorkflowRecordMirror {
* @returns the mirror taps the tool wires around each background run.
*/
export function createWorkflowRecordMirror(ctx: Context): WorkflowRecordMirror {
const active = new Map<WorkflowRunId, RunningJob>()
const active = new Map<WorkflowRunId, RecordingJob>()
ctx.on('workflow/phase', (info, title) => {
const job = active.get(info.id)
+3
View File
@@ -518,6 +518,9 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
JobWireChunk: 'jobs.md',
JobSnapshot: 'jobs.md',
JobStart: 'jobs.md',
PlainJobStart: 'jobs.md',
RecordingJob: 'jobs.md',
RecordingJobStart: 'jobs.md',
JobsChangedListener: 'jobs.md',
JobAppendOptions: 'jobs.md',
JobChannel: 'jobs.md',
+15
View File
@@ -1006,6 +1006,16 @@
"symbol": "JobKindMap",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "PlainJobStart",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "RecordingJobStart",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobStart",
@@ -1036,6 +1046,11 @@
"symbol": "RunningJob",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "RecordingJob",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobRecordChunk",
@@ -1,4 +1,4 @@
{"type":"session","version":0,"id":"{{session:1}}","createdAt":1788361066361,"cwd":"{{cwd}}","agentPreset":"standard"}
{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736473857,"cwd":"{{cwd}}","agentPreset":"standard"}
{"type":"permission/preset","data":{"preset":"workspace-write"}}
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
{"type":"approval/policy","data":{"policy":"ask"}}