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.
This commit is contained in:
1 parent
6a4d7ee242
commit
b48c05d117
35 files changed
+326
-212
No files matched your search
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobOutcome, RecordingJob } from '@deepseek-ai/dsh-jobs'
|
||||
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
@@ -38,13 +38,13 @@ function registerAgent(ctx: Context, rawId: string): Agent {
|
||||
/** Start one record job whose done settles on demand; expose the producer face. */
|
||||
function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) {
|
||||
let settle!: (outcome: JobOutcome) => void
|
||||
let face!: RunningJob
|
||||
let face!: RecordingJob
|
||||
const id = ctx.jobs.start({
|
||||
kind: 'bash',
|
||||
label: options.label ?? 'echo',
|
||||
...options.owner !== undefined ? { owner: options.owner } : {},
|
||||
...options.record === false ? {} : { record: true },
|
||||
run: (job) => {
|
||||
run: (job: RecordingJob) => {
|
||||
face = job
|
||||
return {
|
||||
cancel() {},
|
||||
@@ -54,7 +54,7 @@ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent;
|
||||
})
|
||||
return {
|
||||
id,
|
||||
append: (text: string, opts?: Parameters<RunningJob['append']>[1]) => { face.append(text, opts) },
|
||||
append: (text: string, opts?: Parameters<RecordingJob['append']>[1]) => { face.append(text, opts) },
|
||||
updateDetail: (detail: string) => { face.updateDetail(detail) },
|
||||
settle: async (outcome: JobOutcome) => {
|
||||
settle(outcome)
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<void>((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({
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RunningJob } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RecordingJob } from '@deepseek-ai/dsh-jobs'
|
||||
import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local'
|
||||
|
||||
declare module '@deepseek-ai/dsh-jobs' {
|
||||
@@ -66,14 +66,14 @@ function producer(overrides: Partial<Omit<JobStart, 'run'> & JobHooks> = {}) {
|
||||
done: new Promise<JobOutcome>((res, rej) => { settle = res; reject = rej }),
|
||||
...hookOverrides,
|
||||
}
|
||||
let started: RunningJob | undefined
|
||||
let started: RecordingJob | undefined
|
||||
const spec: JobStart = {
|
||||
kind,
|
||||
label,
|
||||
...owner !== undefined ? { owner } : {},
|
||||
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
|
||||
...record !== undefined ? { record } : {},
|
||||
run: (job) => {
|
||||
run: (job: RecordingJob) => {
|
||||
started = job
|
||||
return hooks
|
||||
},
|
||||
@@ -83,7 +83,7 @@ function producer(overrides: Partial<Omit<JobStart, 'run'> & JobHooks> = {}) {
|
||||
settle,
|
||||
reject,
|
||||
cancels,
|
||||
job(): RunningJob {
|
||||
job(): RecordingJob {
|
||||
if (started === undefined) throw new Error('producer not started')
|
||||
return started
|
||||
},
|
||||
@@ -200,7 +200,7 @@ describe('LocalJobRegistry.start', () => {
|
||||
for (const job of live) ctx.jobs.start(job.spec)
|
||||
|
||||
const blocked = producer()
|
||||
const run = vi.fn((job: RunningJob) => blocked.spec.run(job))
|
||||
const run = vi.fn((job: RecordingJob) => blocked.spec.run(job))
|
||||
expect(() => ctx.jobs.start({ ...blocked.spec, run }))
|
||||
.toThrow('background job limit reached for this owner (limit: 10)')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
@@ -213,7 +213,7 @@ describe('LocalJobRegistry.start', () => {
|
||||
expect(ctx.jobs.start(first.spec)).toBe('bash-1')
|
||||
|
||||
const blocked = producer()
|
||||
const run = vi.fn((job: RunningJob) => blocked.spec.run(job))
|
||||
const run = vi.fn((job: RecordingJob) => blocked.spec.run(job))
|
||||
expect(() => ctx.jobs.start({ ...blocked.spec, run }))
|
||||
.toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry')
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -31,7 +31,7 @@ Use this package when you are composing a background-job capability or writing a
|
||||
|
||||
A producer registers work with a kind and a one-line label; the registry returns a `<kind>-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded.
|
||||
|
||||
A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RunningJob` face its starter receives: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state.
|
||||
A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RecordingJob` face its starter receives — a start without the declaration receives the plain `RunningJob` face, which has no `append`: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state.
|
||||
|
||||
### The ownership boundary
|
||||
|
||||
@@ -77,7 +77,7 @@ This section explains the design decisions behind the contract and points at the
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract |
|
||||
| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `JobSnapshot`, listener types |
|
||||
| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `RecordingJob`, `JobSnapshot`, listener types |
|
||||
| [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency |
|
||||
| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields |
|
||||
|
||||
@@ -31,7 +31,7 @@ kind: "package-reference"
|
||||
|
||||
生产方以 kind 和一行标签注册工作;注册表返回 `<kind>-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。
|
||||
|
||||
声明 `record: true` 的生产方还会通过 starter 收到的 `RunningJob` 面把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。
|
||||
声明 `record: true` 的生产方还会通过 starter 收到的 `RecordingJob` 面(未声明的 start 收到的是没有 `append` 的普通 `RunningJob` 面)把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。
|
||||
|
||||
### 归属边界
|
||||
|
||||
@@ -77,7 +77,7 @@ kind: "package-reference"
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 |
|
||||
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`JobSnapshot`、监听器类型 |
|
||||
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`RecordingJob`、`JobSnapshot`、监听器类型 |
|
||||
| [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 |
|
||||
| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 |
|
||||
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 |
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { JobChannel, RunningJob } from './types.ts'
|
||||
import type { JobChannel, RecordingJob } from './types.ts'
|
||||
|
||||
/** One non-consuming offset read from a producer-owned stream. */
|
||||
export interface JobPumpRead {
|
||||
@@ -74,7 +74,7 @@ export interface JobPumpOptions {
|
||||
* @returns resolves after the final post-settlement drain.
|
||||
*/
|
||||
export async function pumpJobOutput(
|
||||
job: RunningJob,
|
||||
job: RecordingJob,
|
||||
sources: readonly JobPumpSource[],
|
||||
options: JobPumpOptions,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -56,12 +56,8 @@ export interface JobAppendOptions {
|
||||
gapBefore?: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer declaration passed to {@link JobRegistry.start}. The runtime
|
||||
* preflights access and cleanup before invoking {@link run}; the producer owns
|
||||
* execution resources while the runtime owns identity and lifecycle state.
|
||||
*/
|
||||
export interface JobStart {
|
||||
/** Fields shared by every producer declaration passed to {@link JobRegistry.start}. */
|
||||
interface JobStartBase {
|
||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
|
||||
kind: JobKind
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
@@ -80,15 +76,34 @@ export interface JobStart {
|
||||
* open to any caller until service disposal.
|
||||
*/
|
||||
owner?: Agent
|
||||
}
|
||||
|
||||
/**
|
||||
* A start with the model-facing surfaces only. Its producer face carries no
|
||||
* `append`, so output the registry would have nowhere to keep is
|
||||
* unrepresentable rather than dropped.
|
||||
*/
|
||||
export interface PlainJobStart extends JobStartBase {
|
||||
record?: undefined
|
||||
/**
|
||||
* Declares an observable output record beside the model-facing surfaces:
|
||||
* {@link RunningJob.append} retains chunks in a bounded ring that any number
|
||||
* of observers read at absolute byte offsets through
|
||||
* {@link JobRegistry.readRecord}, and snapshots carry
|
||||
* {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}.
|
||||
* Without the declaration `append` logs and drops.
|
||||
* Start the work after preflight and synchronously return its hooks. Called
|
||||
* once with the job's producer face; a throw leaves nothing registered (the
|
||||
* spent ordinal is skipped), and the producer must clean up any partially
|
||||
* started resources.
|
||||
* @param job - the issued id plus the live-detail writer.
|
||||
*/
|
||||
record?: true
|
||||
run(job: RunningJob): JobHooks
|
||||
}
|
||||
|
||||
/**
|
||||
* A start that declares an observable output record beside the model-facing
|
||||
* surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that
|
||||
* any number of observers read at absolute byte offsets through
|
||||
* {@link JobRegistry.readRecord}, and snapshots carry
|
||||
* {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}.
|
||||
*/
|
||||
export interface RecordingJobStart extends JobStartBase {
|
||||
record: true
|
||||
/**
|
||||
* Start the work after preflight and synchronously return its hooks. Called
|
||||
* once with the job's producer face; a throw leaves nothing registered (the
|
||||
@@ -96,34 +111,44 @@ export interface JobStart {
|
||||
* started resources.
|
||||
* @param job - the issued id plus the record append and live-detail writers.
|
||||
*/
|
||||
run(job: RunningJob): JobHooks
|
||||
run(job: RecordingJob): JobHooks
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer face of one registered job, handed to {@link JobStart.run} and
|
||||
* valid for the job's whole life. All methods are synchronous. Writes staged
|
||||
* inside the starter call are retained and become visible with the
|
||||
* registration commit; after settlement — the producer's own outcome, a kill,
|
||||
* or a registry-forced teardown end — both methods log and drop instead of
|
||||
* throwing, so a producer's trailing flush cannot break its own teardown path.
|
||||
* Producer declaration passed to {@link JobRegistry.start}, discriminated by
|
||||
* `record`. The runtime preflights access and cleanup before invoking `run`;
|
||||
* the producer owns execution resources while the runtime owns identity and
|
||||
* lifecycle state.
|
||||
*/
|
||||
export type JobStart = PlainJobStart | RecordingJobStart
|
||||
|
||||
/**
|
||||
* Producer face of one registered job, handed to `run` and valid for the
|
||||
* job's whole life. All methods are synchronous. Writes staged inside the
|
||||
* starter call are retained and become visible with the registration commit;
|
||||
* after settlement — the producer's own outcome, a kill, or a registry-forced
|
||||
* teardown end — writes log and drop instead of throwing, so a producer's
|
||||
* trailing flush cannot break its own teardown path.
|
||||
*/
|
||||
export interface RunningJob {
|
||||
/** The registry-issued id (`<kind>-N`). */
|
||||
readonly id: JobId
|
||||
/**
|
||||
* Replace the snapshot's status detail with a live progress line (`3/10`).
|
||||
* @param detail - the new detail line.
|
||||
*/
|
||||
updateDetail(detail: string): void
|
||||
}
|
||||
|
||||
/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */
|
||||
export interface RecordingJob extends RunningJob {
|
||||
/**
|
||||
* Append one record chunk. Offsets advance by the chunk's UTF-8 byte
|
||||
* length; an empty chunk is dropped without waking observers. Without a
|
||||
* {@link JobStart.record} declaration the chunk is logged and dropped.
|
||||
* length; an empty chunk is dropped without waking observers.
|
||||
* @param text - the chunk text, exactly as produced.
|
||||
* @param options - stream label and gap marker.
|
||||
*/
|
||||
append(text: string, options?: JobAppendOptions): void
|
||||
/**
|
||||
* Replace the snapshot's status detail with a live progress line (`3/10`).
|
||||
* Works for every job, with or without a record.
|
||||
* @param detail - the new detail line.
|
||||
*/
|
||||
updateDetail(detail: string): void
|
||||
}
|
||||
|
||||
/** Hooks through which the runtime controls and observes producer work. */
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JobAppendOptions, JobDoneListener, JobOutputListener, JobPumpSource, JobRead, JobRecordRead,
|
||||
JobSnapshot, JobStart, JobsChangedListener, RunningJob,
|
||||
JobAppendOptions, JobDoneListener, JobHooks, JobOutputListener, JobPumpSource, JobRead, JobRecordRead,
|
||||
JobSnapshot, JobStart, JobsChangedListener, PlainJobStart, RecordingJob, RecordingJobStart,
|
||||
} from '@deepseek-ai/dsh-jobs'
|
||||
|
||||
/**
|
||||
@@ -71,6 +71,40 @@ class StubJobRegistry extends JobRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile-time probe: does the producer face `Face` carry `append`? */
|
||||
type HasAppend<Face> = 'append' extends keyof Face ? true : false
|
||||
|
||||
describe('JobStart producer faces', () => {
|
||||
it('hands append only to a start that declares a record', () => {
|
||||
const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) }
|
||||
// The literal's `record` discriminant selects the face `run` receives.
|
||||
const plain: JobStart = {
|
||||
kind: 'bash',
|
||||
label: 'no record',
|
||||
run: (job) => {
|
||||
job.updateDetail('works for every job')
|
||||
return hooks
|
||||
},
|
||||
}
|
||||
const recording: JobStart = {
|
||||
kind: 'bash',
|
||||
label: 'record',
|
||||
record: true,
|
||||
run: (job) => {
|
||||
job.append('lands in the record')
|
||||
return hooks
|
||||
},
|
||||
}
|
||||
// Negative proof: a plain start's face has no `append` at the type level.
|
||||
const plainFace: HasAppend<Parameters<PlainJobStart['run']>[0]> = false
|
||||
const recordingFace: HasAppend<Parameters<RecordingJobStart['run']>[0]> = true
|
||||
expect(plainFace).toBe(false)
|
||||
expect(recordingFace).toBe(true)
|
||||
expect(plain.record).toBeUndefined()
|
||||
expect(recording.record).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('JobRegistry seam', () => {
|
||||
it('a concrete subclass registers as ctx.jobs and serves the abstract API', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -109,9 +143,9 @@ describe('JobRegistry seam', () => {
|
||||
})
|
||||
|
||||
/** Record every append so pump behavior is observable without a registry. */
|
||||
function recordingJob(): { job: RunningJob; appends: { text: string; options?: JobAppendOptions }[] } {
|
||||
function recordingJob(): { job: RecordingJob; appends: { text: string; options?: JobAppendOptions }[] } {
|
||||
const appends: { text: string; options?: JobAppendOptions }[] = []
|
||||
const job: RunningJob = {
|
||||
const job: RecordingJob = {
|
||||
id: JobId('bash-1'),
|
||||
append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) },
|
||||
updateDetail() {},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
|
||||
import { pumpJobOutput } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs'
|
||||
|
||||
/**
|
||||
* Pump the process's non-consuming offset readers into the job record until
|
||||
@@ -31,7 +31,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
|
||||
*/
|
||||
export function observeProcessRecord(
|
||||
ctx: Context,
|
||||
job: RunningJob,
|
||||
job: RecordingJob,
|
||||
proc: ShellProcess,
|
||||
pollMs: number,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -10,7 +10,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import type { JobId, RunningJob } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobId, RecordingJob } from '@deepseek-ai/dsh-jobs'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
|
||||
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
@@ -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: {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
|
||||
import { pumpJobOutput } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs'
|
||||
|
||||
/**
|
||||
* Pump the process's non-consuming offset readers into the job record until
|
||||
@@ -32,7 +32,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
|
||||
*/
|
||||
export function observeProcessRecord(
|
||||
ctx: Context,
|
||||
job: RunningJob,
|
||||
job: RecordingJob,
|
||||
proc: ShellProcess,
|
||||
pollMs: number,
|
||||
): Promise<void> {
|
||||
|
||||
Reference in new issue
Block a user