Merge master and align SDK resume fixture with persistence plugin exports

This commit is contained in:
Dudu-0223 committed 2026-10-12 15:06:12 +08:00
commit 48ab8620d9
241 files changed
+3701 -908

No files matched your search

@@ -15,5 +15,5 @@
en: 09be2a1cf1d52fd5
zh: 0bf0e18c73542012
/agent-note-one-output-ring-one-projection-one-event-stream-for-background-jobs/consequences:
en: 464eb843a71eb04e
zh: 783062f5b288b1b4
en: 3263801e173b6b86
zh: f6f51bb3f77a817e
@@ -35,3 +35,5 @@ The job seam had grown by accretion. Producers declared their output twice — a
The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it.
Review follow-ups after the master merge tightened four of these edges. A job's first event is always `registered`: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first `job_output` loses nothing the live cap retained. `dsh-tool-jobs` holds one claim per wait call with a `live` flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the `before this read` variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the `spawn failed: …` note as the whole observed stderr stream (the consuming read still folds it in once), and `ShellProcess.observed` with both readers is required: the model's first `job_output` after such a failure reads exactly what master's consuming read rendered.
The [Session lifecycle decision](../bug-fix/2026-09-30-job-session-lifecycle.md) records registration and settlement for durable SDK consumers while preserving this live event stream and output ownership.
@@ -35,3 +35,5 @@ job seam 是逐步堆出来的。生产者要把输出声明两次——模型
模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。
合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 `registered`:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 `job_output` 之前结束的 job 不会丢失运行期上限保留的任何内容。`dsh-tool-jobs` 为每次等待调用保存一条带 `live` 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 `before this read` 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 `spawn failed: …` 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 `ShellProcess.observed` 及其两个读取器改为必填:这类失败之后模型的首次 `job_output` 读到的正是 master 的消耗式读取所渲染的内容。
[Session 生命周期决策](../bug-fix/2026-09-30-job-session-lifecycle.zh.md)为持久化 SDK 消费方记录注册与结算,同时保留这里的运行期事件流和输出归属。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-30-job-session-lifecycle.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md
/agent-note-persist-owned-job-lifecycle-facts:
en: 79d83b765acf6e24
zh: afe6ea9af6d96d10
/agent-note-persist-owned-job-lifecycle-facts/problem:
en: b3d0591cc559e466
zh: aa4c385e84f7eaa1
/agent-note-persist-owned-job-lifecycle-facts/decision:
en: c982a59cbe5202d3
zh: 9315962d2e4b8f0a
/agent-note-persist-owned-job-lifecycle-facts/alternatives-considered:
en: 140cf7152a35bb27
zh: 001aa96687836914
/agent-note-persist-owned-job-lifecycle-facts/consequences:
en: 3761af53744aa0c7
zh: 876cb056c248c43c
@@ -0,0 +1,37 @@
# Agent Note: Persist owned job lifecycle facts
Status: implemented
English | [中文](2026-09-30-job-session-lifecycle.zh.md)
## Problem
SDK consumers receive Session events, while job registration and settlement previously existed only in the live registry. A background tool can return before work finishes, and output collection can happen much later or never. Tool invocation times therefore cannot reconstruct a job's execution interval. Runtime-local ids also repeat after a registry restart.
## Decision
The local provider captures `job/registered` and `job/settled` facts for owned jobs, retaining the registry's epoch-millisecond timestamps, original owner, producer kind, label, and optional creating tool call. A UUID-backed `JobId` identifies both live controls and historical records across registry restarts and copied history. Unowned work has no Session journal.
The terminal record distinguishes a producer report from forced bookkeeping: `producerReported` is true when `JobHooks.done` fulfills, and false when that promise rejects or teardown cancellation throws. It reports fulfillment, not independently confirmed resource release. Missing terminal records remain incomplete and are not reconstructed from later reads or process restart.
Facts are appended synchronously at the registry commit before live hub reactions and completion waiters resume. Recording errors warn independently: already-started work still has its control id, and a failed journal append cannot prevent cleanup or release of waiters. The Session's ordinary persistence and SDK notification paths own delivery after append. SDK `run()` results cover bounded observation windows and can receive a settlement whose registration belongs to an earlier run; settlement records retain the registration facts for that consumer.
The [registry contract](../../../../packages/jobs/jobs/README.md) and [seam consolidation note](../architecture/2026-09-03-jobs-seam-consolidation.md) own live execution, resource ownership, and event routing. This journal adds durable observations without replacing those responsibilities.
## Alternatives considered
**Add timestamps only to `job_output`.** A model may never collect a job or may collect it after a long delay. Logging lifecycle commits records both cases without additional model calls or consuming output.
**Keep a reusable control id alongside an execution UUID.** Both values would identify one job. A unique `JobId` serves live controls and historical pairing.
**Add a launcher cleanup registry.** A separate pre-exit disposer path duplicates Cordis resource ownership and adds a launcher requirement to headless. Keeping persistence available through the final job teardown requires a separate cross-fiber lifecycle change; this journal retains the existing disposal mechanism.
**Defer publication or propagate recording errors.** Deferral lets a synchronous hub subscriber journal a completion notice before the settlement that caused it. Throwing after the starter succeeds can hide an already-running job from its caller. Synchronous, contained recording preserves causal order without abandoning execution.
## Consequences
Application teardown can stop Session routing or close its writer before the final job settlement reaches storage. Normal one-shot completion and signal shutdown can therefore leave incomplete history, as can hard process loss. This change does not enforce cross-fiber disposal order or synthesize a missing terminal record.
The log and both SDKs expose execution intervals without changing tool schemas, output cursors, or job scheduling. Model-facing controls and notices carry the unique JobId; a bounded notice points to job_list when the full id cannot fit. These intervals run from registration commit through registry settlement, including the producer work and cleanup between those points; they are neither CPU time nor a directly additive rollout-latency metric. An append failure can also leave incomplete history. Persisted facts do not resume an external process or its output ring.
Focused tests cover original timestamps, nested call identity, output preservation, delayed cancellation, producer rejection, forced teardown, late completion, unique control ids, causal publication order, and recording-failure containment. SDK snapshots cover the durable and protocol projections.
@@ -0,0 +1,37 @@
# Agent Note: 持久化有所有者 job 的生命周期事实
Status: implemented
[English](2026-09-30-job-session-lifecycle.md) | 中文
## Problem
SDK 消费方接收 Session 事件,而 job 注册和结算此前只存在于运行期注册表中。后台工具可能在工作结束前返回,输出收集则可能晚得多,甚至永远不发生。因此工具调用时间无法重建 job 的执行区间。运行期 id 也会在注册表重启后重复。
## Decision
本地提供方为有所有者的 job 捕获 `job/registered` 与 `job/settled` 事实,保留注册表的 Unix 毫秒时间戳、原始所有者、生产方 kind、label 和可选的创建工具调用。由 UUID 构成的 `JobId` 在注册表重启和历史复制后同时标识运行中的控制对象和历史记录。无主工作没有 Session 日志。
终态记录区分生产方报告与强制记账:`producerReported` 在 `JobHooks.done` 兑现时为 true,在该 promise 拒绝或销毁取消抛错时为 false。它记录兑现情况,不独立确认资源已释放。缺少终态记录时保持不完整,不根据较晚的读取或进程重启重建。
事实在注册表提交时同步追加,先于运行期 hub 的反应和完成等待方恢复。记录错误单独发出警告:已启动的工作仍有可控制的 id,日志追加失败不能阻止清理或释放等待方。追加后的投递由 Session 既有的持久化和 SDK 通知路径负责。SDK `run()` 结果只覆盖有界观察窗口,可能收到注册属于更早运行的结算;结算记录为这个消费方保留注册事实。
[注册表约定](../../../../packages/jobs/jobs/README.zh.md)和[seam 收敛说明](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)负责运行期执行、资源归属和事件路由。本日志增加持久化观测,不替代这些职责。
## Alternatives considered
**仅向 `job_output` 添加时间戳。** 模型可能永远不收集某个 job,也可能很晚才收集。记录生命周期提交能覆盖这两种情况,无需额外模型调用或消费输出。
**保留可重用的控制 id,另设执行 UUID。** 两个值会标识同一个 job。唯一的 `JobId` 同时服务运行期控制和历史配对。
**增加启动器清理注册表。** 独立的退出前 disposer 路径会重复 Cordis 的资源归属机制,并增加 headless 对启动器的要求。让持久化持续可用直至最后一个 job 销毁,需要单独修改跨 fiber 生命周期;本日志保留既有销毁机制。
**延后发布或传播记录错误。** 延后发布让同步 hub 订阅者可能先记录完成通知、后记录产生通知的结算事实。starter 成功后抛错可能使调用方拿不到已运行 job 的 id。同步并隔离记录错误既保留因果顺序,也不会放弃执行。
## Consequences
应用销毁可能在最后一条 job 结算写入存储前停止 Session 路由或关闭写句柄。因此正常的一次性运行结束、信号关停和进程硬退出都可能留下不完整历史。本次变更不强制跨 fiber 的销毁顺序,也不虚构缺失的终态记录。
日志与两种 SDK 暴露执行区间,不改变工具 schema、输出游标或 job 调度。模型侧控制和通知使用唯一的 JobId;有界通知无法容纳完整 id 时指向 job_list。区间从注册提交延续到注册表结算,包含这两个时刻之间的生产方工作与清理;它既不是 CPU 时间,也不是可直接相加的 rollout 延迟指标。追加失败也可能留下不完整历史。持久化事实不会恢复外部进程或其输出环。
定向测试覆盖原始时间戳、嵌套调用标识、输出保留、延后取消、生产方拒绝、强制销毁、较晚完成、唯一控制 id、因果发布顺序,以及记录错误隔离。SDK 快照覆盖持久化与协议投影。
@@ -15,6 +15,8 @@
{"type":"session/title-llm-request","data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[8],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.\nIf the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":4096,"reasoningEffort":"off"}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Inspecting the task before the tool call."},{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[],"texts":["Inspecting the task before the tool call."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Inspecting the task before the tool call."}}},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[],"id":"cli-smoke-call","name":"bash","args":["{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}
{"type":"job/registered","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"}}}
{"type":"job/settled","data":{"jobId":"{{id:1}}","owner":"{{sessionId}}","kind":"bash","label":"printf CLI_TOOL_ROUND_TRIP","startedAt":0,"toolCall":{"callId":"cli-smoke-call","rootCallId":"cli-smoke-call"},"finishedAt":0,"status":"completed","cause":"producer","producerReported":true,"detail":"exit code: 0"}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"cli-smoke-call"},"toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false,"id":"{{sessionId}}"},"meta":{"cwd":"{{cwd}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
@@ -11,7 +11,7 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as SessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
import type { BasicCompactionConfig } from '@deepseek-ai/dsh-compaction-basic'
@@ -212,7 +212,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
});
return started.jobId;
`))
expect(jobId).toBe('bash-1')
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 });
@@ -257,7 +257,8 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
}),
])
const job = ctx.jobs.list()[0]
expect(job).toMatchObject({ id: 'bash-1', status: 'running' })
expect(job).toMatchObject({ status: 'running' })
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' })
@@ -11,7 +11,7 @@ import {
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm'
import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts'
import { describe, expect, it } from 'vitest'
@@ -17,7 +17,7 @@ import {
type SessionEvent,
type SessionHeader,
} from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import {
generationLogFilename,
generationLogPath,
@@ -16,7 +16,7 @@ import {
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { describe, expect, it } from 'vitest'
const fixtureDir = fileURLToPath(new URL('./expected/subagent-diagnostic', import.meta.url))
@@ -16,7 +16,7 @@ import {
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { SessionSeq, SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { describe, expect, it } from 'vitest'
const fixtureDir = fileURLToPath(new URL('./expected/subagent-inheritance', import.meta.url))
@@ -23,7 +23,7 @@ import {
type SessionEvent,
type SessionHeader,
} from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { logPath } from '../../../../../../packages/session/session-persistence-jsonl/src/format.ts'
import { renderAgentInstructions } from '@deepseek-ai/dsh-agent-instructions'
import { resolveConfig, workspaceBaselineIdentity } from '@deepseek-ai/dsh-agent-instructions/src/config.ts'
+2 -2
View File
@@ -57,8 +57,8 @@
en: 8cb4b7babbd8714d
zh: b4aebc1d2d152e01
/deepseek-harness-desktop/updates:
en: 41d0f50eb7173342
zh: ce940ef3cbcc6fa5
en: 4788b6b60489ef92
zh: 6312c5590efa27f1
/deepseek-harness-desktop/updates/local-desktop-settings:
en: 2e57457c44c85b5f
zh: 5b8510a3dc4c6c9d
+1 -1
View File
@@ -269,7 +269,7 @@ On Windows, the downloaded-update confirmation explains that the application clo
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes.
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
+1 -1
View File
@@ -269,7 +269,7 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
+5 -1
View File
@@ -136,7 +136,11 @@ export class DesktopUpdateDialog {
const window = this.window
this.window = undefined
this.parent = undefined
if (window !== undefined && !window.isDestroyed()) window.destroy()
if (window !== undefined && !window.isDestroyed()) {
// Release Windows modality before destruction so another application cannot become active.
if (process.platform === 'win32' && window.isVisible()) window.hide()
window.destroy()
}
}
private owned(event: IpcMainInvokeEvent): void {
+18
View File
@@ -19,6 +19,7 @@ const fixture = await vi.hoisted(async () => {
})
readonly focus = vi.fn()
readonly show = vi.fn()
readonly hide = vi.fn()
readonly setBounds = vi.fn()
readonly loadURL = vi.fn(async () => {})
constructor(readonly options: unknown) { super(); windows.push(this) }
@@ -79,6 +80,23 @@ it('accepts only a displayed choice from its own main frame and retains cancella
expect((await next).response).toBe(0)
})
it('releases visible Windows modality before destroying a dismissed dialog', async () => {
vi.useFakeTimers()
const f = setup()
const pending = f.show()
const window = fixture.windows.at(-1)!
const destroy = vi.spyOn(window, 'destroy').mockImplementation(() => {
expect(window.hide).toHaveBeenCalledTimes(process.platform === 'win32' ? 1 : 0)
window.destroyed = true
window.emit('closed')
})
f.invoke(UPDATE_DIALOG_IPC.respond, 0)
await pending
expect(destroy).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(destroy).toHaveBeenCalledOnce()
})
it('follows the parent geometry and removes listeners when closed or replaced', async () => {
const f = setup()
const first = f.show()
+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-\d+\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-\d+\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])
+29 -17
View File
@@ -19,7 +19,7 @@ import type { Browser, Page, Route, WebSocketRoute } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionListValue } from '@deepseek-ai/dsh-api-session-controller/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import {
@@ -240,22 +240,34 @@ describe('web e2e: fresh round trip through the real assembly', () => {
})
it('exposes the assembled Web URL to the real bash tool', async () => {
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
const agent = scaffold.ctx.agents.get(settledSessionId)
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
const result = await scaffold.ctx.tools.execute({
signal: AbortSignal.timeout(5_000),
callId: ToolCallId('web-url-probe'),
name: 'bash',
arguments: {
command: 'printf \'%s\\n\' "$DSH_WEB_URL"',
description: 'Print current Web runtime',
},
agent,
})
expect(result.isError).toBe(false)
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe(`${scaffold.baseUrl}\n`)
// This host probe records job events too; keep it out of the shared
// conversation fixture that other browser scenarios replay.
const probe = await launchWebScaffold()
try {
const handle = await probe.ctx.agents.create({
sessionId: SessionId('web-url-probe'),
setup: agentCtx => probe.ctx.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
const result = await probe.ctx.tools.execute({
signal: AbortSignal.timeout(5_000),
callId: ToolCallId('web-url-probe'),
name: 'bash',
arguments: {
command: 'printf \'%s\\n\' "$DSH_WEB_URL"',
description: 'Print current Web runtime',
},
agent: handle.agent,
})
expect(result.isError).toBe(false)
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe(`${probe.baseUrl}\n`)
} finally {
await handle.dispose()
}
} finally {
await probe.close()
}
})
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
+2 -1
View File
@@ -78,7 +78,7 @@ import {
type SessionEvent,
type SessionHeader,
} from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
// Empty type imports carry the webServer/agents/sessionPersistence Context merges.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
@@ -1548,6 +1548,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(
+8 -5
View File
@@ -690,9 +690,13 @@ 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]{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({
isError: false,
content: [{ type: 'text', text: 'started background job bash-1' }],
content: [{ type: 'text', text: `started background job ${jobId}` }],
})
// The controller reads what the producer started: same registry, one
@@ -705,9 +709,8 @@ it('lets a preset producer reach the background-job registry', async () => {
agent: handle.agent,
})
expect(listed.isError).toBe(false)
expect(listed.content).toEqual([
{ type: 'text', text: expect.stringContaining('bash-1 [bash]') as unknown as string },
])
expect(listed.content).toHaveLength(1)
expect(listed.content[0]?.type === 'text' ? listed.content[0].text : '').toContain(`${jobId} [bash]`)
// The full round trip: the output a host-plane producer wrote is collected
// through a preset-plane control, which is the linkage the realm severed.
@@ -715,7 +718,7 @@ it('lets a preset producer reach the background-job registry', async () => {
signal,
callId: ToolCallId('shipped-task-output'),
name: 'job_output',
arguments: { job_id: 'bash-1', wait: true },
arguments: { job_id: jobId, wait: true },
agent: handle.agent,
})
expect(collected.isError).toBe(false)
@@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts'
@@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import SubagentRuntime, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
@@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionController from '@deepseek-ai/dsh-api-session-controller'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionCache, { projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
@@ -12,7 +12,7 @@ import SessionController from '@deepseek-ai/dsh-api-session-controller'
import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections'
import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionCache, { checkpointRecord, projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
import type { CheckpointRecord } from '@deepseek-ai/dsh-session-projection-cache'
import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
@@ -6,7 +6,7 @@ import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import {
runBuiltBenchmarkWorker,
type BuiltBenchmarkWorkerRun,
@@ -13,7 +13,7 @@ import SessionStore, {
SessionPreparation,
} from '@deepseek-ai/dsh-session'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import type {
+1 -1
View File
@@ -1679,7 +1679,7 @@ export interface TlsConfig {
## `@deepseek-ai/dsh-jobs-local`
- `source`: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts)
- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts)
```ts config-catalog
/** Configuration for the process-local job registry. */
+1 -1
View File
@@ -1681,7 +1681,7 @@ export interface TlsConfig {
## `@deepseek-ai/dsh-jobs-local`
- `source`: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts)
- `source`: [`packages/jobs/jobs-local/src/index.ts:46`](../packages/jobs/jobs-local/src/index.ts)
```ts config-catalog
/** Configuration for the process-local job registry. */
+66 -18
View File
@@ -9,8 +9,8 @@
en: 1387c4505dde2faf
zh: 48570fa1c13d6910
/session-persistence-event-catalog/persistence-type-fingerprints:
en: d8542cb8987022e0
zh: 38e135021cb21f20
en: f6421cacbfe510db
zh: 6c24e981ac836d0c
/session-persistence-event-catalog/event-envelope:
en: 7ebb1a2e0dd1f916
zh: 8946f415dda5c1b8
@@ -119,6 +119,15 @@
/session-persistence-event-catalog/events/image/image-offload-log-only:
en: 5b3bba4fc4261208
zh: 6d5f536c3ca92710
/session-persistence-event-catalog/events/job:
en: 941358a5fef6a5b4
zh: 941358a5fef6a5b4
/session-persistence-event-catalog/events/job/job-registered-log-only:
en: 37ab7f962b199555
zh: af05a3274adbe530
/session-persistence-event-catalog/events/job/job-settled-log-only:
en: dde416e1c0d32dfb
zh: fa08197cad8acb9d
/session-persistence-event-catalog/events/llm:
en: 8a5c09e48ec0a6ae
zh: 8a5c09e48ec0a6ae
@@ -555,8 +564,20 @@
en: 759b0bea643a3f2b
zh: 759b0bea643a3f2b
/session-persistence-event-catalog/resolved-persistence-types/interrupted:
en: 69a7a30a4e43ff35
zh: 69a7a30a4e43ff35
en: 9f4bd316342f65ef
zh: 9f4bd316342f65ef
/session-persistence-event-catalog/resolved-persistence-types/job-registered:
en: 19aa7833c2187c48
zh: 19aa7833c2187c48
/session-persistence-event-catalog/resolved-persistence-types/job-settled:
en: 0a0b96a87bf8ad25
zh: 0a0b96a87bf8ad25
/session-persistence-event-catalog/resolved-persistence-types/kill:
en: 0401f4463ce25ee0
zh: 0401f4463ce25ee0
/session-persistence-event-catalog/resolved-persistence-types/killed:
en: cf74df062715315c
zh: cf74df062715315c
/session-persistence-event-catalog/resolved-persistence-types/legacy:
en: 1fc6c6420d6eb307
zh: 1fc6c6420d6eb307
@@ -624,8 +645,11 @@
en: de774562ec890a6c
zh: de774562ec890a6c
/session-persistence-event-catalog/resolved-persistence-types/positive:
en: 2a01e347d9604ac6
zh: 2a01e347d9604ac6
en: e048b2b8f8165011
zh: e048b2b8f8165011
/session-persistence-event-catalog/resolved-persistence-types/producer:
en: b829f105842b826c
zh: b829f105842b826c
/session-persistence-event-catalog/resolved-persistence-types/product-interaction:
en: 05fe435810456683
zh: 05fe435810456683
@@ -783,8 +807,11 @@
en: d683235390472024
zh: d683235390472024
/session-persistence-event-catalog/resolved-persistence-types/team-task:
en: f8a5fe2380e1a4c4
zh: f8a5fe2380e1a4c4
en: 7aa9286372341561
zh: 7aa9286372341561
/session-persistence-event-catalog/resolved-persistence-types/teardown:
en: 301cd78f24fe13c5
zh: 301cd78f24fe13c5
/session-persistence-event-catalog/resolved-persistence-types/text:
en: 20b696e15c9cf906
zh: 20b696e15c9cf906
@@ -1041,8 +1068,20 @@
en: a3087ab7bebe9371
zh: abf6afc2f9f5b692
/session-persistence-event-catalog/resolved-persistence-types/inboxtarget:
en: 49855060f429f597
zh: 19d1db4ab892ba44
en: 938113c1b01efad1
zh: d6829dbe979e6819
/session-persistence-event-catalog/resolved-persistence-types/jobregistereddata:
en: f604eebb973fe2cf
zh: 0c0aba1e17ecf66e
/session-persistence-event-catalog/resolved-persistence-types/jobsettlecause:
en: 748a4269d86e007a
zh: 4ce04c4451bdcfe8
/session-persistence-event-catalog/resolved-persistence-types/jobsettleddata:
en: fc8caf270a7581f7
zh: b467e1d4191106eb
/session-persistence-event-catalog/resolved-persistence-types/jobtoolcall:
en: 7ffee3cc96604a88
zh: 026abfab0e46c21a
/session-persistence-event-catalog/resolved-persistence-types/jsonvalue:
en: c239aa9c90226c35
zh: 3561e77832c7c8b5
@@ -1296,11 +1335,11 @@
en: 5e7d82a493a1d4d8
zh: d2d785891dd766fc
/session-persistence-event-catalog/resolved-persistence-types/number~2:
en: 386989934d1bb62e
zh: 69b8bc182a5acbd3
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
en: 52f664fd6203444e
zh: dbebfaccbc666386
en: 5fa180e9ba84fb2f
zh: ba6249510e691374
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1329,9 +1368,12 @@
en: ab07ee7c506483d4
zh: 299e31a106d93dfe
/session-persistence-event-catalog/resolved-persistence-types/union-3-variants~2:
en: faa507a889e1c476
zh: b5f13f6f0590152f
en: 5095d352cbd90e21
zh: e0bfa5c90e3917b6
/session-persistence-event-catalog/resolved-persistence-types/union-3-variants~3:
en: 9e296094df7462ba
zh: e7e3255a3ada9279
/session-persistence-event-catalog/resolved-persistence-types/union-3-variants~4:
en: 5fc89a7ec1cecd78
zh: dc66aa8e4aae1847
/session-persistence-event-catalog/resolved-persistence-types/union-6-variants:
@@ -1944,8 +1986,14 @@
en: 8b523e0be5327519
zh: 86ec891087b8bf24
/session-persistence-event-catalog/resolved-persistence-types/type-image-offload:
en: 06230b6290fa9826
zh: 11930bfa355b70fa
en: bf512693267d4262
zh: 805710898b4e913a
/session-persistence-event-catalog/resolved-persistence-types/type-job-registered:
en: 734d3bb283cc6f66
zh: 75bcb55f71673e34
/session-persistence-event-catalog/resolved-persistence-types/type-job-settled:
en: 742c3a1543414ecb
zh: e256fb92d72b8184
/session-persistence-event-catalog/resolved-persistence-types/type-llm-retry:
en: 6d026baeac30a0f1
zh: 55131683e3d5ed27
+211 -1
View File
@@ -44,6 +44,8 @@ The [format references](persistence-changes/historical-formats/README.md) cover
| `event:hook/invoked` | event | `8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0` | [`{ type: "hook/invoked" }`](#persistence-type-sha256-8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0) |
| `event:hook/result` | event | `e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4` | [`{ type: "hook/result" }`](#persistence-type-sha256-e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4) |
| `event:image/offload` | event | `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | [`{ type: "image/offload" }`](#persistence-type-sha256-b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e) |
| `event:job/registered` | event | `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` | [`{ type: "job/registered" }`](#persistence-type-sha256-f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1) |
| `event:job/settled` | event | `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` | [`{ type: "job/settled" }`](#persistence-type-sha256-edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf) |
| `event:llm/retry` | event | `525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336` | [`{ type: "llm/retry" }`](#persistence-type-sha256-525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336) |
| `event:llm/retry-started` | event | `48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3` | [`{ type: "llm/retry-started" }`](#persistence-type-sha256-48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3) |
| `event:model/selection` | event | `35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc` | [`{ type: "model/selection" }`](#persistence-type-sha256-35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc) |
@@ -627,6 +629,36 @@ Source: [`packages/experimental/hook-protocol/src/types.ts:31`](../packages/expe
Source: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](../packages/compaction/compaction-image-offload/src/projection.ts)
### `job/*`
<a id="jobregistered--log-only"></a>
#### `job/registered` — log-only
```ts persistence-catalog
/**
* Log-only job registration; model output collection is not required.
* @param data - Execution identity, original owner, registration time, and creating tool call.
*/
'job/registered': JobRegisteredData
```
Source: [`packages/jobs/jobs/src/types.ts:59`](../packages/jobs/jobs/src/types.ts)
<a id="jobsettled--log-only"></a>
#### `job/settled` — log-only
```ts persistence-catalog
/**
* Log-only terminal record, paired by the same unique jobId used for live control.
* @param data - Registration facts plus settlement time, outcome, and producer acknowledgement.
*/
'job/settled': JobSettledData
```
Source: [`packages/jobs/jobs/src/types.ts:64`](../packages/jobs/jobs/src/types.ts)
### `llm/*`
<a id="llmretry--log-only"></a>
@@ -1998,6 +2030,38 @@ SHA-256: `69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199`
`"interrupted"`
<a id="persistence-type-sha256-24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc"></a>
### `"job/registered"`
SHA-256: `24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc`
`"job/registered"`
<a id="persistence-type-sha256-347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd"></a>
### `"job/settled"`
SHA-256: `347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd`
`"job/settled"`
<a id="persistence-type-sha256-bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7"></a>
### `"kill"`
SHA-256: `bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7`
`"kill"`
<a id="persistence-type-sha256-ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21"></a>
### `"killed"`
SHA-256: `ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21`
`"killed"`
<a id="persistence-type-sha256-ad3b56aa2fc1ad4250e7399295d34a8eae592e1907e85852e2291b2e498a30bc"></a>
### `"legacy"`
@@ -2182,6 +2246,14 @@ SHA-256: `df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528`
`"positive"`
<a id="persistence-type-sha256-6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65"></a>
### `"producer"`
SHA-256: `6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65`
`"producer"`
<a id="persistence-type-sha256-0341d4c3dfcd53253cdc1e789d9130d3cd848b6f8b0706bf92f313a68b39ff23"></a>
### `"product-interaction"`
@@ -2606,6 +2678,14 @@ SHA-256: `b57ae1b37e52bee0a21cbdace168dc6a15582bcda8c306dad962c5dcc8a8e28b`
`"team/task"`
<a id="persistence-type-sha256-5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c"></a>
### `"teardown"`
SHA-256: `5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c`
`"teardown"`
<a id="persistence-type-sha256-4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d"></a>
### `"text"`
@@ -3696,6 +3776,88 @@ One of:
- `"next-step"`
- `"next-turn"`
<a id="persistence-type-sha256-e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305"></a>
<a id="persistence-type-jobregistereddata"></a>
<a id="persistence-type-packagesjobsjobssrctypestsjobregistereddata"></a>
### `JobRegisteredData`
SHA-256: `e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305`
Sources: [`packages/jobs/jobs/src/types.ts:24`](../packages/jobs/jobs/src/types.ts)
| Property | Presence | Type |
|---|---|---|
| `jobId` | required | `string` |
| `kind` | required | `string` |
| `label` | required | `string` |
| `owner` | required | `string` |
| `startedAt` | required | `number` |
| `toolCall` | optional | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) |
<a id="persistence-type-sha256-750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5"></a>
<a id="persistence-type-jobsettlecause"></a>
<a id="persistence-type-packagesjobsjobssrctypestsjobsettlecause"></a>
### `JobSettleCause`
SHA-256: `750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5`
Sources: [`packages/jobs/jobs/src/types.ts:247`](../packages/jobs/jobs/src/types.ts)
One of:
- `"kill"`
- `"producer"`
- `"teardown"`
<a id="persistence-type-sha256-611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da"></a>
<a id="persistence-type-jobsettleddata"></a>
<a id="persistence-type-packagesjobsjobssrctypestsjobsettleddata"></a>
### `JobSettledData`
SHA-256: `611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da`
Sources: [`packages/jobs/jobs/src/types.ts:40`](../packages/jobs/jobs/src/types.ts)
| Property | Presence | Type |
|---|---|---|
| `cause` | required | [`JobSettleCause`](#persistence-type-sha256-750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5) |
| `detail` | optional | `string` |
| `finishedAt` | required | `number` |
| `jobId` | required | `string` |
| `kind` | required | `string` |
| `label` | required | `string` |
| `owner` | required | `string` |
| `producerReported` | required | `boolean` |
| `startedAt` | required | `number` |
| `status` | required | [`union (3 variants)`](#persistence-type-sha256-c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3) |
| `toolCall` | optional | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) |
<a id="persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f"></a>
<a id="persistence-type-jobtoolcall"></a>
<a id="persistence-type-packagesjobsjobssrctypestsjobtoolcall"></a>
### `JobToolCall`
SHA-256: `39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f`
Sources: [`packages/jobs/jobs/src/types.ts:16`](../packages/jobs/jobs/src/types.ts)
| Property | Presence | Type |
|---|---|---|
| `callId` | required | `string` |
| `rootCallId` | required | `string` |
<a id="persistence-type-sha256-c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6"></a>
<a id="persistence-type-jsonvalue"></a>
@@ -5298,6 +5460,8 @@ Array of `number`.
<a id="persistence-type-goalid"></a>
<a id="persistence-type-jobid"></a>
<a id="persistence-type-messagefeedbackversion"></a>
<a id="persistence-type-messageid"></a>
@@ -5330,6 +5494,8 @@ Array of `number`.
<a id="persistence-type-packagesinteractionuser-approvalsrctypestsapprovalrequestid"></a>
<a id="persistence-type-packagesjobsjobssrcbrandtsjobid"></a>
<a id="persistence-type-packagesllmllm-retrysrcbrandtsretryid"></a>
<a id="persistence-type-packagesllmllmsrcbrandtsmessageid"></a>
@@ -5382,7 +5548,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -5491,6 +5657,18 @@ One of:
- `"replace"`
- `"set"`
<a id="persistence-type-sha256-c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3"></a>
### `union (3 variants)`
SHA-256: `c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3`
One of:
- `"completed"`
- `"failed"`
- `"killed"`
<a id="persistence-type-sha256-fd0d91a1f9dd2efb8959bdf5e07f8f0a98b1cdfa38ce8d4732ebde0b44c3abd4"></a>
### `union (3 variants)`
@@ -8123,6 +8301,38 @@ SHA-256: `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e`
| `time` | required | `number` |
| `type` | required | `"image/offload"` |
<a id="persistence-type-sha256-f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1"></a>
<a id="persistence-type-eventjobregistered"></a>
### `{ type: "job/registered" }`
SHA-256: `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1`
| Property | Presence | Type |
|---|---|---|
| `data` | required | [`JobRegisteredData`](#persistence-type-sha256-e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305) |
| `ignorable` | optional | `true` |
| `seq` | required | `number` |
| `time` | required | `number` |
| `type` | required | `"job/registered"` |
<a id="persistence-type-sha256-edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf"></a>
<a id="persistence-type-eventjobsettled"></a>
### `{ type: "job/settled" }`
SHA-256: `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf`
| Property | Presence | Type |
|---|---|---|
| `data` | required | [`JobSettledData`](#persistence-type-sha256-611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da) |
| `ignorable` | optional | `true` |
| `seq` | required | `number` |
| `time` | required | `number` |
| `type` | required | `"job/settled"` |
<a id="persistence-type-sha256-525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336"></a>
<a id="persistence-type-eventllmretry"></a>
+211 -1
View File
@@ -46,6 +46,8 @@
| `event:hook/invoked` | event | `8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0` | [`{ type: "hook/invoked" }`](#persistence-type-sha256-8a6e1ec9e8db346b0e02f027db73c07a94f067a26d40c1aef1abd09c47ce7ba0) |
| `event:hook/result` | event | `e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4` | [`{ type: "hook/result" }`](#persistence-type-sha256-e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4) |
| `event:image/offload` | event | `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e` | [`{ type: "image/offload" }`](#persistence-type-sha256-b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e) |
| `event:job/registered` | event | `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1` | [`{ type: "job/registered" }`](#persistence-type-sha256-f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1) |
| `event:job/settled` | event | `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf` | [`{ type: "job/settled" }`](#persistence-type-sha256-edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf) |
| `event:llm/retry` | event | `525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336` | [`{ type: "llm/retry" }`](#persistence-type-sha256-525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336) |
| `event:llm/retry-started` | event | `48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3` | [`{ type: "llm/retry-started" }`](#persistence-type-sha256-48e5c9861f16ac07e78cb7b5ae9dabdf7bb85c58baed5a51b4ad275050ea58e3) |
| `event:model/selection` | event | `35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc` | [`{ type: "model/selection" }`](#persistence-type-sha256-35203ba7ad5ef6f97d556b85df20ae98f04f09c65748cecdf8eefdb8b6405ffc) |
@@ -629,6 +631,36 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
来源:[`packages/compaction/compaction-image-offload/src/projection.ts:25`](../packages/compaction/compaction-image-offload/src/projection.ts)
### `job/*`
<a id="jobregistered--log-only"></a>
#### `job/registered` — log-only
```ts persistence-catalog
/**
* Log-only job registration; model output collection is not required.
* @param data - Execution identity, original owner, registration time, and creating tool call.
*/
'job/registered': JobRegisteredData
```
来源:[`packages/jobs/jobs/src/types.ts:59`](../packages/jobs/jobs/src/types.ts)
<a id="jobsettled--log-only"></a>
#### `job/settled` — log-only
```ts persistence-catalog
/**
* Log-only terminal record, paired by the same unique jobId used for live control.
* @param data - Registration facts plus settlement time, outcome, and producer acknowledgement.
*/
'job/settled': JobSettledData
```
来源:[`packages/jobs/jobs/src/types.ts:64`](../packages/jobs/jobs/src/types.ts)
### `llm/*`
<a id="llmretry--log-only"></a>
@@ -2000,6 +2032,38 @@ SHA-256: `69d238a6e9b08d67f601b1825962963d8d3523cb69ebf6208c697dc5d058c199`
`"interrupted"`
<a id="persistence-type-sha256-24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc"></a>
### `"job/registered"`
SHA-256: `24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc`
`"job/registered"`
<a id="persistence-type-sha256-347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd"></a>
### `"job/settled"`
SHA-256: `347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd`
`"job/settled"`
<a id="persistence-type-sha256-bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7"></a>
### `"kill"`
SHA-256: `bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7`
`"kill"`
<a id="persistence-type-sha256-ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21"></a>
### `"killed"`
SHA-256: `ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21`
`"killed"`
<a id="persistence-type-sha256-ad3b56aa2fc1ad4250e7399295d34a8eae592e1907e85852e2291b2e498a30bc"></a>
### `"legacy"`
@@ -2184,6 +2248,14 @@ SHA-256: `df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528`
`"positive"`
<a id="persistence-type-sha256-6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65"></a>
### `"producer"`
SHA-256: `6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65`
`"producer"`
<a id="persistence-type-sha256-0341d4c3dfcd53253cdc1e789d9130d3cd848b6f8b0706bf92f313a68b39ff23"></a>
### `"product-interaction"`
@@ -2608,6 +2680,14 @@ SHA-256: `b57ae1b37e52bee0a21cbdace168dc6a15582bcda8c306dad962c5dcc8a8e28b`
`"team/task"`
<a id="persistence-type-sha256-5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c"></a>
### `"teardown"`
SHA-256: `5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c`
`"teardown"`
<a id="persistence-type-sha256-4e44c9118152f6829634bfd508e47669ab0497caffa380c94b4be91f9390d35d"></a>
### `"text"`
@@ -3698,6 +3778,88 @@ SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c`
- `"next-step"`
- `"next-turn"`
<a id="persistence-type-sha256-e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305"></a>
<a id="persistence-type-jobregistereddata"></a>
<a id="persistence-type-packagesjobsjobssrctypestsjobregistereddata"></a>
### `JobRegisteredData`
SHA-256: `e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305`
来源:[`packages/jobs/jobs/src/types.ts:24`](../packages/jobs/jobs/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
| `jobId` | 必需 | `string` |
| `kind` | 必需 | `string` |
| `label` | 必需 | `string` |
| `owner` | 必需 | `string` |
| `startedAt` | 必需 | `number` |
| `toolCall` | 可选 | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) |
<a id="persistence-type-sha256-750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5"></a>
<a id="persistence-type-jobsettlecause"></a>
<a id="persistence-type-packagesjobsjobssrctypestsjobsettlecause"></a>
### `JobSettleCause`
SHA-256: `750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5`
来源:[`packages/jobs/jobs/src/types.ts:247`](../packages/jobs/jobs/src/types.ts)
以下类型之一:
- `"kill"`
- `"producer"`
- `"teardown"`
<a id="persistence-type-sha256-611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da"></a>
<a id="persistence-type-jobsettleddata"></a>
<a id="persistence-type-packagesjobsjobssrctypestsjobsettleddata"></a>
### `JobSettledData`
SHA-256: `611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da`
来源:[`packages/jobs/jobs/src/types.ts:40`](../packages/jobs/jobs/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
| `cause` | 必需 | [`JobSettleCause`](#persistence-type-sha256-750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5) |
| `detail` | 可选 | `string` |
| `finishedAt` | 必需 | `number` |
| `jobId` | 必需 | `string` |
| `kind` | 必需 | `string` |
| `label` | 必需 | `string` |
| `owner` | 必需 | `string` |
| `producerReported` | 必需 | `boolean` |
| `startedAt` | 必需 | `number` |
| `status` | 必需 | [`union (3 variants)`](#persistence-type-sha256-c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3) |
| `toolCall` | 可选 | [`JobToolCall`](#persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f) |
<a id="persistence-type-sha256-39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f"></a>
<a id="persistence-type-jobtoolcall"></a>
<a id="persistence-type-packagesjobsjobssrctypestsjobtoolcall"></a>
### `JobToolCall`
SHA-256: `39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f`
来源:[`packages/jobs/jobs/src/types.ts:16`](../packages/jobs/jobs/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
| `callId` | 必需 | `string` |
| `rootCallId` | 必需 | `string` |
<a id="persistence-type-sha256-c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6"></a>
<a id="persistence-type-jsonvalue"></a>
@@ -5300,6 +5462,8 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
<a id="persistence-type-goalid"></a>
<a id="persistence-type-jobid"></a>
<a id="persistence-type-messagefeedbackversion"></a>
<a id="persistence-type-messageid"></a>
@@ -5332,6 +5496,8 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
<a id="persistence-type-packagesinteractionuser-approvalsrctypestsapprovalrequestid"></a>
<a id="persistence-type-packagesjobsjobssrcbrandtsjobid"></a>
<a id="persistence-type-packagesllmllm-retrysrcbrandtsretryid"></a>
<a id="persistence-type-packagesllmllmsrcbrandtsmessageid"></a>
@@ -5384,7 +5550,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -5493,6 +5659,18 @@ SHA-256: `904bf499d98be4a3a5f836fcac533b6c4899569b018071e6604e1d32657d00fd`
- `"replace"`
- `"set"`
<a id="persistence-type-sha256-c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3"></a>
### `union (3 variants)`
SHA-256: `c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3`
以下类型之一:
- `"completed"`
- `"failed"`
- `"killed"`
<a id="persistence-type-sha256-fd0d91a1f9dd2efb8959bdf5e07f8f0a98b1cdfa38ce8d4732ebde0b44c3abd4"></a>
### `union (3 variants)`
@@ -8125,6 +8303,38 @@ SHA-256: `b222069eea2d768065161c1147b1f2c78c1b54328c84b3586ae5c8f91b8ed35e`
| `time` | 必需 | `number` |
| `type` | 必需 | `"image/offload"` |
<a id="persistence-type-sha256-f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1"></a>
<a id="persistence-type-eventjobregistered"></a>
### `{ type: "job/registered" }`
SHA-256: `f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1`
| 属性 | 存在性 | 类型 |
|---|---|---|
| `data` | 必需 | [`JobRegisteredData`](#persistence-type-sha256-e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305) |
| `ignorable` | 可选 | `true` |
| `seq` | 必需 | `number` |
| `time` | 必需 | `number` |
| `type` | 必需 | `"job/registered"` |
<a id="persistence-type-sha256-edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf"></a>
<a id="persistence-type-eventjobsettled"></a>
### `{ type: "job/settled" }`
SHA-256: `edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf`
| 属性 | 存在性 | 类型 |
|---|---|---|
| `data` | 必需 | [`JobSettledData`](#persistence-type-sha256-611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da) |
| `ignorable` | 可选 | `true` |
| `seq` | 必需 | `number` |
| `time` | 必需 | `number` |
| `type` | 必需 | `"job/settled"` |
<a id="persistence-type-sha256-525254db03b1d1e6b74cf55aced52817568331ac1f96c0818728910b6692e336"></a>
<a id="persistence-type-eventllmretry"></a>
@@ -0,0 +1,28 @@
# Bilingual-pair consistency record for 2026-09-30-job-session-lifecycle.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-30-job-session-lifecycle.md
/:
en: 3d5a424ae7d7e46b
zh: c22a8e4a62d8cc1a
/2026-09-30-job-session-lifecycle:
en: 9be8dc8cb5ce646a
zh: 6ce0040813c36e92
/2026-09-30-job-session-lifecycle/summary:
en: 7d98020db3395e2e
zh: abe692ef42327e49
/2026-09-30-job-session-lifecycle/table-of-contents:
en: 6cf314a069ceb451
zh: 9adff678e9ecab3b
/2026-09-30-job-session-lifecycle/declaration:
en: 6907136f28e48c90
zh: 02f8298485f3d8e7
/2026-09-30-job-session-lifecycle/compatibility:
en: 546fe003ad5293b6
zh: 86d78c9704a03718
/2026-09-30-job-session-lifecycle/verification:
en: e9660a31a43378fc
zh: e97fbe31ff47ea2e
/2026-09-30-job-session-lifecycle/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
@@ -0,0 +1,52 @@
---
description: "Records a persistence type transition and its compatibility acknowledgement."
kind: persistence-change
---
# 2026-09-30-job-session-lifecycle
English | [中文](2026-09-30-job-session-lifecycle.zh.md)
## Summary
Records owned job registration and settlement with one unique JobId, original timestamps, creating tool calls, and producer completion reports.
## Table of Contents
- [Declaration](#declaration)
- [Compatibility](#compatibility)
- [Verification](#verification)
- [Dev Note](#dev-note)
<a id="declaration"></a>
## Declaration
```yaml persistence-change
schemaVersion: 1
id: 2026-09-30-job-session-lifecycle
baseline: false
changes:
- root: "event:job/registered"
previous: null
after: "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1"
decision: same-version
- root: "event:job/settled"
previous: null
after: "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf"
decision: same-version
```
<a id="compatibility"></a>
## Compatibility
Adds required job event kinds without changing the Session envelope. New JobIds use a kind-prefixed UUID for both live controls and durable pairing; historical sessions receive no fabricated job history. Older builds reject unknown required events. producerReported describes completion-promise fulfillment, not verified resource release.
<a id="verification"></a>
## Verification
Registry and snapshot-normalizer tests cover causal order and unique JobIds. CLI and SDK snapshots verify registration, settlement, and notices during execution; the [job lifecycle contract](../subsystems/jobs.md#session-lifecycle-records) allows incomplete terminal history during application teardown.
<a id="dev-note"></a>
## Dev Note
None.
@@ -0,0 +1,297 @@
{
"formatVersion": 2,
"roots": [
{
"key": "event:job/registered",
"kind": "event",
"event": "job/registered",
"surface": false,
"digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 5,
"optional": true
},
{
"name": "seq",
"type": 3,
"optional": false
},
{
"name": "time",
"type": 3,
"optional": false
},
{
"name": "type",
"type": 6,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "jobId",
"type": 2,
"optional": false
},
{
"name": "kind",
"type": 2,
"optional": false
},
{
"name": "label",
"type": 2,
"optional": false
},
{
"name": "owner",
"type": 2,
"optional": false
},
{
"name": "startedAt",
"type": 3,
"optional": false
},
{
"name": "toolCall",
"type": 4,
"optional": true
}
],
"indices": []
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "object",
"properties": [
{
"name": "callId",
"type": 2,
"optional": false
},
{
"name": "rootCallId",
"type": 2,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "job/registered"
}
]
}
},
{
"key": "event:job/settled",
"kind": "event",
"event": "job/settled",
"surface": false,
"digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 14,
"optional": true
},
{
"name": "seq",
"type": 7,
"optional": false
},
{
"name": "time",
"type": 7,
"optional": false
},
{
"name": "type",
"type": 15,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "cause",
"type": 2,
"optional": false
},
{
"name": "detail",
"type": 6,
"optional": true
},
{
"name": "finishedAt",
"type": 7,
"optional": false
},
{
"name": "jobId",
"type": 6,
"optional": false
},
{
"name": "kind",
"type": 6,
"optional": false
},
{
"name": "label",
"type": 6,
"optional": false
},
{
"name": "owner",
"type": 6,
"optional": false
},
{
"name": "producerReported",
"type": 8,
"optional": false
},
{
"name": "startedAt",
"type": 7,
"optional": false
},
{
"name": "status",
"type": 9,
"optional": false
},
{
"name": "toolCall",
"type": 13,
"optional": true
}
],
"indices": []
},
{
"kind": "union",
"types": [
3,
4,
5
]
},
{
"kind": "literal",
"value": "producer"
},
{
"kind": "literal",
"value": "teardown"
},
{
"kind": "literal",
"value": "kill"
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "primitive",
"type": "boolean"
},
{
"kind": "union",
"types": [
10,
11,
12
]
},
{
"kind": "literal",
"value": "completed"
},
{
"kind": "literal",
"value": "failed"
},
{
"kind": "literal",
"value": "killed"
},
{
"kind": "object",
"properties": [
{
"name": "callId",
"type": 6,
"optional": false
},
{
"name": "rootCallId",
"type": 6,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "job/settled"
}
]
}
}
],
"types": []
}
@@ -0,0 +1,52 @@
---
description: "记录持久化类型更改及其兼容性确认。"
kind: persistence-change
---
# 2026-09-30-job-session-lifecycle
[English](2026-09-30-job-session-lifecycle.md) | 中文
## 概述
以唯一 JobId 将有所有者 job 的注册与结算记录为 Session 事件,包含原始时间戳、创建工具调用和生产方完成报告。
## 目录
- [声明](#declaration)
- [兼容性](#compatibility)
- [验证](#verification)
- [开发备注](#dev-note)
<a id="declaration"></a>
## 声明
```yaml persistence-change
schemaVersion: 1
id: 2026-09-30-job-session-lifecycle
baseline: false
changes:
- root: "event:job/registered"
previous: null
after: "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1"
decision: same-version
- root: "event:job/settled"
previous: null
after: "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf"
decision: same-version
```
<a id="compatibility"></a>
## 兼容性
增加必需的 job 事件类型,不改变 Session 封装。新 JobId 用带 kind 前缀的 UUID 同时进行运行期控制与持久化配对;不为历史会话补造 job 事件。旧构建拒绝未知的必需事件。producerReported 表示完成 promise 兑现,不确认资源已释放。
<a id="verification"></a>
## 验证
注册表和快照归一化测试覆盖因果顺序与唯一 JobId。CLI 和 SDK 快照验证运行中的注册、结算和通知;[job 生命周期约定](../subsystems/jobs.zh.md#session-lifecycle-records)允许应用销毁时留下不完整的终态历史。
<a id="dev-note"></a>
## 开发备注
无。
@@ -15,8 +15,8 @@
en: 14c57f30e676f6e3
zh: b1a0ccf26fab3727
/session-persistence-formats/format-references:
en: 553fd4963cf19abe
zh: 7ca3cd5d0a88d35a
en: 73b4caa341293dda
zh: 305a83174f20c790
/session-persistence-formats/scope-and-evidence:
en: b220062139ddac1e
zh: 7c4c0efe05dafa9c
@@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant.
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 63 / 597 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 610 |
<!-- persistence-format-index:end -->
@@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 63 / 597 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 610 |
<!-- persistence-format-index:end -->
+374
View File
@@ -10339,6 +10339,297 @@
]
}
},
{
"key": "event:job/registered",
"kind": "event",
"event": "job/registered",
"surface": false,
"digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 5,
"optional": true
},
{
"name": "seq",
"type": 3,
"optional": false
},
{
"name": "time",
"type": 3,
"optional": false
},
{
"name": "type",
"type": 6,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "jobId",
"type": 2,
"optional": false
},
{
"name": "kind",
"type": 2,
"optional": false
},
{
"name": "label",
"type": 2,
"optional": false
},
{
"name": "owner",
"type": 2,
"optional": false
},
{
"name": "startedAt",
"type": 3,
"optional": false
},
{
"name": "toolCall",
"type": 4,
"optional": true
}
],
"indices": []
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "object",
"properties": [
{
"name": "callId",
"type": 2,
"optional": false
},
{
"name": "rootCallId",
"type": 2,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "job/registered"
}
]
}
},
{
"key": "event:job/settled",
"kind": "event",
"event": "job/settled",
"surface": false,
"digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 14,
"optional": true
},
{
"name": "seq",
"type": 7,
"optional": false
},
{
"name": "time",
"type": 7,
"optional": false
},
{
"name": "type",
"type": 15,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "cause",
"type": 2,
"optional": false
},
{
"name": "detail",
"type": 6,
"optional": true
},
{
"name": "finishedAt",
"type": 7,
"optional": false
},
{
"name": "jobId",
"type": 6,
"optional": false
},
{
"name": "kind",
"type": 6,
"optional": false
},
{
"name": "label",
"type": 6,
"optional": false
},
{
"name": "owner",
"type": 6,
"optional": false
},
{
"name": "producerReported",
"type": 8,
"optional": false
},
{
"name": "startedAt",
"type": 7,
"optional": false
},
{
"name": "status",
"type": 9,
"optional": false
},
{
"name": "toolCall",
"type": 13,
"optional": true
}
],
"indices": []
},
{
"kind": "union",
"types": [
3,
4,
5
]
},
{
"kind": "literal",
"value": "producer"
},
{
"kind": "literal",
"value": "teardown"
},
{
"kind": "literal",
"value": "kill"
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "primitive",
"type": "boolean"
},
{
"kind": "union",
"types": [
10,
11,
12
]
},
{
"kind": "literal",
"value": "completed"
},
{
"kind": "literal",
"value": "failed"
},
{
"kind": "literal",
"value": "killed"
},
{
"kind": "object",
"properties": [
{
"name": "callId",
"type": 6,
"optional": false
},
{
"name": "rootCallId",
"type": 6,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "job/settled"
}
]
}
},
{
"key": "event:llm/retry",
"kind": "event",
@@ -22567,6 +22858,11 @@
"names": [],
"sources": []
},
{
"digest": "24a8188c4d61b70a01af8cc576748765d36f132632ec55c0064f6fba482369fc",
"names": [],
"sources": []
},
{
"digest": "24b10f7cdae64928f89c49afc2eca0af9eb4a2f135e16f3416e6b8461fef1748",
"names": [],
@@ -22793,6 +23089,11 @@
"packages/core/session/src/types.ts:462"
]
},
{
"digest": "347de3380f2da308d0c74e08821f9e85559f55af4c5010795b110d00f7fc9dbd",
"names": [],
"sources": []
},
{
"digest": "34d1ab5c5df378186d6054b1a1beea9a41e41965d6f336f0bc0025c441a82e66",
"names": [],
@@ -22851,6 +23152,15 @@
"packages/feedback/message-feedback/src/types.ts:17"
]
},
{
"digest": "39cf2d3ff8bac81c62d49ac2ba38a095b18e80f3ad700819fa1f650a4e60197f",
"names": [
"packages/jobs/jobs/src/types.ts#JobToolCall"
],
"sources": [
"packages/jobs/jobs/src/types.ts:16"
]
},
{
"digest": "3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408",
"names": [],
@@ -23126,6 +23436,7 @@
"packages/goal/goal/src/types.ts#GoalId",
"packages/interaction/commands/src/brand.ts#CommandId",
"packages/interaction/user-approval/src/types.ts#ApprovalRequestId",
"packages/jobs/jobs/src/brand.ts#JobId",
"packages/llm/llm-retry/src/brand.ts#RetryId",
"packages/llm/llm/src/brand.ts#MessageId",
"packages/llm/llm/src/brand.ts#ProviderRequestId",
@@ -23151,6 +23462,7 @@
"packages/goal/goal/src/types.ts:17",
"packages/interaction/commands/src/brand.ts:31",
"packages/interaction/user-approval/src/types.ts:17",
"packages/jobs/jobs/src/brand.ts:19",
"packages/llm/llm-retry/src/brand.ts:4",
"packages/llm/llm/src/brand.ts:16",
"packages/llm/llm/src/brand.ts:31",
@@ -23397,6 +23709,11 @@
"names": [],
"sources": []
},
{
"digest": "5cb63db9fe57dc09c3a1c2b84c3edf73ff5b62960155d8436a2377a5c43ce32c",
"names": [],
"sources": []
},
{
"digest": "5cdbf342b7748d152bc987b68f58e565e5bba4b227e076e2d2120d87662e45cd",
"names": [],
@@ -23472,6 +23789,15 @@
"names": [],
"sources": []
},
{
"digest": "611d82d2dc50df706cf08a0fc04bc8eaf78083391fe6f10313de943e615dd2da",
"names": [
"packages/jobs/jobs/src/types.ts#JobSettledData"
],
"sources": [
"packages/jobs/jobs/src/types.ts:40"
]
},
{
"digest": "616f93a5047de668e1c30d57b1786abd76beeb4123599637ec1dc5290320ede5",
"names": [],
@@ -23640,6 +23966,11 @@
"names": [],
"sources": []
},
{
"digest": "6cf082d0fb6034dea61636787ff0ddf43b6e59aad71e644435a479bc9b83ad65",
"names": [],
"sources": []
},
{
"digest": "6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406",
"names": [],
@@ -23731,6 +24062,15 @@
"names": [],
"sources": []
},
{
"digest": "750a2b129d67704df675c06823c20c80b65cbae89d5aaa914fd5a72e3df395b5",
"names": [
"packages/jobs/jobs/src/types.ts#JobSettleCause"
],
"sources": [
"packages/jobs/jobs/src/types.ts:247"
]
},
{
"digest": "7554565be416b0342053ee6a46256c9dab07c487349b4a5f10bab1edc151c619",
"names": [],
@@ -24851,6 +25191,11 @@
"names": [],
"sources": []
},
{
"digest": "bab68657940075483f086fffb711682a45bcc99c43d659fbe1c36af21ac05dd7",
"names": [],
"sources": []
},
{
"digest": "babf9ee4d1af62bf6c3a8103737f7a5e4e78ce179be835ce05a38803e15884b7",
"names": [],
@@ -24939,6 +25284,11 @@
"packages/workflow/workflow/src/types.ts:110"
]
},
{
"digest": "c2f49004d9ae38a964e80850c87bc6849d6a872091a1d43e99eedf00c641e2a3",
"names": [],
"sources": []
},
{
"digest": "c43b119ce579b3e9983a0d3adc4ba402ebb9ba773d89738a2dab01ecf5813943",
"names": [],
@@ -25250,6 +25600,15 @@
"names": [],
"sources": []
},
{
"digest": "e3056e0dda3a379e895386e368c3ce42131460ea65e410e1d091cca98a347305",
"names": [
"packages/jobs/jobs/src/types.ts#JobRegisteredData"
],
"sources": [
"packages/jobs/jobs/src/types.ts:24"
]
},
{
"digest": "e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72",
"names": [],
@@ -25391,6 +25750,11 @@
"names": [],
"sources": []
},
{
"digest": "edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf",
"names": [],
"sources": []
},
{
"digest": "ee577a0cc18664c9daee50e9455153321155604f8f1817362f8f4a4b6fb9e1ad",
"names": [],
@@ -25418,6 +25782,11 @@
"packages/core/session/src/types.ts:301"
]
},
{
"digest": "ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21",
"names": [],
"sources": []
},
{
"digest": "ef4f00923b9edbae15c7ef05320280bf61147ca17146cf59690359bfd5e5da45",
"names": [],
@@ -25500,6 +25869,11 @@
"names": [],
"sources": []
},
{
"digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1",
"names": [],
"sources": []
},
{
"digest": "f56c5b59ca0161310c338d31dd16cf14287ccd602ff4181058df178c1c286d7f",
"names": [
+5 -2
View File
@@ -6,8 +6,11 @@
en: dd515d8548864a91
zh: 16a117d39989a373
/background-job-runtime/ids-and-status:
en: 529f401da4757a98
zh: 041ff5e86b5dd8db
en: 4b286c70e4a83b99
zh: 7edbf090e9cb6487
/background-job-runtime/session-lifecycle-records:
en: 5fb99886e0ab2718
zh: bdea51368c935895
/background-job-runtime/producer-contract:
en: af0be338a0ba5e60
zh: fc0eefb8c31f3a98
+60 -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>-N`. 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
/**
@@ -20,10 +20,64 @@ interface JobKindMap {
`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled.
<a id="session-lifecycle-records"></a>
## Session lifecycle records
Owned jobs publish `job/registered` and `job/settled` in their original owner Session. The globally unique `jobId` pairs those records and controls the live job. The SDK forwards these log-only records through `session.event`, independently of output reads. Timestamps describe registry commits, not CPU time or exact OS process boundaries; a missing settlement remains incomplete. `producerReported` means the producer fulfilled `done` with a terminal outcome; it is false for forced teardown or rejection and does not independently certify resource release.
`job/settled` may follow `turn/end`. An SDK run result covers only its own observation window; consumers needing later settlements retain a Session subscription or read persisted history. Turn completion does not imply that every owned job stopped.
Application teardown can stop Session event routing or close its writer before an owned job's final settlement reaches storage. This includes normal one-shot completion and signal shutdown, as well as hard process loss. A missing `job/settled` remains incomplete even if the live registry reported termination; consumers must not invent a finish timestamp.
```ts type-equiv
/** Durable identity and registration time of one owned job execution. */
interface JobRegisteredData {
/** Unique execution id accepted by job controls and retained in Session history. */
readonly jobId: JobId
/** Original owner; copied history does not transfer execution ownership. */
readonly owner: SessionId
/** Producer kind. */
readonly kind: string
/** Producer-supplied label. */
readonly label: string
/** Unix epoch milliseconds when registration committed. */
readonly startedAt: number
/** Creating tool invocation, when the producer was called through a tool. */
readonly toolCall?: JobToolCall
}
```
```ts type-equiv
/** Registry settlement, including whether the producer reported a terminal outcome. */
interface JobSettledData extends JobRegisteredData {
/** Unix epoch milliseconds when the registry committed the terminal record. */
readonly finishedAt: number
/** Recorded terminal outcome. */
readonly status: JobOutcome['status']
/** Source of the termination request, or producer completion. */
readonly cause: JobSettleCause
/** True when done fulfilled; false for rejection or forced failure. Does not certify resource release. */
readonly producerReported: boolean
/** Terminal reason, including a recorded kill reason when applicable. */
readonly detail?: string
}
```
## Producer contract
A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring.
```ts type-equiv
/** Tool dispatch that created a job; nested calls retain their root invocation. */
interface JobToolCall {
/** Exact invocation, including a PTC sub-call when applicable. */
readonly callId: SessionEventMap['tool/call']['callId']
/** Root model-requested invocation owning this execution. */
readonly rootCallId: SessionEventMap['tool/call']['callId']
}
```
```ts type-equiv
/**
* Producer declaration passed to {@link JobRegistry.start}. The runtime
@@ -43,6 +97,8 @@ interface JobSpec {
* caller until service disposal.
*/
owner?: SessionId
/** Creating tool invocation, retained in the durable lifecycle records. */
toolCall?: JobToolCall
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including controller status metadata. Independent of ring
@@ -76,7 +132,7 @@ interface JobSpec {
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
interface JobHandle {
/** The registry-issued id (`<kind>-N`). */
/** 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
@@ -219,7 +275,7 @@ interface JobOutputRead {
* stream all consume this one shape.
*/
interface JobView {
/** The registry-issued id (`<kind>-N`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* The producer kind the job was registered with: a Host-registered
@@ -405,7 +461,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>-N` id.
* @returns the registry-issued `<kind>-<short-uuid>` id.
*/
abstract start(spec: JobSpec): JobId
+60 -4
View File
@@ -6,7 +6,7 @@
## ID 与状态
`JobId` 是按 `<kind>-N` 生成的[品牌化 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
/**
@@ -20,10 +20,64 @@ interface JobKindMap {
`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。
<a id="session-lifecycle-records"></a>
## Session 生命周期记录
有所有者的 job 在原始所有者 Session 中发布 `job/registered` 与 `job/settled`。全局唯一的 `jobId` 同时配对这两条记录并控制运行中的 job。SDK 通过 `session.event` 转发这些仅日志记录,不依赖输出读取。时间戳描述注册表提交时刻,不是 CPU 时间或精确的 OS 进程边界;缺少结算时保持不完整。`producerReported` 表示生产方通过兑现 `done` 报告了终态;强制销毁或 promise 拒绝时为 false,不独立确认资源已释放。
`job/settled` 可能出现在 `turn/end` 之后。SDK run 结果只覆盖自身观测窗口;需要较晚结算的消费方应保留 Session 订阅或读取持久化历史。轮次完成不表示所有有主 job 都已停止。
应用销毁可能在有主 job 的最后一条结算写入存储前停止 Session 事件路由或关闭写句柄。正常的一次性运行结束、信号关停和进程硬退出都可能出现这种情况。即使运行期注册表报告了终止,缺少 `job/settled` 的区间仍不完整;消费方不得虚构结束时间戳。
```ts type-equiv
/** Durable identity and registration time of one owned job execution. */
interface JobRegisteredData {
/** Unique execution id accepted by job controls and retained in Session history. */
readonly jobId: JobId
/** Original owner; copied history does not transfer execution ownership. */
readonly owner: SessionId
/** Producer kind. */
readonly kind: string
/** Producer-supplied label. */
readonly label: string
/** Unix epoch milliseconds when registration committed. */
readonly startedAt: number
/** Creating tool invocation, when the producer was called through a tool. */
readonly toolCall?: JobToolCall
}
```
```ts type-equiv
/** Registry settlement, including whether the producer reported a terminal outcome. */
interface JobSettledData extends JobRegisteredData {
/** Unix epoch milliseconds when the registry committed the terminal record. */
readonly finishedAt: number
/** Recorded terminal outcome. */
readonly status: JobOutcome['status']
/** Source of the termination request, or producer completion. */
readonly cause: JobSettleCause
/** True when done fulfilled; false for rejection or forced failure. Does not certify resource release. */
readonly producerReported: boolean
/** Terminal reason, including a recorded kill reason when applicable. */
readonly detail?: string
}
```
## 生产方约定
`JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。
```ts type-equiv
/** Tool dispatch that created a job; nested calls retain their root invocation. */
interface JobToolCall {
/** Exact invocation, including a PTC sub-call when applicable. */
readonly callId: SessionEventMap['tool/call']['callId']
/** Root model-requested invocation owning this execution. */
readonly rootCallId: SessionEventMap['tool/call']['callId']
}
```
```ts type-equiv
/**
* Producer declaration passed to {@link JobRegistry.start}. The runtime
@@ -43,6 +97,8 @@ interface JobSpec {
* caller until service disposal.
*/
owner?: SessionId
/** Creating tool invocation, retained in the durable lifecycle records. */
toolCall?: JobToolCall
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including controller status metadata. Independent of ring
@@ -76,7 +132,7 @@ interface JobSpec {
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
interface JobHandle {
/** The registry-issued id (`<kind>-N`). */
/** 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
@@ -219,7 +275,7 @@ interface JobOutputRead {
* stream all consume this one shape.
*/
interface JobView {
/** The registry-issued id (`<kind>-N`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* The producer kind the job was registered with: a Host-registered
@@ -405,7 +461,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>-N` id.
* @returns the registry-issued `<kind>-<short-uuid>` id.
*/
abstract start(spec: JobSpec): JobId
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/job-id-identity/guide.md
/:
en: d6fdce6b58c67691
zh: a103e5028f5442d0
/background-job-ids-identify-one-execution:
en: 9b06780be69e93f6
zh: c33945fd584c6a23
/background-job-ids-identify-one-execution/change:
en: 047ec9d0337bfc0b
zh: 1218472855e81189
/background-job-ids-identify-one-execution/migration:
en: 08921c9c25218c83
zh: 7ff1d3ac63ae40ef
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "JobId now identifies one background execution across registry lifetimes."
---
# Background job ids identify one execution
English | [中文](guide.zh.md)
## Change
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
1. Pass the exact id returned by a start result or `jobs.list()` to `job_output` and `job_kill`; use `job_output` with `wait: true` to wait. Remove code that constructs an id from the kind and an ordinal.
2. Pair SDK lifecycle events by `data.jobId`. Remove imports of `JobExecutionId` and code that maps it to a process-local `jobId`.
3. Verify a new job's registration and settlement contain the same `jobId`, and a later registry instance issues a different one.
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "JobId 现在跨注册表生命周期标识一次后台执行。"
---
# 后台 job id 标识一次执行
[English](guide.md) | 中文
## 变更
job 注册表返回 `<kind>-<short-uuid>`,不再返回 `<kind>-N`;后缀是 UUID 的 16 位小写十六进制字符。运行期控制、`job/registered` 和 `job/settled` 使用同一个 `JobId`;生命周期记录不再携带 `executionId`。访问仍按所有者授权。已有 Session 历史保持原样,不会恢复其中的 job。
## 迁移
1. 把启动结果或 `jobs.list()` 返回的原始 id 传给 `job_output` 和 `job_kill`;等待时为 `job_output` 设置 `wait: true`。移除按 kind 和序号构造 id 的代码。
2. 使用 `data.jobId` 配对 SDK 生命周期事件。移除 `JobExecutionId` 导入,以及将它映射为进程内 `jobId` 的代码。
3. 核对新 job 的注册与结算含有相同 `jobId`,后续注册表实例发放不同 id。
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/jsonl-persistence-plugin/guide.md
/:
en: eff30513d0de970b
zh: c16a32f374bd07e1
/mount-jsonl-persistence-through-its-plugin:
en: ae3ff407a497cf5c
zh: 1b71e651967b8255
/mount-jsonl-persistence-through-its-plugin/change:
en: ea5defddc1a9b989
zh: 97902f22a4196b12
/mount-jsonl-persistence-through-its-plugin/migration:
en: 93d9f202c59303e9
zh: c5ced26ed819d7c5
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "JSONL persistence uses a namespace plugin import; direct service-class construction and class type annotations must use the persistence service instead."
---
# Mount JSONL persistence through its plugin
English | [中文](guide.zh.md)
## Change
`@deepseek-ai/dsh-session-persistence-jsonl` exports a function plugin through named `name`, `Config`, and `apply` exports, with no default export. Default imports, direct construction, subclassing, `instanceof`, and class type annotations are affected. The plugin retains listeners and handles through the consumer unloads triggered by removing this provider; concurrent root disposal remains unordered. The `sessionPersistence` service API, YAML configuration, and stored files remain unchanged.
## Migration
1. Replace the default import with `import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'`. Existing `ctx.plugin(JsonlSessionPersistence, config)` calls remain valid; replace direct construction with this mounting call. For dynamic imports, use the module namespace returned by `await import(...)` instead of its `.default`. Consumers declare `inject: ['sessionPersistence']` and access `ctx.sessionPersistence`.
2. Use the `SessionPersistence` type from `@deepseek-ai/dsh-session-persistence` for service annotations. Replace concrete-class `instanceof` checks and subclasses with consumers of that service; the concrete JSONL class is private.
3. Await plugin startup and confirm a session can be created, flushed, and reopened. Register asynchronous consumer cleanup in the scope that injects `sessionPersistence` so backend unloading waits for it.
@@ -0,0 +1,18 @@
---
kind: upgrade-guide
description: "JSONL 持久化使用插件命名空间导入;直接构造服务类和类类型标注需要改用持久化服务。"
---
# 通过插件挂载 JSONL 持久化
[English](guide.md) | 中文
## 变更
`@deepseek-ai/dsh-session-persistence-jsonl` 通过命名导出的 `name`、`Config` 和 `apply` 提供函数插件,没有默认导出。默认导入、直接构造、继承、`instanceof` 和类类型标注会受到影响。插件会保留监听器和句柄,直到移除该提供者所触发的消费者卸载完成;根作用域的并发卸载仍不保证顺序。`sessionPersistence` 服务 API、YAML 配置和存储文件保持不变。
## 迁移
1. 将默认导入替换为 `import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'`。现有的 `ctx.plugin(JsonlSessionPersistence, config)` 调用仍然有效;将直接构造改为该挂载调用。动态导入直接使用 `await import(...)` 返回的模块命名空间,不再取其 `.default`。消费者声明 `inject: ['sessionPersistence']` 并访问 `ctx.sessionPersistence`。
2. 服务类型标注使用 `@deepseek-ai/dsh-session-persistence` 的 `SessionPersistence` 类型。将针对具体类的 `instanceof` 检查和子类改为消费该服务;具体 JSONL 类是私有的。
3. 等待插件启动完成,确认能够创建、flush 并重新打开会话。将消费者的异步清理注册到注入 `sessionPersistence` 的作用域中,以便后端卸载时等待它完成。
+1 -1
View File
@@ -23,7 +23,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St
import { type GenerateOptions, LlmAdapter, ReasoningEffortId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import * as AcpPlugin from '../src/index.ts'
import type { AcpConfig } from '../src/index.ts'
@@ -9,7 +9,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionCache, {
checkpointRecord,
+2 -2
View File
@@ -57,8 +57,8 @@
en: 0b0f74b412c7c6d8
zh: 6e8f918d0c4bf342
/deepseek-ai-dsh-headless/known-limitations-and-deferred-work:
en: b2df65836d53ba03
zh: 82a56dc1255a0c3b
en: 3a6dc9666d852219
zh: b764f203815e179e
/deepseek-ai-dsh-headless/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+1
View File
@@ -133,6 +133,7 @@ The runner adds nothing to the request prefix; it only drives one user message t
These limits tell you when headless does not fit and what it needs from the `dsh` launcher. They are current package constraints, not a general CLI comparison or a task backlog.
- **One task per run** — after the task is answered the process exits; there is no interactive follow-up, so split multi-step work into separate runs.
- **Final background-job settlement may be absent from persisted history** — process teardown can end Session routing or close its writer before an owned job settles, including normal completion and signal shutdown; the [job lifecycle contract](../../../docs/subsystems/jobs.md#session-lifecycle-records) defines how consumers retain incomplete intervals.
- **Runs through the `dsh` launcher** — starting the headless profile another way fails at startup, because only the launcher can request the process exit.
- **No pre-token heartbeat** — in default mode stderr stays silent until the provider emits a non-empty reasoning delta; a delayed first token exposes no earlier progress signal.
- **Reasoning enters stderr logs** — in default mode, redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when needed.
+1
View File
@@ -133,6 +133,7 @@ runner 不向请求前缀添加任何内容;它只是驱动组合出的配置
这些限制告诉你 headless 何时不适用、它需要 `dsh` 启动器提供什么。它们是当前包约束,不是通用的 CLI(命令行界面)对比或任务积压。
- **每次运行一个任务**——任务得到回答后进程即退出;没有交互式后续,因此多步工作请拆成多次运行。
- **持久化历史可能缺少后台 job 的最后一条结算**——进程销毁可能在有主 job 结算前停止 Session 路由或关闭写句柄,正常结束和信号关停也可能如此;[job 生命周期约定](../../../docs/subsystems/jobs.zh.md#session-lifecycle-records)说明消费方如何保留不完整区间。
- **通过 `dsh` 启动器运行**——以其他方式启动 headless profile 会在启动时失败,因为只有启动器能请求进程退出。
- **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前,stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。
- **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留显著更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。
@@ -13,7 +13,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -8,7 +8,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
@@ -13,7 +13,7 @@ import ToolRuntime, { defineContentToolFixture, TOOL_RUNTIME_SCHEDULER } from '@
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { SessionId } from '@deepseek-ai/dsh-session'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
@@ -12,7 +12,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -42,6 +42,8 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'hook/invoked',
'hook/result',
'image/offload',
'job/registered',
'job/settled',
'llm/retry',
'llm/retry-started',
'model/selection',
@@ -9,7 +9,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -8,7 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionLogOffset, SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentService from '@deepseek-ai/dsh-subagent'
import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
@@ -14,7 +14,7 @@ import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
import Llm, { LlmAdapter, ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import Sessions, { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import Agents from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -8,7 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import WorkingDirectory from '@deepseek-ai/dsh-working-directory'
import ClaudeCodeMods, { defineMod } from '../src/index.ts'
@@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deep
import SessionController from '@deepseek-ai/dsh-api-session-controller'
import Translator from '@deepseek-ai/dsh-experimental-translator'
import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionQuery from '@deepseek-ai/dsh-session-query'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
@@ -2,10 +2,14 @@
import { createServer, type Server } from 'node:http'
import { createContext, runInContext } from 'node:vm'
import WebSocket, { type RawData } from 'ws'
import WebSocket, { WebSocketServer, type RawData } from 'ws'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts'
import { InspectorClientFixture } from './fixtures/client-source.host.ts'
import { COVERAGE_TEST_TIMEOUT_ENV, coverageTestTimeoutOptions } from '../../../../scripts/coverage-partitions.ts'
// A fixture request must retain the test lane's deadline under instrumented CI load.
const CDP_CALL_TIMEOUT_MS = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV]).testTimeout ?? 5_000
interface CdpMessage {
readonly id?: number
@@ -17,15 +21,21 @@ interface CdpMessage {
class TestCdpClient {
private nextId = 0
private readonly pending = new Map<number, (message: CdpMessage) => void>()
private readonly pending = new Map<number, {
respond(message: CdpMessage): void
disconnect(): void
}>()
readonly events: CdpMessage[] = []
private constructor(private readonly socket: WebSocket) {
socket.on('message', (data) => {
const message = JSON.parse(rawText(data)) as CdpMessage
if (message.id !== undefined) this.pending.get(message.id)?.(message)
if (message.id !== undefined) this.pending.get(message.id)?.respond(message)
else this.events.push(message)
})
socket.on('close', () => {
for (const request of this.pending.values()) request.disconnect()
})
}
static async connect(url: string): Promise<TestCdpClient> {
@@ -43,11 +53,18 @@ class TestCdpClient {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`CDP call timed out: ${method}`))
}, 5_000)
this.pending.set(id, (message) => {
clearTimeout(timer)
this.pending.delete(id)
resolve(message)
}, CDP_CALL_TIMEOUT_MS)
this.pending.set(id, {
respond: (message) => {
clearTimeout(timer)
this.pending.delete(id)
resolve(message)
},
disconnect: () => {
clearTimeout(timer)
this.pending.delete(id)
reject(new Error('CDP connection closed'))
},
})
this.socket.send(JSON.stringify({ id, method, params }))
})
@@ -81,6 +98,28 @@ describe('experimental Inspector real Worker', () => {
server = undefined
})
it('settles pending fixture calls when their WebSocket closes', async () => {
server = createServer()
const transport = new WebSocketServer({ server })
try {
const received = new Promise<void>((resolve) => {
transport.once('connection', (socket) => { socket.once('message', () => { resolve() }) })
})
await new Promise<void>((resolve) => { server!.listen(0, '127.0.0.1', resolve) })
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('test server has no TCP address')
cdp = await TestCdpClient.connect(`ws://127.0.0.1:${String(address.port)}`)
const pending = cdp.call('Fixture.pending')
const disconnected = expect(pending).rejects.toThrow('CDP connection closed')
await received
await cdp.close()
await disconnected
} finally {
await cdp?.close()
await new Promise<void>((resolve) => { transport.close(() => { resolve() }) })
}
})
it('limits an embedded connection to its Client while direct connections retain every Client', async (test) => {
inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 })
const sourceCatalog = (name: string) => ({
@@ -9,7 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import { ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
@@ -10,7 +10,7 @@ import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import PtcWorkflowEngine from '@deepseek-ai/dsh-workflow-ptc'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -13,7 +13,7 @@ import SessionStore, {
} from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
@@ -31,7 +31,7 @@ export const inject = ['terminals', 'tools', 'systemPrompt', 'workingDirectory']
/** Default cap for one complete model-facing terminal result. */
export const DEFAULT_MAX_RESULT_BYTES = 256 * 1024
/** Smallest cap that preserves every counter-backed PTY and job id in its creation acknowledgement. */
/** Smallest cap that preserves registry-issued PTY and job ids in creation acknowledgements. */
export const MIN_MAX_RESULT_BYTES = 64
/** Model-facing terminal tool configuration. */
@@ -261,6 +261,7 @@ export function apply(ctx: Context, config: Config = {}): void {
const jobId = jobs.start({
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
toolCall: { callId: exec.callId, rootCallId: exec.rootCallId },
owner: owner.id,
outputLimitBytes: maxResultBytes,
output: [sendSource(() => operation)],
@@ -140,6 +140,16 @@ function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
function backgroundJobId(result: Awaited<ReturnType<typeof call>>): string {
if (!('value' in result)) throw new Error('missing background job result')
const value = result.value
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]{16}$/)
return value.jobId
}
describe('tool-terminal foreground API', () => {
it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => {
const { ctx, agent } = await setup(false)
@@ -345,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)).toContain('pty-send-1')
expect(text(background)).toBe(`started background job ${backgroundJobId(background)}`)
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
})
@@ -427,9 +437,10 @@ describe('tool-terminal task integration', () => {
const { ctx, agent } = await setup(true)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent)
expect(text(started)).toBe('started background job pty-send-1')
expect(started).toMatchObject({ isError: false, value: { kind: 'background', jobId: 'pty-send-1' } })
const output = await call(ctx, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
const jobId = backgroundJobId(started)
expect(text(started)).toBe(`started background job ${jobId}`)
expect(started).toMatchObject({ isError: false, value: { kind: 'background', jobId } })
const output = await call(ctx, 'job_output', { job_id: jobId, wait: true }, agent)
expect(text(output)).toContain('live output')
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
})
@@ -443,8 +454,8 @@ describe('tool-terminal task integration', () => {
stub.sessions[0]!.delta = '界'.repeat(100)
stub.sessions[0]!.deltaTruncated = true
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent)
const background = await call(ctx, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent)
const background = await call(ctx, 'job_output', { job_id: backgroundJobId(started), wait: true }, agent)
expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64)
expect(text(background)).toContain('[status: completed')
expect(text(background).match(/\[output truncated\]/g)).toHaveLength(1)
@@ -459,16 +470,21 @@ describe('tool-terminal task integration', () => {
expect((await callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'x', run_in_background: true }, agent, controller.signal)).isError).toBe(true)
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent))).toContain('pty-send-1')
expect(text(await call(ctx, 'job_kill', { job_id: 'pty-send-1' }, agent))).toContain('requested cancellation')
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent)
const jobId = backgroundJobId(started)
expect(text(started)).toContain(jobId)
expect(text(await call(ctx, 'job_kill', { job_id: jobId }, agent))).toContain('requested cancellation')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'job_output', { job_id: 'pty-send-1' }, agent))).toContain('[status: killed')
expect(text(await call(ctx, 'job_output', { job_id: jobId }, agent))).toContain('[status: killed')
stub.sessions[0]!.rejectOperation = true
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent))).toContain('pty-send-2')
const failed = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent)
const failedJobId = backgroundJobId(failed)
expect(failedJobId).not.toBe(jobId)
expect(text(failed)).toContain(failedJobId)
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'job_output', { job_id: 'pty-send-2' }, agent))).toContain('[status: failed')
expect(text(await call(ctx, 'job_output', { job_id: failedJobId }, agent))).toContain('[status: failed')
})
it('reports foreground cancellation after the terminal operation settles', async () => {
@@ -500,8 +516,8 @@ describe('tool-terminal task integration', () => {
const { ctx, agent, stub } = await setup(true)
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
stub.sessions[0]!.statusValue = { kind: 'exited', exitCode: null, signal: null }
await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
const output = await call(ctx, 'job_output', { job_id: 'pty-send-1', wait: true }, agent)
const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
const output = await call(ctx, 'job_output', { job_id: backgroundJobId(started), wait: true }, agent)
expect(text(output)).toContain('session exited: unknown')
})
})
@@ -1,7 +1,8 @@
/** Common paid requests and results survive Host restart without altering main conversation input. */
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { pluginRecordOf, SessionId, SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { JsonlHandleStorage } from '../../../session/session-persistence-jsonl/src/storage.ts'
import { readFile } from 'node:fs/promises'
import { setImmediate } from 'node:timers/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -39,8 +40,8 @@ function historicalEvents(): SessionEvent[] {
describe('common paid translation persistence', () => {
it.each(['caller cancellation', 'provider deadline'] as const)(
'keeps a pending result ahead of an immediate retry after %s without billing again', async (interruption) => {
const b = await paidFixture(cleanups, { translator: { deepseekTimeoutMs: 60_000 } }), backend = b.ctx.sessionPersistence
if (!(backend instanceof JsonlPersistence)) throw new Error('Expected the real JSONL backend')
const b = await paidFixture(cleanups, { translator: { deepseekTimeoutMs: 60_000 } }), backend = b.ctx.sessionPersistence as SessionPersistence & Pick<JsonlHandleStorage, 'persistBatch'>
expect(typeof backend.persistBatch).toBe('function')
const entered = Promise.withResolvers<undefined>(), release = Promise.withResolvers<undefined>()
cleanups.push(async () => { release.resolve(undefined) })
const persist = backend.persistBatch.bind(backend)
@@ -3,7 +3,7 @@ import type { ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader'
import { boot } from '@deepseek-ai/dsh-app-boot'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as Official from '@deepseek-ai/dsh-llm-deepseek-api-key'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -3,7 +3,7 @@ import type { ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader'
import { boot } from '@deepseek-ai/dsh-app-boot'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type Session, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SqliteSessionQuery from '@deepseek-ai/dsh-session-query-sqlite'
import * as Official from '@deepseek-ai/dsh-llm-deepseek-api-key'
import * as Account from '@deepseek-ai/dsh-llm-deepseek-account'
@@ -5,7 +5,7 @@ import { boot } from '@deepseek-ai/dsh-app-boot'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq, type Session, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence'
import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { once } from 'node:events'
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
@@ -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>-N` id.',
returns: 'the registry-issued `<kind>-<short-uuid>` id.',
},
{
signature: 'abstract list(caller?: SessionId): JobView[]',
@@ -5750,12 +5750,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'JobSpec',
declaration: 'export interface JobSpec {\n kind: JobKind;\n label: string;\n owner?: SessionId;\n outputLimitBytes?: number;\n output?: readonly JobOutputSource[];\n run(job: JobHandle): JobHooks;\n}',
declaration: 'export interface JobSpec {\n kind: JobKind;\n label: string;\n owner?: SessionId;\n toolCall?: JobToolCall;\n outputLimitBytes?: number;\n output?: readonly JobOutputSource[];\n run(job: JobHandle): JobHooks;\n}',
},
{
name: 'JobStatus',
declaration: 'export type JobStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
},
{
name: 'JobToolCall',
declaration: 'export interface JobToolCall {\n readonly callId: SessionEventMap[\'tool/call\'][\'callId\'];\n readonly rootCallId: SessionEventMap[\'tool/call\'][\'callId\'];\n}',
},
{
name: 'JobView',
declaration: 'export interface JobView {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly owner?: SessionId;\n readonly outputLimitBytes?: number;\n readonly status: JobStatus;\n readonly progress?: string;\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly output: {\n readonly total: number;\n readonly earliest: number;\n readonly spillPaths?: readonly string[];\n };\n}',
@@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis'
import Include from '@deepseek-ai/cordis-plugin-include'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
import MessageFeedbackService from '../src/index.ts'
import { appendMessageFixture } from './helpers.ts'
+9 -6
View File
@@ -29,9 +29,12 @@
/deepseek-ai-dsh-jobs-local/use-this-package/lifecycle:
en: 91dd372f742dcdb8
zh: 58eeee0254d33cd1
/deepseek-ai-dsh-jobs-local/use-this-package/session-timing-records:
en: ae926795d8aaa88f
zh: 0e6ec0265eefccf7
/deepseek-ai-dsh-jobs-local/use-this-package/what-can-go-wrong:
en: dcf5bc26dbc018e5
zh: 3dbe65c2df463ec0
en: e1f4511c458dff3b
zh: bea247c2a365a646
/deepseek-ai-dsh-jobs-local/understand-the-implementation:
en: f782557ca8da3138
zh: e8f0082b9692cacc
@@ -51,8 +54,8 @@
en: 49e8bd5e2c1f6ffd
zh: ad3e1aa712fd59b3
/deepseek-ai-dsh-jobs-local/further-exploration:
en: 987d5d15aae77dda
zh: 5b0fa8cfc71a44ad
en: bb0334d20bfb0b5f
zh: 1b02ec08f9618bef
/deepseek-ai-dsh-jobs-local/model-experience:
en: 873ef364fc8d6f2f
zh: 4d0bb38a9c9639c0
@@ -60,8 +63,8 @@
en: e290ccc9b3c92c08
zh: d75d48f485447a74
/deepseek-ai-dsh-jobs-local/known-limitations-and-deferred-work:
en: 5284107cbd22e3dd
zh: 9aa0048908c94e25
en: 8b3e2d5505caf2cd
zh: 46435d6a6b3c9aff
/deepseek-ai-dsh-jobs-local/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+10 -2
View File
@@ -56,9 +56,15 @@ The limit counts the exact owner's `running` and `stopping` records; all unowned
Jobs belong to their owner and backend, not to the producer tool, so producer or controller reloads do not stop them. When an agent that owns jobs is disposed, its jobs are cancelled, their producers awaited, and their snapshots removed; service disposal does the same for every remaining job. A cancellation that throws during teardown force-fails the record and warns that the work may be orphaned, so teardown never deadlocks.
### Session timing records
Owned jobs record registration and settlement in their original Session, independently of model output collection. SDK consumers receive the same events through `session.event`; the [jobs reference](../../../docs/subsystems/jobs.md#session-lifecycle-records) defines identities, timestamps, and producer settlement reports. Events already persisted remain available after process exit; live jobs and output rings are not resumed.
Lifecycle facts are appended synchronously at the registration or settlement commit, before live hub subscribers and completion waiters react. Append failures warn without abandoning execution or blocking cleanup; consumers must retain incomplete histories as incomplete.
### What can go wrong
Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Every record disappears when the harness process exits.
Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Live registry records disappear when the harness process exits; persisted lifecycle events remain.
-----
@@ -114,6 +120,7 @@ Read these pages when the package-level contract is not enough. They move from t
- [Registry contract](../jobs/README.md) — the abstract `ctx.jobs` service this package implements.
- [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices.
- [Generic long-running tool runtime reference](../jobs/README.md) — the design behind the background-job runtime.
- [Job lifecycle recording decision](../../../.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md) — durable timing, execution identity, and recording-failure behavior.
- [job-registry seam Agent Note](../../../.agents/notes/archived/architecture/2026-07-26-job-registry-seam.md) — the owner-fenced registry contract and its rationale.
-----
@@ -134,7 +141,8 @@ No direct invalidation; the named consumers own any request-prefix changes.
These limits define when the registry is a poor fit. They are current package constraints, not a task backlog.
- **Jobs are process-local** — records die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam.
- **Jobs are process-local** — live execution and output rings die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam.
- **Final settlement persistence is not guaranteed during teardown** — normal one-shot completion, signal shutdown, or hard exit can stop Session routing or close its writer before the final `job/settled` reaches storage. Consumers must retain the interval as incomplete when settlement is absent; cross-fiber shutdown ordering is outside this journal's contract.
- **A silently ineffective cancel can stall teardown and hold capacity** — if `cancel` returns without settling `done`, the registry cannot distinguish it from a slow stop; the job keeps one bucket slot for the rest of the service lifetime, and only an explicit throw can be force-failed safely.
<a id="dev-note"></a>
+10 -2
View File
@@ -56,9 +56,15 @@ kind: "package-reference"
任务属于其所有者和后端,而非生产方工具,因此重载生产方或控制器不会停止任务。拥有任务的 agent 被释放时,其任务会被取消、生产方会被等待、快照会被移除;服务释放对每个剩余任务执行同样的操作。销毁期间抛出的取消会强制失败记录并警告工作可能成为孤立工作,因此销毁永远不会死锁。
### Session 时间记录
有所有者的 job 在原始 Session 中记录注册与结算,不依赖模型收集输出。SDK 消费方通过 `session.event` 收到同样的事件;[jobs 参考](../../../docs/subsystems/jobs.zh.md#session-lifecycle-records)定义标识、时间戳和生产方结算报告。已持久化的事件在进程退出后仍可读取;运行中的 job 和输出环不会被恢复。
生命周期事实在注册或结算提交时同步追加,先于运行期 hub 订阅者和完成等待方的反应。追加失败会记录警告,不放弃执行或阻塞清理;消费方必须将不完整历史保留为不完整。
### 可能出什么问题
没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。每条记录都会在 harness 进程退出时消失。
没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。运行期注册表记录会在 harness 进程退出时消失;已持久化的生命周期事件会保留。
-----
@@ -114,6 +120,7 @@ kind: "package-reference"
- [注册表约定](../jobs/README.zh.md)——本包实现的抽象 `ctx.jobs` 服务。
- [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。
- [通用长时间运行工具运行时 reference](../jobs/README.zh.md)——后台任务运行时背后的设计。
- [job 生命周期记录决策](../../../.agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.zh.md)——持久化时间、执行标识及记录失败行为。
- [任务注册表 seam Agent Note](../../../.agents/notes/archived/architecture/2026-07-26-job-registry-seam.md)——按所有者隔离的注册表约定及其理由。
-----
@@ -134,7 +141,8 @@ kind: "package-reference"
这些限制说明注册表何时不合适。它们是当前包约束,不是任务积压。
- **任务只存在于进程本地**——记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。
- **任务只存在于进程本地**——运行期执行与输出环会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。
- **销毁期间不保证最后一条结算持久化**——正常的一次性运行结束、信号关停或硬退出,都可能在最后一条 `job/settled` 写入存储前停止 Session 路由或关闭写句柄。缺少结算时,消费方必须将区间保留为不完整;跨 fiber 的关停顺序不属于本日志的约定。
- **静默无效的取消可能使销毁停滞并持续占用容量**——如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。
<a id="dev-note"></a>
+45 -15
View File
@@ -10,6 +10,7 @@
* @module @deepseek-ai/dsh-jobs-local
*/
import { randomUUID } from 'node:crypto'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -19,7 +20,7 @@ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs'
import type {
JobAppendOptions, JobEvent, JobEvents, JobHandle, JobKind, JobOutcome, JobOutputRead, JobOutputSource,
JobRead, JobSettleCause, JobSpec, JobStatus, JobView,
JobRead, JobRegisteredData, JobSettleCause, JobSettledData, JobSpec, JobStatus, JobToolCall, JobView,
} from '@deepseek-ai/dsh-jobs'
import { JobEventHub, JobLayer } from './events.ts'
import { startPump } from './pump.ts'
@@ -75,6 +76,7 @@ interface ProducerState {
/** The registry's mutable per-job record (never handed out — see {@link LocalJobRegistry.view}). */
interface TrackedJob {
id: JobId
toolCall: JobToolCall | undefined
kind: JobKind
label: string
outputLimitBytes: number | undefined
@@ -158,7 +160,6 @@ export class LocalJobRegistry extends JobRegistry {
/** Schemastery-defaulted pull-source poll interval. */
private readonly pumpPollMs: number
private store = new Map<JobId, TrackedJob>()
private counters = new Map<string, number>()
/**
* Controllers and scoped subscriptions layered by the scope that registered
* them, in the tools-registry shape: a contribution files into its
@@ -223,12 +224,13 @@ export class LocalJobRegistry extends JobRegistry {
)
}
// The id is issued before the starter runs so the producer face can carry
// it; a throwing starter still leaves nothing registered — its ordinal is
// simply skipped.
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = JobId(`${spec.kind}-${count}`)
// 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 }
const handle: JobHandle = {
@@ -242,6 +244,7 @@ export class LocalJobRegistry extends JobRegistry {
const settled = new Promise<void>((resolve) => { markSettled = resolve })
const job: TrackedJob = {
id,
toolCall,
kind: spec.kind,
label: spec.label,
outputLimitBytes: spec.outputLimitBytes,
@@ -269,6 +272,7 @@ export class LocalJobRegistry extends JobRegistry {
// call reaches the registered record for its terminal checks and signals.
state.job = job
this.store.set(id, job)
this.recordSessionEvent(job)
// Registration is complete and cannot fail from here, so the visible set
// has genuinely changed. The announcement precedes the pump because the
// pump drains its sources once synchronously, and that drain may append
@@ -278,11 +282,11 @@ export class LocalJobRegistry extends JobRegistry {
// The producer's settlement or a registry-forced one ends the pump; the
// pump's final drain then lands before this registry trims the ring.
const producerDone = hooks.done.then(
outcome => outcome,
(error: unknown): JobOutcome => {
outcome => ({ outcome, producerReported: true }),
(error: unknown): { outcome: JobOutcome; producerReported: boolean } => {
// Contain a producer contract violation (`done` rejected) so cleanup and waiters cannot hang.
this.selfCtx.logger.warn(`jobs: job ${job.id} producer done promise rejected (producer contract violation): ${String(error)}`)
return { status: 'failed', detail: String(error) }
return { outcome: { status: 'failed', detail: String(error) }, producerReported: false }
},
)
if (spec.output !== undefined && spec.output.length > 0) {
@@ -296,9 +300,9 @@ export class LocalJobRegistry extends JobRegistry {
Promise.race([producerDone, settled]),
)
}
void producerDone.then(async (outcome) => {
void producerDone.then(async ({ outcome, producerReported }) => {
if (job.pump !== undefined) await job.pump.done
this.settle(job, outcome, job.settleCause ?? 'producer')
this.settle(job, outcome, job.settleCause ?? 'producer', producerReported)
})
return id
}
@@ -436,6 +440,28 @@ export class LocalJobRegistry extends JobRegistry {
this.hub.emit(event, owner)
}
/** Publish committed facts before live subscribers or completion waiters react. */
private recordSessionEvent(
job: TrackedJob,
settlement?: Pick<JobSettledData, 'finishedAt' | 'status' | 'cause' | 'producerReported' | 'detail'>,
): void {
const owner = job.owner
if (owner === undefined) return
const session = owner.session
const data: JobRegisteredData = {
jobId: job.id, owner: owner.id,
kind: job.kind, label: job.label, startedAt: job.startedAt,
...job.toolCall !== undefined ? { toolCall: job.toolCall } : {},
}
try {
if (settlement === undefined) session.append('job/registered', data)
else session.append('job/settled', { ...data, ...settlement })
} catch (error: unknown) {
// Recording cannot orphan already-started work or prevent waiter release.
this.selfCtx.logger.warn(`jobs: failed to record lifecycle for ${job.id}: ${String(error)}`)
}
}
/**
* Consume the ring from the model cursor; the result rides the first read
* after settlement. A terminal read is the point the settled stream drops
@@ -574,7 +600,7 @@ export class LocalJobRegistry extends JobRegistry {
* reports whether it released one: a timed-out or aborted wait has already
* left the set, so only a wait still owed the projection counts.
*/
private settle(job: TrackedJob, outcome: JobOutcome, cause: JobSettleCause): void {
private settle(job: TrackedJob, outcome: JobOutcome, cause: JobSettleCause, producerReported: boolean): void {
if (isTerminal(job.status)) return
job.status = outcome.status
// A killed settlement carries the recorded kill reason in its detail:
@@ -591,6 +617,10 @@ export class LocalJobRegistry extends JobRegistry {
job.state.progress = undefined
job.result = outcome.result
job.finishedAt = Date.now()
this.recordSessionEvent(job, {
finishedAt: job.finishedAt, status: outcome.status, cause, producerReported,
...job.detail !== undefined ? { detail: job.detail } : {},
})
// Settlement ends the stream: trim to the settled cap before any observer
// reads the terminal projection, but never below the bytes the model
// cursor has not consumed. A job that finishes before its first model
@@ -678,7 +708,7 @@ export class LocalJobRegistry extends JobRegistry {
} catch (error: unknown) {
const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`
this.selfCtx.logger.warn(`jobs: cancel of ${job.id} threw during teardown; job record forced failed and work may be orphaned: ${String(error)}`)
this.settle(job, { status: 'failed', detail }, 'teardown')
this.settle(job, { status: 'failed', detail }, 'teardown', false)
}
}
}
+184 -16
View File
@@ -1,6 +1,9 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
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'
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
@@ -12,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'
@@ -247,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)).toBe('bash-1')
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))
@@ -257,7 +267,7 @@ describe('LocalJobRegistry.start', () => {
first.settle({ status: 'completed' })
await tick()
expect(ctx.jobs.start(blocked.spec)).toBe('bash-2')
expect(ctx.jobs.start(blocked.spec)).toMatch(/^bash-[0-9a-f]{16}$/)
})
it('keeps a stopping job in the bucket until producer settlement', async () => {
@@ -271,7 +281,7 @@ describe('LocalJobRegistry.start', () => {
first.settle({ status: 'killed' })
await tick()
expect(ctx.jobs.start(replacement.spec)).toBe('bash-2')
expect(ctx.jobs.start(replacement.spec)).toMatch(/^bash-[0-9a-f]{16}$/)
})
it.each(['completed', 'killed', 'failed'] as const)(
@@ -311,12 +321,32 @@ describe('LocalJobRegistry.start', () => {
await disposeAgentScope(oldOwner)
})
it('issues kind-prefixed ids from per-kind counters', async () => {
it('issues unique kind-prefixed ids', async () => {
const ctx = await harness()
expect(ctx.jobs.start(producer().spec)).toBe('bash-1')
expect(ctx.jobs.start(producer().spec)).toBe('bash-2')
expect(ctx.jobs.start(producer({ kind: 'test-task' }).spec)).toBe('test-task-1')
expect(ctx.jobs.start(producer({ kind: 'workflow' }).spec)).toBe('workflow-1')
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()
}
})
})
@@ -723,9 +753,9 @@ describe('LocalJobRegistry owner isolation', () => {
ctx.jobs.attachController('test-controller')
expect(() => ctx.jobs.start({ ...producer().spec, owner: SessionId('a') }))
.toThrow('background job ownership requires the agent registry')
// The failed registration mutated nothing: no stored job, counter untouched.
// The failed registration mutated nothing: no stored job.
expect(ctx.jobs.list()).toEqual([])
expect(ctx.jobs.start(producer().spec)).toBe('bash-1')
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 () => {
@@ -750,7 +780,7 @@ describe('LocalJobRegistry owner isolation', () => {
done: new Promise<JobOutcome>((res) => { settle = res }),
}),
})
expect(id).toBe('bash-1') // the failed attempt burned no counter
expect(id).toMatch(/^bash-[0-9a-f]{16}$/)
await disposeAgentScope(ghost)
expect(cancels).toEqual(['owner disposed'])
expect(ctx.jobs.list(ghost.id)).toEqual([])
@@ -805,7 +835,9 @@ describe('LocalJobRegistry owner cleanup', () => {
await disposeAgentScope(owner)
expect(cancels).toEqual(['owner disposed'])
expect(ctx.jobs.list(owner.id)).toEqual([])
expect(seen.map(event => event.type === 'removed' ? event.job.id : undefined)).toEqual(['test-task-1', 'bash-1'])
expect(seen.map(event => event.type === 'removed' ? event.job.id : undefined)).toEqual([
expect.stringMatching(/^test-task-[0-9a-f]{16}$/), expect.stringMatching(/^bash-[0-9a-f]{16}$/),
])
})
it('announces teardown settlements with cause teardown', async () => {
@@ -1106,8 +1138,8 @@ describe('LocalJobRegistry events', () => {
it('announces an unowned job to every session filter, since every caller can see it', async () => {
const ctx = await harness()
const seen = collect(ctx, { owner: SessionId('anyone') }, ['registered'])
ctx.jobs.start(producer().spec)
expect(seen).toMatchObject([{ type: 'registered', job: { id: 'bash-1' } }])
const id = ctx.jobs.start(producer().spec)
expect(seen).toMatchObject([{ type: 'registered', job: { id } }])
expect((seen[0] as { job: JobView }).job.owner).toBeUndefined()
})
@@ -1139,7 +1171,7 @@ describe('LocalJobRegistry events', () => {
const seen = collect(ctx, { owners: 'all' }, ['registered'])
const id = ctx.jobs.start(producer().spec)
expect(id).toBe('bash-1')
expect(id).toMatch(/^bash-[0-9a-f]{16}$/)
expect(seen).toHaveLength(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('event listener threw on registered'))
})
@@ -1489,3 +1521,139 @@ describe('LocalJobRegistry pull sources', () => {
expect(warn.mock.calls.map(call => String(call[0]))).toEqual([expect.stringContaining('work may be orphaned')])
})
})
describe('owned job Session lifecycle records', () => {
let ctx: Context
beforeEach(async () => { ctx = await harness() })
afterEach(async () => { await ctx.fiber.dispose() })
it('records lifecycle commits before synchronous hub reactions', async () => {
const owner = await liveAgent(ctx, 'ordered-owner')
const p = producer({ owner, cancel: () => { p.settle({ status: 'killed' }) } })
ctx.jobs.events.subscribe({ owners: 'all' }, (event) => {
if (event.type !== 'registered' && event.type !== 'settled') return
owner.session.append('agent/inbox/spliced', {
target: 'next-step', start: 0,
inserted: [createUserMessage({ content: [{ type: 'text', text: event.type }], source: { kind: 'user' } })],
})
})
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed' })
await ctx.jobs.wait(id, 1000, owner.id)
expect(owner.session.snapshotEvents().map(event => event.type)).toEqual([
'job/registered', 'agent/inbox/spliced', 'job/settled', 'agent/inbox/spliced',
])
})
it('publishes original timestamps and nested tool identity without consuming output', async () => {
const owner = await liveAgent(ctx, 'journal-owner')
const p = producer({ owner, cancel: () => { p.settle({ status: 'killed' }) } })
const clock = vi.spyOn(Date, 'now').mockReturnValue(1000)
try {
const toolCall = { callId: ToolCallId('root:ptc:1'), rootCallId: ToolCallId('root') }
const id = ctx.jobs.start({ ...p.spec, toolCall })
p.job().append('unread output')
await Promise.resolve()
const registered = owner.session.snapshotEvents().find(event => event.type === 'job/registered')
expect(registered?.data).toMatchObject({ jobId: id, owner: owner.id, startedAt: 1000, toolCall })
clock.mockReturnValue(2500)
p.settle({ status: 'completed', detail: 'exit code: 0' })
await ctx.jobs.wait(id, 1000, owner.id)
const settled = owner.session.snapshotEvents().find(event => event.type === 'job/settled')
expect(settled?.data).toEqual({
...registered?.data, finishedAt: 2500, status: 'completed', cause: 'producer',
producerReported: true, detail: 'exit code: 0',
})
expect(ctx.jobs.read(id, owner.id).chunks.map(chunk => chunk.text)).toEqual(['unread output'])
expect(owner.session.deriveMessages()).toEqual([])
} finally {
clock.mockRestore()
}
})
it('records cancellation only after producer settlement', async () => {
const owner = await liveAgent(ctx, 'cancel-owner')
const p = producer({ owner, cancel: () => { p.settle({ status: 'killed' }) } })
const id = ctx.jobs.start(p.spec)
expect(ctx.jobs.kill(id, owner.id, 'requested by user')).toBe('requested')
expect(ctx.jobs.get(id, owner.id).status).toBe('stopping')
expect(owner.session.snapshotEvents().filter(event => event.type === 'job/settled')).toEqual([])
await ctx.jobs.wait(id, 1000, owner.id)
expect(owner.session.snapshotEvents().find(event => event.type === 'job/settled')?.data)
.toMatchObject({ status: 'killed', cause: 'kill', producerReported: true, detail: 'requested by user' })
})
it('records a producer-reported failed outcome', async () => {
const owner = await liveAgent(ctx, 'failed-owner')
const p = producer({ owner, cancel: () => { p.settle({ status: 'killed' }) } })
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'failed', detail: 'operation failed after cleanup' })
await ctx.jobs.wait(id, 1000, owner.id)
expect(owner.session.snapshotEvents().find(event => event.type === 'job/settled')?.data)
.toMatchObject({ status: 'failed', cause: 'producer', producerReported: true })
})
it('records absence of a producer report when its done promise rejects', async () => {
const owner = await liveAgent(ctx, 'rejected-owner')
const p = producer({ owner, cancel: () => { p.settle({ status: 'killed' }) } })
const id = ctx.jobs.start(p.spec)
p.reject(new Error('producer rejected'))
await ctx.jobs.wait(id, 1000, owner.id)
expect(owner.session.snapshotEvents().find(event => event.type === 'job/settled')?.data)
.toMatchObject({ status: 'failed', cause: 'producer', producerReported: false })
})
it('preserves a forced teardown record against late completion', async () => {
const owner = await liveAgent(ctx, 'teardown-owner')
const p = producer({ owner, cancel: () => { throw new Error('cancel failed') } })
ctx.jobs.start(p.spec)
await disposeAgentScope(owner)
p.settle({ status: 'completed' })
await tick()
const events = owner.session.snapshotEvents().filter(event => event.type === 'job/settled')
expect(events).toHaveLength(1)
expect(events[0]?.data).toMatchObject({ status: 'failed', cause: 'teardown', producerReported: false })
expect(ctx.jobs.list(owner.id)).toEqual([])
})
it('keeps job identity distinct across fresh registries with the same owner', async () => {
const other = await harness()
try {
const ids: JobId[] = []
const executions: string[] = []
for (const registry of [ctx, other]) {
const owner = await liveAgent(registry, 'same-owner')
const p = producer({ owner, cancel: () => { p.settle({ status: 'killed' }) } })
const id = registry.jobs.start(p.spec)
ids.push(id)
p.settle({ status: 'completed' })
await registry.jobs.wait(id, 1000, owner.id)
const event = owner.session.snapshotEvents().find(event => event.type === 'job/registered')
if (event?.type !== 'job/registered') throw new Error('missing registration')
executions.push(event.data.jobId)
}
expect(new Set(ids).size).toBe(2)
expect(executions).toEqual(ids)
expect(new Set(executions).size).toBe(2)
} finally {
await other.fiber.dispose()
}
})
it('reports append failures without breaking execution or waiter release', async () => {
const owner = await liveAgent(ctx, 'record-error-owner')
const p = producer({ owner, cancel: () => { p.settle({ status: 'killed' }) } })
const append = vi.spyOn(owner.session, 'append').mockImplementation(() => { throw new Error('record unavailable') })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
try {
const id = ctx.jobs.start(p.spec)
p.settle({ status: 'completed' })
await expect(ctx.jobs.wait(id, 1000, owner.id)).resolves.toMatchObject({ status: 'completed' })
expect(warn).toHaveBeenCalledTimes(2)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed to record lifecycle'))
} finally {
append.mockRestore()
warn.mockRestore()
}
})
})
+8 -8
View File
@@ -9,8 +9,8 @@
en: 721d849fde0fbebe
zh: 9578ffb37edc5d7b
/deepseek-ai-dsh-jobs/summary:
en: f64d24a22c76423c
zh: 5bfd3c04ce108bcb
en: eea29995f4b49a6d
zh: acdded5ddee3a974
/deepseek-ai-dsh-jobs/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -18,11 +18,11 @@
en: 1246c880d1c61927
zh: 157c703fd2da6b9c
/deepseek-ai-dsh-jobs/use-this-package/what-a-background-job-gives-you:
en: 56f117c80db88cb2
zh: 4df7046e005f846c
en: 3df0e02f9928afb2
zh: 63cae8da533ec266
/deepseek-ai-dsh-jobs/use-this-package/the-ownership-boundary:
en: 873494750b38dae5
zh: 2e6142ab889f7a8b
en: 167ab1eab8fad704
zh: 7da554826fb483c1
/deepseek-ai-dsh-jobs/use-this-package/starting-background-work-needs-a-controller:
en: 68075d00cde8763d
zh: 4bc42c01d9e4dc8d
@@ -39,8 +39,8 @@
en: 2a42e1e06dfc469e
zh: 5c2c77eca52846f0
/deepseek-ai-dsh-jobs/understand-the-implementation/source-map:
en: d48098f76de74fa9
zh: e69cb8e7f316ce95
en: c9a58b4340999ae4
zh: ef68a0b71a06d183
/deepseek-ai-dsh-jobs/understand-the-implementation/service-operations:
en: b2e4ef34ccf1e749
zh: 128b57b57ed09316
+6 -4
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>-N` 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,13 +29,15 @@ 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>-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 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.
The shipped provider also records owned-job lifecycle metadata in the Session. Producers attach `JobSpec.toolCall` when a tool creates the job, preserving nested and root call identity. See the [durable event fields](../../../docs/subsystems/jobs.md#session-lifecycle-records) for timing and restart semantics.
### The ownership boundary
A job belongs to the agent session that started it: another agent cannot read or stop it. Ids such as `bash-1` are predictable, so this fence is authorization, not secrecy. A job started without an owner is open to any caller and lasts until the service is disposed.
A job belongs to the agent session that started it: another agent cannot read or stop it. An id may appear in tool output; the registry authorizes every control operation using the owner. A job started without an owner is open to any caller and lasts until the service is disposed.
### Starting background work needs a controller
@@ -79,7 +81,7 @@ This section explains the design decisions behind the contract and points at the
| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract |
| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobSpec`, `JobHandle`, `JobHooks`, `JobOutcome`, `JobEvent`, and the read results |
| [`src/view.ts`](src/view.ts) | Client-safe leaf: `JobView`, `JobChunk`, `JobStatus`, and the merge-extensible `JobKindMap` |
| [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency |
| [`src/brand.ts`](src/brand.ts) | the `JobId` branded identifier, importable without the agent dependency |
| [`src/archive-admission.ts`](src/archive-admission.ts) | The `job` family of the Workspace registry's archive admission, installed by the seam's constructor for every implementation |
### Service operations
+6 -4
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`dsh-jobs` 让工具可以在 agent(智能体)继续推进时保持长时间工作运行。每项任务都会获得稳定的 `<kind>-N` id,拥有它的 agent 可以读取输出、带超时等待或请求取消。归属范围限定在 agent 会话内,因此其他 agent 无法查看或停止任务;任务完成时会通过会话内通知送达,无需轮询。用户可以查看保留的实时输出,而不消耗 agent 可读取的内容。只有部署提供任务执行能力时,后台任务才能启动。
`dsh-jobs` 让工具可以在 agent(智能体)继续推进时保持长时间工作运行。每项任务都会获得稳定的 `<kind>-<short-uuid>` id,拥有它的 agent 可以读取输出、带超时等待或请求取消。归属范围限定在 agent 会话内,因此其他 agent 无法查看或停止任务;任务完成时会通过会话内通知送达,无需轮询。用户可以查看保留的实时输出,而不消耗 agent 可读取的内容。只有部署提供任务执行能力时,后台任务才能启动。
## 目录
@@ -29,13 +29,15 @@ kind: "package-reference"
### 后台任务提供什么
生产方以 kind 和一行标签注册工作;注册表返回 `<kind>-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新投影,从 `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` 不消耗任何东西,也从不触碰通知状态。
随附提供方还会在 Session 中记录有所有者 job 的生命周期元数据。工具创建 job 时,生产方附加 `JobSpec.toolCall`,保留嵌套调用与根调用标识。时间与重启语义见[持久化事件字段](../../../docs/subsystems/jobs.zh.md#session-lifecycle-records)。
### 归属边界
任务属于启动它的 agent 会话:其他 agent 无法读取或停止它。`bash-1` 这样的 id 可预测,因此这道隔离是授权,而非保密。没有所有者启动的任务对任何调用方开放,并持续到服务被释放为止。
任务属于启动它的 agent 会话:其他 agent 无法读取或停止它。id 可能出现在工具输出中;注册表按所有者校验每次控制操作。没有所有者启动的任务对任何调用方开放,并持续到服务被释放为止。
### 启动后台工作需要一个控制器
@@ -79,7 +81,7 @@ kind: "package-reference"
| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 |
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobSpec`、`JobHandle`、`JobHooks`、`JobOutcome`、`JobEvent` 与读取结果 |
| [`src/view.ts`](src/view.ts) | 客户端安全叶子:`JobView`、`JobChunk`、`JobStatus` 与可合并扩展的 `JobKindMap` |
| [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 |
| [`src/brand.ts`](src/brand.ts) | 带类型标记的 `JobId` 标识符,无需 agent 依赖即可导入 |
| [`src/archive-admission.ts`](src/archive-admission.ts) | Workspace 注册表归档准入中的 `job` 族,由接缝构造函数为每个实现安装 |
### 服务操作
+3 -3
View File
@@ -13,14 +13,14 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Identifies a background job. The registry generates `<kind>-N`; predictable
* ids rely on owner authorization rather than secrecy.
* 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>-N`).
* @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 {
+4 -1
View File
@@ -31,10 +31,13 @@ export type {
JobOutputRead,
JobOutputSource,
JobRead,
JobRegisteredData,
JobSettledData,
JobSettleCause,
JobSourceRead,
JobSpec,
JobStatus,
JobToolCall,
JobView,
} from './types.ts'
@@ -105,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>-N` id.
* @returns the registry-issued `<kind>-<short-uuid>` id.
*/
abstract start(spec: JobSpec): JobId
+57 -2
View File
@@ -5,13 +5,66 @@
* @module @deepseek-ai/dsh-jobs/types
*/
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session'
import type { JobId } from './brand.ts'
import type { JobChannel, JobChunk, JobKind, JobView } from './view.ts'
export { JobId } from './brand.ts'
export type { JobChannel, JobChunk, JobKind, JobKindMap, JobStatus, JobView } from './view.ts'
/** Tool dispatch that created a job; nested calls retain their root invocation. */
export interface JobToolCall {
/** Exact invocation, including a PTC sub-call when applicable. */
readonly callId: SessionEventMap['tool/call']['callId']
/** Root model-requested invocation owning this execution. */
readonly rootCallId: SessionEventMap['tool/call']['callId']
}
/** Durable identity and registration time of one owned job execution. */
export interface JobRegisteredData {
/** Unique execution id accepted by job controls and retained in Session history. */
readonly jobId: JobId
/** Original owner; copied history does not transfer execution ownership. */
readonly owner: SessionId
/** Producer kind. */
readonly kind: string
/** Producer-supplied label. */
readonly label: string
/** Unix epoch milliseconds when registration committed. */
readonly startedAt: number
/** Creating tool invocation, when the producer was called through a tool. */
readonly toolCall?: JobToolCall
}
/** Registry settlement, including whether the producer reported a terminal outcome. */
export interface JobSettledData extends JobRegisteredData {
/** Unix epoch milliseconds when the registry committed the terminal record. */
readonly finishedAt: number
/** Recorded terminal outcome. */
readonly status: JobOutcome['status']
/** Source of the termination request, or producer completion. */
readonly cause: JobSettleCause
/** True when done fulfilled; false for rejection or forced failure. Does not certify resource release. */
readonly producerReported: boolean
/** Terminal reason, including a recorded kill reason when applicable. */
readonly detail?: string
}
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* Log-only job registration; model output collection is not required.
* @param data - Execution identity, original owner, registration time, and creating tool call.
*/
'job/registered': JobRegisteredData
/**
* Log-only terminal record, paired by the same unique jobId used for live control.
* @param data - Registration facts plus settlement time, outcome, and producer acknowledgement.
*/
'job/settled': JobSettledData
}
}
/** Terminal result supplied by a producer through {@link JobHooks.done}. */
export interface JobOutcome {
/** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
@@ -84,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>-N`). */
/** 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
@@ -135,6 +188,8 @@ export interface JobSpec {
* caller until service disposal.
*/
owner?: SessionId
/** Creating tool invocation, retained in the durable lifecycle records. */
toolCall?: JobToolCall
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including controller status metadata. Independent of ring
+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>-N`). */
/** The registry-issued id (`<kind>-<short-uuid>`). */
readonly id: JobId
/**
* The producer kind the job was registered with: a Host-registered
+2 -2
View File
@@ -39,8 +39,8 @@
en: 6f006ff2d901bc92
zh: 779d18ba7bec4202
/deepseek-ai-dsh-tool-jobs/understand-the-implementation/output-capping:
en: b47638fb0e80287a
zh: c47fa4b99b6df4c3
en: ca18e4bdfb12a1e2
zh: 6592697c561c6e6d
/deepseek-ai-dsh-tool-jobs/understand-the-implementation/notice-delivery-lanes:
en: 2540b7abbb3c9710
zh: 4fb355bceab2f865
+1 -1
View File
@@ -87,7 +87,7 @@ This section explains the design decisions behind the tools and points at the co
### Output capping
`job_output` and `job_kill` capture the caller-visible job in a prepended pre-execute listener before policy runs, so the producer's cap applies to the complete rendered result. `job_output` preserves its output/status split when policy left the default rendering intact, bounding the output tail and the `[status: ...]` suffix; other single-text results — denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks — are bounded as one text, while structured multi-block policy results keep their shape. A bounded completion notice reserves the stable `background job <id>` prefix and the `job_output` collection instruction before spending remaining bytes on variable kind, label, status, detail, and truncation marker, so the notice stays actionable at the PTY's supported 64-byte minimum; an existing producer truncation marker is reused rather than duplicated.
`job_output` and `job_kill` capture the caller-visible job in a prepended pre-execute listener before policy runs, so the producer's cap applies to the complete rendered result. `job_output` preserves its output/status split when policy left the default rendering intact, bounding the output tail and the `[status: ...]` suffix; other single-text results — denials, short-circuits, normalized tool or pipeline failures, replacements, and blocks — are bounded as one text, while structured multi-block policy results keep their shape. A bounded completion notice retains the full job id and `job_output` action when both fit. Otherwise it directs the model to `job_list` when at least eight bytes fit; a smaller cap can carry only a prefix. The PTY's supported 64-byte minimum fits the complete `job_list` instruction. Existing producer truncation markers are reused.
### Notice delivery lanes
+1 -1
View File
@@ -87,7 +87,7 @@ kind: "package-reference"
### 输出上限
`job_output` 与 `job_kill` 会在策略运行前于前置的 pre-execute 监听器中捕获调用方可见任务,因此生产方上限适用于完整渲染结果。`job_output` 在策略保留默认渲染时保持其输出/状态拆分,对输出尾部与 `[status: ...]` 后缀设界;其他单文本结果——拒绝、短路、规范化工具或流水线失败、替换与阻止——按单个文本设界,而结构化多块策略结果保持自身形状。有界完成通知先为稳定的 `background job <id>` 前缀与 `job_output` 收集指令预留空间,再把剩余字节用于可变的 kind、label、status、detail 与截断标记,因此在 PTY 支持的 64 字节下限下通知仍可操作;已有的生产方截断标记会被复用,不会重复添加。
`job_output` 与 `job_kill` 会在策略运行前于前置的 pre-execute 监听器中捕获调用方可见任务,因此生产方上限适用于完整渲染结果。`job_output` 在策略保留默认渲染时保持其输出/状态拆分,对输出尾部与 `[status: ...]` 后缀设界;其他单文本结果——拒绝、短路、规范化工具或流水线失败、替换与阻止——按单个文本设界,而结构化多块策略结果保持自身形状。有界完成通知在完整 job id 与 `job_output` 指令都能容纳时保留两者。否则当上限至少有八字节时,它提示模型使用 `job_list`;更小的上限只能容纳指令前缀。PTY 支持的 64 字节下限能容纳完整的 `job_list` 指令。已有生产方截断标记会被复用。
### 通知投递通道
+4 -3
View File
@@ -137,9 +137,10 @@ function fitCompletionNotice(job: JobView): string {
const compact = `${prefix}${action}`
const compactBytes = encoder.encode(compact).byteLength
if (compactBytes <= maxBytes) return compact
const actionBytes = encoder.encode(action).byteLength
if (actionBytes >= maxBytes) return retainTail(action, maxBytes)
return `${retainHead(prefix, maxBytes - actionBytes)}${action}`
// A partial UUID cannot identify the job. Point the model at the listing instead.
const recovery = 'background job ended; use job_list for id.'
if (encoder.encode(recovery).byteLength <= maxBytes) return recovery
return retainHead('job_list', maxBytes)
}
function rawSingleText(content: readonly ContentBlock[]): string | undefined {
+124 -84
View File
@@ -30,6 +30,17 @@ interface RegistryConfig {
retainBytes?: number
}
/** First-seen ids are test-local ordinals; the runtime id is opaque. */
const issuedIds = new WeakMap<Context, Map<string, JobId[]>>()
function startedId(ctx: Context, legacy: string): JobId {
const match = /^(.*)-([1-9]\d*)$/.exec(legacy)
if (match === null) throw new Error(`invalid test job ordinal ${legacy}`)
const id = issuedIds.get(ctx)?.get(match[1]!)?.[Number(match[2]) - 1]
if (id === undefined) throw new Error(`job ${legacy} was not registered`)
return id
}
async function setup(config: ToolJobs.Config = {}, registry: RegistryConfig = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
@@ -37,6 +48,14 @@ async function setup(config: ToolJobs.Config = {}, registry: RegistryConfig = {}
const agentsFiber = await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry, registry)
const toolsFiber = await ctx.plugin(ToolJobs, config)
const byKind = new Map<string, JobId[]>()
issuedIds.set(ctx, byKind)
ctx.effect(() => ctx.jobs.events.subscribe({ owners: 'all' }, (event) => {
if (event.type !== 'registered') return
const ids = byKind.get(event.job.kind) ?? []
ids.push(event.job.id)
byKind.set(event.job.kind, ids)
}))
return { ctx, agentsFiber, toolsFiber }
}
@@ -151,12 +170,12 @@ describe('tool-jobs setup', () => {
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolJobs)
const p = producer()
ctx.jobs.start(p.spec)
const id = ctx.jobs.start(p.spec)
p.append('open\n')
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('open\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: id }))).toBe('open\n[status: running]')
p.settle({ status: 'completed' })
await tick()
expect(text(await call(ctx, 'job_list', {}))).toBe('bash-1 [bash] completed — sleep 60')
expect(text(await call(ctx, 'job_list', {}))).toBe(`${id} [bash] completed — sleep 60`)
})
it('rejects a config whose default wait exceeds the cap', async () => {
@@ -260,7 +279,7 @@ describe('tool-jobs setup', () => {
? { text: 'x'.repeat(32), nextOffset: 32, lossy: false, spillPath: '/spill/out.log' }
: { text: '', nextOffset: from, lossy: false, spillPath: '/spill/out.log' },
}
ctx.jobs.start({
const id = ctx.jobs.start({
kind: 'bash',
label: 'noisy',
owner: owner.id,
@@ -268,7 +287,7 @@ describe('tool-jobs setup', () => {
run: () => ({ cancel() {}, done: new Promise<JobOutcome>((resolve) => { settle = resolve }) }),
})
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }, owner)))
expect(text(await call(ctx, 'job_output', { job_id: id }, owner)))
.toBe(`${'x'.repeat(8)}\n[some output was dropped from memory; full output: /spill/out.log]\n[status: running]`)
settle({ status: 'completed' })
await tick()
@@ -296,17 +315,17 @@ describe('job_output', () => {
p.append('line one\n')
// A body already ending in a newline gets no doubled separator.
const first = await call(ctx, 'job_output', { job_id: 'bash-1' })
const first = await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') })
if (first.isError) throw new Error('expected job_output success')
const firstValue = first.value as { text: string; job: Record<string, unknown> }
expect(firstValue).toMatchObject({
text: 'line one\n',
job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'running' },
job: { id: startedId(ctx, 'bash-1'), kind: 'bash', label: 'sleep 60', status: 'running' },
})
expect(firstValue.job).not.toHaveProperty('owner')
expect(firstValue.job).not.toHaveProperty('output')
expect(text(first)).toBe('line one\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }))).toBe('(no new output)\n[status: running]')
})
it('renders stderr in a trailing section and never shows log narration', async () => {
@@ -316,8 +335,8 @@ describe('job_output', () => {
p.append('out\n')
p.append('warn\n', { channel: 'stderr' })
p.append('observer-only narration', { channel: 'log' })
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('out\n[stderr]\nwarn\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }))).toBe('out\n[stderr]\nwarn\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }))).toBe('(no new output)\n[status: running]')
})
it('shows live progress beside the status until settlement replaces it with the terminal detail', async () => {
@@ -325,36 +344,36 @@ describe('job_output', () => {
const p = producer()
ctx.jobs.start(p.spec)
p.progress('3/10 files')
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running, 3/10 files]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }))).toBe('(no new output)\n[status: running, 3/10 files]')
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: completed, exit code: 0]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }))).toBe('(no new output)\n[status: completed, exit code: 0]')
})
it('flags a read whose cursor fell behind the ring', async () => {
const { ctx } = await setup({}, { retainBytes: 16 })
const p = producer()
ctx.jobs.start(p.spec)
const id = ctx.jobs.start(p.spec)
p.append('x'.repeat(40))
const first = text(await call(ctx, 'job_output', { job_id: 'bash-1' }))
const first = text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }))
expect(first).toContain('[some output was dropped from memory; full output: (unavailable)]')
expect(first).toContain('[status: running]')
expect(first).not.toContain('x'.repeat(17))
// The cursor now sits at the ring's end: the next read is clean.
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: id }))).toBe('(no new output)\n[status: running]')
})
it('returns the result of a settled job exactly once, after the pending delta', async () => {
const { ctx } = await setup()
const p = producer({ kind: 'test-task', label: 'research' })
ctx.jobs.start(p.spec)
expect(text(await call(ctx, 'job_output', { job_id: 'test-task-1' }))).toBe('(no new output)\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'test-task-1') }))).toBe('(no new output)\n[status: running]')
p.append('partial')
p.settle({ status: 'completed', detail: 'completed', result: 'the answer' })
await tick()
expect(text(await call(ctx, 'job_output', { job_id: 'test-task-1' }))).toBe('partial\nthe answer\n[status: completed, completed]')
expect(text(await call(ctx, 'job_output', { job_id: 'test-task-1' }))).toBe('(no new output)\n[status: completed, completed]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'test-task-1') }))).toBe('partial\nthe answer\n[status: completed, completed]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'test-task-1') }))).toBe('(no new output)\n[status: completed, completed]')
})
it('keeps a newline-terminated delta and the result on separate lines', async () => {
@@ -364,7 +383,7 @@ describe('job_output', () => {
p.append('progress log\n')
p.settle({ status: 'completed', result: 'the answer' })
await tick()
expect(text(await call(ctx, 'job_output', { job_id: 'test-task-1' }))).toBe('progress log\nthe answer\n[status: completed]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'test-task-1') }))).toBe('progress log\nthe answer\n[status: completed]')
})
it('applies a producer limit to the complete body and status result', async () => {
@@ -373,7 +392,7 @@ describe('job_output', () => {
ctx.jobs.start(p.spec)
p.append('界'.repeat(100))
const output = text(await call(ctx, 'job_output', { job_id: 'bash-1' }))
const output = text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }))
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(48)
expect(output).toContain('[status: running]')
})
@@ -383,10 +402,10 @@ describe('job_output', () => {
const p = producer({ outputLimitBytes: 64 })
ctx.jobs.start(p.spec)
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' })))
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') })))
.toBe('(no new output)\n[status: running]')
p.append('line\n')
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' })))
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') })))
.toBe('line\n[status: running]')
})
@@ -400,7 +419,7 @@ describe('job_output', () => {
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'p'.repeat(1_000) }] })
})
const result = await call(ctx, 'job_output', { job_id: 'bash-1' })
const result = await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') })
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
expect(text(result)).toContain('[result truncated]')
expect(text(result)).not.toContain('[status: running]')
@@ -414,7 +433,7 @@ describe('job_output', () => {
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: `${'z'.repeat(1_000)}\n[result truncated]` }] })
})
const result = await call(ctx, 'job_output', { job_id: 'bash-1' })
const result = await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') })
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
expect(text(result).match(/\[result truncated\]/g)).toHaveLength(1)
})
@@ -425,11 +444,11 @@ describe('job_output', () => {
ctx.jobs.start(producer({ owner: alice.id }).spec)
const bob = await fakeAgent(ctx, 'sess-bob')
const result = await call(ctx, 'job_output', { job_id: 'bash-1' }, bob)
const result = await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }, bob)
expect(result.isError).toBe(true)
expect(text(result)).toContain('belongs to another session')
expect((await call(ctx, 'job_output', { job_id: 'bash-1' })).isError).toBe(true)
expect((await call(ctx, 'job_output', { job_id: 'bash-1' }, alice)).isError).toBe(false)
expect((await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') })).isError).toBe(true)
expect((await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }, alice)).isError).toBe(false)
})
it('bounds pre-, around-, and post-execute policy outcomes and failures', async () => {
@@ -439,47 +458,47 @@ describe('job_output', () => {
}
ctx.on('tools/pre-execute', async (exec, next) => {
const jobId = (exec.arguments as { job_id?: unknown }).job_id
if (jobId === 'bash-1') return { kind: 'deny', reason: 'd'.repeat(1_000) }
if (jobId === 'bash-3') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
if (jobId === startedId(ctx, 'bash-1')) return { kind: 'deny', reason: 'd'.repeat(1_000) }
if (jobId === startedId(ctx, 'bash-3')) throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
return next()
})
ctx.on('tools/execute', async (exec, next) => {
const jobId = (exec.arguments as { job_id?: unknown }).job_id
if (jobId === 'bash-2') {
if (jobId === startedId(ctx, 'bash-2')) {
return {
content: [],
isError: false,
value: {
text: 'a'.repeat(1_000),
job: {
id: 'bash-2', kind: 'bash', label: 'sleep 60', status: 'running', startedAt: 0,
id: startedId(ctx, 'bash-2'), kind: 'bash', label: 'sleep 60', status: 'running', startedAt: 0,
},
},
}
}
if (jobId === 'bash-4') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
if (jobId === startedId(ctx, 'bash-4')) throw new Error(`around failed: ${'e'.repeat(1_000)}`)
return next()
})
ctx.on('tools/post-execute', async (exec, _result, next) => {
const jobId = (exec.arguments as { job_id?: unknown }).job_id
if (jobId === 'bash-5') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
if (jobId === startedId(ctx, 'bash-5')) throw new Error(`post failed: ${'o'.repeat(1_000)}`)
return next()
})
const denied = await call(ctx, 'job_output', { job_id: 'bash-1' })
const denied = await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') })
expect(denied.isError).toBe(true)
expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
expect(text(denied)).toContain('[result truncated]')
const shortCircuited = await call(ctx, 'job_output', { job_id: 'bash-2' })
const shortCircuited = await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-2') })
expect(shortCircuited.isError).toBe(false)
expect(Buffer.byteLength(text(shortCircuited))).toBeLessThanOrEqual(64)
expect(text(shortCircuited)).toContain('[output truncated]')
const failures = [
await call(ctx, 'job_output', { job_id: 'bash-3' }),
await call(ctx, 'job_output', { job_id: 'bash-4' }),
await call(ctx, 'job_output', { job_id: 'bash-5' }),
await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-3') }),
await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-4') }),
await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-5') }),
]
for (const failure of failures) {
expect(failure.isError).toBe(true)
@@ -493,7 +512,7 @@ describe('job_output', () => {
const p = producer({ kind: 'test-task', label: 'research' })
ctx.jobs.start(p.spec)
const pending = call(ctx, 'job_output', { job_id: 'test-task-1', wait: true })
const pending = call(ctx, 'job_output', { job_id: startedId(ctx, 'test-task-1'), wait: true })
p.settle({ status: 'completed', result: 'done deal' })
expect(text(await pending)).toBe('done deal\n[status: completed]')
})
@@ -504,7 +523,7 @@ describe('job_output', () => {
// A model-supplied timeout far above the cap is clamped: this returns
// promptly (≤ the 20ms cap), not after ten minutes.
const result = await call(ctx, 'job_output', { job_id: 'bash-1', wait: true, timeout_ms: 600_000 })
const result = await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1'), wait: true, timeout_ms: 600_000 })
expect(text(result)).toBe('(no new output)\n[status: running]')
})
@@ -534,20 +553,20 @@ describe('job_list', () => {
if (listed.isError) throw new Error('expected job_list success')
const listedValue = listed.value as Array<Record<string, unknown>>
expect(listedValue).toHaveLength(3)
expect(listedValue[0]).toMatchObject({ id: 'bash-1', kind: 'bash', label: 'pnpm test', status: 'running' })
expect(listedValue[2]).toMatchObject({ id: 'bash-2', kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' })
expect(listedValue[0]).toMatchObject({ id: startedId(ctx, 'bash-1'), kind: 'bash', label: 'pnpm test', status: 'running' })
expect(listedValue[2]).toMatchObject({ id: startedId(ctx, 'bash-2'), kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' })
for (const job of listedValue) {
expect(job).not.toHaveProperty('owner')
expect(job).not.toHaveProperty('output')
}
expect(text(listed)).toBe([
'bash-1 [bash] running — pnpm test',
'test-task-1 [test-task] running — open research',
'bash-2 [bash] completed — build',
`${startedId(ctx, 'bash-1')} [bash] running — pnpm test`,
`${startedId(ctx, 'test-task-1')} [test-task] running — open research`,
`${startedId(ctx, 'bash-2')} [bash] completed — build`,
].join('\n'))
// A different caller sees only the unowned job.
const bob = await fakeAgent(ctx, 'sess-bob')
expect(text(await call(ctx, 'job_list', {}, bob))).toBe('test-task-1 [test-task] running — open research')
expect(text(await call(ctx, 'job_list', {}, bob))).toBe(`${startedId(ctx, 'test-task-1')} [test-task] running — open research`)
})
})
@@ -557,16 +576,16 @@ describe('job_kill', () => {
const p = producer()
ctx.jobs.start(p.spec)
const result = await call(ctx, 'job_kill', { job_id: 'bash-1', reason: 'superseded' })
const result = await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-1'), reason: 'superseded' })
if (result.isError) throw new Error('expected job_kill success')
const killValue = result.value as { outcome: string; job: Record<string, unknown> }
expect(killValue).toMatchObject({
outcome: 'cancellation-requested',
job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'stopping' },
job: { id: startedId(ctx, 'bash-1'), kind: 'bash', label: 'sleep 60', status: 'stopping' },
})
expect(killValue.job).not.toHaveProperty('owner')
expect(killValue.job).not.toHaveProperty('output')
expect(text(result)).toBe('requested cancellation of job bash-1')
expect(text(result)).toBe(`requested cancellation of job ${startedId(ctx, 'bash-1')}`)
expect(p.cancels).toEqual(['superseded'])
})
@@ -575,7 +594,7 @@ describe('job_kill', () => {
const p = producer({ outputLimitBytes: 8 })
ctx.jobs.start(p.spec)
const result = await call(ctx, 'job_kill', { job_id: 'bash-1' })
const result = await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-1') })
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(8)
expect(p.cancels).toEqual([undefined])
})
@@ -587,11 +606,11 @@ describe('job_kill', () => {
cancel: () => { throw new Error('cancel failed: '.repeat(100)) },
}).spec)
const result = await call(ctx, 'job_kill', { job_id: 'bash-1' })
const result = await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-1') })
expect(result.isError).toBe(true)
expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
expect(text(result)).toContain('[result truncated]')
expect(ctx.jobs.get(JobId('bash-1')).status).toBe('running')
expect(ctx.jobs.get(JobId(startedId(ctx, 'bash-1'))).status).toBe('running')
})
it('bounds single-text post policy while preserving structured policy results', async () => {
@@ -620,20 +639,20 @@ describe('job_kill', () => {
ctx.jobs.start(producer({ outputLimitBytes: 64 }).spec)
}
const replaced = await call(ctx, 'job_kill', { job_id: 'bash-1', reason: 'replace' })
const replaced = await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-1'), reason: 'replace' })
expect(replaced.isError).toBe(false)
expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
expect(text(replaced)).toContain('[result truncated]')
const blocked = await call(ctx, 'job_kill', { job_id: 'bash-2', reason: 'block' })
const blocked = await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-2'), reason: 'block' })
expect(blocked.isError).toBe(true)
expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
expect(text(blocked)).toContain('[result truncated]')
const multi = await call(ctx, 'job_kill', { job_id: 'bash-3', reason: 'multi' })
const multi = await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-3'), reason: 'multi' })
expect(multi.content).toEqual([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }])
const reasoning = await call(ctx, 'job_kill', { job_id: 'bash-4', reason: 'reasoning' })
const reasoning = await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-4'), reason: 'reasoning' })
expect(reasoning.content).toEqual([{ type: 'reasoning', text: 'policy detail' }])
})
@@ -645,15 +664,15 @@ describe('job_kill', () => {
p.settle({ status: 'completed', detail: 'exit code: 0' })
await tick()
const killed = await call(ctx, 'job_kill', { job_id: 'bash-1' })
const killed = await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-1') })
if (killed.isError) throw new Error('expected job_kill success')
expect(killed.value).toMatchObject({
outcome: 'already-finished',
job: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' },
job: { id: startedId(ctx, 'bash-1'), kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' },
})
expect(text(killed)).toBe('job bash-1 had already finished [status: completed, exit code: 0]')
expect(text(killed)).toBe(`job ${startedId(ctx, 'bash-1')} had already finished [status: completed, exit code: 0]`)
// The kill described the job via a non-consuming projection: the delta is intact.
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }))).toBe('unread tail\n[status: completed, exit code: 0]')
})
it('rejects an empty job id as an errored result', async () => {
@@ -750,7 +769,7 @@ describe('completion notice delivery', () => {
expect(inject).toHaveBeenCalledTimes(1)
const message = inject.mock.calls[0]![0] as { content: readonly { type: string; text: string }[] }
expect(message.content[0]!.text).toBe(
'background job bash-1 (bash: pnpm run watch) finished '
`background job ${startedId(ctx, 'bash-1')} (bash: pnpm run watch) finished `
+ '[status: killed, signal: SIGTERM; cancelled by the user]. Read its output with job_output.',
)
})
@@ -894,7 +913,7 @@ describe('completion notices', () => {
expect(inject).toHaveBeenCalledWith({
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'background job bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with job_output.' }],
content: [{ type: 'text', text: `background job ${startedId(ctx, 'bash-1')} (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with job_output.` }],
source: {
kind: 'tool-jobs',
form: 'notice',
@@ -903,7 +922,7 @@ describe('completion notices', () => {
})
})
it('preserves job ids and collection guidance in bounded completion notices', async () => {
it('keeps collection guidance when a bounded notice cannot carry the id', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
@@ -911,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) })
@@ -922,7 +941,7 @@ describe('completion notices', () => {
{
id: expect.any(String) as unknown,
role: 'user',
content: [{ type: 'text', text: 'background job test-task-1\nDone; job_output.' }],
content: [{ type: 'text', text: 'background job ended; use job_list for id.' }],
// The label and status detail are unbounded caller text, so the durable
// one-line account caps itself rather than committing their full length.
source: {
@@ -937,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) })
@@ -946,11 +965,10 @@ describe('completion notices', () => {
const content = (inject.mock.calls[1]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content
const notice = content?.[0]?.text ?? ''
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(80)
expect(notice).toContain('background job test-task-2 (test-task: xxxx')
expect(notice).toContain('[notice truncated]\nDone; job_output.')
expect(notice).toBe(`background job ${startedId(ctx, 'test-task-2')}\nDone; job_output.`)
})
it('keeps the complete PTY job id and collection action at the minimum PTY limit', async () => {
it('guides collection through job_list when the PTY id exceeds its output limit', async () => {
const { ctx } = await setup()
for (let index = 0; index < 99; index += 1) {
const prior = producer({ kind: 'pty-send' })
@@ -964,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)
@@ -973,29 +991,51 @@ 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(notice).toBe('background job pty-send-100\n[notice truncated]\nDone; job_output.')
expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(56)
expect(notice).toBe('background job ended; use job_list for id.')
})
it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
it('keeps a complete job id at the exact notice boundary and uses spare bytes for context', async () => {
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(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]{16}$/)
p.settle({ status: 'completed' })
await tick()
const notice = (inject.mock.calls[index]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text
expect(notice).toBe(`background job ${id}${extra}\n[notice truncated]\nDone; job_output.`)
expect(Buffer.byteLength(notice ?? '')).toBe(fixedBytes + extra.length)
}
})
it('keeps a complete job_list action when the id exceeds a smaller budget', async () => {
const { ctx } = await setup()
const inject = vi.fn()
const owner = await fakeAgent(ctx, 'sess-1', { inject })
const tiny = producer({ owner: owner.id, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
const short = producer({ owner: owner.id, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
const minimal = producer({ owner: owner.id, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 3 })
ctx.jobs.start(tiny.spec)
ctx.jobs.start(short.spec)
ctx.jobs.start(minimal.spec)
tiny.settle({ status: 'completed' })
short.settle({ status: 'completed' })
minimal.settle({ status: 'completed' })
await tick()
const tinyNotice = (inject.mock.calls[0]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
const shortNotice = (inject.mock.calls[1]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
expect(Buffer.byteLength(tinyNotice)).toBeLessThanOrEqual(8)
expect(tinyNotice).toBe('_output.')
expect(tinyNotice).toBe('job_list')
expect(Buffer.byteLength(shortNotice)).toBeLessThanOrEqual(32)
expect(shortNotice).toBe('background job\nDone; job_output.')
expect(shortNotice).toBe('job_list')
const minimalNotice = (inject.mock.calls[2]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
expect(minimalNotice).toBe('job')
})
it('suppresses the notice for a job the model already killed', async () => {
@@ -1005,7 +1045,7 @@ describe('completion notices', () => {
const p = producer({ owner: owner.id })
ctx.jobs.start(p.spec)
await call(ctx, 'job_kill', { job_id: 'bash-1' }, owner)
await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-1') }, owner)
p.settle({ status: 'killed' })
await tick()
expect(inject).not.toHaveBeenCalled()
@@ -1018,7 +1058,7 @@ describe('completion notices', () => {
const p = producer({ owner: owner.id, kind: 'test-task' })
ctx.jobs.start(p.spec)
const pending = call(ctx, 'job_output', { job_id: 'test-task-1', wait: true }, owner)
const pending = call(ctx, 'job_output', { job_id: startedId(ctx, 'test-task-1'), wait: true }, owner)
p.settle({ status: 'completed', result: 'answer' })
expect(text(await pending)).toContain('answer')
await tick()
@@ -1032,8 +1072,8 @@ describe('completion notices', () => {
const p = producer({ owner: owner.id })
ctx.jobs.start(p.spec)
const patient = call(ctx, 'job_output', { job_id: 'bash-1', wait: true, timeout_ms: 1000 }, owner)
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1', wait: true, timeout_ms: 10 }, owner))).toBe('(no new output)\n[status: running]')
const patient = call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1'), wait: true, timeout_ms: 1000 }, owner)
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1'), wait: true, timeout_ms: 10 }, owner))).toBe('(no new output)\n[status: running]')
p.settle({ status: 'completed' })
expect(text(await patient)).toBe('(no new output)\n[status: completed]')
await tick()
@@ -1047,7 +1087,7 @@ describe('completion notices', () => {
const p = producer({ owner: owner.id })
ctx.jobs.start(p.spec)
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1', wait: true }, owner))).toBe('(no new output)\n[status: running]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1'), wait: true }, owner))).toBe('(no new output)\n[status: running]')
p.settle({ status: 'completed' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
@@ -1065,7 +1105,7 @@ describe('completion notices', () => {
signal: aborter.signal,
callId: ToolCallId('call-aborted-wait'),
name: 'job_output',
arguments: { job_id: 'bash-1', wait: true },
arguments: { job_id: startedId(ctx, 'bash-1'), wait: true },
agent: owner,
})
await tick()
@@ -1091,7 +1131,7 @@ describe('completion notices', () => {
signal: aborter.signal,
callId: ToolCallId('call-abort-then-settle'),
name: 'job_output',
arguments: { job_id: 'test-task-1', wait: true },
arguments: { job_id: startedId(ctx, 'test-task-1'), wait: true },
agent: owner,
})
await tick()
@@ -1117,7 +1157,7 @@ describe('completion notices', () => {
ctx.jobs.start(killed.spec)
ctx.jobs.start(other.spec)
await call(ctx, 'job_kill', { job_id: 'bash-1' }, owner)
await call(ctx, 'job_kill', { job_id: startedId(ctx, 'bash-1') }, owner)
other.settle({ status: 'completed' })
await tick()
expect(inject).toHaveBeenCalledTimes(1)
@@ -1153,7 +1193,7 @@ describe('completion notices', () => {
expect(oldInject).not.toHaveBeenCalled()
expect(replacementInject).toHaveBeenCalledTimes(1)
expect(text(await call(ctx, 'job_output', { job_id: 'bash-1' }, replacement))).toBe('(no new output)\n[status: completed]')
expect(text(await call(ctx, 'job_output', { job_id: startedId(ctx, 'bash-1') }, replacement))).toBe('(no new output)\n[status: completed]')
})
it('drops the notice when the agent registry left before settlement', async () => {
@@ -4,7 +4,7 @@ import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { RetryId } from '@deepseek-ai/dsh-llm-retry'
import type {} from '../src/index.ts'
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff. Show more