refactor(jobs): rename visibleTo/VisibleJobs to forCaller/CallerJobs
The caller-bound operation set now says what it is: the jobs one caller may operate on. The caller argument is required (SessionId | undefined), so an anonymous view has to be requested explicitly instead of falling out of a forgotten argument.
This commit is contained in:
1 parent
e001a9d2e4
commit
8bcd6f7519
43 files changed
+132
-132
No files matched your search
@@ -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-07-26-job-registry-seam.md
|
||||
2026-07-26-job-registry-seam.md: b48a0896b7db202b5d7439500f1fa4e54cee97e3
|
||||
2026-07-26-job-registry-seam.zh.md: 0b800430fdf4f77802e6203e948b9defea881d4f
|
||||
2026-07-26-job-registry-seam.md: 52676050bd3adb30923d2640ea7a4cb08746b260
|
||||
2026-07-26-job-registry-seam.zh.md: ab86370dcb5adcfad0ca7e97a24e306470679999
|
||||
@@ -12,7 +12,7 @@ The [background-job runtime](2026-06-20-generic-long-running-tool-runtime.md) sh
|
||||
|
||||
`jobs/` is now a three-package capability family in the bash-trio shape:
|
||||
|
||||
- **`@deepseek-ai/dsh-jobs` (Service Definition)** — the abstract `JobRegistry extends Service` owning `ctx.jobs`, the contract (`start`, the caller-bound `visibleTo` view with `list`, `get`, `read`, `readAt`, `kill`, and `wait`, the `events` stream, and `attachController`), all vocabulary types (`JobId`, `JobKindMap`, `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `JobView`, `JobRead`, `JobEvent`), and the event-protocol invariant companion. The class-level JSDoc states the semantics every Service Provider owes: registrations outlive producer and controller fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached job controller serves the spec's owner (controllers and event subscriptions are scope-layered, so one process-wide registry answers both questions per owner).
|
||||
- **`@deepseek-ai/dsh-jobs` (Service Definition)** — the abstract `JobRegistry extends Service` owning `ctx.jobs`, the contract (`start`, the caller-bound `forCaller` view with `list`, `get`, `read`, `readAt`, `kill`, and `wait`, the `events` stream, and `attachController`), all vocabulary types (`JobId`, `JobKindMap`, `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `JobView`, `JobRead`, `JobEvent`), and the event-protocol invariant companion. The class-level JSDoc states the semantics every Service Provider owes: registrations outlive producer and controller fibers, owned access is session-fenced, settlement is first-wins with contained listeners, and `start` refuses work while no attached job controller serves the spec's owner (controllers and event subscriptions are scope-layered, so one process-wide registry answers both questions per owner).
|
||||
- **`@deepseek-ai/dsh-jobs-local` (Service Provider)** — `LocalJobRegistry`, the process-local registry: the in-memory store, per-kind id counters, waiter bookkeeping, `TASK_WAIT_TIMEOUT` deadline code, owner-cleanup effects, force-fail teardown, and the default-10 configurable admission policy. Admission derives `running` plus `stopping` capacity from the same records per exact owner, with one unowned bucket; it adds no public count or second state owner. The `dsh-timeout` dependency and Schemastery-owned provider config live here; the Service Definition package has no provider dependencies.
|
||||
- **`@deepseek-ai/dsh-tool-jobs` (Consumer)** — unchanged; it injects `'jobs'` and never imports provider types.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
`jobs/` 如今是一个 bash 三件套形态的三包能力家族:
|
||||
|
||||
- **`@deepseek-ai/dsh-jobs`(Service Definition)**——抽象的 `JobRegistry extends Service`,拥有 `ctx.jobs`、约定(`start`、绑定调用方的 `visibleTo` 视图及其 `list`、`get`、`read`、`readAt`、`kill`、`wait`、`events` 事件流,以及 `attachController`)、全部词汇类型(`JobId`、`JobKindMap`、`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`JobView`、`JobRead`、`JobEvent`),以及事件协议不变式配套插件。类级 JSDoc 陈述了每个 Service Provider 都必须兑现的语义:注册的存续期长于生产方与控制器的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的任务控制器服务于 spec 的所有者时 `start` 拒绝启动工作(控制器与事件订阅按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。
|
||||
- **`@deepseek-ai/dsh-jobs`(Service Definition)**——抽象的 `JobRegistry extends Service`,拥有 `ctx.jobs`、约定(`start`、绑定调用方的 `forCaller` 视图及其 `list`、`get`、`read`、`readAt`、`kill`、`wait`、`events` 事件流,以及 `attachController`)、全部词汇类型(`JobId`、`JobKindMap`、`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`JobView`、`JobRead`、`JobEvent`),以及事件协议不变式配套插件。类级 JSDoc 陈述了每个 Service Provider 都必须兑现的语义:注册的存续期长于生产方与控制器的 fiber,有所有者的访问以会话为界,结算遵循首次结果优先且监听器错误被隔离,并且当没有任何已附加的任务控制器服务于 spec 的所有者时 `start` 拒绝启动工作(控制器与事件订阅按 scope 分层,因此一个进程级注册表能逐所有者地回答这两个问题)。
|
||||
- **`@deepseek-ai/dsh-jobs-local`(Service Provider)**——`LocalJobRegistry`,即进程内注册表:内存存储、按 kind 划分的 id 计数器、等待方簿记、`TASK_WAIT_TIMEOUT` deadline 代码、所有者清理 effect、强制失败的拆除,以及默认值为 10 且可配置的准入策略。准入从同一组记录中按确切 owner 派生 `running` 加 `stopping` 容量,并为无 owner 任务使用一个共享桶;它不新增公开计数或第二个状态 owner。`dsh-timeout` 依赖与由 Schemastery 管理的 Service Provider 配置都位于此包;Service Definition 包不含任何提供方依赖。
|
||||
- **`@deepseek-ai/dsh-tool-jobs`(Consumer)**——保持不变;它注入 `'jobs'`,从不导入提供方类型。
|
||||
|
||||
|
||||
+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: a1da1ba6f47dd0f5d557542d6b9668d6723fd683
|
||||
2026-09-01-jobs-absorb-activity-record.zh.md: fd735f1d9764dc83c0469209e241d47176584f5a
|
||||
2026-09-01-jobs-absorb-activity-record.md: 3e39d62a99283529f7a867a808f97cd46f26afcb
|
||||
2026-09-01-jobs-absorb-activity-record.zh.md: a2904d01712213eff7f35075d732c3b318f04092
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring, `VisibleJobs`, and `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds.
|
||||
Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring, `CallerJobs`, and `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds.
|
||||
|
||||
English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Status: implemented
|
||||
|
||||
Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环、`VisibleJobs` 与 `ctx.jobs`——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。
|
||||
Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环、`CallerJobs` 与 `ctx.jobs`——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。
|
||||
|
||||
[English](2026-09-01-jobs-absorb-activity-record.md) | 中文
|
||||
|
||||
|
||||
@@ -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-03-jobs-seam-consolidation.md
|
||||
2026-09-03-jobs-seam-consolidation.md: 0524870826d9721897b6d1b568f49f2d5da6cd1b
|
||||
2026-09-03-jobs-seam-consolidation.zh.md: bdc74d820832635bab5fe805b9d0fe68112457d1
|
||||
2026-09-03-jobs-seam-consolidation.md: 2b7f9c5cab7bba20c50dded9d361d8701e0ad792
|
||||
2026-09-03-jobs-seam-consolidation.zh.md: 169bb52a2fe512e7e94911e42e0c7981c5fa7273
|
||||
@@ -11,10 +11,10 @@ The job seam had grown by accretion. Producers declared their output twice — a
|
||||
## Decision
|
||||
|
||||
- **One ring per job.** `JobSpec` replaces `JobStart`: a spec names optional pull `output` sources (`JobOutputSource`, the subprocess `readFrom` family) that the registry pumps at its own cadence (`pumpPollMs` on `dsh-jobs-local`), and every producer receives a `JobHandle` whose `append` pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; `readOutput` and the `record` discriminator are gone. Shell producers hand the registry their `observed` offset readers and fold sandbox facts into the terminal `detail`; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as `JobOutcome.result`, which the first read after settlement carries once.
|
||||
- **Two cursors, one storage.** `VisibleJobs.read(id)` is the stateful model cursor; `VisibleJobs.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did.
|
||||
- **Two cursors, one storage.** `CallerJobs.read(id)` is the stateful model cursor; `CallerJobs.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did.
|
||||
- **`updateProgress` replaces `updateDetail`.** `JobView.progress` is the live line and is cleared at settlement; `JobView.detail` is the terminal reason, with the model's `job_kill` reason merged in.
|
||||
- **One projection.** `JobView` (client-safe leaf `@deepseek-ai/dsh-jobs/view`) is what the model tools, the browser roster, and the observation frames consume; `owner` is the session id, never the `Agent`.
|
||||
- **One caller-bound view.** `JobRegistry.visibleTo(caller?)` binds a session once and returns `list`, `get`, `read`, `readAt`, `kill`, and `wait`; every method throws for an id outside the caller's set without distinguishing unknown from foreign.
|
||||
- **One caller-bound view.** `JobRegistry.forCaller(caller)` binds a session once and returns `list`, `get`, `read`, `readAt`, `kill`, and `wait`; every method throws for an id outside the caller's set without distinguishing unknown from foreign.
|
||||
- **One event stream.** `events.subscribe(filter, listener)` with `{ owner }`, `{ owners: 'scope' }`, or `{ owners: 'all' }` replaces the three listener families. `settled` names its cause (`producer`, `kill`, `teardown`); `output` carries only the id and the new total.
|
||||
- **The notice ledger moves to `dsh-tool-jobs`.** The registry no longer tracks `reported`. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when `job_kill` is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement.
|
||||
- **The roster leaves the session control stream.** `dsh-api-job-controller` owns `job.rows({ sessionId })` — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside `job.observe`, and its client installs `ctx.jobs` (`watchRows`, `observe`, one snapshot). `dsh-api-session-controller` carries queues and projections only; `ui-jobs` watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind.
|
||||
|
||||
@@ -11,10 +11,10 @@ job seam 是逐步堆出来的。生产者要把输出声明两次——模型
|
||||
## 决策
|
||||
|
||||
- **每个 job 一个环。** `JobSpec` 取代 `JobStart`:spec 声明可选的拉取式 `output` 源(`JobOutputSource`,子进程的 `readFrom` 家族),由注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送;每个生产者都收到一个 `JobHandle`,其 `append` 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;`readOutput` 与 `record` 判别字段被移除。shell 生产者把它们的 `observed` 偏移读取器交给注册表,并把沙箱事实折进终态 `detail`;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 `JobOutcome.result` 返回,结算后的第一次读取携带它一次。
|
||||
- **两个游标,一份存储。** `VisibleJobs.read(id)` 是有状态的模型游标;`VisibleJobs.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。
|
||||
- **两个游标,一份存储。** `CallerJobs.read(id)` 是有状态的模型游标;`CallerJobs.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。
|
||||
- **`updateProgress` 取代 `updateDetail`。** `JobView.progress` 是实时行,结算时清除;`JobView.detail` 是终态原因,合并了模型 `job_kill` 给出的原因。
|
||||
- **一个投影。** `JobView`(客户端安全叶子 `@deepseek-ai/dsh-jobs/view`)是模型工具、浏览器名册与观测帧共同消费的形状;`owner` 是会话 id,从不是 `Agent`。
|
||||
- **一个绑定调用方的视图。** `JobRegistry.visibleTo(caller?)` 绑定一次会话并返回 `list`、`get`、`read`、`readAt`、`kill` 与 `wait`;每个方法对调用方集合之外的 id 抛出,不区分未知与他人。
|
||||
- **一个绑定调用方的视图。** `JobRegistry.forCaller(caller)` 绑定一次会话并返回 `list`、`get`、`read`、`readAt`、`kill` 与 `wait`;每个方法对调用方集合之外的 id 抛出,不区分未知与他人。
|
||||
- **一条事件流。** 带 `{ owner }`、`{ owners: 'scope' }` 或 `{ owners: 'all' }` 过滤的 `events.subscribe(filter, listener)` 取代三个监听器家族。`settled` 标出原因(`producer`、`kill`、`teardown`);`output` 只携带 id 与新的 total。
|
||||
- **播报台账移到 `dsh-tool-jobs`。** 注册表不再跟踪 `reported`。工具在等待开始时认领 job(超时或中止则撤回),在 `job_kill` 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。
|
||||
- **名册离开会话控制流。** `dsh-api-job-controller` 在 `job.observe` 旁拥有 `job.rows({ sessionId })`——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 `ctx.jobs`(`watchRows`、`observe`、一份快照)。`dsh-api-session-controller` 只承载队列与投影;`ui-jobs` 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => {
|
||||
return await tools.bash({ command: 'sleep 10', description: 'Must never start', run_in_background: true });
|
||||
`, pre.signal)
|
||||
expect(preResult.isError).toBe(true)
|
||||
expect(ctx.jobs.visibleTo().list()).toEqual([])
|
||||
expect(ctx.jobs.forCaller(undefined).list()).toEqual([])
|
||||
|
||||
const afterPublication = new AbortController()
|
||||
const running = runCode(ctx, `
|
||||
@@ -230,14 +230,14 @@ describe('PTC mode typed values: keyless real-worker contracts', () => {
|
||||
console.log(started.jobId);
|
||||
await new Promise(() => {});
|
||||
`, afterPublication.signal)
|
||||
for (let attempt = 0; attempt < 100 && ctx.jobs.visibleTo().list().length === 0; attempt++) {
|
||||
for (let attempt = 0; attempt < 100 && ctx.jobs.forCaller(undefined).list().length === 0; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
const job = ctx.jobs.visibleTo().list()[0]
|
||||
const job = ctx.jobs.forCaller(undefined).list()[0]
|
||||
expect(job).toMatchObject({ id: 'bash-1', status: 'running' })
|
||||
afterPublication.abort('outer-call-cancelled')
|
||||
expect((await running).isError).toBe(true)
|
||||
expect(ctx.jobs.visibleTo().list()[0]).toMatchObject({ id: job!.id, status: 'running' })
|
||||
expect(ctx.jobs.forCaller(undefined).list()[0]).toMatchObject({ id: job!.id, status: 'running' })
|
||||
|
||||
const killed = completion(await runCode(ctx, `
|
||||
return await tools.job_kill({ job_id: ${JSON.stringify(job!.id)}, reason: 'test owns cancellation' });
|
||||
@@ -261,7 +261,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => {
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(Date.now() - startedAt).toBeLessThan(5_000)
|
||||
expect(ctx.jobs.visibleTo().list()).toEqual([])
|
||||
expect(ctx.jobs.forCaller(undefined).list()).toEqual([])
|
||||
}, 15_000)
|
||||
|
||||
it('uses versioned Cordis DTO ids directly for running and pending Plugins, then confirms removal', async () => {
|
||||
|
||||
@@ -111,7 +111,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
|
||||
phase = 'settled'
|
||||
expect(scaffold.ctx.jobs.visibleTo(agent.id).kill(jobId, { reason: 'web e2e cancellation' })).toBe('requested')
|
||||
expect(scaffold.ctx.jobs.forCaller(agent.id).kill(jobId, { reason: 'web e2e cancellation' })).toBe('requested')
|
||||
|
||||
const idle = page.getByRole('button', { name: '1 background job', exact: true })
|
||||
await idle.waitFor({ timeout: 20_000 })
|
||||
|
||||
@@ -116,7 +116,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
|
||||
phase = 'settled'
|
||||
expect(scaffold.ctx.jobs.visibleTo(agent.id).kill(jobId, { reason: 'web e2e cancellation' })).toBe('requested')
|
||||
expect(scaffold.ctx.jobs.forCaller(agent.id).kill(jobId, { reason: 'web e2e cancellation' })).toBe('requested')
|
||||
|
||||
// Settlement arrives on the observation stream itself, so the open panel
|
||||
// flips without any further interaction; the roster trigger follows.
|
||||
|
||||
@@ -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: d312ed3d49b951a1a03e29b8528ec8acb47400d4
|
||||
jobs.zh.md: ec303b0f3e3f0e0d77da60fdc3719e8b2ffeb94e
|
||||
jobs.md: 2123e1d7896ab29af734becee0e9352a4b2dbaeb
|
||||
jobs.zh.md: c09caaef7f3f6f75cd836b7faef2f0796841c386
|
||||
+15
-15
@@ -122,14 +122,14 @@ interface JobOutcome {
|
||||
status: 'completed' | 'killed' | 'failed'
|
||||
/**
|
||||
* Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`).
|
||||
* When the job settles `killed` after a {@link VisibleJobs.kill} with a
|
||||
* When the job settles `killed` after a {@link CallerJobs.kill} with a
|
||||
* reason, the registry appends that reason.
|
||||
*/
|
||||
detail?: string
|
||||
/**
|
||||
* Return value for jobs whose result is a value rather than a stream (a
|
||||
* workflow's rendered result, a subagent's report). The output ring carries
|
||||
* the stream; this is handed out once by the model's next {@link VisibleJobs.read}.
|
||||
* the stream; this is handed out once by the model's next {@link CallerJobs.read}.
|
||||
*/
|
||||
result?: string
|
||||
}
|
||||
@@ -172,7 +172,7 @@ interface JobSourceRead {
|
||||
|
||||
## The output ring
|
||||
|
||||
Every job owns one bounded ring. Pull sources are pumped into it and `JobHandle.append` pushes land whole; the model consumes the ring through a registry-kept cursor (`VisibleJobs.read`), any number of observers read it at absolute byte offsets (`VisibleJobs.readAt`), and neither disturbs the other. `JobChannel` labels `stdout`, `stderr`, and `log`; `log` is producer narration that reaches observers only, never the model's consuming read. Settlement ends the stream and trims retention to the settled cap — the ring has no separate lifecycle. Browsers reach the roster and the ring through `job.rows` and `job.observe`, the Remote streams of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below.
|
||||
Every job owns one bounded ring. Pull sources are pumped into it and `JobHandle.append` pushes land whole; the model consumes the ring through a registry-kept cursor (`CallerJobs.read`), any number of observers read it at absolute byte offsets (`CallerJobs.readAt`), and neither disturbs the other. `JobChannel` labels `stdout`, `stderr`, and `log`; `log` is producer narration that reaches observers only, never the model's consuming read. Settlement ends the stream and trims retention to the settled cap — the ring has no separate lifecycle. Browsers reach the roster and the ring through `job.rows` and `job.observe`, the Remote streams of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below.
|
||||
|
||||
```ts type-equiv
|
||||
/** One chunk of a job's output ring: absolute offset, text, and its provenance. */
|
||||
@@ -191,7 +191,7 @@ interface JobChunk {
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Result of one non-consuming {@link VisibleJobs.readAt}. */
|
||||
/** Result of one non-consuming {@link CallerJobs.readAt}. */
|
||||
interface JobOutputRead {
|
||||
/** Retained chunks overlapping `[from, total)`, in offset order. */
|
||||
chunks: readonly JobChunk[]
|
||||
@@ -251,10 +251,10 @@ interface JobView {
|
||||
}
|
||||
```
|
||||
|
||||
`JobRegistry.visibleTo(caller)` binds one caller's identity and returns its operations; a caller sees its own jobs and every unowned job, and every operation throws for an id outside that set.
|
||||
`JobRegistry.forCaller(caller)` binds one caller's identity and returns its operations; a caller sees its own jobs and every unowned job, and every operation throws for an id outside that set.
|
||||
|
||||
```ts type-equiv
|
||||
/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */
|
||||
/** Output and post-read state returned by the consuming {@link CallerJobs.read}. */
|
||||
interface JobRead {
|
||||
/** Ring chunks appended since the model cursor, in offset order; every channel included. */
|
||||
chunks: readonly JobChunk[]
|
||||
@@ -269,11 +269,11 @@ interface JobRead {
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* The operations one caller may perform, bound by {@link JobRegistry.visibleTo}.
|
||||
* The operations one caller may perform, bound by {@link JobRegistry.forCaller}.
|
||||
* A caller sees its own jobs and every unowned job; every method throws for
|
||||
* an id outside that set, without distinguishing unknown from foreign.
|
||||
*/
|
||||
interface VisibleJobs {
|
||||
interface CallerJobs {
|
||||
/**
|
||||
* List the visible jobs in registration order.
|
||||
* @returns fresh projections.
|
||||
@@ -334,7 +334,7 @@ The registry announces every commit through one filtered stream. Lifecycle event
|
||||
/**
|
||||
* One lifecycle or output event. Lifecycle events carry the job's projection
|
||||
* after the commit they announce; `output` carries only the id and the new
|
||||
* total, so an observer schedules a {@link VisibleJobs.readAt} from its own
|
||||
* total, so an observer schedules a {@link CallerJobs.readAt} from its own
|
||||
* cursor and the registry never pushes payloads.
|
||||
*/
|
||||
type JobEvent =
|
||||
@@ -373,7 +373,7 @@ type JobEventFilter =
|
||||
|
||||
## Service behavior
|
||||
|
||||
The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-bound `visibleTo` (`list`, `get`, the consuming `read`, the non-consuming `readAt`, `kill`, and bounded `wait`), the filtered `events` stream, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the live `Agent` registered under the owner session when the job starts. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity; `retainBytes` (default 262144) and `settledRetainBytes` (default 16384) bound each ring's live and settled retention, and `pumpPollMs` (default 150) is the pull cadence. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer.
|
||||
The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-bound `forCaller` (`list`, `get`, the consuming `read`, the non-consuming `readAt`, `kill`, and bounded `wait`), the filtered `events` stream, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the live `Agent` registered under the owner session when the job starts. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity; `retainBytes` (default 262144) and `settledRetainBytes` (default 16384) bound each ring's live and settled retention, and `pumpPollMs` (default 150) is the pull cadence. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -443,13 +443,13 @@ abstract start(spec: JobSpec): JobId
|
||||
|
||||
/**
|
||||
* Bind the caller's identity once and return its operations. A caller sees
|
||||
* its own jobs and every unowned job; a caller-less view sees unowned jobs
|
||||
* only. The view resolves visibility on every call, so it stays valid as
|
||||
* jobs come and go.
|
||||
* @param caller - the reading session, or undefined for an anonymous caller.
|
||||
* its own jobs and every unowned job; an anonymous caller sees unowned jobs
|
||||
* only. The operations resolve visibility on every call, so they stay valid
|
||||
* as jobs come and go.
|
||||
* @param caller - the calling session, or `undefined` for an anonymous caller.
|
||||
* @returns the operations available to that caller.
|
||||
*/
|
||||
abstract visibleTo(caller?: SessionId): VisibleJobs
|
||||
abstract forCaller(caller: SessionId | undefined): CallerJobs
|
||||
|
||||
/**
|
||||
* Attach an effect-scoped controller that can read and stop jobs. It serves the
|
||||
|
||||
+15
-15
@@ -122,14 +122,14 @@ interface JobOutcome {
|
||||
status: 'completed' | 'killed' | 'failed'
|
||||
/**
|
||||
* Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`).
|
||||
* When the job settles `killed` after a {@link VisibleJobs.kill} with a
|
||||
* When the job settles `killed` after a {@link CallerJobs.kill} with a
|
||||
* reason, the registry appends that reason.
|
||||
*/
|
||||
detail?: string
|
||||
/**
|
||||
* Return value for jobs whose result is a value rather than a stream (a
|
||||
* workflow's rendered result, a subagent's report). The output ring carries
|
||||
* the stream; this is handed out once by the model's next {@link VisibleJobs.read}.
|
||||
* the stream; this is handed out once by the model's next {@link CallerJobs.read}.
|
||||
*/
|
||||
result?: string
|
||||
}
|
||||
@@ -172,7 +172,7 @@ interface JobSourceRead {
|
||||
|
||||
## 输出环
|
||||
|
||||
每个 job 拥有一个有界的环。拉取源被泵入其中,`JobHandle.append` 的推送整块落地;模型通过注册表保管的游标(`VisibleJobs.read`)消耗该环,任意数量的观察者按绝对字节偏移读取它(`VisibleJobs.readAt`),二者互不干扰。`JobChannel` 标记 `stdout`、`stderr` 与 `log`;`log` 是只到达观察者、从不进入模型消耗式读取的生产方叙述。结算即封流并把保留量裁剪到结算上限——环没有独立的生命周期。浏览器通过 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.rows` 与 `job.observe` 触达名册与环,其帧列于下文的 Cordis API 一节。
|
||||
每个 job 拥有一个有界的环。拉取源被泵入其中,`JobHandle.append` 的推送整块落地;模型通过注册表保管的游标(`CallerJobs.read`)消耗该环,任意数量的观察者按绝对字节偏移读取它(`CallerJobs.readAt`),二者互不干扰。`JobChannel` 标记 `stdout`、`stderr` 与 `log`;`log` 是只到达观察者、从不进入模型消耗式读取的生产方叙述。结算即封流并把保留量裁剪到结算上限——环没有独立的生命周期。浏览器通过 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.rows` 与 `job.observe` 触达名册与环,其帧列于下文的 Cordis API 一节。
|
||||
|
||||
```ts type-equiv
|
||||
/** One chunk of a job's output ring: absolute offset, text, and its provenance. */
|
||||
@@ -191,7 +191,7 @@ interface JobChunk {
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Result of one non-consuming {@link VisibleJobs.readAt}. */
|
||||
/** Result of one non-consuming {@link CallerJobs.readAt}. */
|
||||
interface JobOutputRead {
|
||||
/** Retained chunks overlapping `[from, total)`, in offset order. */
|
||||
chunks: readonly JobChunk[]
|
||||
@@ -251,10 +251,10 @@ interface JobView {
|
||||
}
|
||||
```
|
||||
|
||||
`JobRegistry.visibleTo(caller)` 绑定一个调用方的身份并返回其操作;调用方看得到自己的 job 与所有无主 job,每个操作对该集合之外的 id 抛出。
|
||||
`JobRegistry.forCaller(caller)` 绑定一个调用方的身份并返回其操作;调用方看得到自己的 job 与所有无主 job,每个操作对该集合之外的 id 抛出。
|
||||
|
||||
```ts type-equiv
|
||||
/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */
|
||||
/** Output and post-read state returned by the consuming {@link CallerJobs.read}. */
|
||||
interface JobRead {
|
||||
/** Ring chunks appended since the model cursor, in offset order; every channel included. */
|
||||
chunks: readonly JobChunk[]
|
||||
@@ -269,11 +269,11 @@ interface JobRead {
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* The operations one caller may perform, bound by {@link JobRegistry.visibleTo}.
|
||||
* The operations one caller may perform, bound by {@link JobRegistry.forCaller}.
|
||||
* A caller sees its own jobs and every unowned job; every method throws for
|
||||
* an id outside that set, without distinguishing unknown from foreign.
|
||||
*/
|
||||
interface VisibleJobs {
|
||||
interface CallerJobs {
|
||||
/**
|
||||
* List the visible jobs in registration order.
|
||||
* @returns fresh projections.
|
||||
@@ -334,7 +334,7 @@ interface VisibleJobs {
|
||||
/**
|
||||
* One lifecycle or output event. Lifecycle events carry the job's projection
|
||||
* after the commit they announce; `output` carries only the id and the new
|
||||
* total, so an observer schedules a {@link VisibleJobs.readAt} from its own
|
||||
* total, so an observer schedules a {@link CallerJobs.readAt} from its own
|
||||
* cursor and the registry never pushes payloads.
|
||||
*/
|
||||
type JobEvent =
|
||||
@@ -373,7 +373,7 @@ type JobEventFilter =
|
||||
|
||||
## 服务行为
|
||||
|
||||
抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、绑定调用方的 `visibleTo`(`list`、`get`、消耗式 `read`、非消耗的 `readAt`、`kill` 与有界的 `wait`)、带过滤的 `events` 流,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较拥有者会话;拥有者清理与准入使用 job 启动时登记在该拥有者会话下的活体 `Agent`。本地提供方的正安全整数配置 `maxConcurrentJobsPerOwner` 默认为 `10`,按精确拥有者统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方的终态结算释放容量;`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个环的运行期与结算后保留量,`pumpPollMs`(默认 150)是拉取节奏。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md) 了解 Service Definition 约定,[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md) 了解注册表生命周期与准入策略,[`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md) 了解面向模型的 Consumer。
|
||||
抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、绑定调用方的 `forCaller`(`list`、`get`、消耗式 `read`、非消耗的 `readAt`、`kill` 与有界的 `wait`)、带过滤的 `events` 流,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较拥有者会话;拥有者清理与准入使用 job 启动时登记在该拥有者会话下的活体 `Agent`。本地提供方的正安全整数配置 `maxConcurrentJobsPerOwner` 默认为 `10`,按精确拥有者统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方的终态结算释放容量;`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个环的运行期与结算后保留量,`pumpPollMs`(默认 150)是拉取节奏。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md) 了解 Service Definition 约定,[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md) 了解注册表生命周期与准入策略,[`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md) 了解面向模型的 Consumer。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -443,13 +443,13 @@ abstract start(spec: JobSpec): JobId
|
||||
|
||||
/**
|
||||
* Bind the caller's identity once and return its operations. A caller sees
|
||||
* its own jobs and every unowned job; a caller-less view sees unowned jobs
|
||||
* only. The view resolves visibility on every call, so it stays valid as
|
||||
* jobs come and go.
|
||||
* @param caller - the reading session, or undefined for an anonymous caller.
|
||||
* its own jobs and every unowned job; an anonymous caller sees unowned jobs
|
||||
* only. The operations resolve visibility on every call, so they stay valid
|
||||
* as jobs come and go.
|
||||
* @param caller - the calling session, or `undefined` for an anonymous caller.
|
||||
* @returns the operations available to that caller.
|
||||
*/
|
||||
abstract visibleTo(caller?: SessionId): VisibleJobs
|
||||
abstract forCaller(caller: SessionId | undefined): CallerJobs
|
||||
|
||||
/**
|
||||
* Attach an effect-scoped controller that can read and stop jobs. It serves the
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/job-controller/README.md
|
||||
README.md: f38d29b40caf57821d66ce6d8d39c6215014c7be
|
||||
README.zh.md: 7a0fab218eb67ff6220cbbdff124555bc0b762c8
|
||||
README.md: 7ce3022b1266ef8634d90b2055df2a2bf9cee8a9
|
||||
README.zh.md: 71ea23dd3827ad25db4b1c5c1dcd16741b89e51f
|
||||
@@ -22,7 +22,7 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.rows({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.observe({ sessionId?, jobId, from? })` reads through `visibleTo(sessionId)` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them.
|
||||
The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.rows({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.observe({ sessionId?, jobId, from? })` reads through `forCaller(sessionId)` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them.
|
||||
|
||||
The Client entry provides `ClientJobs` (`ctx.jobs`) over `ClientJobsModel`. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`.
|
||||
|
||||
@@ -52,7 +52,7 @@ No direct effect; observation reads never touch model requests.
|
||||
|
||||
- The ring is a best-effort live preview, not a terminal transcript: producers' pull sources are copied per poll round, so two streams' writes inside one poll window land stdout first, and the client concatenates chunks regardless of `channel`.
|
||||
- Both streams are process-local: a Host restart loses every ring and roster; a resumed observation then anchors on an empty registry, and a re-opened roster starts empty.
|
||||
- Per-session fencing is enforced by the registry's `visibleTo` read; the Remote layer itself serves any connected browser.
|
||||
- Per-session fencing is enforced by the registry's `forCaller` read; the Remote layer itself serves any connected browser.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -22,7 +22,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.rows({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.observe({ sessionId?, jobId, from? })` 经 `visibleTo(sessionId)` 读取——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。
|
||||
Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.rows({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.observe({ sessionId?, jobId, from? })` 经 `forCaller(sessionId)` 读取——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。
|
||||
|
||||
Client 入口在 `ClientJobsModel` 之上提供 `ClientJobs`(`ctx.jobs`)。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。
|
||||
|
||||
@@ -52,7 +52,7 @@ Client 入口在 `ClientJobsModel` 之上提供 `ClientJobs`(`ctx.jobs`)。`
|
||||
|
||||
- 环是尽力而为的实时预览,不是终端转录:生产者的拉取源按轮询轮次复制,同一轮询窗口内两条流的写入先 stdout 落地,客户端拼接 chunk 时也不区分 `channel`。
|
||||
- 两条流都是进程本地的:Host 重启丢失全部环与名册,续读的观测随后锚定在空注册表上,重开的名册从空开始。
|
||||
- 按 session 的围栏由注册表的 `visibleTo` 读取强制;Remote 层本身服务任何已连接浏览器。
|
||||
- 按 session 的围栏由注册表的 `forCaller` 读取强制;Remote 层本身服务任何已连接浏览器。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function* observeJobOutput(
|
||||
waiter.wake()
|
||||
})
|
||||
try {
|
||||
const visible = registry.visibleTo(request.sessionId)
|
||||
const visible = registry.forCaller(request.sessionId)
|
||||
let job = visible.get(id)
|
||||
let cursor = request.from ?? job.output.earliest
|
||||
yield { type: 'opened', job, from: cursor }
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function* streamJobRows(
|
||||
if (owner === undefined || owner === request.sessionId) waiter.wake()
|
||||
})
|
||||
try {
|
||||
const visible = registry.visibleTo(request.sessionId)
|
||||
const visible = registry.forCaller(request.sessionId)
|
||||
yield { type: 'rows', jobs: visible.list() }
|
||||
while (true) {
|
||||
await waiter.wait(signal)
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('observeJobOutput', () => {
|
||||
await done
|
||||
expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status'])
|
||||
expect(frames.at(-1)).toMatchObject({ type: 'status', job: { id: job.id, status: 'killed' } })
|
||||
expect(() => ctx.jobs.visibleTo(owner.id).get(job.id)).toThrow(/unknown job/)
|
||||
expect(() => ctx.jobs.forCaller(owner.id).get(job.id)).toThrow(/unknown job/)
|
||||
})
|
||||
|
||||
it('stops cleanly on abort while waiting for output', async () => {
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('streamJobRows', () => {
|
||||
|
||||
job.progress('3/10')
|
||||
expect((await next(iterator))?.jobs[0]).toMatchObject({ progress: '3/10' })
|
||||
ctx.jobs.visibleTo(alice.id).kill(job.id, { reason: 'enough' })
|
||||
ctx.jobs.forCaller(alice.id).kill(job.id, { reason: 'enough' })
|
||||
expect((await next(iterator))?.jobs[0]).toMatchObject({ status: 'stopping', progress: '3/10' })
|
||||
startJob(ctx, { label: 'second', owner: alice.id })
|
||||
expect((await next(iterator))?.jobs.map(entry => entry.label)).toEqual(['first', 'second'])
|
||||
|
||||
@@ -1162,9 +1162,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
returns: 'the registry-issued `<kind>-N` id.',
|
||||
},
|
||||
{
|
||||
signature: 'abstract visibleTo(caller?: SessionId): VisibleJobs',
|
||||
description: 'Bind the caller\'s identity once and return its operations. A caller sees its own jobs and every unowned job; a caller-less view sees unowned jobs only. The view resolves visibility on every call, so it stays valid as jobs come and go.',
|
||||
parameters: [{ name: 'caller', description: 'the reading session, or undefined for an anonymous caller.' }],
|
||||
signature: 'abstract forCaller(caller: SessionId | undefined): CallerJobs',
|
||||
description: 'Bind the caller\'s identity once and return its operations. A caller sees its own jobs and every unowned job; an anonymous caller sees unowned jobs only. The operations resolve visibility on every call, so they stay valid as jobs come and go.',
|
||||
parameters: [{ name: 'caller', description: 'the calling session, or `undefined` for an anonymous caller.' }],
|
||||
returns: 'the operations available to that caller.',
|
||||
},
|
||||
{
|
||||
@@ -3701,6 +3701,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'BrandedNumber',
|
||||
declaration: 'export type BrandedNumber<B extends string> = number & {\n readonly [BRAND]: B;\n};',
|
||||
},
|
||||
{
|
||||
name: 'CallerJobs',
|
||||
declaration: 'export interface CallerJobs {\n list(): JobView[];\n get(id: JobId): JobView;\n read(id: JobId): JobRead;\n readAt(id: JobId, from: number): JobOutputRead;\n kill(id: JobId, options?: JobKillOptions): \'requested\' | \'already-finished\';\n wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise<JobView>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ClientArtifactBaseline',
|
||||
declaration: 'export interface ClientArtifactBaseline {\n readonly path: string;\n readonly mtimeMs: number;\n readonly size: number;\n}',
|
||||
@@ -6165,10 +6169,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'VerifiedWebhookDelivery',
|
||||
declaration: 'export interface VerifiedWebhookDelivery<K extends string = string> {\n readonly kind: K;\n readonly source: WebhookSourceId;\n readonly deliveryId: WebhookDeliveryId;\n readonly event: WebhookEventOf<K>;\n readonly receivedAt: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'VisibleJobs',
|
||||
declaration: 'export interface VisibleJobs {\n list(): JobView[];\n get(id: JobId): JobView;\n read(id: JobId): JobRead;\n readAt(id: JobId, from: number): JobOutputRead;\n kill(id: JobId, options?: JobKillOptions): \'requested\' | \'already-finished\';\n wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise<JobView>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebBootBatch',
|
||||
declaration: 'export interface WebBootBatch {\n phase: WebBootBatchPhase;\n url: string;\n rev: string;\n entries: string[];\n}',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/jobs/jobs-local/README.md
|
||||
README.md: da48df0917986b7ecc03ee83d2ab31b5b6e8fad7
|
||||
README.zh.md: f217341b3f89864b6fc80d9f76c968ac0dc3f72c
|
||||
README.md: 00a07c328cbdc46d3dbeebcdddbbce37f1d67d7a
|
||||
README.zh.md: b95b80d316819982dbd800abd0dced3495d08a6f
|
||||
@@ -95,7 +95,7 @@ This section explains the design decisions behind the registry and points at the
|
||||
|
||||
### Admission and settlement
|
||||
|
||||
`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` records the terminal outcome once (merging a recorded kill reason into a `killed` detail), clears the progress line, trims the ring to the settled cap (keeping every byte the model cursor has not consumed), resolves every waiter, then emits `settled` with its cause and the ring's final `output` signal. The cause is `kill` after `VisibleJobs.kill`, `teardown` after an owner or service cancel, and `producer` otherwise; `dsh-tool-jobs` uses it to skip notices nobody can read.
|
||||
`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` records the terminal outcome once (merging a recorded kill reason into a `killed` detail), clears the progress line, trims the ring to the settled cap (keeping every byte the model cursor has not consumed), resolves every waiter, then emits `settled` with its cause and the ring's final `output` signal. The cause is `kill` after `CallerJobs.kill`, `teardown` after an owner or service cancel, and `producer` otherwise; `dsh-tool-jobs` uses it to skip notices nobody can read.
|
||||
|
||||
### Teardown
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ kind: "package-reference"
|
||||
|
||||
### 准入与结算
|
||||
|
||||
`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 只记录一次终止结果(把记录下来的 kill 原因合并进 `killed` 的 detail),清除进度行,把环裁剪到结算保留量(保留模型游标尚未消费的全部字节),解析每个等待方,然后发出带原因的 `settled` 与环的最终 `output` 信号。原因在 `VisibleJobs.kill` 之后为 `kill`,在所有者或服务取消之后为 `teardown`,否则为 `producer`;`dsh-tool-jobs` 据此跳过没人能读的通知。
|
||||
`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 只记录一次终止结果(把记录下来的 kill 原因合并进 `killed` 的 detail),清除进度行,把环裁剪到结算保留量(保留模型游标尚未消费的全部字节),解析每个等待方,然后发出带原因的 `settled` 与环的最终 `output` 信号。原因在 `CallerJobs.kill` 之后为 `kill`,在所有者或服务取消之后为 `teardown`,否则为 `producer`;`dsh-tool-jobs` 据此跳过没人能读的通知。
|
||||
|
||||
### 销毁
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKillOptions, JobKind, JobOutcome, JobOutputRead, JobOutputSource,
|
||||
JobRead, JobSettleCause, JobSpec, JobStatus, JobView, VisibleJobs,
|
||||
JobRead, JobSettleCause, JobSpec, JobStatus, JobView, CallerJobs,
|
||||
} from '@deepseek-ai/dsh-jobs'
|
||||
import { JobEventHub, JobLayer } from './events.ts'
|
||||
import { startPump } from './pump.ts'
|
||||
@@ -83,7 +83,7 @@ interface TrackedJob {
|
||||
cancel: (reason?: string) => void
|
||||
status: JobStatus
|
||||
ring: OutputRing
|
||||
/** The model's consuming cursor; {@link VisibleJobs.readAt} never moves it. */
|
||||
/** The model's consuming cursor; {@link CallerJobs.readAt} never moves it. */
|
||||
modelCursor: number
|
||||
/** Whether the first post-settlement read already handed out `result`. */
|
||||
resultDelivered: boolean
|
||||
@@ -94,7 +94,7 @@ interface TrackedJob {
|
||||
result: string | undefined
|
||||
startedAt: number
|
||||
finishedAt: number | undefined
|
||||
/** Reason recorded by {@link VisibleJobs.kill}, merged into a `killed` settlement's detail. */
|
||||
/** Reason recorded by {@link CallerJobs.kill}, merged into a `killed` settlement's detail. */
|
||||
killReason: string | undefined
|
||||
/** Set once a kill or teardown cancel ran; settlement reports it as the cause. */
|
||||
settleCause: JobSettleCause | undefined
|
||||
@@ -292,7 +292,7 @@ export class LocalJobRegistry extends JobRegistry {
|
||||
return id
|
||||
}
|
||||
|
||||
visibleTo(caller?: SessionId): VisibleJobs {
|
||||
forCaller(caller: SessionId | undefined): CallerJobs {
|
||||
const reach = (id: JobId): TrackedJob => {
|
||||
const job = this.expect(id)
|
||||
this.assertAccess(job, caller)
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 {
|
||||
JobEvent, JobEventFilter, JobHandle, JobHooks, JobKind, JobOutcome, JobOutputSource, JobSpec, JobView, VisibleJobs,
|
||||
JobEvent, JobEventFilter, JobHandle, JobHooks, JobKind, JobOutcome, JobOutputSource, JobSpec, JobView, CallerJobs,
|
||||
} from '@deepseek-ai/dsh-jobs'
|
||||
import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local'
|
||||
|
||||
@@ -112,8 +112,8 @@ async function harness(config: JobsConfig = {}) {
|
||||
}
|
||||
|
||||
/** The caller-bound view for an agent, or the anonymous view. */
|
||||
function jobsOf(ctx: Context, agent?: Agent): VisibleJobs {
|
||||
return ctx.jobs.visibleTo(agent?.id)
|
||||
function jobsOf(ctx: Context, agent?: Agent): CallerJobs {
|
||||
return ctx.jobs.forCaller(agent?.id)
|
||||
}
|
||||
|
||||
/** Collect events matching `filter`; `types` narrows what is recorded. */
|
||||
|
||||
@@ -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: 491f082d8083e75a1149dbed33f6c697e2f785ac
|
||||
README.zh.md: a15b4f7b458fde206bd548e02a6e8b91993ea0ba
|
||||
README.md: cde6b84f4574224305915410de24ea57175125d7
|
||||
README.zh.md: 93f466076c629bb989248f2647bd7815f69525a4
|
||||
@@ -77,14 +77,14 @@ This section explains the design decisions behind the contract and points at the
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract |
|
||||
| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `VisibleJobs`, `JobEvent`, and the read results |
|
||||
| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `CallerJobs`, `JobEvent`, and the read results |
|
||||
| [`src/view.ts`](src/view.ts) | Client-safe leaf: `JobView`, `JobChunk`, `JobStatus`, and the merge-extensible `JobKindMap` |
|
||||
| [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: checks the announced event protocol per job (registered first, one settlement, removal last) and each announced projection against the registry's own read |
|
||||
|
||||
### Service operations
|
||||
|
||||
Every operation is a thin projection over the registered jobs, bound to one caller by `visibleTo`: `list` and `get` return fresh projections, `read` advances the model's cursor and hands out the producer's result once after settlement, `readAt` reads retained chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status and records the reason for the terminal `detail`, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; `events.subscribe` delivers registration, progress, stopping, settlement, removal, and output commits at owner, scope, or process granularity.
|
||||
Every operation is a thin projection over the registered jobs, bound to one caller by `forCaller`: `list` and `get` return fresh projections, `read` advances the model's cursor and hands out the producer's result once after settlement, `readAt` reads retained chunks at an absolute offset without consuming anything, `kill` invokes producer cancellation before changing status and records the reason for the terminal `detail`, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; `events.subscribe` delivers registration, progress, stopping, settlement, removal, and output commits at owner, scope, or process granularity.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -77,14 +77,14 @@ kind: "package-reference"
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 |
|
||||
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`VisibleJobs`、`JobEvent` 与读取结果 |
|
||||
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`CallerJobs`、`JobEvent` 与读取结果 |
|
||||
| [`src/view.ts`](src/view.ts) | 客户端安全叶子:`JobView`、`JobChunk`、`JobStatus` 与可合并扩展的 `JobKindMap` |
|
||||
| [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验每个 job 的事件协议(先 registered、恰一次结算、最后 removed)以及每个通告的投影与注册表自身读取的一致性 |
|
||||
|
||||
### 服务操作
|
||||
|
||||
每个操作都是已注册任务之上的薄投影,由 `visibleTo` 绑定到一个调用方:`list` 与 `get` 返回全新投影,`read` 推进模型游标并在结算后把生产方的 result 交出一次,`readAt` 按绝对偏移读取保留块且不消耗任何东西,`kill` 在改变状态前调用生产方取消并为终态 `detail` 记录原因,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;`events.subscribe` 按所有者、scope 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。
|
||||
每个操作都是已注册任务之上的薄投影,由 `forCaller` 绑定到一个调用方:`list` 与 `get` 返回全新投影,`read` 推进模型游标并在结算后把生产方的 result 交出一次,`readAt` 按绝对偏移读取保留块且不消耗任何东西,`kill` 在改变状态前调用生产方取消并为终态 `detail` 记录原因,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;`events.subscribe` 按所有者、scope 或进程粒度投递注册、进度、停止中、结算、移除与输出提交。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { JobEvents, JobId, JobSpec, VisibleJobs } from './types.ts'
|
||||
import type { JobEvents, JobId, JobSpec, CallerJobs } from './types.ts'
|
||||
|
||||
export { JobId } from './types.ts'
|
||||
export type {
|
||||
@@ -36,7 +36,7 @@ export type {
|
||||
JobSpec,
|
||||
JobStatus,
|
||||
JobView,
|
||||
VisibleJobs,
|
||||
CallerJobs,
|
||||
} from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -105,13 +105,13 @@ export abstract class JobRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Bind the caller's identity once and return its operations. A caller sees
|
||||
* its own jobs and every unowned job; a caller-less view sees unowned jobs
|
||||
* only. The view resolves visibility on every call, so it stays valid as
|
||||
* jobs come and go.
|
||||
* @param caller - the reading session, or undefined for an anonymous caller.
|
||||
* its own jobs and every unowned job; an anonymous caller sees unowned jobs
|
||||
* only. The operations resolve visibility on every call, so they stay valid
|
||||
* as jobs come and go.
|
||||
* @param caller - the calling session, or `undefined` for an anonymous caller.
|
||||
* @returns the operations available to that caller.
|
||||
*/
|
||||
abstract visibleTo(caller?: SessionId): VisibleJobs
|
||||
abstract forCaller(caller: SessionId | undefined): CallerJobs
|
||||
|
||||
/**
|
||||
* Attach an effect-scoped controller that can read and stop jobs. It serves the
|
||||
|
||||
@@ -24,7 +24,7 @@ export const inject = ['invariants']
|
||||
/** The registry's own current projection of `id`, or undefined once the record is gone. */
|
||||
function readBack(ctx: Context, id: JobId, owner: JobView['owner']): JobView | undefined {
|
||||
try {
|
||||
return ctx.jobs.visibleTo(owner).get(id)
|
||||
return ctx.jobs.forCaller(owner).get(id)
|
||||
} catch {
|
||||
// The registry throws for an id outside the caller's set; after a removal that is the expected answer.
|
||||
return undefined
|
||||
|
||||
@@ -18,14 +18,14 @@ export interface JobOutcome {
|
||||
status: 'completed' | 'killed' | 'failed'
|
||||
/**
|
||||
* Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`).
|
||||
* When the job settles `killed` after a {@link VisibleJobs.kill} with a
|
||||
* When the job settles `killed` after a {@link CallerJobs.kill} with a
|
||||
* reason, the registry appends that reason.
|
||||
*/
|
||||
detail?: string
|
||||
/**
|
||||
* Return value for jobs whose result is a value rather than a stream (a
|
||||
* workflow's rendered result, a subagent's report). The output ring carries
|
||||
* the stream; this is handed out once by the model's next {@link VisibleJobs.read}.
|
||||
* the stream; this is handed out once by the model's next {@link CallerJobs.read}.
|
||||
*/
|
||||
result?: string
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export interface JobSpec {
|
||||
run(job: JobHandle): JobHooks
|
||||
}
|
||||
|
||||
/** Options for {@link VisibleJobs.kill}. */
|
||||
/** Options for {@link CallerJobs.kill}. */
|
||||
export interface JobKillOptions {
|
||||
/**
|
||||
* Cancellation reason: forwarded verbatim to the producer's cancel hook, and
|
||||
@@ -165,7 +165,7 @@ export interface JobKillOptions {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */
|
||||
/** Output and post-read state returned by the consuming {@link CallerJobs.read}. */
|
||||
export interface JobRead {
|
||||
/** Ring chunks appended since the model cursor, in offset order; every channel included. */
|
||||
chunks: readonly JobChunk[]
|
||||
@@ -177,7 +177,7 @@ export interface JobRead {
|
||||
job: JobView
|
||||
}
|
||||
|
||||
/** Result of one non-consuming {@link VisibleJobs.readAt}. */
|
||||
/** Result of one non-consuming {@link CallerJobs.readAt}. */
|
||||
export interface JobOutputRead {
|
||||
/** Retained chunks overlapping `[from, total)`, in offset order. */
|
||||
chunks: readonly JobChunk[]
|
||||
@@ -193,11 +193,11 @@ export interface JobOutputRead {
|
||||
}
|
||||
|
||||
/**
|
||||
* The operations one caller may perform, bound by {@link JobRegistry.visibleTo}.
|
||||
* The operations one caller may perform, bound by {@link JobRegistry.forCaller}.
|
||||
* A caller sees its own jobs and every unowned job; every method throws for
|
||||
* an id outside that set, without distinguishing unknown from foreign.
|
||||
*/
|
||||
export interface VisibleJobs {
|
||||
export interface CallerJobs {
|
||||
/**
|
||||
* List the visible jobs in registration order.
|
||||
* @returns fresh projections.
|
||||
@@ -251,7 +251,7 @@ export interface VisibleJobs {
|
||||
|
||||
/**
|
||||
* Why a job settled: its producer finished (`producer`), a
|
||||
* {@link VisibleJobs.kill} ran first (`kill`), or owner or service teardown
|
||||
* {@link CallerJobs.kill} ran first (`kill`), or owner or service teardown
|
||||
* cancelled it (`teardown`) — a settlement whose owner has no reader left.
|
||||
*/
|
||||
export type JobSettleCause = 'producer' | 'kill' | 'teardown'
|
||||
@@ -259,7 +259,7 @@ export type JobSettleCause = 'producer' | 'kill' | 'teardown'
|
||||
/**
|
||||
* One lifecycle or output event. Lifecycle events carry the job's projection
|
||||
* after the commit they announce; `output` carries only the id and the new
|
||||
* total, so an observer schedules a {@link VisibleJobs.readAt} from its own
|
||||
* total, so an observer schedules a {@link CallerJobs.readAt} from its own
|
||||
* cursor and the registry never pushes payloads.
|
||||
*/
|
||||
export type JobEvent =
|
||||
|
||||
@@ -28,7 +28,7 @@ async function setup() {
|
||||
const reads = new Map<string, JobView>()
|
||||
let listener: JobEventListener | undefined
|
||||
const probe = {
|
||||
visibleTo: (caller?: SessionId) => ({
|
||||
forCaller: (caller?: SessionId) => ({
|
||||
get: (id: JobId) => {
|
||||
const view = reads.get(String(id))
|
||||
if (view === undefined || view.owner !== caller) throw new Error(`unknown job ${String(id)}`)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JobEvent, JobEventFilter, JobEventListener, JobHooks, JobOutputSource, JobRead, JobSpec, JobView, VisibleJobs,
|
||||
JobEvent, JobEventFilter, JobEventListener, JobHooks, JobOutputSource, JobRead, JobSpec, JobView, CallerJobs,
|
||||
} from '@deepseek-ai/dsh-jobs'
|
||||
|
||||
/** Compile-time probe: does `Shape` carry a `Key` member? */
|
||||
@@ -45,7 +45,7 @@ class StubJobRegistry extends JobRegistry {
|
||||
return id
|
||||
}
|
||||
|
||||
visibleTo(caller?: SessionId): VisibleJobs {
|
||||
forCaller(caller: SessionId | undefined): CallerJobs {
|
||||
const view = (id: JobId): JobView => this.view(id, caller)
|
||||
return {
|
||||
list: () => [view(JobId('bash-1'))],
|
||||
@@ -73,7 +73,7 @@ describe('JobRegistry seam', () => {
|
||||
const id = ctx.jobs.start({ kind: 'bash', label: 'sleep 60', owner: caller, run: () => hooks })
|
||||
expect(id).toBe('bash-1')
|
||||
|
||||
const jobs = ctx.jobs.visibleTo(caller)
|
||||
const jobs = ctx.jobs.forCaller(caller)
|
||||
expect(jobs.list()).toHaveLength(1)
|
||||
expect(jobs.get(id).status).toBe('running')
|
||||
expect(jobs.get(id).owner).toBe(caller)
|
||||
@@ -82,7 +82,7 @@ describe('JobRegistry seam', () => {
|
||||
expect(jobs.kill(id, { reason: 'seam test' })).toBe('requested')
|
||||
await expect(jobs.wait(id, 5)).resolves.toMatchObject({ id })
|
||||
// A caller-less view is the unowned-only view.
|
||||
expect(ctx.jobs.visibleTo().get(id).owner).toBeUndefined()
|
||||
expect(ctx.jobs.forCaller(undefined).get(id).owner).toBeUndefined()
|
||||
|
||||
const events: JobEvent[] = []
|
||||
const unsubscribe = ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { events.push(event) })
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TextRetainer } from '@deepseek-ai/dsh-output-retention'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView, ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
import { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobView, VisibleJobs } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobView, CallerJobs } from '@deepseek-ai/dsh-jobs'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { publicJob, renderModelDelta, statusLine } from './render.ts'
|
||||
@@ -158,7 +158,7 @@ function visibleOutputLimit(ctx: Context, exec: ToolExecution): number | undefin
|
||||
if (exec.name !== 'job_output' && exec.name !== 'job_kill') return undefined
|
||||
const jobId = (exec.arguments as { job_id?: unknown } | null | undefined)?.job_id
|
||||
if (typeof jobId !== 'string' || jobId.length === 0) return undefined
|
||||
return ctx.jobs.visibleTo(exec.agent?.id).list().find(job => job.id === jobId)?.outputLimitBytes
|
||||
return ctx.jobs.forCaller(exec.agent?.id).list().find(job => job.id === jobId)?.outputLimitBytes
|
||||
}
|
||||
|
||||
/** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */
|
||||
@@ -180,7 +180,7 @@ function isTerminal(job: JobView): boolean {
|
||||
}
|
||||
|
||||
/** The consuming read as the model sees it: the delta, then the result once, then the status line. */
|
||||
function readBody(jobs: VisibleJobs, id: JobId): { text: string; job: PublicJobSnapshot } {
|
||||
function readBody(jobs: CallerJobs, id: JobId): { text: string; job: PublicJobSnapshot } {
|
||||
const read = jobs.read(id)
|
||||
const delta = renderModelDelta(read.chunks, read.lossy)
|
||||
const text = read.result === undefined
|
||||
@@ -349,7 +349,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const id = validateJobId(args.job_id)
|
||||
const jobs = ctx.jobs.visibleTo(exec.agent?.id)
|
||||
const jobs = ctx.jobs.forCaller(exec.agent?.id)
|
||||
if (args.wait === true) {
|
||||
const timeout = Math.min(args.timeout_ms ?? waitDefault, waitCap)
|
||||
const withdraw = claim(id)
|
||||
@@ -380,7 +380,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}],
|
||||
},
|
||||
execute(_args, exec) {
|
||||
const jobs = ctx.jobs.visibleTo(exec.agent?.id).list()
|
||||
const jobs = ctx.jobs.forCaller(exec.agent?.id).list()
|
||||
return Promise.resolve(jobs.map(publicJob))
|
||||
},
|
||||
presentCall: () => presentTaskCall('List background jobs', 'read'),
|
||||
@@ -416,7 +416,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
},
|
||||
execute(args, exec) {
|
||||
const id = validateJobId(args.job_id)
|
||||
const jobs = ctx.jobs.visibleTo(exec.agent?.id)
|
||||
const jobs = ctx.jobs.forCaller(exec.agent?.id)
|
||||
const result = jobs.kill(id, args.reason === undefined ? {} : { reason: args.reason })
|
||||
// The model's own kill is its delivery: the settlement notice would only
|
||||
// repeat what this tool result already said.
|
||||
|
||||
@@ -558,7 +558,7 @@ describe('job_kill', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
|
||||
expect(text(result)).toContain('[result truncated]')
|
||||
expect(ctx.jobs.visibleTo().get(JobId('bash-1')).status).toBe('running')
|
||||
expect(ctx.jobs.forCaller(undefined).get(JobId('bash-1')).status).toBe('running')
|
||||
})
|
||||
|
||||
it('bounds single-text post policy while preserving structured policy results', async () => {
|
||||
|
||||
@@ -63,7 +63,7 @@ async function until<T>(read: () => T | undefined, timeoutMs = 5_000): Promise<T
|
||||
}
|
||||
|
||||
function retainedText(ctx: Context, id: JobId, caller?: Agent): string {
|
||||
return ctx.jobs.visibleTo(caller?.id).readAt(id, 0).chunks.map(chunk => chunk.text).join('')
|
||||
return ctx.jobs.forCaller(caller?.id).readAt(id, 0).chunks.map(chunk => chunk.text).join('')
|
||||
}
|
||||
|
||||
describe('background bash output', () => {
|
||||
@@ -75,7 +75,7 @@ describe('background bash output', () => {
|
||||
run_in_background: true,
|
||||
})
|
||||
expect(text(ack)).toContain('started background job')
|
||||
const jobs = ctx.jobs.visibleTo()
|
||||
const jobs = ctx.jobs.forCaller(undefined)
|
||||
const job = jobs.list()[0]
|
||||
expect(job).toBeDefined()
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('background bash output', () => {
|
||||
it('a killed background job settles killed with the kill reason merged into its detail', async () => {
|
||||
const ctx = await setup()
|
||||
await call(ctx, { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
const jobs = ctx.jobs.visibleTo()
|
||||
const jobs = ctx.jobs.forCaller(undefined)
|
||||
const job = jobs.list()[0]
|
||||
jobs.kill(job!.id, { reason: 'test cleanup' })
|
||||
await until(() => jobs.get(job!.id).status === 'killed' ? true : undefined)
|
||||
@@ -113,7 +113,7 @@ describe('background bash output', () => {
|
||||
run_in_background: true,
|
||||
})
|
||||
expect(text(started)).toMatch(/^started background job bash-\d+$/)
|
||||
const jobs = ctx.jobs.visibleTo()
|
||||
const jobs = ctx.jobs.forCaller(undefined)
|
||||
const job = jobs.list()[0]
|
||||
await until(() => jobs.get(job!.id).status === 'killed' ? true : undefined)
|
||||
const read = text(await ctx.tools.execute({
|
||||
@@ -132,7 +132,7 @@ describe('background bash output', () => {
|
||||
description: 'test command',
|
||||
run_in_background: true,
|
||||
})
|
||||
const jobs = ctx.jobs.visibleTo()
|
||||
const jobs = ctx.jobs.forCaller(undefined)
|
||||
const job = jobs.list()[0]
|
||||
await until(() => jobs.get(job!.id).status === 'completed' ? true : undefined)
|
||||
const chunks = jobs.readAt(job!.id, 0).chunks
|
||||
@@ -190,7 +190,7 @@ describe('owned background output', () => {
|
||||
arguments: { command: 'echo owned', description: 'test command', run_in_background: true },
|
||||
agent: owner,
|
||||
})
|
||||
const owned = ctx.jobs.visibleTo(owner.id)
|
||||
const owned = ctx.jobs.forCaller(owner.id)
|
||||
const job = owned.list()[0]
|
||||
expect(job).toBeDefined()
|
||||
expect(job!.owner).toBe(owner.id)
|
||||
|
||||
@@ -114,7 +114,7 @@ describe('background pwsh output', () => {
|
||||
pwsh.backgroundHandler = () => scripted.proc
|
||||
|
||||
await call(ctx, { command: 'Get-Progress', description: 'test command', run_in_background: true })
|
||||
const jobs = ctx.jobs.visibleTo()
|
||||
const jobs = ctx.jobs.forCaller(undefined)
|
||||
const job = jobs.list()[0]
|
||||
expect(job).toBeDefined()
|
||||
expect(job!.kind).toBe('pwsh')
|
||||
@@ -158,7 +158,7 @@ describe('background pwsh output', () => {
|
||||
}
|
||||
pwsh.backgroundHandler = () => proc
|
||||
await call(ctx, { command: 'Get-Broken', description: 'test command', run_in_background: true })
|
||||
const jobs = ctx.jobs.visibleTo()
|
||||
const jobs = ctx.jobs.forCaller(undefined)
|
||||
const job = jobs.list()[0]
|
||||
await until(() => warn.mock.calls.some(args => String(args[0]).includes('output source for')) ? true : undefined)
|
||||
// The reader already failed; the job must wait for real settlement rather
|
||||
@@ -179,7 +179,7 @@ describe('background pwsh output', () => {
|
||||
pwsh.backgroundHandler = () => scripted.proc
|
||||
|
||||
await call(ctx, { command: 'Start-Job', description: 'test command', run_in_background: true })
|
||||
const jobs = ctx.jobs.visibleTo()
|
||||
const jobs = ctx.jobs.forCaller(undefined)
|
||||
const job = jobs.list()[0]
|
||||
expect(jobs.readAt(job!.id, 0).chunks).toEqual([])
|
||||
|
||||
@@ -208,11 +208,11 @@ describe('owned background output (pwsh)', () => {
|
||||
arguments: { command: 'Get-Slow', description: 'test command', run_in_background: true },
|
||||
agent: owner,
|
||||
})
|
||||
const owned = ctx.jobs.visibleTo(owner.id)
|
||||
const owned = ctx.jobs.forCaller(owner.id)
|
||||
const job = owned.list()[0]
|
||||
expect(job).toBeDefined()
|
||||
expect(job!.owner).toBe(owner.id)
|
||||
expect(() => ctx.jobs.visibleTo().readAt(job!.id, 0)).toThrow(/belongs to another session/)
|
||||
expect(() => ctx.jobs.forCaller(undefined).readAt(job!.id, 0)).toThrow(/belongs to another session/)
|
||||
expect(owned.readAt(job!.id, 0).chunks).toEqual([])
|
||||
scripted.finish()
|
||||
await until(() => owned.get(job!.id).status === 'completed' ? true : undefined)
|
||||
|
||||
@@ -972,7 +972,7 @@ describe('dsh-tool-subagent background mode', () => {
|
||||
const result = await resultPromise
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([])
|
||||
expect(ctx.jobs.forCaller(parent.id).list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects startup when the provider changes during asynchronous route preflight', async () => {
|
||||
@@ -1241,7 +1241,7 @@ describe('dsh-tool-subagent continuable background mode', () => {
|
||||
expect(match).not.toBeNull()
|
||||
const [, childId] = match!
|
||||
// No Task was created for the continuable child.
|
||||
expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([])
|
||||
expect(ctx.jobs.forCaller(parent.id).list()).toEqual([])
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.agents.get(SessionId(childId!))).toBeUndefined()
|
||||
@@ -1272,7 +1272,7 @@ describe('dsh-tool-subagent continuable background mode', () => {
|
||||
if (result.isError) throw new Error('expected foreground subagent success')
|
||||
expect(result.value).toMatchObject({ kind: 'foreground' })
|
||||
expect(text(result)).toBe('continuable answer')
|
||||
expect(ctx.jobs.visibleTo(parent.id).list()).toEqual([])
|
||||
expect(ctx.jobs.forCaller(parent.id).list()).toEqual([])
|
||||
})
|
||||
|
||||
it('isolates a cancelled continuable preparation from a concurrent sibling', async () => {
|
||||
|
||||
@@ -545,7 +545,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
JobEventListener: 'jobs.md',
|
||||
JobEvents: 'jobs.md',
|
||||
JobSettleCause: 'jobs.md',
|
||||
VisibleJobs: 'jobs.md',
|
||||
CallerJobs: 'jobs.md',
|
||||
CreateTeamTaskRequest: 'agent-team.md',
|
||||
SendTeamMessageRequest: 'agent-team.md',
|
||||
SendTeamMessageResult: 'agent-team.md',
|
||||
|
||||
@@ -1078,7 +1078,7 @@
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/jobs.md",
|
||||
"symbol": "VisibleJobs",
|
||||
"symbol": "CallerJobs",
|
||||
"source": "packages/jobs/jobs/src/types.ts"
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user