fix(jobs): use short UUIDs for visible job ids

This commit is contained in:
fz committed 2026-10-08 21:04:58 +08:00
1 parent 7af8741513
commit e493fd40dc
33 files changed
+107 -76

No files matched your search

@@ -212,7 +212,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
});
return started.jobId;
`))
expect(jobId).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(jobId).toMatch(/^bash-[0-9a-f]{16}$/)
const polled = completion(await runCode(ctx, `
return await tools.job_output({ job_id: ${JSON.stringify(jobId)}, wait: true, timeout_ms: 5000 });
@@ -258,7 +258,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
])
const job = ctx.jobs.list()[0]
expect(job).toMatchObject({ status: 'running' })
expect(job?.id).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(job?.id).toMatch(/^bash-[0-9a-f]{16}$/)
afterPublication.abort('outer-call-cancelled')
expect((await running).isError).toBe(true)
expect(ctx.jobs.list()[0]).toMatchObject({ id: job!.id, status: 'running' })
+1 -1
View File
@@ -95,7 +95,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
agent,
})
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
const matched = /\bbash-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/.exec(reported)
const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported)
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
const jobId = JobId(matched[0])
+1 -1
View File
@@ -95,7 +95,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => {
agent,
})
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
const matched = /\bbash-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\b/.exec(reported)
const matched = /\bbash-[0-9a-f]{16}\b/.exec(reported)
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
const jobId = JobId(matched[0])
+1
View File
@@ -1545,6 +1545,7 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st
.split(workspaceCwd).join('{{cwd}}')
.split(base).join('{{workspace}}')
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
.replace(/(?<=-)[0-9a-f]{16}(?![0-9a-f])/gi, '{{uuid}}')
// The optional space in `\d+m ?\d+s` covers both minute spellings: the
// stats line's compact `2m42s` and the message-chrome template's `2m 42s`.
.replace(
+1 -1
View File
@@ -690,7 +690,7 @@ it('lets a preset producer reach the background-job registry', async () => {
agent: handle.agent,
})
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
const matched = /^started background job (bash-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})$/.exec(reported)
const matched = /^started background job (bash-[0-9a-f]{16})$/.exec(reported)
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
const jobId = matched[1]
expect({ isError: started.isError, content: started.content }).toEqual({
+2 -2
View File
@@ -6,8 +6,8 @@
en: dd515d8548864a91
zh: 16a117d39989a373
/background-job-runtime/ids-and-status:
en: 3cb96fdb6ae34bd2
zh: 7c85929b455f9431
en: 4b286c70e4a83b99
zh: 7edbf090e9cb6487
/background-job-runtime/session-lifecycle-records:
en: 1b6b65e4c2983518
zh: 12bd4c7158d5e55e
+4 -4
View File
@@ -6,7 +6,7 @@ Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam
## Ids and status
`JobId` is a [branded id](core.md#branded-ids) generated as `<kind>-<uuid>`. Access control relies on owner authorization, not id secrecy. `JobKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces.
`JobId` is a [branded id](core.md#branded-ids) generated as `<kind>-<short-uuid>` with 16 lowercase UUID hex characters after the kind. Access control relies on owner authorization, not id secrecy. `JobKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces.
```ts type-equiv
/**
@@ -130,7 +130,7 @@ interface JobSpec {
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
interface JobHandle {
/** The registry-issued id (`<kind>-<uuid>`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* Append one chunk to the output ring. Offsets advance by the chunk's UTF-8
@@ -273,7 +273,7 @@ interface JobOutputRead {
* stream all consume this one shape.
*/
interface JobView {
/** The registry-issued id (`<kind>-<uuid>`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* The producer kind the job was registered with: a Host-registered
@@ -459,7 +459,7 @@ Implementations must honor these semantics:
* rejection leaves no job id or execution resource. A throwing starter
* leaves nothing registered; after it returns, registration cannot fail.
* @param spec - job identity, owner, output sources, and synchronous starter.
* @returns the registry-issued `<kind>-<uuid>` id.
* @returns the registry-issued `<kind>-<short-uuid>` id.
*/
abstract start(spec: JobSpec): JobId
+4 -4
View File
@@ -6,7 +6,7 @@
## ID 与状态
`JobId` 是按 `<kind>-<uuid>` 生成的[品牌化 id](core.zh.md#branded-ids)。访问控制依赖拥有者授权,而非 id 的保密性。`JobKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。
`JobId` 是按 `<kind>-<short-uuid>` 生成的[品牌化 id](core.zh.md#branded-ids),kind 后是 UUID 的 16 位小写十六进制字符。访问控制依赖拥有者授权,而非 id 的保密性。`JobKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。
```ts type-equiv
/**
@@ -130,7 +130,7 @@ interface JobSpec {
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
interface JobHandle {
/** The registry-issued id (`<kind>-<uuid>`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* Append one chunk to the output ring. Offsets advance by the chunk's UTF-8
@@ -273,7 +273,7 @@ interface JobOutputRead {
* stream all consume this one shape.
*/
interface JobView {
/** The registry-issued id (`<kind>-<uuid>`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* The producer kind the job was registered with: a Host-registered
@@ -459,7 +459,7 @@ Implementations must honor these semantics:
* rejection leaves no job id or execution resource. A throwing starter
* leaves nothing registered; after it returns, registration cannot fail.
* @param spec - job identity, owner, output sources, and synchronous starter.
* @returns the registry-issued `<kind>-<uuid>` id.
* @returns the registry-issued `<kind>-<short-uuid>` id.
*/
abstract start(spec: JobSpec): JobId
@@ -9,8 +9,8 @@
en: 9b06780be69e93f6
zh: c33945fd584c6a23
/background-job-ids-identify-one-execution/change:
en: 93ae25c874b916b9
zh: 38dee1fc3edd2873
en: 047ec9d0337bfc0b
zh: 1218472855e81189
/background-job-ids-identify-one-execution/migration:
en: c5ec322772f41799
zh: 8e9f27d749995be2
@@ -9,7 +9,7 @@ English | [中文](guide.zh.md)
## Change
The job registry returns `<kind>-<uuid>` instead of `<kind>-N`. The same `JobId` appears in live controls, `job/registered`, and `job/settled`; lifecycle records no longer carry `executionId`. The identifier remains owner-authorized. Existing Session histories remain unchanged and do not resume their jobs.
The job registry returns `<kind>-<short-uuid>` instead of `<kind>-N`; the suffix is 16 lowercase UUID hex characters. The same `JobId` appears in live controls, `job/registered`, and `job/settled`; lifecycle records no longer carry `executionId`. The identifier remains owner-authorized. Existing Session histories remain unchanged and do not resume their jobs.
## Migration
@@ -9,7 +9,7 @@ description: "JobId 现在跨注册表生命周期标识一次后台执行。"
## 变更
job 注册表返回 `<kind>-<uuid>`,不再返回 `<kind>-N`。运行期控制、`job/registered` 和 `job/settled` 使用同一个 `JobId`;生命周期记录不再携带 `executionId`。访问仍按所有者授权。已有 Session 历史保持原样,不会恢复其中的 job。
job 注册表返回 `<kind>-<short-uuid>`,不再返回 `<kind>-N`;后缀是 UUID 的 16 位小写十六进制字符。运行期控制、`job/registered` 和 `job/settled` 使用同一个 `JobId`;生命周期记录不再携带 `executionId`。访问仍按所有者授权。已有 Session 历史保持原样,不会恢复其中的 job。
## 迁移
@@ -145,13 +145,6 @@ function sendDetail(result: TerminalSendResult): string {
: `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
}
function renderBackgroundJobId(jobId: string, maxBytes: number): string {
const acknowledgement = `started background job ${jobId}`
return Buffer.byteLength(acknowledgement) <= maxBytes
? acknowledgement
: `started job ${jobId}`
}
/** Register all terminal tools and the minimal usage guidance. */
export function apply(ctx: Context, config: Config = {}): void {
const enableRunInBackground = config.enableRunInBackground ?? true
@@ -243,7 +236,7 @@ export function apply(ctx: Context, config: Config = {}): void {
render: (_args, value) => [{
type: 'text',
text: value.kind === 'background'
? renderBackgroundJobId(value.jobId, maxResultBytes)
? `started background job ${value.jobId}`
: renderSend(value, maxResultBytes),
}],
presentationMeta: (_args, value) => value.kind === 'foreground'
@@ -146,7 +146,7 @@ function backgroundJobId(result: Awaited<ReturnType<typeof call>>): string {
if (typeof value !== 'object' || value === null || !('jobId' in value) || typeof value.jobId !== 'string') {
throw new Error('missing background job id')
}
expect(value.jobId).toMatch(/^pty-send-[0-9a-f-]{36}$/)
expect(value.jobId).toMatch(/^pty-send-[0-9a-f]{16}$/)
return value.jobId
}
@@ -355,7 +355,7 @@ describe('tool-terminal foreground API', () => {
const background = await call(ctx, 'terminal_send', {
sessionId: 'pty-1', text: 'work', run_in_background: true,
}, agent)
expect(text(background)).toBe(`started job ${backgroundJobId(background)}`)
expect(text(background)).toBe(`started background job ${backgroundJobId(background)}`)
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
})
@@ -1353,7 +1353,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'abstract start(spec: JobSpec): JobId',
description: 'Preflight access, validation, owner cleanup, and implementation-owned admission before starting and atomically registering work. Any preflight rejection leaves no job id or execution resource. A throwing starter leaves nothing registered; after it returns, registration cannot fail.',
parameters: [{ name: 'spec', description: 'job identity, owner, output sources, and synchronous starter.' }],
returns: 'the registry-issued `<kind>-<uuid>` id.',
returns: 'the registry-issued `<kind>-<short-uuid>` id.',
},
{
signature: 'abstract list(caller?: SessionId): JobView[]',
+6 -2
View File
@@ -224,8 +224,12 @@ export class LocalJobRegistry extends JobRegistry {
)
}
// The producer uses the same identity as controls and durable lifecycle records.
const id = JobId(`${spec.kind}-${randomUUID()}`)
// The producer uses the same short UUID identity as controls and durable lifecycle records.
// Retry a collision while this registry still holds an earlier id.
let id: JobId
do {
id = JobId(`${spec.kind}-${randomUUID().replaceAll('-', '').slice(0, 16)}`)
} while (this.store.has(id))
const toolCall = spec.toolCall === undefined ? undefined : { ...spec.toolCall }
const ring = new OutputRing()
const state: ProducerState = { progress: undefined, job: undefined }
+39 -11
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto'
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -14,6 +15,13 @@ import type {
import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
vi.mock('node:crypto', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:crypto')>()
return { ...actual, randomUUID: vi.fn(actual.randomUUID) }
})
const randomUuid = vi.mocked(randomUUID)
declare module '@deepseek-ai/dsh-jobs' {
interface JobKindMap {
'test-task': 'test-task'
@@ -249,7 +257,7 @@ describe('LocalJobRegistry.start', () => {
it('rejects before producer start and id allocation, then admits immediately after settlement', async () => {
const ctx = await harness({ maxConcurrentJobsPerOwner: 1 })
const first = producer()
expect(ctx.jobs.start(first.spec)).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(ctx.jobs.start(first.spec)).toMatch(/^bash-[0-9a-f]{16}$/)
const blocked = producer()
const run = vi.fn((job: JobHandle) => blocked.spec.run(job))
@@ -259,7 +267,7 @@ describe('LocalJobRegistry.start', () => {
first.settle({ status: 'completed' })
await tick()
expect(ctx.jobs.start(blocked.spec)).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(ctx.jobs.start(blocked.spec)).toMatch(/^bash-[0-9a-f]{16}$/)
})
it('keeps a stopping job in the bucket until producer settlement', async () => {
@@ -273,7 +281,7 @@ describe('LocalJobRegistry.start', () => {
first.settle({ status: 'killed' })
await tick()
expect(ctx.jobs.start(replacement.spec)).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(ctx.jobs.start(replacement.spec)).toMatch(/^bash-[0-9a-f]{16}$/)
})
it.each(['completed', 'killed', 'failed'] as const)(
@@ -315,10 +323,30 @@ describe('LocalJobRegistry.start', () => {
it('issues unique kind-prefixed ids', async () => {
const ctx = await harness()
expect(ctx.jobs.start(producer().spec)).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(ctx.jobs.start(producer().spec)).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(ctx.jobs.start(producer({ kind: 'test-task' }).spec)).toMatch(/^test-task-[0-9a-f-]{36}$/)
expect(ctx.jobs.start(producer({ kind: 'workflow' }).spec)).toMatch(/^workflow-[0-9a-f-]{36}$/)
const first = ctx.jobs.start(producer().spec)
const second = ctx.jobs.start(producer().spec)
expect(first).toMatch(/^bash-[0-9a-f]{16}$/)
expect(second).toMatch(/^bash-[0-9a-f]{16}$/)
expect(second).not.toBe(first)
expect(ctx.jobs.start(producer({ kind: 'test-task' }).spec)).toMatch(/^test-task-[0-9a-f]{16}$/)
expect(ctx.jobs.start(producer({ kind: 'workflow' }).spec)).toMatch(/^workflow-[0-9a-f]{16}$/)
})
it('retries a short UUID collision without replacing a live job', async () => {
const ctx = await harness()
randomUuid
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')
.mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-bbbbbbbbbbbb')
.mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')
try {
const first = ctx.jobs.start(producer().spec)
const second = ctx.jobs.start(producer().spec)
expect(first).toBe('bash-aaaaaaaaaaaa4aaa')
expect(second).toBe('bash-bbbbbbbbbbbb4bbb')
expect(ctx.jobs.list().map(job => job.id)).toEqual([first, second])
} finally {
randomUuid.mockRestore()
}
})
})
@@ -727,7 +755,7 @@ describe('LocalJobRegistry owner isolation', () => {
.toThrow('background job ownership requires the agent registry')
// The failed registration mutated nothing: no stored job.
expect(ctx.jobs.list()).toEqual([])
expect(ctx.jobs.start(producer().spec)).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(ctx.jobs.start(producer().spec)).toMatch(/^bash-[0-9a-f]{16}$/)
})
it('rejects a session without a live agent and attaches cleanup once it has one', async () => {
@@ -752,7 +780,7 @@ describe('LocalJobRegistry owner isolation', () => {
done: new Promise<JobOutcome>((res) => { settle = res }),
}),
})
expect(id).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(id).toMatch(/^bash-[0-9a-f]{16}$/)
await disposeAgentScope(ghost)
expect(cancels).toEqual(['owner disposed'])
expect(ctx.jobs.list(ghost.id)).toEqual([])
@@ -808,7 +836,7 @@ describe('LocalJobRegistry owner cleanup', () => {
expect(cancels).toEqual(['owner disposed'])
expect(ctx.jobs.list(owner.id)).toEqual([])
expect(seen.map(event => event.type === 'removed' ? event.job.id : undefined)).toEqual([
expect.stringMatching(/^test-task-[0-9a-f-]{36}$/), expect.stringMatching(/^bash-[0-9a-f-]{36}$/),
expect.stringMatching(/^test-task-[0-9a-f]{16}$/), expect.stringMatching(/^bash-[0-9a-f]{16}$/),
])
})
@@ -1143,7 +1171,7 @@ describe('LocalJobRegistry events', () => {
const seen = collect(ctx, { owners: 'all' }, ['registered'])
const id = ctx.jobs.start(producer().spec)
expect(id).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(id).toMatch(/^bash-[0-9a-f]{16}$/)
expect(seen).toHaveLength(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('event listener threw on registered'))
})
+4 -4
View File
@@ -9,8 +9,8 @@
en: 721d849fde0fbebe
zh: 9578ffb37edc5d7b
/deepseek-ai-dsh-jobs/summary:
en: 64000a337f374627
zh: 251c13da2768bbd1
en: eea29995f4b49a6d
zh: acdded5ddee3a974
/deepseek-ai-dsh-jobs/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -18,8 +18,8 @@
en: 1246c880d1c61927
zh: 157c703fd2da6b9c
/deepseek-ai-dsh-jobs/use-this-package/what-a-background-job-gives-you:
en: 92a9b9c0b6c9e748
zh: fc354b68facee753
en: 3df0e02f9928afb2
zh: 63cae8da533ec266
/deepseek-ai-dsh-jobs/use-this-package/the-ownership-boundary:
en: 167ab1eab8fad704
zh: 7da554826fb483c1
+2 -2
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`dsh-jobs` lets tools keep long-running work active while an agent continues. Each job receives a stable `<kind>-<uuid>` id, and its owning agent can read output, wait with a timeout, or request cancellation. Ownership is scoped to the agent session, so other agents cannot inspect or stop the job; completion arrives as an in-session notice without polling. Users can watch retained live output without consuming what the agent can read. Background jobs can start only when the deployment supplies job execution.
`dsh-jobs` lets tools keep long-running work active while an agent continues. Each job receives a stable `<kind>-<short-uuid>` id, and its owning agent can read output, wait with a timeout, or request cancellation. Ownership is scoped to the agent session, so other agents cannot inspect or stop the job; completion arrives as an in-session notice without polling. Users can watch retained live output without consuming what the agent can read. Background jobs can start only when the deployment supplies job execution.
## Table of Contents
@@ -29,7 +29,7 @@ Use this package when you are composing a background-job capability or writing a
### What a background job gives you
A producer registers work with a kind and a one-line label; the registry returns a `<kind>-<uuid>` id such as `bash-<uuid>`. 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 projection of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the registry's event stream announces it and `dsh-tool-jobs` turns the settlement into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing read or notice stays bounded.
A producer registers work with a kind and a one-line label; the registry returns a `<kind>-<short-uuid>` id such as `bash-<short-uuid>`, where the suffix is 16 lowercase UUID hex characters. 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 projection of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the registry's event stream announces it and `dsh-tool-jobs` turns the settlement into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing read or notice stays bounded.
A producer streams output by naming pull sources on its spec — non-consuming offset readers the registry pumps at its own cadence — or by pushing chunks through the `JobHandle` its starter receives; both land in the job's bounded ring, where `stdout` and `stderr` chunks reach the model and `log` chunks reach observers only. Observers read retained chunks at absolute byte offsets and are signaled on advancement; the settlement that ends the job also ends the stream, and `updateProgress` publishes a live progress line into every projection until then. Observation is invisible to the model: `readAt` consumes nothing and never touches notice state.
+2 -2
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`dsh-jobs` 让工具可以在 agent(智能体)继续推进时保持长时间工作运行。每项任务都会获得稳定的 `<kind>-<uuid>` id,拥有它的 agent 可以读取输出、带超时等待或请求取消。归属范围限定在 agent 会话内,因此其他 agent 无法查看或停止任务;任务完成时会通过会话内通知送达,无需轮询。用户可以查看保留的实时输出,而不消耗 agent 可读取的内容。只有部署提供任务执行能力时,后台任务才能启动。
`dsh-jobs` 让工具可以在 agent(智能体)继续推进时保持长时间工作运行。每项任务都会获得稳定的 `<kind>-<short-uuid>` id,拥有它的 agent 可以读取输出、带超时等待或请求取消。归属范围限定在 agent 会话内,因此其他 agent 无法查看或停止任务;任务完成时会通过会话内通知送达,无需轮询。用户可以查看保留的实时输出,而不消耗 agent 可读取的内容。只有部署提供任务执行能力时,后台任务才能启动。
## 目录
@@ -29,7 +29,7 @@ kind: "package-reference"
### 后台任务提供什么
生产方以 kind 和一行标签注册工作;注册表返回 `<kind>-<uuid>` id,例如 `bash-<uuid>`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新投影,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,注册表的事件流宣布它,`dsh-tool-jobs` 把结算变成会话内通知,因此无需轮询。生产方可以附加一个可选的字节上限,让每次完整的模型侧读取或通知保持有界。
生产方以 kind 和一行标签注册工作;注册表返回 `<kind>-<short-uuid>` id,例如 `bash-<short-uuid>`,其中后缀是 UUID 的 16 位小写十六进制字符。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新投影,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,注册表的事件流宣布它,`dsh-tool-jobs` 把结算变成会话内通知,因此无需轮询。生产方可以附加一个可选的字节上限,让每次完整的模型侧读取或通知保持有界。
生产方通过在 spec 上声明拉取源——由注册表按自己的节奏泵送的非消耗偏移读取器——或经 starter 收到的 `JobHandle` 推送块来流式输出;二者都落入该 job 的有界环,其中 `stdout` 与 `stderr` 块到达模型,`log` 块只到达观察者。观察者按绝对字节偏移读取保留块并在推进时收到信号;结束 job 的结算同时封流,在此之前 `updateProgress` 把实时进度行发布进每个投影。观测对模型不可见:`readAt` 不消耗任何东西,也从不触碰通知状态。
+2 -2
View File
@@ -13,14 +13,14 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Identifies one job across registry lifetimes. The registry generates `<kind>-<uuid>`;
* Identifies one job across registry lifetimes. The registry generates `<kind>-<short-uuid>`;
* access still requires owner authorization.
*/
export type JobId = Branded<'JobId'>
/**
* Brand a string as a {@link JobId}.
* @param id - the raw job-id string (the registry generates `<kind>-<uuid>`).
* @param id - the raw job-id string (the registry generates 16 UUID hex characters after the kind).
* @returns the same string, branded; no validation is performed.
*/
export function JobId(id: string): JobId {
+1 -1
View File
@@ -108,7 +108,7 @@ export abstract class JobRegistry extends Service {
* rejection leaves no job id or execution resource. A throwing starter
* leaves nothing registered; after it returns, registration cannot fail.
* @param spec - job identity, owner, output sources, and synchronous starter.
* @returns the registry-issued `<kind>-<uuid>` id.
* @returns the registry-issued `<kind>-<short-uuid>` id.
*/
abstract start(spec: JobSpec): JobId
+1 -1
View File
@@ -137,7 +137,7 @@ export interface JobAppendOptions {
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
export interface JobHandle {
/** The registry-issued id (`<kind>-<uuid>`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* Append one chunk to the output ring. Offsets advance by the chunk's UTF-8
+1 -1
View File
@@ -62,7 +62,7 @@ export interface JobChunk {
* stream all consume this one shape.
*/
export interface JobView {
/** The registry-issued id (`<kind>-<uuid>`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* The producer kind the job was registered with: a Host-registered
@@ -930,7 +930,7 @@ describe('completion notices', () => {
owner: owner.id,
kind: 'test-task',
label: 'x'.repeat(1_000),
outputLimitBytes: 61,
outputLimitBytes: 55,
})
ctx.jobs.start(first.spec)
first.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
@@ -956,7 +956,7 @@ describe('completion notices', () => {
owner: owner.id,
kind: 'test-task',
label: 'x'.repeat(1_000),
outputLimitBytes: 80,
outputLimitBytes: 60,
})
ctx.jobs.start(second.spec)
second.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
@@ -982,7 +982,7 @@ describe('completion notices', () => {
owner: owner.id,
kind: 'pty-send',
label: 'x'.repeat(1_000),
outputLimitBytes: 64,
outputLimitBytes: 56,
})
ctx.jobs.start(target.spec)
@@ -991,7 +991,7 @@ describe('completion notices', () => {
const content = (inject.mock.calls[0]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content
const notice = content?.[0]?.text ?? ''
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(64)
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(56)
expect(notice).toBe('background job ended; use job_list for id.')
})
@@ -999,11 +999,11 @@ describe('completion notices', () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'notice-boundary', { inject })
const fixedBytes = Buffer.byteLength(`background job test-task-${'0'.repeat(36)}\n[notice truncated]\nDone; job_output.`)
const fixedBytes = Buffer.byteLength(`background job test-task-${'0'.repeat(16)}\n[notice truncated]\nDone; job_output.`)
for (const [index, extra] of ['', ' (t'].entries()) {
const p = producer({ owner: owner.id, kind: 'test-task', label: 'x'.repeat(1_000), outputLimitBytes: fixedBytes + extra.length })
const id = ctx.jobs.start(p.spec)
expect(id).toMatch(/^test-task-[0-9a-f-]{36}$/)
expect(id).toMatch(/^test-task-[0-9a-f]{16}$/)
p.settle({ status: 'completed' })
await tick()
const notice = (inject.mock.calls[index]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text
@@ -122,7 +122,7 @@ describe('background bash output', () => {
workdir: '/nonexistent-dsh',
run_in_background: true,
})
expect(text(started)).toMatch(/^started background job bash-[0-9a-f-]{36}$/)
expect(text(started)).toMatch(/^started background job bash-[0-9a-f]{16}$/)
const jobs = ctx.jobs
const job = jobs.list()[0]
await until(() => jobs.get(job!.id).status === 'killed' ? true : undefined)
@@ -218,7 +218,7 @@ describe('foreground commands as jobs', () => {
await until(() => execution?.observed.stdout.readFrom(0).text.includes('early-output') ? true : undefined, task.timeout)
// The job exists from the start: the command is listed while the call still waits.
const jobId = ctx.jobs.list()[0]?.id
expect(jobId).toMatch(/^bash-[0-9a-f-]{36}$/)
expect(jobId).toMatch(/^bash-[0-9a-f]{16}$/)
expect(ctx.jobs.list()[0]).toMatchObject({ id: jobId, kind: 'bash', status: 'running', label: 'printf "early-output\\n"; sleep 30' })
await vi.advanceTimersByTimeAsync(250)
const result = await pending
@@ -227,7 +227,7 @@ describe('bash tool through the agent loop', () => {
}),
textResponse('Started it in the background.'),
(options) => {
const jobId = /started background job (bash-[0-9a-f-]{36})/.exec(JSON.stringify(options.messages))?.[1]
const jobId = /started background job (bash-[0-9a-f]{16})/.exec(JSON.stringify(options.messages))?.[1]
if (jobId === undefined) throw new Error('model history lacks the started job id')
return toolCallResponse('call-2', 'job_output', { job_id: jobId })
},
@@ -241,7 +241,7 @@ describe('bash tool through the agent loop', () => {
const firstResult = findEvent(events(agent), 'tool/result')
expect(firstResult.data.message.isError).toBe(false)
const jobId = /^started background job (bash-[0-9a-f-]{36})$/.exec(resultText(firstResult))?.[1]
const jobId = /^started background job (bash-[0-9a-f]{16})$/.exec(resultText(firstResult))?.[1]
if (jobId === undefined) throw new Error('first tool result lacks a job id')
// The turn closed with the task still running, so the notice cannot exist yet.
const isNotice = (e: SessionEvent): e is SessionEvent<'user/message'> =>
@@ -289,7 +289,7 @@ describe('foreground commands as jobs (pwsh)', () => {
const body = (result.content[0] as { text: string }).text
expect(body).toContain('early-output')
const jobId = ctx.jobs.list()[0]?.id
expect(jobId).toMatch(/^pwsh-[0-9a-f-]{36}$/)
expect(jobId).toMatch(/^pwsh-[0-9a-f]{16}$/)
expect(body).toContain(`moved to background job ${jobId}]`)
expect(body).toContain('read newer output with job_output, stop it with job_kill')
@@ -1,6 +1,7 @@
/** Relationship-preserving identity redaction for committed session snapshots. */
const UUID_FRAGMENT_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
const SHORT_JOB_ID_RE = /-[0-9a-f]{16}$/i
const LEGACY_TOKEN_RE = /^\{\{(?:sessionId|messageId)\}\}$/
const CANONICAL_TOKEN_RE = /^\{\{(session|message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}$/
const ID_KEY_RE = /(?:^id$|Id$|Ids$)/
@@ -35,7 +36,8 @@ function messageId(value: unknown): string | undefined {
}
function redactedCandidate(value: string): boolean {
return UUID_FRAGMENT_RE.test(value) || LEGACY_TOKEN_RE.test(value) || CANONICAL_TOKEN_RE.test(value)
return UUID_FRAGMENT_RE.test(value) || SHORT_JOB_ID_RE.test(value)
|| LEGACY_TOKEN_RE.test(value) || CANONICAL_TOKEN_RE.test(value)
}
/**
@@ -48,6 +48,7 @@ const FILE_URI_PATH_PREFIX_RE = /(?:^|[^a-z0-9+.-])file:\/\/\/?$/i
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
const SHORT_JOB_ID_RE = /-[0-9a-f]{16}$/i
// Separator runs also match JSON-escaped Windows paths; extraction preserves their exact serialized spelling.
const LOCAL_SPILL_PATH_RE = new RegExp(
String.raw`\{\{cwd\}\}[\\/]+\.spill[\\/]+session-[0-9a-f]{12}[\\/]+[0-9a-f]{12}-([A-Za-z0-9._~-]+?)`
@@ -363,7 +364,7 @@ export function normalizeSessionLog(
if ((record.type !== 'job/registered' && record.type !== 'job/settled')
|| record.data === null || typeof record.data !== 'object') continue
const id = (record.data as Record<string, unknown>).jobId
if (typeof id !== 'string' || id.search(UUID_RE) < 0 || jobTokens.has(id)) continue
if (typeof id !== 'string' || (id.search(UUID_RE) < 0 && !SHORT_JOB_ID_RE.test(id)) || jobTokens.has(id)) continue
let token: string
do { token = `{{id:${nextJobToken++}}}` } while (reservedTokens.has(token))
jobTokens.set(id, token)
@@ -256,8 +256,10 @@ Additional instructions from: nested\AGENTS.md`,
})
describe('normalizeSessionLog', () => {
it('keeps job execution identities distinct in single-log and single-snapshot paths', () => {
const ids = ['bash-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bash-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb']
it.each([
{ format: 'full UUIDs', ids: ['bash-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bash-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'] },
{ format: 'short UUIDs', ids: ['bash-aaaaaaaaaaaaaaaa', 'bash-bbbbbbbbbbbbbbbb'] },
])('keeps $format distinct in single-log and single-snapshot paths', ({ ids }) => {
const raw = [
{ type: 'session', version: 0, id: ctx.sessionIds[0], cwd: ctx.cwd, createdAt: 100 },
{ type: 'user/message', data: { text: 'Keep {{id:1}} literal.' } },
@@ -52,7 +52,7 @@
"index": 0,
"id": "async-confinement-result",
"name": "job_output",
"argumentsDelta": "{\"job_id\":\"{{fromRequest:(bash-[0-9a-f-]{36})}}\",\"wait\":true}"
"argumentsDelta": "{\"job_id\":\"{{fromRequest:(bash-[0-9a-f]{16})}}\",\"wait\":true}"
},
{
"type": "block-end",
@@ -61,7 +61,7 @@
"type": "tool-call",
"id": "async-confinement-result",
"name": "job_output",
"arguments": "{\"job_id\":\"{{fromRequest:(bash-[0-9a-f-]{36})}}\",\"wait\":true}"
"arguments": "{\"job_id\":\"{{fromRequest:(bash-[0-9a-f]{16})}}\",\"wait\":true}"
}
},
{
@@ -23,8 +23,8 @@
"kind": "chunks",
"chunks": [
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
{ "type": "tool-call-delta", "index": 0, "id": "bounded-task-kill", "name": "job_kill", "argumentsDelta": "{\"job_id\":\"{{fromRequest:(bash-[0-9a-f-]{36})}}\",\"reason\":\"free the bounded task slot\"}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-kill", "name": "job_kill", "arguments": "{\"job_id\":\"{{fromRequest:(bash-[0-9a-f-]{36})}}\",\"reason\":\"free the bounded task slot\"}" } },
{ "type": "tool-call-delta", "index": 0, "id": "bounded-task-kill", "name": "job_kill", "argumentsDelta": "{\"job_id\":\"{{fromRequest:(bash-[0-9a-f]{16})}}\",\"reason\":\"free the bounded task slot\"}" },
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "bounded-task-kill", "name": "job_kill", "arguments": "{\"job_id\":\"{{fromRequest:(bash-[0-9a-f]{16})}}\",\"reason\":\"free the bounded task slot\"}" } },
{ "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } },
{ "type": "finish", "reason": { "kind": "tool-calls" } }
]
+1 -1
View File
@@ -746,7 +746,7 @@ async function verifyBackgroundConfinementFailure(log: string, cwd: string): Pro
const inspected = results.find(result => result.toolCallId === 'async-confinement-result')
const registered = events.find(event => event.type === 'job/registered')
if (registered?.type !== 'job/registered') throw new Error('missing registered job')
expect(registered.data.jobId).toMatch(/^bash-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
expect(registered.data.jobId).toMatch(/^bash-[0-9a-f]{16}$/)
expect(started).toMatchObject({
isError: false, content: [{ type: 'text', text: `started background job ${registered.data.jobId}` }],
})