fix(headless): terminal model failures reach stderr

This commit is contained in:
Tianyi Cui
2026-08-09 13:35:00 +08:00
parent 9d5eb37638
commit 63b6e18f71
11 changed files with 76 additions and 22 deletions

View File

@@ -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: 6faf1dc732345a86ec1d7b60c4f9a606e0491512
README.zh.md: a189f77f57cf0f641abe7c11288dbc1cdf05355a
README.md: fbdf80be3d587b0ef526c1a36952a761035f7312
README.zh.md: 833737e08643e0ad6b585f93fdc6ae018242b937

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`). It mounts no Host, HTTP server, Web runtime, or browser plugin.
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` seam (final `turn/end` completed → 0, otherwise 1). A successful run writes nothing to stderr and opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row.
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` seam (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row.
## Model Experience

View File

@@ -4,7 +4,7 @@
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR热模块替换、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent智能体将任务作为普通用户消息提交并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout再经启动器提供的 `ctx.headlessIo` seam 请求退出(最终 `turn/end` 完成 → 0否则为 1。成功运行时不会向 stderr 写入任何内容,也不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent智能体将任务作为普通用户消息提交并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout再经启动器提供的 `ctx.headlessIo` seam 请求退出(最终 `turn/end` 完成 → 0否则为 1最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
## 模型体验

View File

@@ -38,7 +38,7 @@ export const Config: z<Config> = z.object({
/** Outcome of one owned run interval. */
interface RunOutcome {
text: string
reason: string
reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
}
/**
@@ -63,7 +63,7 @@ declare module 'cordis' {
function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome {
let started = false
let text = ''
let reason = 'error'
let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
for (const event of events) {
if (event.seq < firstSeq) continue
if (event.type === 'turn/start') {
@@ -78,7 +78,7 @@ function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcom
.join('')
if (joined !== '') text = joined
}
if (event.type === 'turn/end') reason = event.data.reason.kind
if (event.type === 'turn/end') reason = event.data.reason
}
return { text, reason }
}
@@ -125,7 +125,10 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
await sessions.flush(agent.session)
const outcome = summarize(agent.session.events, firstSeq)
io.stdout.write(outcome.text + '\n')
io.exit(outcome.reason === 'completed' ? 0 : 1)
if (outcome.reason?.kind === 'error') {
io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
}
io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
}
/**

View File

@@ -151,6 +151,27 @@ describe('headless runner', () => {
await test.ctx.fiber.dispose()
})
it('prints the durable model failure when the final turn ends in error', async () => {
const test = await bench({
afterPrompt(session, message) {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,
reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } },
})
},
})
expect(await test.run()).toMatchObject({
code: 1,
out: '\n',
err: 'dsh: SERVER: provider unavailable\n',
})
await test.ctx.fiber.dispose()
})
it('exits 1 when the owned interval contains no turn', async () => {
const test = await bench({ afterPrompt: () => {} })
expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })