Repository navigation
plugin pipe: degenerate-turn one-shot retry is dead on the Gemini wire — gate eligibility + per-lane answer-accounting asymmetries (checklist) #2769
Description
来源: #2764 (fix #2756) 分析退化轮门控 (answer-text gate) 时发现
Context
PR #2764 redefines the plugin pipes' one-shot degenerate-turn retry (#732/#821) gate as "did the host receive ANSWER text?" (an answerProse accumulator alongside byte-exact visibleTextChars). While reviewing it, three unverified facets of this SAME mechanism family surfaced (per-wire gate eligibility + answer accounting, one failure domain), managed here as one checklist.
Checklist
1. The gate is unreachable on the Gemini wire (the retry machinery is dead there; pre-existing)
- The
turnTerminalassignment at the chat-pipe call site (src/plugin.ts, ~line 2915 at fix(#2756): degenerate-turn gate asks for ANSWER text, not visible bytes #2764 head) covers anthropic (message_delta) and openai (finish_reason) ONLY — there is no google branch, soretryEmptyTurn()is never invoked for protocol "google". - Even with the branch added: Gemini's terminal reason is UPPERCASE
"STOP"whileCLEAN_TURN_REASONS = {"stop","end_turn","stop_sequence"}— the reason check would refuse every retry anyway. - Impact: the [BUG] In some cases, with Codex's auto model + DeepSeek, it will suddenly terminate automatically. #732/workbuddy报错Empty stream: upstream gateway sent only placeholder chunks without any model output (chunks=0, bytes=0) #821 stall protection (and the DSH native mode: assistant text ending with </summary> is treated as a completed step, so the turn silently stops (149 samples from session transcripts) #2303 draft-tail retry, which lives inside the same function) simply does not exist on the Gemini wire. Pre-existing since those mechanisms landed; NOT introduced by fix(#2756): degenerate-turn gate asks for ANSWER text, not visible bytes #2764.
2. Google-lane answer-accounting asymmetries (must close BEFORE activating item 1)
- Fast path:
processGoogle's fast path incrementedvisibleTextCharswithout feedinganswerProse— fixed in fix(#2756): degenerate-turn gate asks for ANSWER text, not visible bytes #2764 review commit 392c7a1 plus a regression test. Left unfixed, the moment the gate reaches this lane EVERY healthy thought+prose turn would read as zero-answer and fire a spurious one-shot retry (double-billed turn, duplicated continuation). - Fold site (
processGoogle, ~line 2782): unlike the openai/anthropic lanes it never consumesdroppedTagInFrame. Today the consequence is classification-only (post-drop released bytes count intoanswerProseinstead ofreleasedMarkupChars; net gate outcome for affected turns matches master becausesawStrippedEchocovers the same turns), but the lane should reach parity in the same change that activates item 1.
3. OpenAI-fold slice-subtraction corner (#2764 new code, latent)
At the openai fold site, when droppedTagInFrame && tailLen > 0 && the folded tail is non-orphan && |tail| < |released|, answerProse = answerProse.slice(0, answerProse.length - released.length) removes bytes from the TAIL instead of from released (keeps a prefix of the released markup, drops real tail prose). Reachability needs an exotic combination (e.g. an absorb-instructed tcHold flush co-existing with a dropped tag in the same terminal frame); TagEchoFilter.flush() CAN return non-orphan prose, so the corner is reachable in principle. Fix: pin flush()'s return semantics with a targeted filter test first, then replace the subtraction with explicit branch accounting (exclude released when dropped-tag, include the tail when non-orphan).
Suggested path
- Wire item 1: add the google
turnTerminalbranch AND accept"STOP"(case-normalize or extend the set), keeping the clean-stop semantics identical to the other wires. - In the SAME change, complete item 2 parity (google fold-site
droppedTagInFrameconsumption). - Prove both directions with tests: a gemini degenerate turn (thinking-only or whitespace-only content) retries exactly once; a healthy gemini turn (thought part + real prose) never retries and logs no refusal.
- Item 3 separately, after the filter-semantics pin.
Activity
ranxianglei commentedon Oct 11, 2026
[bot] 🏷 收到。已核实:PR #2764 仍 open(head
392c7a1e5,base master),item 2 的 fast-path 修复已在该分支(392c7a1)。由于 item 1/2 依赖 #2764 的 answerProse 机制且要求「同一变更」完成,我将在2026-10-11_degenerate-gate分支上以 additive commits 继续(沿用 392c7a1 的先例),item 3 作为独立 commit 跟进。开始审代码,完成后按八问体例回复并更新 PR body(Closes #2769)。fix(#2769): reach the degenerate-turn gate on the Gemini wire
fix(#2769): account tag interiors by branch, not slice-subtraction
ranxianglei commentedon Oct 11, 2026
[bot] 🏷 #2769 三个 checklist 项全部关闭,外加接线时发现的一个小修。以 additive commit 形式推在 PR #2764 的分支上(
2026-10-11_degenerate-gate,392c7a1e5..9656dc4f7,与 review commit392c7a1e5同模式),PR 描述已更新(行为变更披露 +Closes #2769)。全量回归交给 CI。1. 是什么问题:退化轮一次重试(#732/#821/#2303)在 Gemini wire 上完全不存在——门控根本够不到;同一机制族还有两处记账不对称(google lane 不消费 dropped-tag、openai lane 用 slice 减法做 tag 内部记账),不先堵死就会在门控接通时造成误重试/漏重试。
2. 什么原因导致的:三层——① 门控调用点最初只写了 anthropic/openai 两个分支,google 分支缺失(机制先天缺位,非 #2764 引入);② Gemini 的终止原因是大写
STOP,与CLEAN_TURN_REASONS的小写集合不匹配(只加分支仍会全拒);③ 两处 fold 站点的 tag 内部记账各写各的:google 从不消费droppedTagInFrame;openai 用answerProse.slice(0, len - released.length)从字符串末尾减——但 flag 跨 stream 全局而 released/tail 是 per-stream,且 folded tail 在 released 之后还可能带非孤儿 prose,减法会砍掉真实尾部 prose、留下 markup 前缀。3. 影响范围与严重程度:
item.content[]带 reasoning 的退化轮永不重试(客户端拿到空轮) #2756 同级但面限于 Gemini wire 用户。flush()确实能返回非孤儿 prose,已用定向测试钉住),目前无生产实例。4. 怎么修复的:
turnTerminal调用点补 google 分支(镜像 openai 形状);大小写归一化放在该分支内完成(原始原因保留给日志/诊断),没有扩展共享 clean-stop 集合、也没有全局 lowercase——其他 wire 字节不变。lastChunkMeta["finishReason"]——google 的syntheticTail会把该字段重复进合成 chunk,不清的话重试中途的 flush 会把带着第一次尝试终止标记的 chunk 递给客户端。只有 google 往这个 meta 里放 finishReason(openai 的 meta 是 id/created/model,不受影响)。droppedTagInFrame,与 openai/anthropic 对齐(dropped-tag 下的 released 字节计入 markup 残量而非 answer)。TagEchoFilter.flush()语义(push 可返回非空 release 并触发 onDrop,其后的 flush 可返回非孤儿 prose),再把 slice 减法换成显式分支记账(dropped-tag 时排除 released;tail 按其自身孤儿分类计数)。5. 是否有回归问题:逐项过过既有行为——其他 wire(anthropic/openai;responses pipe 本就不在此机制面内)的门控可达性、clean-stop 判定、字节精确路径均不变;google 健康轮(thought+真 prose)永不重试且不发 refusal 日志(新测试钉住);MAX_TOKENS/SAFETY 等非干净停止照旧拒绝并记录 refusal 原因(新测试钉住);
visibleTextChars/stats/#673 warn 不变。负向对照实测:stash 掉修复后新增测试全部失败(含项 3 corner 回归),恢复后全绿。6. 是否破坏缓存:不涉及——未触碰会话状态、快照、ref 游标或任何失效策略;重试复用既有的 refetch/接管机制。
7. 是否改了设计方向:没有。沿用 #2764 确立的方向("host 有没有收到 ANSWER 文本?"),只是把同一门控语义延伸到第三条 wire,并把三处记账收敛到同一纪律。
8. 是否引入了新的配置:无。
行为变更披露(旧→新):退化轮一次重试现在会在 Gemini wire 上触发(旧:该 wire 上机制死亡,退化轮静默结束;新:恰好重试一次,clean-stop 语义与其他 wire 一致——仅 stop/end_turn/stop_sequence 合格,大写 STOP 经 google 分支归一化)。这正是本 issue 项 1 要求的激活,已在 PR 描述显著位置标注。
判定:三项全部关闭,证据齐(负向对照 + 定向套件 107 绿 + typecheck 干净),等 CI 全量门禁后由人合并。PR: #2764