Просмотр исходного кода

fix(headless): refuse a live Agent identity and finish the adopt-only docs

Address the ds-review-bot findings on 472554a29e:

- resolveAgent refuses an Agent already live under the requested id: its owner
  may still drive it, and `whenIdle` is not a single-message signal, so the
  one-shot runner cannot own an exclusive run interval over it. The adoptability
  rules still run first so the refusal stays specific.
- Refresh the README intro, identity section, and limitations, and the Agent
  Note live-identity rule and change scope, for adopt-only.
- Clarify the loader-smoke `cwd` JSDoc (the prefix/parent inputs are ignored)
  and pin the resumed wake's final text in the two-wake e2e.
lsdsjy 3 недель назад
Родитель
Сommit
9bcb1e214c

+ 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: bf98ea7976aff9a79c159c43133b0cfd04ddb929
-2026-09-09-headless-machine-readable-run-surface.zh.md: 68fbeea65a131bde3c68a5e2cab995f3e6cdb9ae
+2026-09-09-headless-machine-readable-run-surface.md: c436e57c4c3461a1d3700ce873041e3b8ab9a603
+2026-09-09-headless-machine-readable-run-surface.zh.md: b55ec5f7df5c86720d9aa611594bbadd335a1b31

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

@@ -22,7 +22,7 @@ Three additions extend the app-owned command line that [Apps own their command l
 
 A per-run `--model` override is deliberately out of scope; the composition default stays authoritative.
 
-The change is confined to `packages/bundle/headless`: `src/startup.ts`, `src/index.ts`, the new `src/json-stream.ts`, the package manifest and `tsconfig.json`, and its tests. No core session, persistence, session-controller, base composition, or launcher file changes.
+The change is confined to `packages/bundle/headless`: `src/startup.ts`, `src/index.ts`, the new `src/json-stream.ts`, the package manifest and `tsconfig.json`, and its tests. The product-profile expectation test in `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` covers both output modes end to end, which adds one optional caller-owned `cwd` to the `packages/test-support/loader-smoke` harness so two wakes can share a world. No core session, persistence, session-controller, base composition, or launcher file changes.
 
 ### Command-line contract
 
@@ -65,7 +65,7 @@ The runtime owns identity. A run without `--session-id` mints `session-<uuid>` a
 
 `--session-id <id>` is adopt-only: observe the persisted session and resume it, failing when the log does not exist. The first wake omits the flag, so the runtime mints the identity and reports it in the `session` event; every later wake names that value and continues the history. A requested id with no stored log is an error rather than a fresh conversation, so a mistyped or stale id cannot silently open an empty history the caller believes it is resuming, and the JSONL store's refusal of an existing log id ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)) is never on this path. 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, and a malformed selection record fails closed rather than reading as no preset. 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, and again after the runner's idle wait, so a live identity cannot bypass them and a preset selected in that window is still rejected. A whitespace-only `sessionId` is rejected on both the CLI and the direct-config path. 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 — including when a live Agent already holds the id, because a later process has to find it — or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing.
+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, and a malformed selection record fails closed rather than reading as no preset. A session linked to a parent or subagent — including a user fork — is rejected. A live Agent already holding the requested id is refused outright: its owner may still drive it, and `whenIdle` is not a single-message signal, so the runner cannot own an exclusive interval over it. The resumed log is checked again after the runner's idle wait, so a preset selected in that window is still rejected. A whitespace-only `sessionId` is rejected on both the CLI and the direct-config path. 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 — the observation is how it finds the id it must resume — or when the requested identity would lack the `sessionPersistence` service that makes it durable.
 
 ## Consequences
 
@@ -74,7 +74,7 @@ What landed: `src/startup.ts` parses `--json` and `--session-id <id>`, treats an
 - Default mode is unchanged: a text-only run writes one final assistant line to stdout and nothing to stderr, and exit status still follows the terminal reason.
 - `--json` stdout parses line by line as JSON, starts with `session`, ends with `final`, and contains no plain text. Stderr carries no reasoning in this mode.
 - A step that retries publishes `text` and `thinking` only for the attempt that commits, so a discarded attempt leaves no trace in the stream.
-- Two consecutive runs with the same `--session-id` share history, and a requested id with no stored log exits 1 before the task runs. A run whose cwd differs from the persisted session, that recorded no cwd, that is a subagent or forked session, that runs under an agent preset, that carries a malformed preset record, or whose live identity has no stored record, exits 1 with a diagnostic, whether the identity is live or persisted.
+- Two consecutive runs with the same `--session-id` share history, and a requested id with no stored log exits 1 before the task runs. A run whose cwd differs from the persisted session, that recorded no cwd, that is a subagent or forked session, that runs under an agent preset, that carries a malformed preset record, or whose identity is already live in the process, exits 1 with a diagnostic.
 - A piped task with no positional task is honored, a whitespace-only positional is rejected instead of consuming the pipe, and an interactive invocation without a task still fails with the usage error.
 - Unit coverage lands in `packages/bundle/headless/tests/startup.spec.ts`, `tests/headless.spec.ts`, and `tests/json-stream.spec.ts`. The product headless profile expectation test in `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` covers both output modes end to end.
 

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

@@ -22,7 +22,7 @@ Status: implemented
 
 每次运行的 `--model` 覆盖被明确排除在范围之外;组合默认模型仍然权威。
 
-改动范围限于 `packages/bundle/headless`:`src/startup.ts`、`src/index.ts`、新增的 `src/json-stream.ts`、包清单与 `tsconfig.json`,以及测试。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。
+改动范围限于 `packages/bundle/headless`:`src/startup.ts`、`src/index.ts`、新增的 `src/json-stream.ts`、包清单与 `tsconfig.json`,以及测试。产品 profile 的期望测试位于 `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts`,端到端覆盖两种输出模式;为此给 `packages/test-support/loader-smoke` harness 增加了一个可选的调用方自有 `cwd`,让两次唤醒共享同一个世界。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。
 
 ### 命令行契约
 
@@ -65,7 +65,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 
 `--session-id <id>` 是只采用:先观察持久化会话并 resume,日志不存在时失败。首轮不传该 flag,由运行时生成身份并在 `session` 事件里报告;后续每一轮都用该值指名并续接历史。请求的 id 没有持久化日志时报错,而不是开一段新会话,因此写错或过期的 id 不会静默开出一段调用方自以为在续接的空历史,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 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,并在 runner 等待 idle 后再次执行,因此存活身份无法绕过它们,在该窗口内选中的 preset 也仍会被拒绝。纯空白的 `sessionId` 在 CLI 与直接配置两条路径上都会被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——即便本进程已有存活 Agent 持有该 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 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。本进程已存在持有请求 id 的存活 Agent 时直接拒绝:它的 owner 可能仍在驱动它,而 `whenIdle` 不是单条消息的完成信号,runner 无法对它取得独占区间。resume 后的日志会在 runner 等待 idle 后再次检查,因此该窗口内选中的 preset 仍会被拒绝。纯空白的 `sessionId` 在 CLI 与直接配置两条路径上都会被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——观察结果正是它找到待 resume id 的途径;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败。
 
 ## 后果
 
@@ -74,7 +74,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 - 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。
 - `--json` 的 stdout 逐行可解析为 JSON,以 `session` 开头、以 `final` 结尾,不含纯文本。该模式下 stderr 不承载推理。
 - 发生重试的步骤只为最终提交的 attempt 发布 `text` 与 `thinking`,因此被丢弃的 attempt 不会在事件流中留下任何痕迹。
-- 两次连续的相同 `--session-id` 运行共享历史,而请求一个没有持久化日志的 id 会在任务运行前退出 1。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或存活身份没有持久化记录的运行都以诊断退出 1,无论身份是存活还是持久化的。
+- 两次连续的相同 `--session-id` 运行共享历史,而请求一个没有持久化日志的 id 会在任务运行前退出 1。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或本进程已存在同 id 存活 Agent 的运行都以诊断退出 1。
 - 无位置参数但 stdin 有管道输入时任务被采纳,只有空白的位置参数会被拒绝而不会消费管道,交互式无任务调用仍以用法错误失败。
 - 单元覆盖落在 `packages/bundle/headless/tests/startup.spec.ts`、`tests/headless.spec.ts` 与 `tests/json-stream.spec.ts`。`apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` 的产品 headless profile 期望测试端到端覆盖两种输出模式。
 

+ 4 - 1
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -356,7 +356,10 @@ describe('headless stream-json snapshots', () => {
       })
       const secondEvents = second.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject)
       expect(secondEvents[0]).toMatchObject({ type: 'session', sessionId })
-      expect(secondEvents.at(-1)).toMatchObject({ type: 'final' })
+      expect(secondEvents.at(-1)).toMatchObject({
+        type: 'final',
+        text: 'CLI tool round trip complete: CLI_TOOL_ROUND_TRIP',
+      })
     } finally {
       await rm(cwd, { recursive: true, force: true })
     }

+ 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: 390782d702fa1fca26459cc6b2910506bb9d340f
-README.zh.md: ab1bb98b69c5f951618de809e7bb3f15491ff311
+README.md: 76712b905ac515d8fcf3c908d62795c7cbd0d782
+README.zh.md: 550036e7fa0650781628e709b3429cc005b7ac66

+ 3 - 3
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, 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; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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, 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. Adoption is scoped to the current working 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.
 
 ### Machine-readable output
 
@@ -73,7 +73,7 @@ Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The runner is a direct driver over the core API carrier: it creates one fresh Agent through the registry and folds the owned durable event interval into one process-level outcome.
+The runner is a direct driver over the core API carrier: it resolves the Agent identity — a fresh `session-<uuid>` by default, or the persisted Session `--session-id` names — and folds the owned durable event interval into one process-level outcome.
 
 ### Run flow
 
@@ -142,7 +142,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 plus a stored record for a live identity.
+- **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.
 - **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>

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

@@ -51,7 +51,7 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
 
 ### 选择 Session 标识
 
-每次调用默认使用全新的 `session-<uuid>` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id <id>` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 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 无法取得独占的运行区间。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
 
 ### 机器可读输出
 
@@ -73,7 +73,7 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
 <details>
 <summary>实现细节——点击展开</summary>
 
-runner 是核心 API 载体之上的直接驱动器:它通过注册表创建一个全新的 Agent,并把所属的持久化事件区间折叠成一个进程级结果。
+runner 是核心 API 载体之上的直接驱动器:它确定 Agent 标识——默认是全新的 `session-<uuid>`,或 `--session-id` 指名的持久化 Session——并把所属的持久化事件区间折叠成一个进程级结果。
 
 ### 运行流程
 
@@ -142,7 +142,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 查询与持久化服务,且存活身份必须已有持久化记录。
+- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或运行在本 profile 不组合的 agent preset 下的 Session、preset 记录畸形的 Session,并要求已组合的 Session 查询与持久化服务;本进程中已存活的身份同样会被拒绝,因为 runner 无法对它取得独占的运行区间。
 - **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。
 
 <a id="dev-note"></a>

+ 17 - 20
packages/bundle/headless/src/index.ts

@@ -241,15 +241,16 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>
 }
 
 /**
- * Resolve the Agent for one run: reuse a live identity or adopt the persisted
- * Session with the requested id. The identity must already exist; a first round
- * omits the option instead, so a typo cannot pass as a brand-new conversation.
+ * Resolve the Agent for one run: adopt the persisted Session with the requested
+ * id. The identity must already exist, and no Agent may be live under it; a
+ * first round omits the option instead, so a typo cannot pass as a brand-new
+ * conversation.
  * @param ctx - plugin context carrying the Session query service.
  * @param agents - the core Agent registry.
  * @param sessionId - exact Session identity to adopt.
  * @param agentOptions - provider/model pair for this run.
  * @param setup - per-Agent scope setup installing the model selection.
- * @returns the live or resumed Agent.
+ * @returns the resumed Agent.
  */
 async function resolveAgent(
   ctx: Context,
@@ -258,32 +259,28 @@ async function resolveAgent(
   agentOptions: { provider: string; model: string },
   setup: (agentCtx: Context) => void,
 ): Promise<Agent> {
-  // Reusing a live identity and resuming a stored 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.
-  const persistence = ctx.get('sessionPersistence')
-  if (persistence === undefined) {
+  // Resuming promises 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 the resume.
+  if (ctx.get('sessionPersistence') === undefined) {
     throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process')
   }
   // A later process holds no live Agent and has to find the id through the
-  // query service, so every --session-id run requires it even when this
-  // process already has the identity live.
+  // query service, so every --session-id run requires it.
   const query = ctx.get('sessionQuery')
   if (query === undefined) {
     throw new Error('headless --session-id requires the sessionQuery service; dsh-base provides it')
   }
   const live = agents.get(sessionId)
   if (live !== undefined) {
-    // A live identity skips adoption, not the rules that make adoption safe.
+    // A live Agent already has an owner that may still drive it, and `whenIdle`
+    // is not a single-message signal: folding its next interval into this run
+    // would mix that owner's events — even its final answer — into the stream.
+    // The runner cannot claim an exclusive interval over an Agent it did not
+    // create, so it refuses the identity; the adoptability rules run first so a
+    // real mismatch is named instead of the generic refusal.
     assertAdoptable(live.session.header, liveEvents(live.session), sessionId)
-    // A stored record rules out an Agent registered only in memory, whose
-    // `session/flush` would store nothing; write-handle ownership itself is
-    // not queryable through the persistence contract.
-    if (await persistence.stat(sessionId) === undefined) {
-      throw new Error(`live session "${sessionId}" has no persisted record, so the one-shot runner cannot promise it survives this process`)
-    }
-    return live
+    throw new Error(`session "${sessionId}" is live in this process, so the one-shot runner cannot own an exclusive run interval`)
   }
   try {
     using observation = await query.observeSession(sessionId)

+ 8 - 45
packages/bundle/headless/tests/headless.spec.ts

@@ -48,14 +48,10 @@ interface BenchOptions {
   omitSessionQuery?: boolean
   /** Leave the persistence service unmounted to exercise the fail-loud path. */
   omitPersistence?: boolean
-  /** Mount the service but make it report no stored record for any id. */
-  unbackedPersistence?: boolean
   /** Register a live Agent under `sessionId` before the runner starts. */
   prelive?: boolean
   /** Header facts for that pre-registered live Agent. */
   preliveMeta?: { cwd?: string; origin?: 'subagent'; agentPreset?: string }
-  /** Run when the live path reads persistence, e.g. to mutate the live log. */
-  onStat?: (agent: Agent) => void
 }
 
 const frameStates = new WeakMap<Agent, { attemptId: ReturnType<typeof LlmAttemptId>; revision: number; index: number }>()
@@ -182,14 +178,8 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{
     const observe = options.observe ?? (() => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')))
     ctx.provide('sessionQuery', { observeSession: () => observe() } as never)
   }
-  let preliveAgent: Agent | undefined
   if (options.omitPersistence !== true) {
-    ctx.provide('sessionPersistence', {
-      stat: () => {
-        if (options.onStat !== undefined && preliveAgent !== undefined) options.onStat(preliveAgent)
-        return Promise.resolve(options.unbackedPersistence === true ? undefined : { header: {} })
-      },
-    } as never)
+    ctx.provide('sessionPersistence', {} as never)
   }
   return {
     ctx,
@@ -203,10 +193,10 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{
         ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
       })
       if (options.prelive === true || options.preliveMeta !== undefined) {
-        preliveAgent = (await ctx.agents.create({
+        await ctx.agents.create({
           sessionId: brandString<SessionId>(options.sessionId ?? 'session-exact'),
           meta: { cwd: process.cwd(), ...options.preliveMeta },
-        })).agent
+        })
       }
       apply(ctx, {
         ...options.useStdin === true ? {} : { task: options.task ?? 'do the thing' },
@@ -554,21 +544,6 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
-  it('rejects a live Session the persistence service does not know', async () => {
-    const test = await bench({
-      afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) },
-    }, {
-      sessionId: 'session-exact',
-      prelive: true,
-      unbackedPersistence: true,
-    })
-    const result = await test.run()
-    expect(result.code).toBe(1)
-    expect(result.err).toContain('has no persisted record')
-    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) },
@@ -713,14 +688,17 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
-  it('reuses a live Agent already registered under the requested identity', async () => {
+  it('refuses a live Agent identity it cannot own exclusively', async () => {
     const test = await bench({
       afterPrompt(session, message) { appendTurn(session, 1, message, 'live answer', true) },
     }, {
       sessionId: 'session-exact',
       prelive: true,
     })
-    expect(await test.run()).toMatchObject({ code: 0, out: 'live answer\n', err: '' })
+    const result = await test.run()
+    expect(result.code).toBe(1)
+    expect(result.err).toContain('is live in this process, so the one-shot runner cannot own an exclusive run interval')
+    expect(result.out).toBe('')
     await test.ctx.fiber.dispose()
   })
 
@@ -777,21 +755,6 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
-  it('rejects a preset selected while the runner awaits idle', async () => {
-    const test = await bench({
-      afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) },
-    }, {
-      sessionId: 'session-exact',
-      prelive: true,
-      onStat: (agent) => { selectPreset(agent.session, 'minimal') },
-    })
-    const result = await test.run()
-    expect(result.code).toBe(1)
-    expect(result.err).toContain('runs under agent preset "minimal"')
-    expect(result.out).toBe('')
-    await test.ctx.fiber.dispose()
-  })
-
   it('rejects a preset appended after the observation snapshot was taken', async () => {
     const test = await bench({ afterPrompt: () => {} }, {
       sessionId: 'session-exact',

+ 5 - 1
packages/test-support/loader-smoke/src/index.ts

@@ -138,7 +138,11 @@ export interface LoaderSmokeOptions {
   readonly tempDirPrefix: string
   /** Existing parent for the generated cwd; defaults to the platform temporary directory. */
   readonly tempDirParent?: string
-  /** Existing process cwd to reuse instead of a fresh temporary directory; the caller owns its cleanup. */
+  /**
+   * Existing directory to use as the process cwd instead of a fresh temporary
+   * one; the caller owns its cleanup, and `tempDirPrefix`/`tempDirParent` are
+   * ignored.
+   */
   readonly cwd?: string
   /** Absolute app-bin source path (`<pkg>/src/bin.ts`); the `lib` bin is derived from it. */
   readonly binScript: string