Bladeren bron

fix(headless): require persistence for every --session-id path

Address the ds-review-bot v6 review on PR #3849: the guard sat before the
create branch only, so adopting an already-live identity could still run a
session whose history dies at exit. Move it to the top of resolveAgent, where
it covers both the live and the create path, and add a rejection test for the
live path alongside the existing create-path one.
lsdsjy 2 weken geleden
bovenliggende
commit
a5c21a69b8

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md
-2026-09-09-headless-machine-readable-run-surface.md: a2889ee17923407d659d133938445947ae21ff17
-2026-09-09-headless-machine-readable-run-surface.zh.md: d8f2a8284fb8d078b7171ffdc26f88a475eb76c9
+2026-09-09-headless-machine-readable-run-surface.md: 9e0e6165b195de8dc96dc61512a56af7164c8bf6
+2026-09-09-headless-machine-readable-run-surface.zh.md: 18cf93a13d7dc03a09c70d14d352f8f5f690e025

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md

@@ -65,7 +65,7 @@ The runtime owns identity. A run without `--session-id` mints `session-<uuid>` a
 
 `--session-id <id>` is adopt-or-create: observe the persisted session, resume it when it exists, create it otherwise. Create-only would fail the second run, because the JSONL store rejects an existing log id ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)). The id is opaque, so the runner validates non-emptiness on the trimmed value and passes the caller's exact string through, whitespace included.
 
-Adoption compares the persisted session's recorded cwd with the process cwd, since sessions are organized per project directory ([project session directories](../../implemented/architecture/2026-07-24-project-session-directories.md)). A mismatch exits 1 with a `dsh:` diagnostic instead of silently continuing a conversation rooted elsewhere, and a session that recorded no cwd is rejected for the same reason. A session running under an agent preset is rejected because this bundle composes no preset roster: resuming it here would run it under the headless tools and prompts instead of the composition its log records. The check reads the preset the log currently records — the creation header advanced by any `agent-preset/selected` event — because a blank session may switch preset after creation while the header stays a creation fact. A session linked to a parent or subagent — including a user fork — is rejected. All checks run when a live Agent already holds the requested id, so a live identity cannot bypass them. Two live processes cannot write one id; the store's write lease already rejects the second writer. The runner reads the observation through the composed `sessionQuery` service and fails loudly when `--session-id` is requested without it, or when creating the requested identity would lack the `sessionPersistence` service that makes it durable.
+Adoption compares the persisted session's recorded cwd with the process cwd, since sessions are organized per project directory ([project session directories](../../implemented/architecture/2026-07-24-project-session-directories.md)). A mismatch exits 1 with a `dsh:` diagnostic instead of silently continuing a conversation rooted elsewhere, and a session that recorded no cwd is rejected for the same reason. A session running under an agent preset is rejected because this bundle composes no preset roster: resuming it here would run it under the headless tools and prompts instead of the composition its log records. The check reads the preset the log currently records — the creation header advanced by any `agent-preset/selected` event — because a blank session may switch preset after creation while the header stays a creation fact. A session linked to a parent or subagent — including a user fork — is rejected. All checks run when a live Agent already holds the requested id, so a live identity cannot bypass them. Two live processes cannot write one id; the store's write lease already rejects the second writer. The runner reads the observation through the composed `sessionQuery` service and fails loudly when `--session-id` is requested without it, or when the requested identity would lack the `sessionPersistence` service that makes it durable.
 
 ## Consequences
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md

@@ -65,7 +65,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 
 `--session-id <id>` 是采用或创建:先观察持久化会话,存在就 resume,不存在就 create。只创建会让第二次运行失败,因为 JSONL 存储拒绝已存在的日志 id(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))。标识是不透明的,因此 runner 只在 trim 后的值上校验非空,并把调用方的原始字符串(含空白字符)原样传下去。
 
-采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话;未记录 cwd 的会话出于同样理由被拒绝。运行在 agent preset 下的会话被拒绝,因为本 bundle 不组合任何 preset roster:在这里 resume 它,会用 headless 的工具与提示词运行它,而不是它日志当前记录的组合。该检查读取日志当前记录的 preset——创建 header 再叠加任何 `agent-preset/selected` 事件——因为空白会话可能在创建后切换 preset,而 header 始终只是创建事实。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败;若创建所请求的身份时缺少让它持久化的 `sessionPersistence` 服务,同样显式失败。
+采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话;未记录 cwd 的会话出于同样理由被拒绝。运行在 agent preset 下的会话被拒绝,因为本 bundle 不组合任何 preset roster:在这里 resume 它,会用 headless 的工具与提示词运行它,而不是它日志当前记录的组合。该检查读取日志当前记录的 preset——创建 header 再叠加任何 `agent-preset/selected` 事件——因为空白会话可能在创建后切换 preset,而 header 始终只是创建事实。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败。
 
 ## 后果
 

+ 2 - 2
packages/bundle/headless/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
-README.md: 81e978b5863d1271314cb58b7bab25a383bc08da
-README.zh.md: 2125c4c5cc310059cbd00dabc4b0bb01cd33de1d
+README.md: 138da22830a7ce93635418912266b816540f9db1
+README.zh.md: 3388ea5e729d4e4aeb1d434427768d05165bb22a

+ 1 - 1
packages/bundle/headless/README.md

@@ -51,7 +51,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### Choosing the session identity
 
-Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-id <id>` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise — creating requires the composed `sessionPersistence` service, so a profile that omits it fails loudly instead of returning an id whose history dies with the process. The identity is opaque, so the exact string is used, whitespace included. Adoption is scoped to the current working directory and refuses a Session that is a subagent or forked session, that recorded no working directory, or that runs under an agent preset this profile does not compose — 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. Pass `--session-id <id>` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require the composed `sessionPersistence` service, so a profile that omits it fails loudly instead of returning an id whose history dies with the process. The identity is opaque, so the exact string is used, whitespace included. Adoption is scoped to the current working directory and refuses a Session that is a subagent or forked session, that recorded no working directory, or that runs under an agent preset this profile does not compose — 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.
 
 ### Machine-readable output
 

+ 1 - 1
packages/bundle/headless/README.zh.md

@@ -51,7 +51,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量
 
 ### 选择 Session 标识
 
-每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建——创建要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent preset 下的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
+每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及运行在本 profile 不组合的 agent preset 下的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
 
 ### 机器可读输出
 

+ 7 - 6
packages/bundle/headless/src/index.ts

@@ -250,6 +250,13 @@ async function resolveAgent(
   agentOptions: { provider: string; model: string },
   setup: (agentCtx: Context) => void,
 ): Promise<Agent> {
+  // Adopting a live identity and creating a missing one both promise the
+  // caller a log a later process can continue. Without a durable log the run
+  // would succeed, print the id, and still lose the whole history at exit, so
+  // a miscomposed profile fails loud before either path.
+  if (ctx.get('sessionPersistence') === undefined) {
+    throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process')
+  }
   const live = agents.get(sessionId)
   if (live !== undefined) {
     // A live identity skips adoption, not the rules that make adoption safe.
@@ -272,12 +279,6 @@ async function resolveAgent(
   } catch (error: unknown) {
     if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
   }
-  // Creating the requested identity without a durable log would succeed, print
-  // the id, and still lose the whole history at exit — the exact continuity
-  // `--session-id` promises. A miscomposed profile fails loud instead.
-  if (ctx.get('sessionPersistence') === undefined) {
-    throw new Error('headless --session-id requires the sessionPersistence service; the created Session would not survive this process')
-  }
   const { agent } = await agents.create({
     sessionId,
     meta: { cwd: process.cwd() },

+ 16 - 1
packages/bundle/headless/tests/headless.spec.ts

@@ -506,7 +506,7 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
-  it('rejects creating the requested Session when persistence is not mounted', async () => {
+  it('rejects --session-id when persistence is not mounted', async () => {
     const test = await bench({
       afterPrompt(session, message) { appendTurn(session, 1, message, 'created', true) },
     }, {
@@ -521,6 +521,21 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
+  it('rejects adopting a live Session when persistence is not mounted', async () => {
+    const test = await bench({
+      afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) },
+    }, {
+      sessionId: 'session-exact',
+      prelive: true,
+      omitPersistence: true,
+    })
+    const result = await test.run()
+    expect(result.code).toBe(1)
+    expect(result.err).toContain('requires the sessionPersistence service')
+    expect(result.out).toBe('')
+    await test.ctx.fiber.dispose()
+  })
+
   it('resumes the persisted Session when the query finds it', async () => {
     const test = await bench({
       afterPrompt(session, message) { appendTurn(session, 1, message, 'resumed answer', true) },