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:
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
|
||||
+4
-3
@@ -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.
|
||||
+6
-5
@@ -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` 拥有合并后的全部文案,包括时长词汇。
|
||||
@@ -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
-2
@@ -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,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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 偏移字段 |
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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() {},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
Reference in new issue
Block a user