diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml index e845acce34..163ff9b1d3 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml +++ b/.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: c436e57c4c3461a1d3700ce873041e3b8ab9a603 -2026-09-09-headless-machine-readable-run-surface.zh.md: b55ec5f7df5c86720d9aa611594bbadd335a1b31 +2026-09-09-headless-machine-readable-run-surface.md: e49efb3761a8c43d06fff3f3d10de68eef16dcc3 +2026-09-09-headless-machine-readable-run-surface.zh.md: 465d07b966576b08ed0085da752ec8da647eec58 diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md index c436e57c4c..e49efb3761 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md +++ b/.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. 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. +The product 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. Around it, the product-profile expectation test in `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` covers both output modes end to end and adds one optional caller-owned `cwd` to the `packages/test-support/loader-smoke` harness so two wakes can share a world, while `scripts/check-workspace-constraints.ts`, `pnpm-lock.yaml`, and the package manifest publish the shared `lib/json-stream-*.js` chunk both entries import. No core session, persistence, session-controller, base composition, or launcher file changes. ### Command-line contract diff --git a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md b/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md index b55ec5f7df..465d07b966 100644 --- a/.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md +++ b/.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`,以及测试。产品 profile 的期望测试位于 `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts`,端到端覆盖两种输出模式;为此给 `packages/test-support/loader-smoke` harness 增加了一个可选的调用方自有 `cwd`,让两次唤醒共享同一个世界。不修改任何 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`,让两次唤醒共享同一个世界;`scripts/check-workspace-constraints.ts`、`pnpm-lock.yaml` 与包清单负责发布两个入口共同引用的共享 chunk `lib/json-stream-*.js`。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。 ### 命令行契约 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index df64c70a8e..3aa7232f9d 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -359,8 +359,9 @@ async function run(ctx: Context, config: Config, io: HeadlessIo): Promise : await resolveAgent(ctx, agents, sessionId, agentOptions, setup) await agent.whenIdle() if (config.sessionId !== undefined) { - // A live Session can select an agent preset while the runner awaits idle; - // re-read its log so the rejection cannot be outrun by that timing. + // The resume-time check read a snapshot; an overlay can still append a + // preset selection between it and the interval this run now owns, so + // re-read the log the runner holds before submitting the task. assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId) } const firstSeq = agent.session.seq diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index 6bcd04c675..46525ddbfb 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-headless/startup */ -import { Command } from 'commander' +import { Command, CommanderError } from 'commander' import type { Context } from '@deepseek-ai/cordis' import { parseCmdline } from '@deepseek-ai/dsh-cmdline' import { boundJsonLine } from './json-stream.ts' @@ -89,13 +89,15 @@ export function apply(ctx: Context): void { // 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 (jsonRequested(ctx.get('cmdlineArgs')?.get() ?? [])) { - const originalError = program.error.bind(program) - program.error = (message: string, errorOptions?: Parameters[1]): never => { + program.error = (message: string, errorOptions?: Parameters[1]): never => { // The event message matches the runner's runtime errors, which carry no - // commander `error: ` prefix; the stderr line keeps commander's text. + // commander `error: ` prefix. const payload = boundJsonLine({ type: 'error', message: message.replace(/^error: /, '') }) internals.stdout.write(`${payload}\n`) - return originalError(message, errorOptions) + // The JSON contract keeps stderr to `dsh:` diagnostics, so commander's + // own print of this message must not run; throwing the same control-flow + // error still leaves through the launcher's exit path. + throw new CommanderError(1, errorOptions?.code ?? 'commander.error', message) } } program.action(() => { diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index abaf0a1108..aadcad5d0d 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -52,6 +52,8 @@ interface BenchOptions { prelive?: boolean /** Header facts for that pre-registered live Agent. */ preliveMeta?: { cwd?: string; origin?: 'subagent'; agentPreset?: string } + /** Run when the runner awaits idle, e.g. to append to the attached log. */ + onWhenIdle?: (agent: Agent) => void } const frameStates = new WeakMap; revision: number; index: number }>() @@ -147,7 +149,10 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{ }, steer: () => {}, inject: () => {}, - whenIdle: () => idle, + whenIdle: () => { + options.onWhenIdle?.(agent) + return idle + }, } await createOptions.setup?.(ownerCtx, agent) ctx.agents.register(agent) @@ -772,6 +777,24 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('rejects a preset an overlay appends while the runner awaits idle', async () => { + const test = await bench({ afterPrompt: () => {} }, { + sessionId: 'session-exact', + observe: () => Promise.resolve({ + header: { cwd: process.cwd() }, + events: [], + [Symbol.dispose]() {}, + }), + onWhenIdle: (agent) => { selectPreset(agent.session, 'minimal') }, + }) + test.ctx.sessions.create(brandString('session-exact'), { meta: { cwd: process.cwd() } }) + 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('fails when a live event below the captured Session length cannot be read', async () => { let capturedLength = 0 const test = await bench({ diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index e8697746b4..6d88fbe85c 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -24,6 +24,7 @@ import { interface Observed { exits: number[] out: string + err: string runnerConfig?: unknown } @@ -56,7 +57,7 @@ async function bootStartup( ): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) tempDirs.push(dir) - const observed: Observed = { exits: [], out: '' } + const observed: Observed = { exits: [], out: '', err: '' } writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') // Loader imports through Node's resolver, so this fixture delegates to the // source-plane plugin already imported by the test. @@ -79,8 +80,17 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx) '', ].join('\n')) const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + // Commander's own output keeps landing in `out` so existing assertions see + // the full transcript, while `err` isolates what stderr actually carried. + const observingErr = { + write: (chunk: string) => { + observed.out += chunk + observed.err += chunk + return true + }, + } cmdlineInternals.stdout = observing - cmdlineInternals.stderr = observing + cmdlineInternals.stderr = observingErr startupInternals.stdinIsTty = () => options.stdinIsTty === true startupInternals.stdout = observing const globals = globalThis as unknown as { @@ -163,6 +173,7 @@ describe('headless command-line provider', () => { message: 'a task is required, for example: dsh --profile headless "run the tests"', }) expect(task).toBeUndefined() + expect(observed.err).toBe('') expect(observed.exits).toEqual([1]) }) @@ -171,6 +182,7 @@ describe('headless command-line provider', () => { const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string } expect(first.type).toBe('error') expect(first.message).toContain('--session-id requires a non-empty session id') + expect(observed.err).toBe('') expect(observed.exits).toEqual([1]) }) @@ -179,6 +191,7 @@ describe('headless command-line provider', () => { const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string } expect(first).toEqual({ type: 'error', message: "unknown option '--bogus'" }) expect(task).toBeUndefined() + expect(observed.err).toBe('') expect(observed.exits).toEqual([1]) })