瀏覽代碼

fix(headless): bound whole events, re-check adoption after resume, tighten --json scan

Address the ds-review-bot v5/v6 review on PR #3849:

- Register the shared `lib/json-stream-*.js` chunk in `packageFileExtras` and
  order `files` as the workspace-constraints gate requires, so
  `pnpm run constraints` passes.
- Re-validate the preset on the Session `agents.resume` actually attached,
  closing the TOCTOU window between `observeSession` and the write lease.
- Bound one serialized event line at 32 KiB: an over-long event keeps its
  scalar fields, drops structured ones, and at the extreme reduces to `type`.
- Normalize an empty tool-argument string to `{}`, matching the executor.
- Scan only real `--json` flags (stop at `--`, skip a `--session-id` value)
  before installing the JSON error override.

Tests: 74 headless unit tests with per-file 100% coverage on src, 14 profile
e2e tests, constraints/typecheck/lint/doc/pairing gates, tarball import smoke.
lsdsjy 2 周之前
父節點
當前提交
0ca42f3b78

+ 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: 0284f3b7de060f14bd4eb19b04865a12ba9334b4
-2026-09-09-headless-machine-readable-run-surface.zh.md: 59c8f97941d2af87d098b830bf737f90e76a9ae2
+2026-09-09-headless-machine-readable-run-surface.md: a109bc08817c208ae08a0d1316f3831b7c2656c4
+2026-09-09-headless-machine-readable-run-surface.zh.md: d2a7cc74270abab8051b0ab86cd1854a77554f78

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

@@ -54,7 +54,7 @@ Projection rules:
 - Text and reasoning are projected only from a committed `assistant/message`, never from live attempt deltas. A retried or discarded attempt appends `assistant/attempt`, which the projection ignores, so the stream never carries content the durable log does not contain ([publish state only at its commit point](../../../../packages/AGENTS.md)).
 - Each committed content block becomes exactly one `text` or `thinking` event in content order; `tool-call` blocks are not projected because the `tool/call` event owns them. `user/message` echoes and internal session events (title, model selection, projection, checkpoint, goal, subagent) are not projected.
 - A `tool/result` is projected only when its `surfaceOp` is `append`. A compaction replacement of an older result is history, and projecting it would emit a call id with no matching `tool_call`.
-- Every projected string and object key is bounded at 8 KiB, and an event with a cut value carries `truncated: true`, including the process-level `error` event; a literal `__proto__` argument key is copied as data rather than through the inherited setter. The terminal `final` event is deliberately unbounded: it carries the same lossless answer the default mode prints.
+- Every projected string and object key is bounded at 8 KiB, an event with a cut value carries `truncated: true`, and one serialized event line is bounded at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`. This includes the process-level `error` event; a literal `__proto__` argument key is copied as data rather than through the inherited setter, and an empty tool-argument string projects as `{}` to match the executor. The terminal `final` event is deliberately unbounded: it carries the same lossless answer the default mode prints.
 - Text and reasoning arrive when the step commits, not per token; default-mode stderr reasoning remains the only live text channel. A turn that fails in-turn still ends the stream with `final` and no `error` event, so a supervisor classifies that run from the exit code and the `turn_end` reason even when the stream is well formed.
 - `usage` appears on `step_end`, matching the token accounting a provider reports per step.
 - Raw session events stay out of scope. A debug escape hatch can be added later without changing this vocabulary.
@@ -83,7 +83,7 @@ Deferred and open:
 - The per-run `--model` override is unimplemented. A later change must respect the session-local selection precedence owned by the Session Controller rather than overriding a stored selection.
 - Cold start plus log replay grows with session length, so a long-lived conversation pays more per wake than a fresh one.
 - `--json` moves reasoning from stderr to stdout, so a log collector that watches stderr sees nothing on a reasoned run in that mode.
-- Bounded `tool_result` payloads hide full output from the supervisor; the 8 KiB cap is owned by `src/json-stream.ts` and should stay a single constant, with the terminal `final` event the only exemption.
+- Bounded `tool_result` payloads hide full output from the supervisor; the 8 KiB string/key cap and the 32 KiB line cap are owned by `src/json-stream.ts` and should stay constants, with the terminal `final` event the only exemption.
 
 ## Alternatives considered
 

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

@@ -54,7 +54,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 - 文本与推理只从已提交的 `assistant/message` 投影,绝不来自实时的 attempt 增量。被重试或丢弃的 attempt 会追加 `assistant/attempt`,投影直接忽略,因此事件流永远不会承载持久化日志中不存在的内容([只在提交点发布状态](../../../../packages/AGENTS.md))。
 - 每个已提交的内容块按内容顺序变成恰好一条 `text` 或 `thinking` 事件;`tool-call` 块不投影,因为 `tool/call` 事件已经拥有它。`user/message` 回显和内部会话事件(标题、模型选择、投影、检查点、目标、子 agent)都不投影。
 - `tool/result` 仅在其 `surfaceOp` 为 `append` 时投影。压缩对旧结果的替换属于历史,投影它会产生没有对应 `tool_call` 的 call id。
-- 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 `truncated: true`,进程级 `error` 事件同样如此;字面量 `__proto__` 参数键会作为数据复制,而不经过继承的 setter。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。
+- 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 `truncated: true`,单条序列化事件行限制在 32 KiB——超长事件保留标量字段、丢弃结构化字段,极端情况下只剩 `type` 与 `truncated`。进程级 `error` 事件同样受限;字面量 `__proto__` 参数键会作为数据复制,而不经过继承的 setter;空工具参数字符串会投影为 `{}`,与执行器保持一致。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。
 - 文本与推理在步骤提交时到达,而不是逐 token 到达;默认模式的 stderr 推理仍是唯一的实时文本通道。轮次内失败的运行仍以 `final` 结束且没有 `error` 事件,因此即使事件流格式良好,监督进程也要用退出码与 `turn_end` 原因来分类该次运行。
 - `usage` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。
 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。
@@ -83,7 +83,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 - 每次运行的 `--model` 覆盖尚未实现。后续改动必须尊重 Session Controller 拥有的会话局部选择优先级,而不是覆盖已保存的选择。
 - 冷启动加上日志重放会随会话变长而增长,因此长会话每次唤醒的代价高于新会话。
 - `--json` 把推理从 stderr 移到 stdout,因此只监听 stderr 的日志收集器在该模式的有推理运行上什么都看不到。
-- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 上限由 `src/json-stream.ts` 拥有,应保持单一常量,终止 `final` 事件是唯一的例外。
+- 有界的 `tool_result` 负载会让监督进程看不到完整输出;8 KiB 字符串/键上限与 32 KiB 行上限由 `src/json-stream.ts` 拥有,应保持为常量,终止 `final` 事件是唯一的例外。
 
 ## 备选方案
 

+ 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: 950c562cd61047db0b7bb2c72961e49eee0a2799
-README.zh.md: 23331334a6490778f8b18da8f09fa4a4008df42e
+README.md: 97ba97eec987f304ac163cbac945bf7764a28d78
+README.zh.md: 526e4ebf12d3a24d9f23ad8e3511f1c5a5e0d4d2

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

@@ -55,7 +55,7 @@ Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-
 
 ### Machine-readable output
 
-`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `text` and `thinking` are projected from committed assistant messages, so a retried or discarded attempt never reaches the stream; they arrive when the step commits, not per token, and default-mode stderr reasoning remains the only live text channel. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string and object key is capped at 8 KiB and flagged with `truncated`. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line. A turn that fails in-turn still ends with a `final` event (often empty) and no `error` event, so a well-formed stream can still describe a failed run: treat exit code 1 and the `turn_end` reason as the failure signal.
+`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `text` and `thinking` are projected from committed assistant messages, so a retried or discarded attempt never reaches the stream; they arrive when the step commits, not per token, and default-mode stderr reasoning remains the only live text channel. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string and object key is capped at 8 KiB and flagged with `truncated`, and one event line is capped at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`. An empty tool-argument string projects as `{}`, matching what the executor runs. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line. A turn that fails in-turn still ends with a `final` event (often empty) and no `error` event, so a well-formed stream can still describe a failed run: treat exit code 1 and the `turn_end` reason as the failure signal.
 
 ### When to use it
 

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

@@ -55,7 +55,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量
 
 ### 机器可读输出
 
-`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流;它们在步骤提交时到达,而不是逐 token 到达,默认模式的 stderr 推理仍是唯一的实时文本通道。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串与对象键上限为 8 KiB,超出时标记 `truncated`。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。
+`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流;它们在步骤提交时到达,而不是逐 token 到达,默认模式的 stderr 推理仍是唯一的实时文本通道。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串与对象键上限为 8 KiB,超出时标记 `truncated`,单条事件行上限为 32 KiB——超长事件保留标量字段、丢弃结构化字段,极端情况下只剩 `type` 与 `truncated`。空工具参数字符串会投影为 `{}`,与执行器实际运行的值一致。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。
 
 ### 何时使用
 

+ 1 - 1
packages/bundle/headless/package.json

@@ -29,8 +29,8 @@
   "files": [
     "lib/index.js",
     "lib/startup.js",
-    "lib/json-stream-*.js",
     "cordis.patch.yml",
+    "lib/json-stream-*.js",
     "lib/types/**/*.d.ts"
   ],
   "license": "MIT",

+ 4 - 0
packages/bundle/headless/src/index.ts

@@ -264,6 +264,10 @@ async function resolveAgent(
     using observation = await query.observeSession(sessionId)
     assertAdoptable(observation.header, observation.events, sessionId)
     const { agent } = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup })
+    // The observation is a snapshot: another writer may have appended a preset
+    // selection before this process took the write lease. Re-check the log
+    // resume actually attached, now that no other writer can append.
+    assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId)
     return agent
   } catch (error: unknown) {
     if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error

+ 34 - 2
packages/bundle/headless/src/json-stream.ts

@@ -14,6 +14,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
 /** Default per-string and per-key cap applied to every bounded projected payload. */
 export const MAX_STRING_BYTES = 8 * 1024
 
+/** Default cap on one projected event's serialized bytes; the terminal `final` is exempt. */
+export const MAX_EVENT_BYTES = 32 * 1024
+
 /** The stdout sink a projection writes newline-delimited events to. */
 export interface JsonSink {
   /** Write one chunk of the event stream. */
@@ -95,8 +98,37 @@ export function boundJsonEvent(
   return bounded
 }
 
-/** Parse raw tool-call arguments, keeping the unparsed string when it is not JSON. */
+/**
+ * Serialize one projected payload under both limits: every string and key is
+ * capped at `maxStringBytes`, and the serialized line at `maxEventBytes`. When
+ * the line is still too long, scalar fields survive and structured fields are
+ * dropped; when even those are too long, only `type` and `truncated` remain.
+ * @param event - the event payload to serialize.
+ * @param maxStringBytes - per-string and per-key byte cap.
+ * @param maxEventBytes - cap on the serialized line.
+ * @returns the bounded JSON line, without a trailing newline.
+ */
+export function boundJsonLine(
+  event: Record<string, unknown>,
+  maxStringBytes: number = MAX_STRING_BYTES,
+  maxEventBytes: number = MAX_EVENT_BYTES,
+): string {
+  const bounded = boundJsonEvent(event, maxStringBytes)
+  const line = JSON.stringify(bounded)
+  if (Buffer.byteLength(line, 'utf8') <= maxEventBytes) return line
+  const scalars = Object.create(null) as Record<string, unknown>
+  for (const [key, value] of Object.entries(bounded)) {
+    if (value === null || typeof value !== 'object') scalars[key] = value
+  }
+  scalars.truncated = true
+  const short = JSON.stringify(scalars)
+  if (Buffer.byteLength(short, 'utf8') <= maxEventBytes) return short
+  return JSON.stringify({ type: bounded.type, truncated: true })
+}
+
+/** Parse raw tool-call arguments as the executor does: empty input is `{}`, invalid JSON stays text. */
 function parseArguments(raw: string): unknown {
+  if (raw === '') return {}
   try {
     return JSON.parse(raw) as unknown
   } catch {
@@ -138,7 +170,7 @@ export function projectJsonRun(
   let stepUsage: SessionEvent<'assistant/message'>['data']['usage']
 
   const write = (event: Record<string, unknown>): void => {
-    sink.write(`${JSON.stringify(boundJsonEvent(event, maxStringBytes))}\n`)
+    sink.write(`${boundJsonLine(event, maxStringBytes)}\n`)
   }
 
   const onSessionEvent = (session: unknown, event: SessionEvent): void => {

+ 18 - 1
packages/bundle/headless/src/startup.ts

@@ -60,6 +60,23 @@ Examples:
 `)
 }
 
+/**
+ * Whether the raw invocation asks for the machine-readable stream. The scan
+ * stops at `--` and skips a `--session-id` value, so a literal `--json` used as
+ * an option value or a positional never installs the JSON error override.
+ * @param argv - the invocation's raw arguments.
+ * @returns whether `--json` is a real flag of this invocation.
+ */
+function jsonRequested(argv: readonly string[]): boolean {
+  for (let index = 0; index < argv.length; index += 1) {
+    const argument = argv[index]
+    if (argument === '--') return false
+    if (argument === '--json') return true
+    if (argument === '--session-id') index += 1
+  }
+  return false
+}
+
 /**
  * Parse and provide the one-shot task as an ordinary Cordis service. The
  * command's action publishes the task; a missing task on an interactive stdin
@@ -71,7 +88,7 @@ export function apply(ctx: Context): void {
   // The raw snapshot decides the JSON contract: Commander rejects a grammar
   // error (an unknown option, a missing option value) before the action runs,
   // and such a rejection still owes a --json caller the error event.
-  if ((ctx.get('cmdlineArgs')?.get() ?? []).includes('--json')) {
+  if (jsonRequested(ctx.get('cmdlineArgs')?.get() ?? [])) {
     const originalError = program.error.bind(program)
     program.error = (message: string, errorOptions?: Parameters<typeof originalError>[1]): never => {
       // The event message matches the runner's runtime errors, which carry no

+ 17 - 0
packages/bundle/headless/tests/headless.spec.ts

@@ -672,6 +672,23 @@ describe('headless runner', () => {
     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',
+      observe: () => Promise.resolve({
+        header: { cwd: process.cwd() },
+        events: [],
+        [Symbol.dispose]() {},
+      }),
+    })
+    const session = test.ctx.sessions.create(brandString<SessionId>('session-exact'), { meta: { cwd: process.cwd() } })
+    selectPreset(session, 'minimal')
+    const result = await test.run()
+    expect(result.code).toBe(1)
+    expect(result.err).toContain('runs under agent preset "minimal"')
+    await test.ctx.fiber.dispose()
+  })
+
   it('fails when a live event below the captured Session length cannot be read', async () => {
     let capturedLength = 0
     const test = await bench({

+ 23 - 1
packages/bundle/headless/tests/json-stream.spec.ts

@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
-import { boundJsonEvent, projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts'
+import { boundJsonEvent, boundJsonLine, MAX_STRING_BYTES, projectJsonRun, type JsonProjectionOptions } from '../src/json-stream.ts'
 
 interface ProjectionHarness {
   readonly lines: string[]
@@ -243,6 +243,28 @@ describe('--json projection', () => {
     expect(test.parsed()[1]).toEqual({ type: 'text', text: 'éé', truncated: true })
   })
 
+  it('normalizes empty tool arguments to an empty object like the executor', () => {
+    const test = harness({}, 's1')
+    test.emitSession({
+      type: 'tool/call',
+      data: { turn: 1, step: 1, callId: 'empty', name: 'bash', arguments: '' },
+    } as unknown as SessionEvent)
+    expect(test.parsed()[1]).toEqual({ type: 'tool_call', callId: 'empty', tool: 'bash', input: {} })
+  })
+
+  it('bounds one whole event line, dropping structured fields before scalars', () => {
+    const input = Array.from({ length: 20_000 }, (_, index) => index)
+    const line = boundJsonLine({ type: 'tool_call', callId: 'c', tool: 'bash', input }, MAX_STRING_BYTES, 1024)
+    expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(1024)
+    expect(JSON.parse(line)).toEqual({ type: 'tool_call', callId: 'c', tool: 'bash', truncated: true })
+  })
+
+  it('reduces a payload to its type when even its scalars exceed the line cap', () => {
+    const line = boundJsonLine({ type: 'text', text: 'x'.repeat(2000) }, 4096, 64)
+    expect(JSON.parse(line)).toEqual({ type: 'text', truncated: true })
+    expect(JSON.parse(boundJsonLine({ type: 'text', text: 'ok' }))).toEqual({ type: 'text', text: 'ok' })
+  })
+
   it('writes the terminal final event without bounding its answer', () => {
     const test = harness({ maxStringBytes: 4 })
     test.projection.finish('abcdefgh')

+ 13 - 0
packages/bundle/headless/tests/startup.spec.ts

@@ -182,6 +182,19 @@ describe('headless command-line provider', () => {
     expect(observed.exits).toEqual([1])
   })
 
+  it('does not install the JSON error override for a --json option value', async () => {
+    const { observed } = await bootStartup(['--session-id', '--json'], { stdinIsTty: true })
+    expect(observed.out).toContain('a task is required')
+    expect(observed.out).not.toContain('"type":"error"')
+    expect(observed.exits).toEqual([1])
+  })
+
+  it('does not install the JSON error override for a --json positional after --', async () => {
+    const { task, observed } = await bootStartup(['--', '--json'], { stdinIsTty: false })
+    expect(task).toEqual({ task: '--json', sessionId: undefined, json: false })
+    expect(observed.out).not.toContain('"type":"error"')
+  })
+
   it('rejects a blank positional task instead of reading stdin', async () => {
     const { task, observed } = await bootStartup(['   '], { stdinIsTty: false })
     expect(observed.out).toContain('a task is required')

+ 3 - 0
scripts/check-workspace-constraints.ts

@@ -184,6 +184,9 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   // through a hashed chunk. The committed bin.js is the link target pnpm can
   // resolve at install time, before the build produces lib/bin.js.
   '@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
+  // The headless entry and its startup row share the JSON projection code
+  // through a hashed tsdown chunk; both import it by relative path.
+  '@deepseek-ai/dsh-headless': ['lib/json-stream-*.js'],
 }
 
 function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {