Repository navigation
bug(turns): Gemini/Antigravity batch output is lost on turn timeout #411
Description
Problem Summary
When users run complex workbench turns with deep-reasoning Gemini/Antigravity models and large workspace context, the turn can exceed the default 120-second engine budget and end with:
{
"code": "turn_timeout",
"message": "the turn exceeded its time budget and was terminated; partial output was preserved",
"retryable": true
}In the observed failures, the desktop UI receives no model.delta events and displays no partial response. The message therefore claims that partial output was preserved even when the turn record contains no output.
Root Cause
The failure is caused by the interaction of batch CLI output, adapter-side buffering, and an absolute turn timeout.
1. Gemini/Antigravity runs in batch JSON mode
In apps/server/bin/qoder-engine.mjs:
args = ["--prompt", prompt, "--output-format", "json", "--mode=plan", "--disable-slash-commands"];The Gemini branch likewise uses --output-format json.
The adapter accumulates the child process's stdout and does not emit the response as model.delta until the process closes successfully:
let stdout = "";
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.on("close", async (code) => {
// ...
const result = JSON.parse(stdout);
emit({ type: "model.delta", runId, timestamp: now(), text: result.response });
});Consequently, the control plane cannot preserve visible response text produced before a timeout unless the CLI and adapter expose it incrementally.
2. The turn driver enforces an absolute timeout
DigitalEmployeeCliDriver defaults to 120_000 ms (configurable through ORG_WORKBENCH_TURN_TIMEOUT_MS). Its timer is wall-clock based and is not reset by engine activity:
const timer = setTimeout(() => {
if (settled) return;
timedOut = true;
acceptingOutput = false;
stdoutBuffer = "";
terminateChild();
}, this.timeoutMs);If the batch CLI has not completed by then, the process group is terminated and the result is classified as status: "indeterminate", code: "turn_timeout".
3. The partial-output message is unconditional
apps/server/src/routes/turns.ts always maps turn_timeout to:
the turn exceeded its time budget and was terminated; partial output was preserved
However, extractPartialOutput() returns undefined when the event list contains no model.delta. The message does not reflect whether any partial output actually exists.
Observed Evidence
From local turn records under .digital-employee/workbench/sessions/conversations/:
-
Simple prompt (
"hi") with a large injected context- Input context: 96,564 tokens.
- Recorded output-token usage: 2,045 tokens, including model reasoning usage.
- Duration: 41.4 seconds.
- Visible result: one short greeting.
-
Cross-repository planning request
- Involved six PRs/issues across
digital-employeeandroleweave, plus CI and design constraints. run.started:08:57:46.545Z.- Terminated:
08:59:41.703Z(~115.2 seconds afterrun.started). - Recorded events: one
run.started, zeromodel.deltaevents.
- Involved six PRs/issues across
The ~115-second event interval is compatible with a 120-second outer timeout because the timeout starts before the adapter emits run.started; process startup and context preparation consume part of the budget.
These records prove that no visible partial output reached RoleWeave. They do not establish how much final-answer text, as distinct from hidden reasoning tokens, had already been generated inside the provider.
Proposed Remediation
1. Stream observable CLI events
- Use
--output-format stream-jsonwhere supported. - Parse JSONL incrementally in
qoder-engine.mjs. - Map assistant message chunks to
model.deltaas they arrive. - Map terminal result/error and usage events to the existing
engine.v1protocol. - Verify Gemini CLI and Antigravity independently; do not assume that both clients expose identical flags or event schemas.
- Keep a safe fallback or return an attributable compatibility error when the installed client does not support the required streaming protocol.
2. Use bounded activity-aware timeouts
- Introduce an idle timeout that is reset only by valid, observable engine progress.
- Keep a longer maximum wall-clock timeout so a stuck or trickle-output process cannot run indefinitely.
- Do not treat adapter-generated heartbeats alone as evidence of model progress.
- Keep
ORG_WORKBENCH_TURN_TIMEOUT_MS(or equivalent separate idle/max settings) configurable.
Streaming improves preservation of visible output, but it does not guarantee frequent events while a provider is producing hidden reasoning tokens. Timeout defaults therefore need to accommodate long first-token latency as well as ongoing streamed output.
3. Report the actual preservation outcome
- If
extractPartialOutput()returns text, report that partial output was preserved. - If no delta exists, report that the turn ended before any visible output could be committed.
- Apply the same conditional wording to cancellation where appropriate.
Acceptance Criteria
- Supported Gemini/Antigravity clients are consumed incrementally instead of waiting for a complete batch JSON response.
- Client capability and event-schema handling are covered separately for Gemini CLI and Antigravity.
- Assistant text received before timeout or cancellation is emitted as
model.delta, displayed in the UI, and persisted in the turn record. - Timeout handling distinguishes observable inactivity from the maximum allowed wall-clock duration.
- Timeout defaults allow realistic long-context reasoning and first-token latency without permitting unbounded execution.
- Timeout and cancellation messages state that partial output was preserved only when partial output actually exists.
- Tests cover: streamed deltas followed by timeout, timeout before the first delta, cancellation after partial output, unsupported streaming capability, and maximum-duration termination.
Activity
[-]bug(turns): batch JSON print mode (--output-format json) in qoder-engine causes turn_timeout on thinking models[/-][+]bug(turns): Gemini/Antigravity batch output is lost on turn timeout[/+]PeterGuy326 commentedon Sep 20, 2026
分析我逐条对着
main@ac916af核过了,四个论断全部成立,而且比你写的更集中——补几条能直接省掉一轮摸索的事实。确认无误的部分
apps/server/bin/qoder-engine.mjs:854(antigravity)/:856(gemini)都是--output-format json;:905stdout += String(chunk);:917才emit({type:"model.delta", ..., text: result.response})。整个turnRunGemini在 732–936 行。apps/server/src/engine/driver-cli.ts:789那个setTimeout是唯一一个,只在 settle 时clearTimeout(824/834),没有任何活动重置。默认值来自apps/server/src/config.ts:99-102(ORG_WORKBENCH_TURN_TIMEOUT_MS??120000)。apps/server/src/routes/turns.ts:588-589的turn_timeout/turn_cancelled都硬写 "partial output was preserved"。extractPartialOutput会返回 undefined:turns.ts:601-605,没有model.delta就是undefined。补充一:Gemini/Antigravity 是唯一还在批处理的适配器
同一个文件里
turnRunQoder已经用-o stream-json(:1039),turnRunClaude也用--output-format stream-json(:1217),两边都在流式过程中直接emit model.delta(:1102、:1289、:1299)。所以这不是"要不要引入流式"的问题,而是 Gemini 分支漏掉了仓库里已有的既定模式——改造有现成参照,reviewer 也不需要接受新范式。补充二:Gemini CLI 侧的流式能力已确认(0.45.2)
本机装的
@google/gemini-cli0.45.2,-o/--output-format的 choices 明确是text | json | stream-json。从 bundle 里挖出的事件枚举是:具体载荷:
{type:"init", timestamp, session_id, model}{type:"message", timestamp, role:"user"|"assistant", content, delta:true}← 助手正文就是靠这个逐块出来的{type:"error", timestamp, severity:"warning"|"error", message}{type:"result", timestamp, status:"success"|"error", error?:{type,message}, stats}一个会直接踩的坑:stream 模式下
result事件不带response字段,正文全部走message增量。现在适配器JSON.parse(stdout)后读result.response的写法必须换成累积role==="assistant"的message.content,否则会从"超时丢输出"变成"成功也丢输出"。usage 仍然在
result.stats,由convertToStreamStats(metrics, durationMs)产出:per-model 是 snake_case(total_tokens/input_tokens/output_tokens/cached/input),聚合层是 camelCase(totalTokens/inputTokens/...)。现有usage.input_tokens ?? usage.inputTokens的双写兜底刚好能吃下,这块不用改。Antigravity 我没法确认——这台机器上没装(
antigravity not found)。你 issue 里写的"两个 client 分别验证、别假设 flag 和事件 schema 一致"是对的,而且必须做成能力探测 + 可归因的 compatibility error,不能靠 try 出来。补充三:修复顺序有硬依赖,反了会更糟
你的方案 2(idle timeout 只被可观测进度重置)如果先于方案 1 落地,会把每个 Gemini/Antigravity 回合都变成 idle 超时——因为批处理适配器在 close 之前一个字节的引擎事件都不发,idle 计时器永远等不到"进度"。
所以顺序必须是:
turns.ts:448已经算出了partialOutput,只是:458的indeterminateMessage(result.code)没拿到它。把 partial 是否存在传进去,分三种文案:有 partial / 无 partial / 取消。这条不依赖任何适配器改造,可以单独一个 PR 先合。stream-json+ 能力探测 + Antigravity 兜底。补充四:
stdoutBuffer = ""那行的语义driver-cli.ts:789超时回调里除了acceptingOutput = false还有stdoutBuffer = "",已缓冲的字节直接丢弃。对批处理适配器无所谓(JSON 不完整本来就没用),但切到 stream-json 之后,已解析并发出去的model.delta事件不会被这行影响(它们在events里),所以方案 A 的诚实措辞在 B 落地后会自然变成"确实保住了 partial"。另外stdoutBytes的累计不受清空影响,MAX_TURN_OUTPUT_BYTES的语义保持不变——这点是好的。顺带一个要主动决定的点:
qoder-engine.mjs:906那个 4MB 上限(gemini.output_limit)现在量的是"原始 JSON 字节",切流式之后累积对象变成助手正文,同一个阈值量到的东西不一样了。要么显式改注释说明新语义,要么给流式单独定一个上限。结论
issue 定级
bug合适,根因判断正确,验收标准也够具体。建议按 A → B → C 拆三个 PR,A 我可以立刻安排。B 的 Antigravity 能力探测这块需要一台装了 Antigravity 的机器才能收口,别在没探测能力的情况下先把 gemini 分支的client === "antigravity"路径一起切过去。Bindy-lbb commentedon Sep 21, 2026
Split A is now #419: it makes the timeout/cancel wording conditional on whether
partialOutputexists, with both positive and negative-path coverage. It deliberately does not change the adapter or timer model; Gemini stream-json remains the next dependent PR, and idle/first-token timeout work stays after that.Bindy-lbb commentedon Sep 23, 2026
Resolved and verified by #419. Closing as completed.