fix(headless): resume ordinary forks using subagent origin

This commit is contained in:
Dudu-0223 committed 2026-10-23 14:59:25 +08:00
1 parent 30650824c9
commit cf072384b5
8 files changed
+32 -29

No files matched your search

@@ -9,8 +9,8 @@
en: 4556d51bd8086cfe
zh: 8811b03db9225739
/ordinary-forks-are-independent-sessions/change:
en: efd6ae1e08042c03
zh: dc1b472bb41f4a9c
en: 0a2790279aec9e24
zh: 5cca673af988a228
/ordinary-forks-are-independent-sessions/migration:
en: 53b586ad6498fdb8
zh: dfa70928e17478e2
@@ -11,7 +11,7 @@ English | [中文](guide.zh.md)
Ordinary forks copy the selected history and continue independently. They omit `SessionHeader.parentSession` and the Client's `parentSessionId`; no replacement source field is written. Subagents, including fork-backend subagents, retain their direct creator. Existing logs retain their recorded metadata.
SDK `subagent.started` excludes ordinary forks, including historical ones carrying a parent id. ACP list/resume admits ordinary forks. Client lists no longer indent them or insert newly discovered forks before their sources; saved manual positions remain intact. Source-based trace queries, parent filters, telemetry, and remote logs cannot recover the source of new ordinary forks. Workspace binding, optional title increments, inherited history, and seed boundaries remain available.
SDK `subagent.started` excludes ordinary forks, including historical ones carrying a parent id. ACP list/resume admits ordinary forks; headless adoption also accepts them when cwd and preset checks pass. Client lists no longer indent them or insert newly discovered forks before their sources; saved manual positions remain intact. Source-based trace queries, parent filters, telemetry, and remote logs cannot recover the source of new ordinary forks. Workspace binding, optional title increments, inherited history, and seed boundaries remain available.
The Session format stays V4. Older V4 readers may reject a parentless fork containing inherited delivery acknowledgements as a corrupt log with `current-generation delivery marker names the wrong Session`. The tested readers refuse before granting a handle or truncating the file. They do not provide an upgrade-specific diagnostic. See the [compatibility decision](../../../../.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.md).
@@ -11,7 +11,7 @@ description: "普通 Session fork 停止记录来源关系,也不再显示为
普通 fork 复制选定历史并独立继续运行。它们省略 `SessionHeader.parentSession` 及客户端的 `parentSessionId`,不写入替代来源字段。子代理(包括使用 fork backend 的子代理)保留直接创建者。已有日志保留记录中的元数据。
SDK 的 `subagent.started` 排除普通 fork,包括携带父 ID 的历史会话。ACP 列表和恢复允许普通 fork。客户端列表不再将它们缩进,也不再把新发现的 fork 插到来源之前;已保存的手动位置保持不变。基于来源的追踪查询、父关系筛选、遥测和远端日志无法还原新普通 fork 的来源。工作区绑定、可选标题递增、继承历史和 seed 边界仍然可用。
SDK 的 `subagent.started` 排除普通 fork,包括携带父 ID 的历史会话。ACP 列表和恢复允许普通 fork;通过 cwd 和 preset 检查的普通 fork 也可由 headless 沿用。客户端列表不再将它们缩进,也不再把新发现的 fork 插到来源之前;已保存的手动位置保持不变。基于来源的追踪查询、父关系筛选、遥测和远端日志无法还原新普通 fork 的来源。工作区绑定、可选标题递增、继承历史和 seed 边界仍然可用。
Session 格式保持 V4。旧 V4 reader 可能将包含继承投递确认的无父关系 fork 作为损坏日志拒绝,报错为 `current-generation delivery marker names the wrong Session`。已测试的 reader 在授予句柄或截断文件之前拒绝,不提供专门的升级诊断。参见[兼容性决策](../../../../.agents/notes/implemented/architecture/2026-10-23-independent-session-forks.zh.md)。
+4 -4
View File
@@ -24,8 +24,8 @@
en: ca8217837ee9c698
zh: fa20daf27d4e09ef
/deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity:
en: 0ef0c533ef9aebea
zh: fcb267b61bd23437
en: 85120c2d6db2cac4
zh: bac532df6c84e755
/deepseek-ai-dsh-headless/use-this-package/supervising-implementation-tasks:
en: b10dee3026e8d999
zh: 63eda004e228a48a
@@ -66,8 +66,8 @@
en: 0b0f74b412c7c6d8
zh: 6e8f918d0c4bf342
/deepseek-ai-dsh-headless/known-limitations-and-deferred-work:
en: ff000db625d2d0db
zh: 7d08a158aade75f9
en: 94d8c7731c1435c3
zh: d0ddc9dd4e030b3f
/deepseek-ai-dsh-headless/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+2 -2
View File
@@ -72,7 +72,7 @@ The restored store uses [saved credentials before environment fallbacks](../../c
### Choosing the session identity
Every invocation defaults to a fresh `session-<uuid>` identity, which `--json` reports in its opening `session` event. Pass `--session-id <id>` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
Every invocation defaults to a fresh `session-<uuid>` identity, which `--json` reports in its opening `session` event. Pass `--session-id <id>` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process. An Agent already live under the requested id in this process is refused: another owner may still drive it, so the runner cannot claim an exclusive run interval over it. The identity is opaque, so the exact string is used, whitespace included. The working directory is resolved through the mounted filesystem provider (`fs.resolve('.')` and `fs.processPath()`), or the process cwd when no filesystem service is mounted; new Sessions record that directory. Adoption compares the recorded cwd with the same provider-resolved directory and refuses a Session that is a subagent session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
On adoption, the runner records the current deployment route before submitting the task. An omitted effort preserves an explicit saved effort only for the same route. A step already prepared in this process keeps its captured route; subsequent steps read the shared Session selection.
@@ -186,7 +186,7 @@ These limits tell you when headless does not fit and what it needs from the `dsh
- **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.
- **Default stdout carries only the final answer** — a run without an assistant message prints an empty stdout line and exits 1; intermediate tool output is not printed unless you opt into `--json`.
- **Adoption is cwd-, ownership-, and preset-scoped** — `--session-id` refuses a Session recorded in another working directory, one that recorded no working directory, one that is a subagent or forked session, or one that runs under an agent preset this profile does not compose or whose preset record is malformed, and requires the composed Session query and persistence services; an identity already live in the process is refused too, because the runner cannot own an exclusive run interval over it.
- **Adoption is cwd-, ownership-, and preset-scoped** — `--session-id` refuses a Session recorded in another working directory, one that recorded no working directory, one that is a subagent session, or one that runs under an agent preset this profile does not compose or whose preset record is malformed, and requires the composed Session query and persistence services; an identity already live in the process is refused too, because the runner cannot own an exclusive run interval over it.
- **The event stream is a projection, not the log** — `--json` caps every string except the terminal `final` at 8 KiB and omits events the projection does not model, so it is not a lossless copy of the Session log.
<a id="dev-note"></a>
+2 -2
View File
@@ -72,7 +72,7 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
### 选择 Session 标识
每次调用默认使用全新的 `session-<uuid>` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id <id>` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
每次调用默认使用全新的 `session-<uuid>` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id <id>` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。本进程中已存在持有该 id 的存活 Agent 时会被拒绝:它的原 owner 可能仍在驱动它,runner 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。工作目录通过已挂载的文件系统提供方解析(`fs.resolve('.')` 与 `fs.processPath()`);未挂载文件系统服务时使用进程 cwd,新 Session 会记录该目录。沿用会将已记录 cwd 与同一提供方解析出的目录比较,并拒绝子 agent 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
接管会话时,运行器会在提交任务前记录当前部署路由。未声明推理强度时,仅在路由相同的情况下保留已保存的显式强度。本进程中已准备的步骤保留捕获的路由,后续步骤读取共享的 Session 选择。
@@ -186,7 +186,7 @@ runner 不向请求前缀添加任何内容;它只是驱动组合出的配置
- **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前,stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。
- **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留显著更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。
- **默认 stdout 只承载最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印,除非显式启用 `--json`。
- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或运行在本 profile 不组合的 agent preset 下的 Session、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务;本进程中已存活的身份同样会被拒绝,因为 runner 无法对它取得独占的运行区间。
- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 会话,或运行在本 profile 不组合的 agent preset 下的 Session、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务;本进程中已存活的身份同样会被拒绝,因为 runner 无法对它取得独占的运行区间。
- **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。
<a id="dev-note"></a>
+2 -3
View File
@@ -180,7 +180,6 @@ function streamReasoning(
interface AdoptableHeader {
cwd?: string | undefined
origin?: 'subagent' | undefined
parentSession?: SessionId | undefined
agentPreset?: string | undefined
}
@@ -231,8 +230,8 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>
`session "${sessionId}" runs under agent preset "${preset}", which the one-shot runner does not compose`,
)
}
if (header.origin === 'subagent' || header.parentSession !== undefined) {
throw new Error(`session "${sessionId}" is a subagent or forked session and cannot be driven directly`)
if (header.origin === 'subagent') {
throw new Error(`session "${sessionId}" is a subagent session and cannot be driven directly`)
}
if (header.cwd === undefined) {
throw new Error(`session "${sessionId}" recorded no working directory, so it cannot be adopted`)
+18 -14
View File
@@ -14,7 +14,7 @@ import type {
} from '@deepseek-ai/dsh-agent'
import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
import { LlmAttemptId, ReasoningEffortId, ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionLogOffset } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import ApprovalService, { setApprovalPolicy, type ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
@@ -987,7 +987,7 @@ describe('headless runner', () => {
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('is a subagent or forked session')
expect(result.err).toContain('is a subagent session')
await test.ctx.fiber.dispose()
})
@@ -1055,7 +1055,7 @@ describe('headless runner', () => {
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('is a subagent or forked session')
expect(result.err).toContain('is a subagent session')
await test.ctx.fiber.dispose()
})
@@ -1161,19 +1161,23 @@ describe('headless runner', () => {
await test.ctx.fiber.dispose()
})
it('rejects a persisted Session linked to a parent', async () => {
const test = await bench({ afterPrompt: () => {} }, {
it.each([false, true])('resumes an ordinary fork with historical parent metadata: %s', async (legacy) => {
const header = { cwd: process.cwd(), ...(legacy ? { parentSession: brandString<SessionId>('parent-1') } : {}) }
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, 'fork resumed', true) },
}, {
sessionId: 'session-exact',
observe: () => Promise.resolve({
header: { cwd: process.cwd(), origin: 'user', parentSession: 'parent-1' },
events: [],
[Symbol.dispose]() {},
}),
observe: () => Promise.resolve({ header, events: [], [Symbol.dispose]() {} }),
})
const result = await test.run()
expect(result.code).toBe(1)
expect(result.err).toContain('is a subagent or forked session')
await test.ctx.fiber.dispose()
try {
const session = test.ctx.sessions.create(brandString<SessionId>('session-exact'), {
seed: [], inheritedEventCount: SessionLogOffset(0), meta: { ...header, isSeeded: true },
})
expect(await test.run()).toMatchObject({ code: 0, out: 'fork resumed\n', err: '' })
expect(session.header.parentSession).toBe(header.parentSession)
} finally {
await test.ctx.fiber.dispose()
}
})
it('propagates a Session query failure that is not a missing log', async () => {