diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 35c3e974bd..a623855811 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -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' }) diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index 8647ec76b0..f407bdb53e 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -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]) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 3c15589471..2b4fb64d68 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -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]) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 8009cf4453..c9a52ebbe0 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -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( diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 88adae1129..9c7d827aba 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -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({ diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 00861e6f06..65fe615ce8 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -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 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 05d01cf75e..08586d5143 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -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 `-`. 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 `-` 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 (`-`). */ + /** The registry-issued id (`-`). */ 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 (`-`). */ + /** The registry-issued id (`-`). */ 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 `-` id. + * @returns the registry-issued `-` id. */ abstract start(spec: JobSpec): JobId diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index a55b25b484..0096f6d093 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -6,7 +6,7 @@ ## ID 与状态 -`JobId` 是按 `-` 生成的[品牌化 id](core.zh.md#branded-ids)。访问控制依赖拥有者授权,而非 id 的保密性。`JobKind` 派生自可合并扩展的 map;注册表将各个 kind 视为不透明的 id 命名空间。 +`JobId` 是按 `-` 生成的[品牌化 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 (`-`). */ + /** The registry-issued id (`-`). */ 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 (`-`). */ + /** The registry-issued id (`-`). */ 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 `-` id. + * @returns the registry-issued `-` id. */ abstract start(spec: JobSpec): JobId diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml index d5fc8af6f3..09f6fb75d4 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.i18n.yaml @@ -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 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md index 2ff0d63d42..b8c1e3233e 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -The job registry returns `-` instead of `-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 `-` instead of `-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 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md index 0e313ecb98..a862df0aa2 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.zh.md @@ -9,7 +9,7 @@ description: "JobId 现在跨注册表生命周期标识一次后台执行。" ## 变更 -job 注册表返回 `-`,不再返回 `-N`。运行期控制、`job/registered` 和 `job/settled` 使用同一个 `JobId`;生命周期记录不再携带 `executionId`。访问仍按所有者授权。已有 Session 历史保持原样,不会恢复其中的 job。 +job 注册表返回 `-`,不再返回 `-N`;后缀是 UUID 的 16 位小写十六进制字符。运行期控制、`job/registered` 和 `job/settled` 使用同一个 `JobId`;生命周期记录不再携带 `executionId`。访问仍按所有者授权。已有 Session 历史保持原样,不会恢复其中的 job。 ## 迁移 diff --git a/packages/experimental/tool-terminal/src/index.ts b/packages/experimental/tool-terminal/src/index.ts index b070b5ef40..f966141c8b 100644 --- a/packages/experimental/tool-terminal/src/index.ts +++ b/packages/experimental/tool-terminal/src/index.ts @@ -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' diff --git a/packages/experimental/tool-terminal/tests/tools.spec.ts b/packages/experimental/tool-terminal/tests/tools.spec.ts index 2039871876..fcc0a10405 100644 --- a/packages/experimental/tool-terminal/tests/tools.spec.ts +++ b/packages/experimental/tool-terminal/tests/tools.spec.ts @@ -146,7 +146,7 @@ function backgroundJobId(result: Awaited>): 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) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index df2e8c353b..044e4a334c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -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 `-` id.', + returns: 'the registry-issued `-` id.', }, { signature: 'abstract list(caller?: SessionId): JobView[]', diff --git a/packages/jobs/jobs-local/src/index.ts b/packages/jobs/jobs-local/src/index.ts index 214a9989ab..daa4ce0db3 100644 --- a/packages/jobs/jobs-local/src/index.ts +++ b/packages/jobs/jobs-local/src/index.ts @@ -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 } diff --git a/packages/jobs/jobs-local/tests/jobs.spec.ts b/packages/jobs/jobs-local/tests/jobs.spec.ts index eba365895b..538c956140 100644 --- a/packages/jobs/jobs-local/tests/jobs.spec.ts +++ b/packages/jobs/jobs-local/tests/jobs.spec.ts @@ -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() + 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((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')) }) diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 9f4e8c2123..390bb53bdd 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/README.i18n.yaml @@ -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 diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index a0fc8af18d..034bf232fe 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -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 `-` 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 `-` 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 `-` id such as `bash-`. 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 `-` id such as `bash-`, 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. diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 66116fc34b..c05628b4c5 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-jobs` 让工具可以在 agent(智能体)继续推进时保持长时间工作运行。每项任务都会获得稳定的 `-` id,拥有它的 agent 可以读取输出、带超时等待或请求取消。归属范围限定在 agent 会话内,因此其他 agent 无法查看或停止任务;任务完成时会通过会话内通知送达,无需轮询。用户可以查看保留的实时输出,而不消耗 agent 可读取的内容。只有部署提供任务执行能力时,后台任务才能启动。 +`dsh-jobs` 让工具可以在 agent(智能体)继续推进时保持长时间工作运行。每项任务都会获得稳定的 `-` id,拥有它的 agent 可以读取输出、带超时等待或请求取消。归属范围限定在 agent 会话内,因此其他 agent 无法查看或停止任务;任务完成时会通过会话内通知送达,无需轮询。用户可以查看保留的实时输出,而不消耗 agent 可读取的内容。只有部署提供任务执行能力时,后台任务才能启动。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 后台任务提供什么 -生产方以 kind 和一行标签注册工作;注册表返回 `-` id,例如 `bash-`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新投影,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,注册表的事件流宣布它,`dsh-tool-jobs` 把结算变成会话内通知,因此无需轮询。生产方可以附加一个可选的字节上限,让每次完整的模型侧读取或通知保持有界。 +生产方以 kind 和一行标签注册工作;注册表返回 `-` id,例如 `bash-`,其中后缀是 UUID 的 16 位小写十六进制字符。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新投影,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,注册表的事件流宣布它,`dsh-tool-jobs` 把结算变成会话内通知,因此无需轮询。生产方可以附加一个可选的字节上限,让每次完整的模型侧读取或通知保持有界。 生产方通过在 spec 上声明拉取源——由注册表按自己的节奏泵送的非消耗偏移读取器——或经 starter 收到的 `JobHandle` 推送块来流式输出;二者都落入该 job 的有界环,其中 `stdout` 与 `stderr` 块到达模型,`log` 块只到达观察者。观察者按绝对字节偏移读取保留块并在推进时收到信号;结束 job 的结算同时封流,在此之前 `updateProgress` 把实时进度行发布进每个投影。观测对模型不可见:`readAt` 不消耗任何东西,也从不触碰通知状态。 diff --git a/packages/jobs/jobs/src/brand.ts b/packages/jobs/jobs/src/brand.ts index 0fe1986b4c..82e59f3663 100644 --- a/packages/jobs/jobs/src/brand.ts +++ b/packages/jobs/jobs/src/brand.ts @@ -13,14 +13,14 @@ import type { Branded } from '@deepseek-ai/dsh-brand' /** - * Identifies one job across registry lifetimes. The registry generates `-`; + * Identifies one job across registry lifetimes. The registry generates `-`; * 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 `-`). + * @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 { diff --git a/packages/jobs/jobs/src/index.ts b/packages/jobs/jobs/src/index.ts index 513f01f346..7f31c3795b 100644 --- a/packages/jobs/jobs/src/index.ts +++ b/packages/jobs/jobs/src/index.ts @@ -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 `-` id. + * @returns the registry-issued `-` id. */ abstract start(spec: JobSpec): JobId diff --git a/packages/jobs/jobs/src/types.ts b/packages/jobs/jobs/src/types.ts index 9fd2294956..33ea07d75b 100644 --- a/packages/jobs/jobs/src/types.ts +++ b/packages/jobs/jobs/src/types.ts @@ -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 (`-`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 diff --git a/packages/jobs/jobs/src/view.ts b/packages/jobs/jobs/src/view.ts index ad999ef418..ad778954ab 100644 --- a/packages/jobs/jobs/src/view.ts +++ b/packages/jobs/jobs/src/view.ts @@ -62,7 +62,7 @@ export interface JobChunk { * stream all consume this one shape. */ export interface JobView { - /** The registry-issued id (`-`). */ + /** The registry-issued id (`-`). */ readonly id: JobId /** * The producer kind the job was registered with: a Host-registered diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 4bf5ba59ed..0e69eca22c 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -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 diff --git a/packages/shell/tool-bash/tests/background.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts index 9801a6dc1b..a4c6cd41de 100644 --- a/packages/shell/tool-bash/tests/background.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -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 diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index c62436c099..f8c6cc87dd 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -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'> => diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 8c6582be5b..59b99b965c 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -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') diff --git a/packages/test-support/session-snapshot/src/identity.ts b/packages/test-support/session-snapshot/src/identity.ts index 22d8d69c0f..cca42657c2 100644 --- a/packages/test-support/session-snapshot/src/identity.ts +++ b/packages/test-support/session-snapshot/src/identity.ts @@ -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) } /** diff --git a/packages/test-support/session-snapshot/src/normalize.ts b/packages/test-support/session-snapshot/src/normalize.ts index c4fcaaa762..39f3522a8c 100644 --- a/packages/test-support/session-snapshot/src/normalize.ts +++ b/packages/test-support/session-snapshot/src/normalize.ts @@ -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).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) diff --git a/packages/test-support/session-snapshot/tests/normalize.spec.ts b/packages/test-support/session-snapshot/tests/normalize.spec.ts index 62a6d03e7a..8457749b48 100644 --- a/packages/test-support/session-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/session-snapshot/tests/normalize.spec.ts @@ -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.' } }, diff --git a/snapshots/session/background-confinement-failure/replay.override.json b/snapshots/session/background-confinement-failure/replay.override.json index 74a53d0a36..e8c78b8282 100644 --- a/snapshots/session/background-confinement-failure/replay.override.json +++ b/snapshots/session/background-confinement-failure/replay.override.json @@ -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}" } }, { diff --git a/snapshots/session/background-job-admission/replay.override.json b/snapshots/session/background-job-admission/replay.override.json index c454fbdaa1..5d56fa2de8 100644 --- a/snapshots/session/background-job-admission/replay.override.json +++ b/snapshots/session/background-job-admission/replay.override.json @@ -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" } } ] diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 23c77b0377..1d98c97e83 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -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}` }], })