Skip to content

bug(turns): Gemini/Antigravity batch output is lost on turn timeout #411

Closed

Description

@Bindy-lbb

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/:

  1. 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.
  2. Cross-repository planning request

    • Involved six PRs/issues across digital-employee and roleweave, plus CI and design constraints.
    • run.started: 08:57:46.545Z.
    • Terminated: 08:59:41.703Z (~115.2 seconds after run.started).
    • Recorded events: one run.started, zero model.delta events.

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-json where supported.
  • Parse JSONL incrementally in qoder-engine.mjs.
  • Map assistant message chunks to model.delta as they arrive.
  • Map terminal result/error and usage events to the existing engine.v1 protocol.
  • 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

  1. changed the title [-]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[/+] on Sep 20, 2026
  2. PeterGuy326 commented on Sep 20, 2026

    @PeterGuy326
    Contributor

    分析我逐条对着 main @ ac916af 核过了,四个论断全部成立,而且比你写的更集中——补几条能直接省掉一轮摸索的事实。

    确认无误的部分

    1. 批处理 + 攒到 close 才发:apps/server/bin/qoder-engine.mjs:854(antigravity)/:856(gemini)都是 --output-format json;:905 stdout += String(chunk);:917 才 emit({type:"model.delta", ..., text: result.response})。整个 turnRunGemini 在 732–936 行。
    2. 绝对墙钟超时: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)。
    3. 措辞无条件:apps/server/src/routes/turns.ts:588-589 的 turn_timeout / turn_cancelled 都硬写 "partial output was preserved"。
    4. 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-cli 0.45.2,-o/--output-format 的 choices 明确是 text | json | stream-json。从 bundle 里挖出的事件枚举是:

    JsonStreamEventType = init | message | tool_use | tool_result | error | result
    

    具体载荷:

    • {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 计时器永远等不到"进度"。

    所以顺序必须是:

    • A(先做,独立且极小):方案 3 的诚实措辞。turns.ts:448 已经算出了 partialOutput,只是 :458 的 indeterminateMessage(result.code) 没拿到它。把 partial 是否存在传进去,分三种文案:有 partial / 无 partial / 取消。这条不依赖任何适配器改造,可以单独一个 PR 先合。
    • B:Gemini 分支切 stream-json + 能力探测 + Antigravity 兜底。
    • C:超时模型改造。而且 C 里要显式区分"首个 token 之前的长上下文/隐藏推理阶段"和"已经在流式输出但停滞"——前者需要独立的、更长的 first-token 上限,否则深推理模型照样会被 idle 判死。你 issue 里已经点到这层意思,建议直接写成两个独立配置项而不是一个 idle 值。

    补充四: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" 路径一起切过去。

  3. Bindy-lbb commented on Sep 21, 2026

    @Bindy-lbb
    CollaboratorAuthor

    Split A is now #419: it makes the timeout/cancel wording conditional on whether partialOutput exists, 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.

  4. Bindy-lbb commented on Sep 23, 2026

    @Bindy-lbb
    CollaboratorAuthor

    Resolved and verified by #419. Closing as completed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions