From b48c05d117fe06296cc0f21c5fc77ba7abbecf4e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 3 Sep 2026 13:15:23 +0800 Subject: [PATCH] refactor(jobs): type the producer face by the record declaration, tidy review residue - `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob` (with `append`), `PlainJobStart.run` receives the plain `RunningJob`, and the registry builds the plain face without the method, so the former warn-and-drop path is unrepresentable. - Remove the duplicated JSDoc opener in the registry seam and correct the job-list slot comment (order 20 sits before the subagent catalog's 30). - Restore the `cordis-tool-round` session fixture whose only change was a re-recorded `createdAt`. - Update the 2026-08-08 job-display note's stale service-disposal and slot ordering facts; archive the superseded 2026-08-25 unified-task-list note and point its remaining inbound links at the archived snapshot. --- .../2026-08-25-unified-task-list.i18n.yaml | 6 ++ .../feature/2026-08-25-unified-task-list.md | 5 +- .../2026-08-25-unified-task-list.zh.md | 9 +- .agents/notes/archived/manifest.json | 3 + ...9-01-jobs-absorb-activity-record.i18n.yaml | 4 +- .../2026-09-01-jobs-absorb-activity-record.md | 3 +- ...26-09-01-jobs-absorb-activity-record.zh.md | 3 +- ...08-08-web-background-job-display.i18n.yaml | 4 +- .../2026-08-08-web-background-job-display.md | 4 +- ...026-08-08-web-background-job-display.zh.md | 4 +- ...-08-24-activity-observation-seam.i18n.yaml | 4 +- .../2026-08-24-activity-observation-seam.md | 4 +- ...2026-08-24-activity-observation-seam.zh.md | 4 +- .../2026-08-25-unified-task-list.i18n.yaml | 6 -- docs/subsystems/jobs.i18n.yaml | 4 +- docs/subsystems/jobs.md | 102 ++++++++++-------- docs/subsystems/jobs.zh.md | 102 ++++++++++-------- .../job-controller/tests/observe.host.spec.ts | 8 +- packages/client/ui-jobs/src/client/index.ts | 3 +- .../extensions/tool-cordis/src/api-catalog.ts | 18 +++- packages/jobs/jobs-local/src/index.ts | 39 +++---- packages/jobs/jobs-local/tests/jobs.spec.ts | 22 ++-- packages/jobs/jobs/README.i18n.yaml | 4 +- packages/jobs/jobs/README.md | 4 +- packages/jobs/jobs/README.zh.md | 4 +- packages/jobs/jobs/src/index.ts | 4 +- packages/jobs/jobs/src/pump.ts | 4 +- packages/jobs/jobs/src/types.ts | 81 +++++++++----- packages/jobs/jobs/tests/service.spec.ts | 42 +++++++- packages/shell/tool-bash/src/observe.ts | 4 +- .../shell/tool-bash/tests/observe.spec.ts | 6 +- packages/shell/tool-pwsh/src/observe.ts | 4 +- scripts/gen-cordis-catalog.ts | 3 + scripts/type-equiv.manifest.json | 15 +++ snapshots/web/cordis-tool-round/session.jsonl | 2 +- 35 files changed, 326 insertions(+), 212 deletions(-) create mode 100644 .agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml rename .agents/notes/{implemented => archived}/feature/2026-08-25-unified-task-list.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-08-25-unified-task-list.zh.md (94%) delete mode 100644 .agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml new file mode 100644 index 0000000000..c5b051c49c --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/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 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-25-unified-task-list.md rename to .agents/notes/archived/feature/2026-08-25-unified-task-list.md index daf6582bf2..f2f955e36a 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.md +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md similarity index 94% rename from .agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md rename to .agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md index 624747396e..5a4c51a6cc 100644 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.zh.md +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md @@ -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 混排、单行、无时长)也缺乏视觉层次。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index e2bf423d56..f4cae45ef7 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -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", diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml index f7cf077333..e895f7c65b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md -2026-09-01-jobs-absorb-activity-record.md: 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 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md index c1a668f8a0..2f2bf7a65b 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md index a23f3cda4c..ed62a04e8f 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -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 的测试链。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 444bbac0ad..ee733d059e 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 3dd003c0e972ec7a5857e84980520eee93b53955 -2026-08-08-web-background-job-display.zh.md: 5d1ee4bada9c1966e4329d35f5a4db64300ca3b4 +2026-08-08-web-background-job-display.md: 7787466154d65ba0970010c3fdaf964ae6d8507f +2026-08-08-web-background-job-display.zh.md: 478acdf00936baed1700d18db2f63b1da1749e37 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 3dd003c0e9..7787466154 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 5d1ee4bada..478acdf009 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -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'` 会让中断那一期恰好对这批任务没有入口。 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml index 651ef99e3c..548e9f5b36 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-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 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md index 29ee08b1bb..21fe08fd21 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md index 97ac4f195c..ffb6766b3e 100644 --- a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -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 消费端、重启后的持久回放。 diff --git a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml deleted file mode 100644 index eab6f6fa1b..0000000000 --- a/.agents/notes/implemented/feature/2026-08-25-unified-task-list.i18n.yaml +++ /dev/null @@ -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: daf6582bf2c9e4b4a5ce26d6ae632fb78b30dbcb -2026-08-25-unified-task-list.zh.md: 624747396e175a216047239333575322ec4b1808 diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 022befcbd2..8e4c26b4bb 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/jobs.md -jobs.md: 0610d99892da22a25590ab7efe8ff71d9a004f01 -jobs.zh.md: 4c35fd7c879ddf7133c0db55df24948b40118b9d +jobs.md: 639faa8da3836cb3efc865c494cd8ca8c60f49fb +jobs.zh.md: 3d008671657847520eccd41b8a3bd75014777680 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 0610d99892..639faa8da3 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -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 (`-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 } ``` @@ -368,7 +377,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 — diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 4c35fd7c87..3d00867165 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -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 (`-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 } ``` @@ -368,7 +377,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 — diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts index 5bbb5a8c66..bc6827fdc2 100644 --- a/packages/api/job-controller/tests/observe.host.spec.ts +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -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[1]) => { face.append(text, opts) }, + append: (text: string, opts?: Parameters[1]) => { face.append(text, opts) }, updateDetail: (detail: string) => { face.updateDetail(detail) }, settle: async (outcome: JobOutcome) => { settle(outcome) diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 209cec1f23..3edaf89efb 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -38,7 +38,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 => ({ diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 3002c82a56..ff56058008 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1095,7 +1095,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.', }, @@ -4239,7 +4239,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', @@ -4537,6 +4537,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};', @@ -4657,6 +4661,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}', @@ -4739,7 +4751,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', diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 15cb5336b0..e5415aee15 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -19,7 +19,7 @@ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs' import type { JobAppendOptions, JobChannel, JobDoneListener, JobKind, JobOutcome, JobOutputListener, JobRead, - JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RunningJob, + JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RecordingJob, RunningJob, } from '@deepseek-ai/dsh-jobs' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ @@ -236,17 +236,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((resolve) => { markSettled = resolve }) @@ -556,23 +552,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({ diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index 9771023057..5bcb9b156e 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -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 & JobHooks> = {}) { done: new Promise((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 & 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() @@ -658,7 +658,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() @@ -1223,14 +1223,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() diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 662a521a55..54d4ecc8e9 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md -README.md: 10c74af908f64c18616e3c1eb71f19a3cbbf4eb3 -README.zh.md: 1fc500311c58a538ebfc7fbd89886d9c12b96acd +README.md: 5d4e10de9fa852dc4a38c521321bae53c744909b +README.zh.md: b584191a6b94e79ba8908875e7a0706a525edf4f diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index 10c74af908..5d4e10de9f 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -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 `-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 | diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 1fc500311c..b584191a6b 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 生产方以 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 偏移字段 | diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 796afc0882..4b6d6d9d43 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -33,6 +33,9 @@ export type { JobStart, JobStatus, JobsChangedListener, + PlainJobStart, + RecordingJob, + RecordingJobStart, RunningJob, } from './types.ts' export { pumpJobOutput } from './pump.ts' @@ -163,7 +166,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 — diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts index 5203157a9f..a3c1956019 100644 --- a/packages/jobs/jobs/src/pump.ts +++ b/packages/jobs/jobs/src/pump.ts @@ -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 { diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index b42d7815e4..3ec3f87357 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -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 (`-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. */ diff --git a/packages/jobs/jobs/tests/service.spec.ts b/packages/jobs/jobs/tests/service.spec.ts index 3938328478..331bf6596b 100644 --- a/packages/jobs/jobs/tests/service.spec.ts +++ b/packages/jobs/jobs/tests/service.spec.ts @@ -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 = '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[0]> = false + const recordingFace: HasAppend[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() {}, diff --git a/packages/shell/tool-bash/src/observe.ts b/packages/shell/tool-bash/src/observe.ts index 0758be7b7b..89a28817bc 100644 --- a/packages/shell/tool-bash/src/observe.ts +++ b/packages/shell/tool-bash/src/observe.ts @@ -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 { diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 8a4ac6ae2a..4586ada7a5 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -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' @@ -121,8 +121,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: { diff --git a/packages/shell/tool-pwsh/src/observe.ts b/packages/shell/tool-pwsh/src/observe.ts index 911145bd93..357567e31e 100644 --- a/packages/shell/tool-pwsh/src/observe.ts +++ b/packages/shell/tool-pwsh/src/observe.ts @@ -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 { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ac52455c6d..b78e1dcf07 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -513,6 +513,9 @@ export const LINK_MAP: Readonly> = { 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', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 2c24e9b407..327f58f9e3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -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", diff --git a/snapshots/web/cordis-tool-round/session.jsonl b/snapshots/web/cordis-tool-round/session.jsonl index e69dbc7f2a..354033a43e 100644 --- a/snapshots/web/cordis-tool-round/session.jsonl +++ b/snapshots/web/cordis-tool-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1788357779640,"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"}}