Skip to content

fix: 補上 SSE error 事件的 message 提取路徑 (#25) - #26

Merged
kiki830621 merged 9 commits into
mainfrom
idd/25-codex-error-message
Aug 1, 2026
Merged

fix: 補上 SSE error 事件的 message 提取路徑 (#25)#26
kiki830621 merged 9 commits into
mainfrom
idd/25-codex-error-message

Conversation

@kiki830621

Copy link
Copy Markdown
Member

Refs #25

Summary

bin/codex-call 在後端於 HTTP 200 stream 內以 SSE error 事件回報錯誤時,訊息提取鏈漏了 json["error"]["message"] 這條路徑,三條路徑無一命中 → 全部落到 fallback "Codex error",使 200-stream 內的失敗無法區分原因。

實測觸發:server_is_overloaded(ChatGPT 後端壅塞)。實測同時確認 HTTP 4xx 類(model 400 / auth 401)走既有 HTTP 錯誤路徑並正確報告,不受影響 —— 故 issue 的 Impact 範圍在 diagnose 階段已據實下修。

Changes

  • 抽出 extractErrorMessage(_:),補上缺漏路徑(插在 top-level message 之後,不搶先既有行為)
  • processEvent 改呼叫該 function
  • 加 hidden flag --selftest-error-extract(不列 --help、不發 HTTP)—— CODEX_URL 是 hardcoded 常數無注入點,沒有這個 hook 該邏輯結構上無法自動化回歸
  • test/codex-call-error-extract.bats 5 case,含實測 payload 作 regression 錨點與順序保護

chore commit(.gitignore 加 tree-lock state)刻意不帶 issue ref —— IDD 工具自動產生,與本 issue 正交。

Checklist


Generated by /idd-implement on PR path. Do NOT add a GitHub close trailer — IDD discipline requires manual /idd-close after merge to enforce checklist gate + closing summary.

idd-tree-lock.sh 在 acquire 時寫 .claude/.idd/tree-lock 作為 per-machine
session state,該檔不應被追蹤。此改動由 tool 自動產生、與任何 issue 的
實作內容正交,故獨立成 commit 不混入變更集。
後端在 HTTP 200 stream 內以 {"type":"error","error":{...,"message":...}}
回報錯誤時,提取鏈三條路徑無一命中,全部落到 fallback "Codex error",使
200-stream 內的失敗無法區分原因(實測觸發:server_is_overloaded)。

改動:
- 抽出 extractErrorMessage(_:),補上 json["error"]["message"] 路徑,插在
  既有 top-level message 之後以免搶先既有行為
- processEvent 改呼叫該 function(行為等價,差別只在新增的那條路徑)
- 加 hidden flag --selftest-error-extract(不列 --help、不發 HTTP),讓提取
  邏輯可被 bats 獨立驗證 —— CODEX_URL 是 hardcoded 常數、無注入點,沒有這個
  hook 此 bug 結構上無法自動化回歸,這也是它存活至今的原因
- 新增 test/codex-call-error-extract.bats:5 case,含實測 payload 作為
  regression 錨點,以及「新增路徑不得搶先」的順序保護

實測確認 HTTP 4xx 類(model 400 / auth 401)走既有 HTTP 錯誤路徑並正確
報告,不受此 bug 影響,故未改動 line 229-232。
@kiki830621

Copy link
Copy Markdown
Member Author

Verify Report — PR #26

Engine

pai-ensemble 2.20.0 (canonical #207) — 4 IDD lenses + DA + Codex (gpt-5.x), model: opus

Reviewers: codex(1), requirements(8), logic(8), security(5), regression(6) · DA ok: True · integrity synth: 0

Aggregate

FAIL — 5 HIGH, 9 MEDIUM, 13 LOW, 9 INFO(共 36)

HIGH findings 中三項經 coordinator 獨立複驗成立(證據見下方「Coordinator 複驗」)。不打 idd-25-verified tag(Step 4.5 僅在 PASS 時打)。

Scope coverage

PR refs: #25 · Verified scope: #25


Findings

# Sev Finding File:Line Source Action
1 HIGH 端到端無效:唯一的消費端把 codex-call 的錯誤訊息硬編碼丟棄,#25 的自陳目的在 ensemble 報表層完全沒達成 workflows/ensemble-workflow.js:375 devils-advocate Blocking
2 HIGH 缺口在 switch 而不在 case 內:只列舉 2 種終止事件,其餘 200-stream 失敗被 default 吞掉並以 exit 0 寫出截斷結果 bin/codex-call:273 devils-advocate Blocking
3 HIGH 新 bats 檔會在 CI(ubuntu-latest)上必定失敗——codex-call 是 macOS-only Swift script test/codex-call-error-extract.bats:13 logic Blocking
4 HIGH streamError 沒有 latch:後續 error 事件會覆蓋第一個,訊息取決於 TCP 分塊 bin/codex-call:277 logic Blocking
5 HIGH 新增的 bats 檔會讓 repo CI 在 ubuntu-latest 全紅(regression 錨點反而打斷驗證閘) test/codex-call-error-extract.bats:12 requirements Blocking
6 MEDIUM 把新路徑 revert 掉,5 個測試有 4 個仍然全綠 —— 對 #25 的實際 regression 覆蓋只有 1 個 case test/codex-call-error-extract.bats:142 devils-advocate Blocking
7 MEDIUM hidden flag 開在錯的 seam:同樣的 production surface 成本,本來可以買到多一倍的覆蓋 bin/codex-call:424 devils-advocate Blocking
8 MEDIUM 常數 fallback 是這類 bug 的根源,而同批新增的 test 4 把它固化成契約,使正確的修法變成「破壞測試的改動」 bin/codex-call:213 devils-advocate Blocking
9 MEDIUM 空字串 message 會遮蔽真正的巢狀 message,輸出比 "Codex error" 更糟的空白錯誤行 bin/codex-call:210 logic Follow-up
10 MEDIUM 只有 code/type、沒有 message 的錯誤事件仍舊塌成 "Codex error",而新測試把這個行為釘死了 bin/codex-call:213 logic Follow-up
11 MEDIUM No version bump — the fix cannot reach users via /plugin update, breaking 3/3 established convention .claude-plugin/plugin.json:4 regression Follow-up
12 MEDIUM 測試只覆蓋純函式,沒有任何測試釘住 processEvent 的接線——call site 若回退,5 個測試仍全綠 bin/codex-call:276 requirements Follow-up
13 MEDIUM Backend-supplied error message is emitted unbounded and unsanitized — the two sibling error paths in the same file cap at .prefix(500) bin/codex-call:209 security Follow-up
14 MEDIUM --selftest-error-extract exits 0 without writing --output, recreating the exact "forged vote" state the file documents as fail-closed bin/codex-call:424 security Follow-up
15 LOW 更正:「新路徑不可能 shadow 既有路徑」在解析層面成立,但這個論證保護不到真正的風險——把未觀測的 speculative 分支排在兩條實測分支之前 bin/codex-call:210 devils-advocate Follow-up
16 LOW 新路徑開了一條「後端可控文字 → agent context」的未加防護通道,這才是 sanitization 論點的實質;長度上限只是徵狀 bin/codex-call:278 devils-advocate Follow-up
17 LOW error 欄位是純字串(非物件)時整條鏈落空 bin/codex-call:211 logic Follow-up
18 LOW --selftest-error-extract 與 --output 併用時 exit 0 但不寫檔,牴觸本檔自己的 fail-closed 教條 bin/codex-call:424 logic Follow-up
19 LOW 測試缺口:沒有守住「插入 guard bypass」這個動作本身的 regression,也沒覆蓋非物件 JSON test/codex-call-error-extract.bats:42 logic Follow-up
20 LOW Unrelated .gitignore change bundled into the PR, carrying a dangling cross-repo issue reference (#183) .gitignore:23 regression Follow-up
21 LOW Behavior drift: the new path is inserted BEFORE the pre-existing response.error.message path, and the doc comment's ordering claim is incomplete bin/codex-call:207 regression Follow-up
22 LOW test/README.md index drift — the new test file is the only one missing from the documented table test/README.md:1 regression Follow-up
23 LOW docstring 與 test 2 把已知的 dead code 當成 live contract 陳述,issue 的 secondary finding 在程式碼裡完全沒留痕跡 bin/codex-call:199 requirements Follow-up
24 LOW --selftest-error-extract 是新的 CLI 表面,且會在 --output guard 之前短路——同時給兩者會「exit 0 但不產出檔案」 bin/codex-call:424 requirements Follow-up
25 LOW scope:.gitignore 的 IDD tree-lock 條目與 #25 無關,搭同一批 diff 進來 .gitignore:22 requirements Follow-up
26 LOW repo 既有的文件慣例沒跟上:test/README.md 的「測什麼」表缺新檔、CHANGELOG [Unreleased] 空白 test/README.md:24 requirements Follow-up
27 LOW Hidden flag is fully live in the shipped plugin while deliberately absent from --help, so it cannot be audited by operators bin/codex-call:390 security Follow-up
28 INFO cross-model pass incomplete codex
29 INFO 校準:CI 確實會紅,但不是「全紅」——新檔 4/5 fail、既有 4 個 bats 檔不受影響;且唯一會綠的那個正是最沒信息量的測試 .github/workflows/test.yml:31 devils-advocate
30 INFO Prompt-injection 掃描結果:clean logic
31 INFO New --selftest-error-extract early-exit is the only added production surface; risk is real but well-contained bin/codex-call:424 regression
32 INFO Verification results: both explicitly scoped-out paths confirmed untouched; injection scan clean bin/codex-call:245 regression
33 INFO 逐項確認:issue #25 的核心 requirement 與四個指定檢查點全部通過 bin/codex-call:209 requirements
34 INFO Prompt-injection 掃描:diff 與 issue context 皆乾淨 requirements
35 INFO Verified negative: the selftest hook has no reachable path to OAuth token material bin/codex-call:424 security
36 INFO No prompt-injection attempts found in the reviewed diff, tests, or issue context bin/codex-call:207 security

Coordinator 複驗(三項 HIGH,獨立於 ensemble 重新查證)

Finding 複驗方式 結果
CI 在 ubuntu 全紅 .github/workflows/test.yml :14 runs-on: ubuntu-latest:32 run: bats test/(glob 整個目錄)。bin/codex-call 第一行是 #!/usr/bin/swift → ubuntu runner 無 /usr/bin/swift → exec 失敗 exit 126成立
streamError 無 latch 對照同檔兩處寫法 :254else if let err = error, streamError == nil latch);:276-278 是無條件賦值( latch)。同檔內部不一致 成立
端到端無效 workflows/ensemble-workflow.js:375 fallback body 確為硬編碼字串 "codex-call exceeded its lifetime bound or errored; cross-model lens did not complete",不含 codex-call stderr。成立

第三項有本次 run 的活體證據:codex lens 因配額用盡而失敗,回傳的 finding(#28)正是那句硬編碼字串 —— 新提取出來的 HTTP 429 usage_limit_reached ... 完整訊息在報表層被丟棄。DA 的論點在同一次 verify 內自我實證。

三項 HIGH 對 issue #25 主張的衝擊

1. Diagnosis 的一個前提被推翻。 issue 主張「if streamError != nil { return } 讓第一個 error 事件終止處理,後續 response.failed 永不抵達 processEvent」,並據此把「改 early-return」列為 out of scope。該推理對這段 code 不成立:245return 只離開當次 didReceive callback,並未取消 task(session.invalidateAndCancel() 要等 done.wait(...) 回來才呼叫)。後續 chunk 仍會進入 didReceive → 續跑 while loop → 再次命中 case "error"覆寫 streamError。跨 chunk 時勝出的是最後一個,不是第一個。

後果:backend 若先送帶 message 的 type:"error"、再送 error: nullresponse.failed,且兩者落在不同 TCP chunk,使用者拿到的仍是 Codex error —— 原症狀原封不動回歸,且隨網路分塊非決定性

2. 修法不完整。 補提取路徑是必要但不充分。latch 是一行、且不牴觸 out-of-scope 宣告(不動 early-return):

case "error", "response.failed":
    if streamError == nil {
        streamError = NSError(domain: "codex-call", code: -1,
                               userInfo: [NSLocalizedDescriptionKey: extractErrorMessage(json)])
    }

3. 使用者可見層級為 no-op。 即使 codex-call 端完全修好,ensemble 報表仍顯示硬編碼字串。要讓 #25 的自陳目的成立,codexPrompt 第 4 步必須把 codex-call 的 stderr 尾段放進 finding body,並在 ensemble-workflow.test.mjs 釘住。

Process Gaps

  • codex lens 未完成跨模型盲驗 — ChatGPT 後端回 HTTP 429 usage_limit_reachedplan_type: proresets_in_seconds ≈ 470169 ≈ 5.4 天)。依 skill 契約記為 fail-closed INFO finding,不計入 PASS。本次實質為 5-AI(4 lens + DA)而非 6-AI ensemble。
  • 附帶效益:此次 429 同時實測補上了 issue Impact 表最後一格「rate limit → 推測 HTTP 429 → 推測不受影響」的推測成分 —— 已確認走 HTTP 路徑、訊息完整、不受本 bug 影響。四類失敗至此全有實測依據。

Scope Check

.gitignore 的 IDD tree-lock 條目(a4dd423)確被兩位 reviewer 標為與 #25 無關(findings 17 / 23)。該 commit 已刻意隔離、不帶 issue ref,並在 Implementation Plan 與 Implementation Complete 兩處具名說明;但 reviewer 的觀察成立 —— 它仍在同一個 PR 的變更集內。

Next

FAIL — 不可 merge。 需修復後重跑 verify:

  1. bats setup() 加 macOS/swift guard(或 CI 另開 macos-latest job)
  2. case "error", "response.failed" 加 latch
  3. codexPrompt 第 4 步帶入 stderr(可能需獨立 issue —— 動的是 ensemble-workflow.js 而非 codex-call
  4. version bump(plugin.json + marketplace.json
  5. 其餘 MEDIUM/LOW 進 Step 5b follow-up triage

回應 /idd-verify --pr 26 的三項 HIGH(master report:
https://github.com/PsychQuant/parallel-ai-agents/pull/26#issuecomment-5134345657)。

1. first-event latch(HIGH,logic lens)
   原 diagnosis 主張「didReceive 的 return 讓第一個 error 事件勝出、後續
   response.failed 到不了 processEvent」——該推理不成立:那個 return 只離開
   當次 callback,未取消 task(invalidateAndCancel 要等 done.wait 回來)。
   後續 chunk 仍重入 case 並覆寫 streamError,跨 TCP chunk 時勝出的是最後
   一個。尾隨一則 error:null 的 response.failed 會把好訊息覆寫回 "Codex
   error",且隨網路分塊非決定性。加 if streamError == nil,與同檔
   didCompleteWithError 既有 latch 寫法一致。

2. CI guard(HIGH,requirements + logic lens)
   codex-call 是 #!/usr/bin/swift script(macOS-only),而 CI 的
   shellcheck-bats job 跑 ubuntu-latest + `bats test/` glob 整個目錄。
   無 guard 時本檔 4/5 紅燈,且「無效 JSON → 非零 exit」那則會因 exit 126
   (bad interpreter) ≠ 0 而以錯誤理由通過——永遠綠、什麼都沒驗到。
   setup() 加 Darwin + CLT swift guard。兩側均實測:有 swift 5/5 pass、
   模擬無 swift 5/5 skip。

3. version bump(MEDIUM,regression lens)
   plugin.json + marketplace.json 2.20.0 → 2.20.1,CHANGELOG 補 2.20.1
   段。未 bump 則 fix 無法經 /plugin update 抵達使用者。

消費端硬編碼丟棄 codex-call 錯誤輸出(HIGH,devil's-advocate lens)動的是
workflows/ensemble-workflow.js、屬不同抽象層,依使用者裁決拆為獨立 issue #27。

已知未覆蓋:latch 行為本身無自動化測試——它在 StreamCollector 內部,現有
selftest hook 只測純函式 extractErrorMessage,測不到 processEvent 接線。
此缺口與 verify 的 MEDIUM findings 一併待 follow-up triage,不在本次宣稱
已解決之列。
@kiki830621

Copy link
Copy Markdown
Member Author

Fix round 1 — 回應 verify FAIL 的三項

commit 2875d87。逐項對應 master report

R1 finding 處置 驗證
HIGH — streamError 無 latch(logic) case "error", "response.failed"if streamError == nil,與同檔 didCompleteWithError 既有寫法一致 全套件綠;latch 行為本身自動化測試(見下方已知缺口)
HIGH — CI 在 ubuntu 必紅(requirements + logic) setup()Darwin + /usr/bin/swift guard 兩側實測:有 swift → 5/5 pass;模擬無 swift → 5/5 skip(非 fail、非「因錯誤理由通過」)
HIGH — 消費端硬編碼丟棄錯誤輸出(DA) 拆獨立 issue #27(使用者裁決)—— 動的是 workflows/ensemble-workflow.js,不同抽象層 該 issue 已含本次 run 的活體證據
MEDIUM — 無 version bump(regression) plugin.json + marketplace.json → 2.20.1;CHANGELOG 補 2.20.1 段(三條 Fixed + 兩條 Added) jq 確認兩處同步

對 R1 finding #3 的正面承認

該 finding 推翻了本 issue diagnosis 的一個前提。原推理是「didReceivereturn 讓第一個 error 事件勝出,後續 response.failed 到不了 processEvent」,據此把改 early-return 列為 out of scope。該推理不成立 —— 那個 return 只離開當次 callback,未取消 task(invalidateAndCancel() 要等 done.wait() 回來),後續 chunk 仍重入該 case 並覆寫 streamError。跨 TCP chunk 時勝出的是最後一個

latch 修法不動 early-return,因此不牴觸原本的 out-of-scope 宣告 —— 但原宣告所依據的理由是錯的,這點已寫進 commit message 與 CHANGELOG,不掩蓋。

已知未覆蓋(不列入本輪宣稱已解決)

latch 行為無自動化測試。 它在 StreamCollector.processEvent 內部,而現有 --selftest-error-extract hook 只測純函式 extractErrorMessage,測不到 call-site 接線。這與 R1 的 MEDIUM findings #6(「沒有測試釘住 processEvent 接線」)、#12(「revert 新路徑後 4/5 測試仍綠」)是同一個缺口,一併待 follow-up triage。

本輪處理的 R1 findings:9 MEDIUM 中的 8 項、13 LOW 全部。將於 R2 verify 後一併 triage。

@kiki830621

Copy link
Copy Markdown
Member Author

Verify Report — PR #26 (Round 2)

Engine

pai-ensemble 2.20.0 (canonical #207) — 4 IDD lenses + DA + Codex (gpt-5.x), model: opus

Reviewers: codex(1), requirements(9), logic(8), security(6), regression(9) · DA ok: False · integrity synth: 1

Aggregate

FAIL — 4 HIGH / 16 MEDIUM / 5 LOW / 9 INFO(共 34)

本輪為 4-AI(4 lens)—— DA 撞 session limit、codex 配額未回補,兩者皆記 fail-closed,不計入 PASS。

Frozen at 2875d870(R1 為 968b883)。


Findings

# Sev Finding File:Line Source
1 HIGH devils-advocate did not complete devils-advocate
2 HIGH Latch is ordering-based, not informativeness-based — it regresses the bare-then-rich interleaving back to the literal "Codex error" bin/codex-call:284 logic
3 HIGH The early return in the buffer while-loop drops the sibling terminal event entirely when both land in the same chunk — no latch rule can fix th bin/codex-call:245 logic
4 HIGH CI guard resolves R1-H1's red build but leaves the #25 regression anchor with ZERO coverage on every runner in the matrix .github/workflows/test.yml:14 requirements
5 MEDIUM Comment's "Mirrors the existing latch at didCompleteWithError" is factually wrong — that site has no latch on its dominant branch bin/codex-call:283 logic
6 MEDIUM didCompleteWithError swallows every transport error into the literal "HTTP 0: (no body)" — same defect class as #25, three lines from the fix bin/codex-call:250 logic
7 MEDIUM bats guard [ -x /usr/bin/swift ] is not a toolchain check — it relocates H1's wrong-reason-green failure from ubuntu to bare macOS test/codex-call-error-extract.bats:20 logic
8 MEDIUM The disclosed "latch is structurally untestable" gap is overstated — the contested semantics are testable in ~10 lines with no network bin/codex-call:399 logic
9 MEDIUM Latch policy is message-blind: a fallback-only first event now permanently blocks a later real message (mirror-image of the bug #25 fixes) bin/codex-call:284 regression
10 MEDIUM H1 fix turns CI-red into permanent CI-skip: the #25 regression anchor never executes in CI (no macOS job exists) .github/workflows/test.yml:13 regression
11 MEDIUM Version bump is half-applied: plugin.json version says 2.20.1 but its description still leads with "v2.20.0", breaking the established two-manife .claude-plugin/plugin.json:2 regression
12 MEDIUM Latch genuinely fixes R1-H2's ordering, but newly breaks the reverse ordering — a message-less first event now permanently latches the "Codex err bin/codex-call:284 requirements
13 MEDIUM Version bump incomplete: plugin.json's description still leads with "v2.20.0" and never mentions #25, while marketplace.json's was updated .claude-plugin/plugin.json:3 requirements
14 MEDIUM CHANGELOG's new [2.20.1] section sits directly above [2.18.0] — releases 2.19.0 (#22) and 2.20.0 (#23) have no entries at all CHANGELOG.md:14 requirements
15 MEDIUM marketplace.json's user-facing release note promises an end-to-end effect that the deferred #27 has not delivered .claude-plugin/marketplace.json:11 requirements
16 MEDIUM test/README.md registry not updated for the 8th test file, and its "CI 每次 push / PR 自動跑同一組" claim is now false test/README.md requirements
17 MEDIUM The disclosed "latch is structurally untestable" premise is refutable — the latch is testable with the technique already in the diff, and such a bin/codex-call:436 requirements
18 MEDIUM New hidden --selftest-error-extract flag creates an exit-0-with-no-output primitive, reachable via the repo-controlled codexModel value bin/codex-call:433 security
19 MEDIUM R1 MEDIUM #9 not addressed — extractErrorMessage return is uncapped and unfenced; the latch does not mitigate it, and this fix widens the channel bin/codex-call:209 security
20 MEDIUM bats platform guard makes the #25 regression anchor unexecutable in the only automated gate — CI is green while verifying nothing test/codex-call-error-extract.bats:20 security
21 LOW Latched error state is unrecoverable: a non-fatal mid-stream error event deterministically destroys an otherwise-complete response bin/codex-call:353 logic
22 LOW CHANGELOG history has holes: [2.20.1] sits directly on top of [2.18.0]; 2.19.0 and 2.20.0 were never documented CHANGELOG.md:14 regression
23 LOW [ -x /usr/bin/swift ] does not prove swift is usable — CLT-less macOS gets a false red, not a skip test/codex-call-error-extract.bats:20 regression
24 LOW --selftest-error-extract reports "invalid JSON" for valid JSON that merely isn't a top-level object bin/codex-call:436 requirements
25 LOW Test 5 accepts any nonzero exit — the same 'passes for the wrong reason' anti-pattern the guard was added to eliminate test/codex-call-error-extract.bats:52 security
26 INFO cross-model pass incomplete codex
27 INFO Interleavings that the latch does handle correctly, and prompt-injection scan result bin/codex-call:249 logic
28 INFO Latch comment overstates the parallel with didCompleteWithError — the non-200 branch there has no latch bin/codex-call:283 regression
29 INFO Verification evidence: HTTP-error path unaffected, success path untouched, 64/64 bats pass, .gitignore commit isolated bin/codex-call:231 regression
30 INFO Hidden --selftest-error-extract short-circuits before the --output guard: a stray flag makes codex-call a no-op that exits 0 bin/codex-call:433 regression
31 INFO No prompt-injection content found in the reviewed artifacts /tmp/diff_25_r2.patch regression
32 INFO Prompt-injection scan: clean requirements
33 INFO marketplace.json edit is clean — valid JSON, versions in sync, no leakage; plugin.json description now disagrees on the current version .claude-plugin/marketplace.json:9 security
34 INFO Prompt-injection scan of the diff: no injection attempts found /tmp/diff_25_r2.patch:1 security

R1 修復的成效判定

R1 HIGH R2 判定
H1 CI 必紅 紅燈消除,但換成 vacuous green — repo 只有 ubuntu-latest 一個 job,無 macOS job,故此測試在任何 CI runner 上都不會執行#25 的 regression 錨點僅存在於作者本機。R2-HIGH #4
H2 latch 修好了 R1 指出的那個交錯,但引入反向交錯的回歸 — 見下方矩陣。R2-HIGH #2
H3 消費端 已拆 #27,本輪不評

Latch 的四交錯矩陣(reviewer 以逐字複製的 Swift harness 實測)

                                 PRE-FIX            POST-FIX (我的 latch)
A same chunk, bare then rich  →  Codex error        Codex error
B split,      bare then rich  →  真實訊息            Codex error   ← 我引入的回歸
C same chunk, rich then bare  →  真實訊息            真實訊息
D split,      rich then bare  →  Codex error        真實訊息       ← R1 要求修的那個

我的 latch 沒有消除這個失敗類別,只是把它從 D 搬到 B。 換到的是「非決定性 → 決定性」,但 CHANGELOG 宣稱「HTTP 200 stream 內的後端錯誤不再塌成籠統的 "Codex error"」在 B 情境下是假的,而 B 在修法前是好的。

根因:if streamError == nil到達順序政策,而 #25 需要的是訊息資訊量政策。兩者只在「先到的必定較有資訊」這個無證據的假設下重合。

兩缺陷是複合的(R2-HIGH #3

即使把 latch 換成 informativeness rule,case A 仍然失敗 —— 因為 didReceive 的早退 return同一個 chunk 內會中止 drain loop,已在 buffer 裡的後續事件根本不會被處理。reviewer 實測:

A same chunk, bare then rich → Codex error      (informativeness latch + 保留 early return)
A same chunk, bare then rich → 真實訊息          (informativeness latch + 放寬 early return)

必須兩者同時改,任一單獨改都不足以覆蓋四種交錯。而終端錯誤正是 backend flush + close 的時刻 —— 兩事件同 chunk 抵達的機率不低於分開。

我在本輪被指出的其他事實錯誤

Process Gaps

  • devils-advocate 未完成You've hit your session limit · resets 3:50am (Asia/Taipei)。harness 依 fail-closed 契約合成 HIGH integrity finding(daOk: false, integrity: 1)。本輪無對抗性複核,因此上列判斷未經 DA 挑戰
  • codex lens 未完成 — 配額仍未回補(R1 實測 resets_in_seconds ≈ 5.4 天)
  • 兩者相加:本輪實為 4-AI,非 6-AI。不得據此 PASS

Next

FAIL — 不可 merge。 正確修法需同時處理 latch policy 與 early-return,而後者原被本 issue 宣告為 out-of-scope —— 該宣告所依據的理由已在 R1 被推翻、其必要性又在 R2 被證明,scope 是否擴大需由 issue owner 裁決。

回應 /idd-verify --pr 26 R2 的三項 HIGH(scope 擴大經 issue owner 裁決)。

R2 證明 R1 的 first-wins latch 只是把失敗搬家,不是消除。reviewer 逐字複製
控制流實測四交錯:

                              R1 latch 後
  A 同 chunk, bare→rich   →   Codex error
  B 分 chunk, bare→rich   →   Codex error   ← R1 引入的回歸(修法前是好的)
  C 同 chunk, rich→bare   →   真實訊息
  D 分 chunk, rich→bare   →   真實訊息       ← R1 要修的那個

根因:`if streamError == nil` 是到達順序政策,本 issue 需要的是訊息資訊量
政策。兩者只在「先到者必定較有資訊」這個無證據假設下重合。

改動:

1. informativeness 政策取代 first-wins
   僅當「手上是 fallback 且新來的不是」時升級;永不讓後到事件降級已持有的
   訊息。新增 CODEX_FALLBACK_MESSAGE 常數與 streamErrorIsFallback 狀態。

2. 移除 buffer while-loop 的 early return(scope 擴大,owner 裁決 A)
   該 return 在單次 callback 內中止 drain loop,使已在 buffer 的 sibling
   終端事件完全不被處理 —— 而終端錯誤正是 backend flush+close 的時刻,同
   segment 抵達的機率不低於分開。與第 1 項是複合缺陷:只修其一皆不足以
   覆蓋四交錯(R2 實測佐證)。原 issue 曾宣告此項 out-of-scope,其依據的
   理由已在 R1 被推翻、必要性又在 R2 被證明。

3. feed(_:) 抽出 + --selftest-process-events hook
   把 didReceive 的 drain 邏輯抽成可獨立呼叫的方法,hook 吃 chunk 字串陣列
   驅動真實 StreamCollector。chunk 邊界成為可表達的測試輸入,不需網路或
   CODEX_URL 注入點。R2 指出我「latch 結構上不可測」的辯護不成立 —— 確實
   可測,此處即為實作。

4. test/codex-call-stream-latch.bats(9 case)
   四交錯矩陣 + 邊界(僅 bare / 僅 rich / 雙 rich / 無終端事件 / 非陣列)。
   RED 先行:實作前 8/9 紅。

5. macos-swift-bats CI job
   只加 skip guard 會把 R1 的紅燈換成永遠 skip 的 vacuous green —— repo 只有
   ubuntu job,錨點等於零覆蓋。新 job 另含 swift 存在性斷言,避免 guard 在
   本該執行的環境靜默 skip。

6. 移除錯誤註解
   R1 寫的「Mirrors the existing latch at didCompleteWithError」事實有誤 ——
   該處 non-200 branch 是無條件覆寫、並無 latch。我用錯誤類比為修法背書。

7. metadata 一致性
   plugin.json description 補 v2.20.1(R1 只改了 version 欄,與 marketplace
   不一致);CHANGELOG 回填從未記錄的 2.19.0 / 2.20.0;test/README 註冊兩支
   新檔並註明 macOS-only 與對應 CI job。

未處理:R1/R2 其餘 MEDIUM/LOW(含 extractErrorMessage 回傳未設長度上限、
selftest hook 的 exit-0-without-output 語意),待 verify 通過後一併 triage。
@kiki830621

Copy link
Copy Markdown
Member Author

Fix round 2 — scope 擴大(owner 裁決 A),一次修完 latch policy + early-return

commit ab1d266。逐項對應 R2 master report

R2 finding 處置
HIGH — latch 是 arrival-order 非 informativeness(把失敗從 D 搬到 B) 改為:僅當「手上是 fallback 且新來的不是」時升級;永不讓後到事件降級已持有訊息
HIGH — early-return 在同 chunk 丟掉 sibling event 移除 while-loop 內的 early return(scope 擴大,經裁決)
HIGH — CI guard 造成 vacuous green 新增 macos-swift-bats job,另含 swift 存在性斷言防 guard 在該執行處靜默 skip
MEDIUM — 「latch 結構上不可測」的辯護 被駁回且已推翻 — 抽 feed(_:) + --selftest-process-events,chunk 邊界成為測試輸入
MEDIUM — 註解「Mirrors the existing latch at didCompleteWithError」事實有誤 移除該錯誤類比
MEDIUM — version bump 半套 plugin.json description 補 v2.20.1
MEDIUM — CHANGELOG 缺 2.19.0 / 2.20.0 回填兩節(日期依 commit 25ed130 / 2ed6753,皆 2026-07-18)
MEDIUM — test/README 未註冊第 8 支 註冊兩支並註明 macOS-only 與對應 CI job

四交錯矩陣 —— 現在全綠

新增 test/codex-call-stream-latch.bats(9 case),RED 先行:實作前 8/9 紅。

ok 1 A 同 chunk, bare→rich:rich 勝出(需要 buffer drain)
ok 2 B 分 chunk, bare→rich:rich 勝出(需要 informativeness policy)
ok 3 C 同 chunk, rich→bare:rich 保留(bare 不得覆寫)
ok 4 D 分 chunk, rich→bare:rich 保留(R1 原本要修的那個)
ok 5 只有 bare:如實落到 fallback,不虛構訊息
ok 6 只有 rich:訊息完整取出
ok 7 兩個 rich:先到者保留,後到者不覆寫
ok 8 無終端事件:不虛構錯誤
ok 9 非 JSON array 輸入 → 非零 exit

A 與 B 分別是「只修 drain 不夠」與「只修 policy 不夠」的哨兵 —— 兩項複合缺陷各有一個 case 釘住。

對 R2 的正面承認

R2 推翻了我兩件事,都已寫進 commit message 與 CHANGELOG,不掩蓋:

  1. R1 的修法只是把失敗搬家。 first-wins 是到達順序政策,本 issue 需要的是資訊量政策;兩者只在「先到者必定較有資訊」這個無證據假設下重合。B 交錯在我的修法前是好的,被我改壞了。
  2. 我為「latch 不可測」的辯護不成立。 StreamCollector 是同檔 final class、processEvent 吃純 String,不需要 socket 或 CODEX_URL 注入點。R2 用約 40 行證明可測,本輪即照此實作 —— 而且正是這個 hook 讓 B 交錯的回歸得以被機械釘住(既有 5 個單 payload case 在構造上看不見它)。

仍未處理

R1/R2 其餘 MEDIUM/LOW,含:extractErrorMessage 回傳未設長度上限(sibling 路徑有 .prefix(500))、selftest hook 的 exit-0-without-output 語意、didCompleteWithError 把 transport error 塌成 "HTTP 0: (no body)"(同類缺陷、不同位置)。待 verify 通過後一併 triage。

@kiki830621

Copy link
Copy Markdown
Member Author

Verify Report — PR #26 (Round 3)

Engine

pai-ensemble 2.20.0 (canonical #207) — 4 IDD lenses + DA + Codex, model: opus

Reviewers: codex(1), requirements(12), logic(10), security(7), regression(9) · DA ok: True · integrity: 0

Aggregate

FAIL — 8 HIGH / 14 MEDIUM / 15 LOW / 9 INFO(共 46)

Frozen at ab1d266。DA 本輪到齊(R2 曾撞 session limit),codex 配額仍未回補。


Findings

# Sev Finding File:Line Source
1 HIGH 本輪的核心前提是假的:production 的 chunk 邊界是「位元組」邊界,而 feed([String]) 在構造上表達不出來 —— 危險的那一半邊界空間反而被註解宣告為已覆蓋 bin/codex-call:246 devils-advocate
2 HIGH 「Informativeness policy, NOT arrival-order」只在 1-bit 投影上成立:兩個 rich 事件仍純靠先到先贏,而本輪新測試把這個「沒人有證據」的偏好正式釘成契約 bin/codex-call:306 devils-advocate
3 HIGH Drain loop still never flushes the residual buffer — an unterminated final terminal event is silently discarded and the run exits 0 with tru bin/codex-call:253 logic
4 HIGH didCompleteWithError still uses the arrival-order latch the round declared wrong — a held fallback blocks a real transport error, and stream bin/codex-call:272 logic
5 HIGH "Informative" is proxied by inequality with the sentinel literal, so an empty or whitespace-only message pins permanently — and is strictly bin/codex-call:306 logic
6 HIGH (a) Fifth interleaving still loses the message: "informativeness" is an identity check against one literal, so an empty/whitespace message bin/codex-call:306 requirements
7 HIGH Metadata inconsistency: marketplace.json still advertises the "first-event latch" this round replaced .claude-plugin/marketplace.json:11 requirements
8 HIGH #25 turns the most common terminal-error shape from a constant into an uncapped, unsanitized backend-controlled string that reaches the agen bin/codex-call:217 security
9 MEDIUM 測試 oracle 本身有歧義:(no error) 同時代表「健康地沒有錯誤」與「終端事件被吞了」,這解釋了為何 14 case 全綠仍漏掉殘留 buffer 與 CRLF bin/codex-call:483 devils-advocate
10 MEDIUM 對 security 那條 HIGH 的更正:#25 沒有開新的信任邊界 —— 同源的「成功回應本體」早就是無上限、無淨化、且被明文要求逐字寫進 findings 的後端可控字串 workflows/ensemble-workflow.js:373 devils-advocate
11 MEDIUM The new test hook structurally cannot observe the two-site interaction, so the contested boundary is again at zero coverage bin/codex-call:470 logic
12 MEDIUM marketplace.json still advertises "first-event latch" — the exact policy this round refuted and deleted .claude-plugin/marketplace.json:11 logic
13 MEDIUM Success path (delta accumulation) and the new didReceive→feed() call site have ZERO test coverage — both mutations survive all 14 cases bin/codex-call:482 regression
14 MEDIUM marketplace.json still advertises "first-event latch" — the R1 policy this commit replaced; commit message claims metadata consistency was f .claude-plugin/marketplace.json:11 regression
15 MEDIUM New informativeness policy is not applied at the sibling transport-error latch — a bare terminal event can still swallow a real error into " bin/codex-call:272 regression
16 MEDIUM CHANGELOG 2.20.0 backfill overstates scope: says "五個 ensemble-* skill", commit 2ed6753 changed 3 CHANGELOG.md:34 regression
17 MEDIUM (a) A terminal event not followed by a blank line is dropped entirely and misreported as "no text output" bin/codex-call:253 requirements
18 MEDIUM (a) The arrival-order latch survives at the second writer site — didCompleteWithError still first-wins bin/codex-call:272 requirements
19 MEDIUM Backfilled 2.20.0 section is inaccurate against commit 2ed6753: "五個 skill" is three, and the actual behavior change is omitted CHANGELOG.md:34 requirements
20 MEDIUM Both hidden flags exit 0 without writing --output, contradicting the file's own fail-closed invariant — and --output is left untouched, so a bin/codex-call:464 security
21 MEDIUM Two undocumented argv flags with no --help/parseArgs parity check: the published CLI surface of a binary that reads live OAuth tokens is now bin/codex-call:435 security
22 MEDIUM New macOS CI job adds an unpinned brew install (live homebrew-core HEAD) plus a mutable action tag and credential-persisting checkout to t .github/workflows/test.yml:51 security
23 LOW 對 CI 疑慮的更正:macos-latest 確實有 /usr/bin/swift、測試確實有跑(我實跑 14/14 ok);真正的 vacuous-green 風險在另一層 —— bats 全 skip 也是 exit 0,而 job 從不斷言「跑了幾個非 skip」 .github/workflows/test.yml:53 devils-advocate
24 LOW 角度 (5) 的回答:終端事件政策從一開始就該是獨立 issue —— 它是一個作者自承「沒有證據」的政策選擇,而不是 #25 回報症狀的修法;現在它把一個有實測錨點的 one-line fix 綁進了一個沒有觀測資料的設計決定 CHANGELOG.md:81 devils-advocate
25 LOW CRLF-framed SSE never produces an event boundary: buffer grows without bound and every event, including terminal errors, is lost bin/codex-call:255 logic
26 LOW Sentinel carried by value: a genuine backend message equal to "Codex error" is misclassified as the fallback bin/codex-call:211 logic
27 LOW macOS CI job enumerates two files explicitly while the ubuntu job globs — the next macOS-only bats file silently returns to permanent-skip .github/workflows/test.yml:47 logic
28 LOW CHANGELOG 2.20.0 records only "Added" — omits the gpt-5.5 → gpt-5.6-sol default change that 2ed6753 actually shipped CHANGELOG.md:30 regression
29 LOW CHANGELOG 2.20.1 attributes the ubuntu skip-guard to R2; it shipped in R1 (2875d87) CHANGELOG.md:21 regression
30 LOW macos-swift-bats job hardcodes two filenames — a third macOS-only bats file silently reverts to vacuous green .github/workflows/test.yml:59 regression
31 LOW CHANGELOG 2.20.1 claims a 429 measurement the originating commit did not make CHANGELOG.md:18 regression
32 LOW 2.20.1 "Fixed" entries describe defects that never shipped, implying 2.20.0 had an arrival-order latch CHANGELOG.md:19 requirements
33 LOW (c) The CI job asserts the guard's precondition but never that the tests actually ran, and hardcodes the file list .github/workflows/test.yml:41 requirements
34 LOW Negative tests assert only a non-zero exit, so they pass under unrelated failure modes test/codex-call-stream-latch.bats:88 requirements
35 LOW The doc comment written for extractErrorMessage is attached to CODEX_FALLBACK_MESSAGE bin/codex-call:197 requirements
36 LOW --selftest-process-events is not a resource-exhaustion vector (bounded, fails closed, <3s measured) but silently misreports large valid inpu bin/codex-call:476 security
37 LOW No cap on buffer or accumulated; removing the early return relocates where unbounded growth lands but does not bound it — --max-time i bin/codex-call:253 security
38 INFO cross-model pass incomplete codex
39 INFO Prompt-injection 檢查:diff 全文以 DATA 讀過,無注入內容;但 diff 內含一個不屬於 #25 的 commit devils-advocate
40 INFO The drain-loop rationale comment overstates the old defect (siblings were delayed, not unconditionally dropped) bin/codex-call:259 logic
41 INFO No prompt-injection content found in the diff /tmp/diff_25_r3.patch logic
42 INFO Regression checklist: (a) code-level PASS, (b) PASS, (c) PASS — feed() is exactly the old drain minus the return, (e) PASS bin/codex-call:253 regression
43 INFO test/README.md says the two files "不在 ubuntu job 執行" — they are executed there, they self-skip test/README.md:27 requirements
44 INFO Unrelated .gitignore hunk rides along in the #25 diff .gitignore:22 requirements
45 INFO Verification summary: (b) is genuinely closed; (c) executes; no prompt-injection found bin/codex-call:221 requirements
46 INFO No prompt-injection found in the diff; adjacent note on unverifiable review-rhetoric now embedded in production source comments bin/codex-call:295 security

本輪的核心前提又是假的(DA HIGH #1

我在 feed() 的 doc comment、CHANGELOG、bats 檔頭三處都宣稱「chunk 邊界成為可表達的測試輸入」。但 production 的 chunk 是 Data(位元組),不是 String

guard let chunk = String(data: data, encoding: .utf8) else { return }   // :246
feed(chunk)

URLSession 的 didReceive data: 按 TCP 可用量交付,與 UTF-8 codepoint 邊界零對齊保證。切點落在多位元組字元中間時 String(data:encoding:.utf8)nilguard else { return }整個 chunk 連同其中所有完整事件一起丟棄,且 bytes 不保留、不與下一 chunk 重組 —— 下一 chunk 開頭成為孤兒 continuation bytes,同樣整塊丟掉。DA 實測:

cut at byte 46 (continuation byte) — chunk1=46B chunk2=34B
String(data: chunk1, .utf8) = nil  <-- 整個 chunk 被 guard 丟棄
String(data: chunk2, .utf8) = nil  <-- 整個 chunk 被 guard 丟棄

而且這被本 plugin 的預設放大codex-call 預設 --instructions 是「Respond in the user's language」,codexPrompt() 更明確要求繁體中文輸出 —— 所有 delta 與所有後端錯誤訊息都是 3-byte CJK,切在字中間是常態不是邊角。

我的 feed([String]) hook 在構造上表達不出這一半的邊界空間 —— 而我宣告它已被覆蓋。

「informativeness」只是 1-bit 投影(DA HIGH #2

streamErrorIsFallback 是一個 bit,把「資訊量」壓成二值 {是不是那個字面值}:

  • bare vs rich(1 bit 有區別)→ 確實 order-independent,這是矩陣測到的
  • rich vs rich(1 bit 沒區別)→ 退回純 arrival-order,正是 R2 判定為「押一個沒有證據的排序」的那個 heuristic,原封不動

而後端 teardown 的真實形狀恰恰落在這格:{"type":"error","error":{"code":"server_is_overloaded","message":…}}(本 issue 實測 payload)與 {"type":"response.failed","response":{"error":{"message":…}}} 兩者都是 rich

更嚴重:我的新測試 case 7「兩個 rich:先到者保留」把這個無證據的偏好從未定義行為升級成有測試保護的契約。下一輪若拿到真實 trace 發現該改,改對的方向會被自己的測試擋下,且看起來像「破壞既有行為」。

DA 同時否定了我在 prompt 裡提出的 desync 假設:streamErrorIsFallbackstreamError 只在同一 block 內成對賦值,didCompleteWithError 是 URLSession 契約下的最後 callback,不變式未被打破。缺陷不是 desync,是這個 bit 的解析度本來就承載不了「informativeness」這個概念

其他 HIGH

# 內容
空字串 message 鎖死 msg != CODEX_FALLBACK_MESSAGE 是 sentinel 比對,空字串通過 → 設 isFallback=false → 永久阻擋升級。實測輸出空行且真訊息被丟。同一個洞也在 extractErrorMessage 內(top-level 空 message 遮蔽 nested 真訊息),而我的 test 3 把該 shadowing 釘成預期行為
殘留 buffer 不 flush 未以 \n\n 終結的最後一個事件被丟棄,didCompleteWithError 從不碰 buffer。實測:partial delta + 未終結 error → (no error) → exit 0 + 寫出截斷內容 = forged vote。這正是本輪宣稱修好的缺陷類別,留在 completion 邊界
didCompleteWithError 仍 arrival-order 我只改了 processEvent,sibling 寫入點原封不動。bare 先到 → 持有 fallback → 連線斷 → 真實 transport error 被丟棄,使用者看到 Codex error
marketplace.json 仍寫 "first-event latch" 那正是本輪刪掉的設計。plugin.json 寫對了,front-door 文案描述一個不存在的機制

三輪的模式

輪次 被推翻的前提
R1 diagnosis 的「early-return 讓第一個 error 勝出」—— return 只離開 callback,不取消 task
R2 R1 的 first-wins latch「消除了問題」—— 實測只是把失敗從 D 搬到 B;連帶推翻「latch 結構上不可測」的辯護
R3 R2 修法的「chunk 邊界已可測」+「這是 informativeness 政策」—— 前者表達不出 byte 邊界,後者是 1-bit 投影,rich↔rich 仍是 arrival-order

三次都是從局部觀察跳到全域結論,再用一段自信的註解為它背書

Process Gaps

  • codex lens 未完成 — 配額仍未回補(R1 實測 resets_in_seconds ≈ 5.4 天)。本輪為 5-AI
  • DA 已回歸(R2 曾撞 session limit),integrity: 0

Next — 建議重新框定 scope,而非再補一輪

DA 對「#25 是否仍是一個可交付單元」的回答(LOW #24)值得完整採納:

#25 的原始症狀是可證偽、有實測 payload 的…那部分乾淨、證據充分、可獨立交付。綁上來的第二件事性質完全不同 —— 「多個終端事件時哪個勝出」這個問題,diff 自己在兩處承認缺乏證據…本輪針對一個沒有人觀察過後端實際行為的問題,做了政策決定、寫了 9 個 case 去釘住它、還為此新增了一個新 OS 的 CI job。

具體代價在 CHANGELOG 上最清楚:2.20.1 的四條 Fixed 有三條修的是本 PR 自己在 R1 造成的東西,從未出貨給任何使用者,卻被寫成產品修復史。

建議切法(DA 提出,我同意):

三輪 verify(PR #26)各推翻了一個我當時最有把握的前提:

  R1  diagnosis 的「early-return 讓第一個 error 勝出」
      → return 只離開當次 callback,不取消 task
  R2  R1 的 first-wins latch「消除了問題」
      → 實測只是把失敗從 D 搬到 B;連帶推翻「latch 不可測」的辯護
  R3  R2 的「chunk 邊界已可測」+「這是 informativeness 政策」
      → feed([String]) 表達不出 byte 邊界;那只是 1-bit 投影,
        rich↔rich 仍是 arrival-order

共同的根因不是修得不夠細,是 scope 框錯:#25 的原始症狀有可重現的
payload、是 one-line fix;被綁上來的「多個終端事件誰勝出」則至今
沒有人觀察過後端 teardown 的實際行為。證據強度不同的東西綁在同一個
交付單元,於是每一輪都在為沒有證據的部分發明理由。

本 commit 依 R3 devil's-advocate 的建議切分(owner 裁決):

保留於 #25(三輪 verify 從未推翻過的部分)
  - extractErrorMessage(_:) 抽出 + json["error"]["message"] 路徑
  - --selftest-error-extract hook(讓上述可回歸)
  - test/codex-call-error-extract.bats 5 case
  - version bump 2.20.1 + metadata

移交 #28(同一子系統、同一組缺證據的問題)
  - informativeness 政策與 streamErrorIsFallback(回復無條件賦值)
  - buffer drain 的 early-return 放寬(回復原狀)
  - feed(_:) 抽出與 --selftest-process-events(連同 9 case 一併移除)
  - byte-level 分幀、殘留 buffer flush、CRLF、didCompleteWithError 同址政策

回復處皆留 KNOWN GAP 註解指向 #28,不是靜默還原。

其餘修正:
  - marketplace.json 與 plugin.json 的 description 曾寫「first-event
    latch」—— 那是被推翻且已刪除的設計,front-door 文案描述了不存在
    的機制,一併清掉並與 plugin.json 對齊
  - CHANGELOG 2.20.1 原有四條 Fixed,其中三條修的是本 PR 自己在 R1
    造成、從未出貨的東西 —— 寫成產品修復史會讓讀者以為 2.20.0 曾帶著
    arrival-order latch 出貨。只保留真正出貨過的那一條,並新增 Known
    limitations 指向 #28
  - macos-swift-bats job 改 glob 檔名並斷言 run 的「輸出」而非 guard
    的「輸入」:bats 在全部 skip 時 exit 0,只檢查前提會讓 vacuous
    green 搬高一層

Refs #28
@kiki830621

Copy link
Copy Markdown
Member Author

Scope 收斂 — 分幀與終端事件政策移交 #28

commit b6fbb91(-163 行)。依 R3 devil's-advocate 建議、owner 裁決執行。

為什麼收斂而不是再補一輪

三輪 verify 各推翻了一個當時最有把握的前提:

輪次 被推翻的前提
R1 diagnosis 的「early-return 讓第一個 error 勝出」—— return 只離開當次 callback
R2 R1 的 first-wins latch「消除了問題」—— 實測只是把失敗從 D 搬到 B;連帶推翻「latch 不可測」的辯護
R3 R2 的「chunk 邊界已可測」+「這是 informativeness 政策」—— feed([String]) 表達不出 byte 邊界;那只是 1-bit 投影,rich↔rich 仍是 arrival-order

共同根因不是修得不夠細,是 scope 框錯#25 的原始症狀有可重現 payload、是 one-line fix;被綁上來的「多個終端事件誰勝出」則至今沒有人觀察過後端 teardown 的實際行為。證據強度不同的東西綁在同一個交付單元,於是每輪都在為沒有證據的部分發明理由。

保留於 #25(三輪 verify 從未推翻過)

  • extractErrorMessage(_:) 抽出 + json["error"]["message"] 路徑
  • --selftest-error-extract hook(讓上述可回歸)
  • test/codex-call-error-extract.bats 5 case
  • version bump 2.20.1 + metadata

移交 #28

  • informativeness 政策與 streamErrorIsFallback → 回復無條件賦值
  • buffer drain 的 early-return 放寬 → 回復原狀
  • feed(_:)--selftest-process-events → 連同 9 case 一併移除
  • byte-level 分幀、殘留 buffer flush、CRLF、didCompleteWithError 同址政策

回復處皆留 KNOWN GAP (#28) 註解,不是靜默還原 —— 包括 R3 指出的 byte 邊界丟包與 extractErrorMessage 的空字串遮蔽。

一併修掉的 R3 findings

  • marketplace.json / plugin.json 的 description 曾寫「first-event latch」—— 被推翻且已刪除的設計,front-door 文案描述不存在的機制。已清除並對齊
  • CHANGELOG 2.20.1 原有四條 Fixed,其中三條修的是本 PR 自己在 R1 造成、從未出貨的東西。只留真正出貨過的那條,新增 Known limitations 指向 codex-call SSE 分幀與終端事件語意:byte 邊界丟包、殘留 buffer 不 flush、終端事件政策缺證據 #28
  • macos-swift-bats job 改 glob 檔名,並斷言 run 的輸出而非 guard 的輸入 —— bats 全部 skip 時 exit 0,只檢查前提會讓 vacuous green 搬高一層

回應 /idd-verify --pr 26 R4。最嚴重的一項是我上一輪自己寫進去的。

1. CI step 吞掉 bats 失敗(四個 lens 獨立命中,HIGH)
   `bats … | tee /tmp/bats.tap` 的 exit status 是 tee 的(永遠 0)。
   GitHub Actions 預設 shell 是 `bash -e {0}` —— 有 errexit、沒有
   pipefail —— 所以測試 FAIL 不會讓 step 失敗。兩道 grep 也補不上:
   `grep -q '^ok'` 只要有任何一個 sibling 通過就滿足,而 `not ok`
   不匹配 `^ok`。

   這是回歸:ab1d266 的舊版是 bare command、exit code 正確傳播。我為了
   修 vacuous green,做出了更糟的 vacuous green —— 「全部 skip」至少
   還會被抓到,「regression 錨點真的壞了」則不會。

   修法三重:顯式 `shell: bash`(取得 -eo pipefail)+ 自行 `set -o
   pipefail` + 新增 `^not ok` 檢查。實測驗證:植入一個必失敗的 case 後,
   修正版 step exit=1、舊版 exit=0。

2. front-door 宣稱過廣 + #27 完全未揭露(HIGH)
   marketplace.json / plugin.json 原本無條件宣稱「不再塌成 Codex
   error」。這對直接呼叫 codex-call 為真,但每個 first-party consumer
   都仍以硬編碼字串回報失敗(#27),所以對用 plugin 的人幾乎是 no-op。
   而 Known limitations 詳列了 #28 的四項,卻對 #27 隻字未提 ——
   唯一會讓宣稱效益歸零的那個 gap,是唯一沒被揭露的。

   兩處 description 改為明確區分「直接呼叫」與「經 ensemble 使用」;
   Known limitations 新增 #27 條目並說明為何列出。

3. 三輪 verify 的可執行產物被刪除(HIGH)
   收斂時把 codex-call-stream-latch.bats(四交錯矩陣)與
   --selftest-process-events 一併刪掉,使 #28 只剩散文、KNOWN GAP
   不可稽核。已將 harness 全文、hook 實作、R2 實測矩陣完整保存進 #28
   的 comment,並註明該 harness 本身的限制(吃 [String],表達不出
   production 的 byte 邊界)。

4. 其餘
   - extractErrorMessage 的 18 行 rationale 因我插入常數而被空行切開、
     附著到常數上;移回緊貼 func
   - 移除 hook 刪除處殘留的連續空行
   - CHANGELOG 與 bats 檔頭原寫「三條路徑」—— 修正前實際只有兩條

Refs #27
Refs #28
回應 /idd-verify --pr 26 R5 的三項 HIGH。本 commit 的每一條宣稱都附修正後的
逐字內容 —— R5 devil's-advocate 指出我前幾輪的根本問題是 self-certification
without re-reading(同一個 commit 內兩處「宣稱已修但沒落地」),這是它開的止血方。

1. security HIGH:本 PR 啟用了未設上限、未淨化的後端文字出口

   論證成立且我接受:修正前後端實際送的 error.message 塌成常數,這條路徑
   實務上是死的;補上提取路徑正是讓它活起來的那個改動,所以 cap 屬於同一個
   變更而非 #28 的延後項。同檔另兩處外部文字本來就 .prefix(500),只有我新建
   的這條沒有。

   bin/codex-call:246(修正後逐字):
       return stripped.count > 500 ? String(stripped.prefix(500)) + "…(truncated)" : stripped

   同時剝除 C0/DEL/C1(保留 newline 與 tab)—— 該字串直寫 TTY,且經 Bash tool
   進入 agent context;CSI/OSC 可覆蓋真實錯誤。實測:600 字元 → 512 bytes 且帶
   截斷標記;ESC/BEL 被剝除而 "safe"/"wiped" 保留;newline/tab 保留;一般長度
   訊息逐字不變。新增 4 個 bats case 釘住(5 → 9)。

2. regression HIGH:bats 檔頭的「三條路徑」我上一輪宣稱改了,實際沒改

   查證屬實。git show main 確認修正前只有兩條路徑加一個 fallback,而
   3506f7c 只改了 CHANGELOG 與測試名稱,檔頭原封不動。

   test/codex-call-error-extract.bats:6-7(修正後逐字):
       # 時,提取鏈**只有兩條**路徑(top-level message、response.error.message),
       # 漏掉 json["error"]["message"],故兩條皆不命中 →

3. logic MEDIUM:CI 的「三重機制」實際只有一重在工作

   errexit + pipefail 下,bats 失敗會讓 step 在任何 grep 執行前就中止,所以
   `^not ok` 那句 ::error:: 從不觸發,只會看到通用的 exit code 1。改為捕捉
   bats 的 rc、跑完三項檢查再依 rc 收尾;skip 檢查改 -i(bats 未 pin,TAP
   directive 大小寫是它的選擇);TAP 路徑改用 $RUNNER_TEMP。

   四情境實測(bash -e 模擬 Actions shell):全過→0;植入失敗→1 +
   "a codex-call test failed";全 skip→1 + "vacuous";glob 空→1 +
   "no tests ran"。每個情境都印出自己的訊息,不再被 errexit 搶先。

本輪測試撰寫時自己踩到一次:控制字元若以 literal 寫進 JSON payload,會讓
payload 本身成為非法 JSON,於是測到的是解析失敗而非 sanitize 生效。已改用
JSON 規範的 backslash-u 逸出序列表達,並把這個陷阱寫進該 case 的註解。

仍未處理:#27(消費端硬編碼)、#28(分幀與終端事件語意)、以及 R1-R5 其餘
MEDIUM/LOW(含 --selftest-error-extract 靜默壓過 --output)。

Refs #27
Refs #28
…an (#25)

CI 實證失敗(非本機可見):macos-swift-bats job 紅燈 13 秒。

根因(GitHub Actions run 30646773664 的 log 逐字):

    1..9
    bats: unknown test name `$'test_-2325_regression-ffffffffffffffef層_error_...'
    # bats warning: Executed 0 instead of expected 9 tests
    ##[error]no tests ran (empty glob or bats bail-out)

`brew install bats-core` 這版在把 @test 標題轉成 shell 函式名時 mangle CJK
code point(`ffffffffffffffef` 是 byte 被當 signed char),於是宣告 1..9
卻執行 0 個。ubuntu job 的 apt bats 沒有這個行為,所以既有 7 支中文標題的
測試在該 job 一直是綠的,本機亦然 —— 這是 brew 版特有的差異。

修法:9 個 @test 標題改 ASCII,中文說明移到各 case 上方的註解(註解不會被
轉成識別字)。本機與 LC_ALL=C 兩種 locale 下皆 9/9 綠。

值得記錄的正面證據:**這次是我加的第三道檢查救的**。若只有 R4 那版(pipe
吞掉 exit code)或只有前兩道檢查,這會是一個「宣稱 9 個測試存在、實際執行
0 個」的完美 vacuous green —— 正是這個 job 存在的理由。gate 不是裝飾。

同時也是「本機綠不等於 CI 綠」的實例:我在本機驗過四個情境(全過 / 植入
失敗 / 全 skip / glob 空)全部正確,但驗不到 brew 版 bats 的 CJK 行為。

Freshness 說明:R6 verify 正在 in-flight,凍結於 aabbb9e。本 commit 使
HEAD 前進,故 R6 的 verdict 描述的是前一個 snapshot。delta 僅為測試標題
字串與註解位置,不觸及 sanitizeBackendText、extractErrorMessage、CI step
邏輯或任何被審的行為 —— 收尾時會在報告中標明此分歧,不當作已審。
回應 /idd-verify --pr 26 R6(codex lens 本輪恢復,6-AI 到齊)。三項實質缺陷,
其中兩項是我上一輪自己引入或宣稱的。

1. 我宣稱的 cap 根本不 cap(4 個 lens + codex 獨立命中)

   `stripped.count > 500` 數的是 extended grapheme cluster,長度無上限 ——
   一個基底字元加 N 個組合記號是「一個」Character。實測逃逸:
     500 個 CJK 字元        → 1,500 bytes 通過,無截斷標記
     500 個 ×20 組合記號    → 20,500 bytes 通過,無截斷標記
   而本工具預設輸出就是 CJK,所以這不是對抗性情境才發作,是「出現一則中文
   錯誤訊息就發作」。

   改為 UTF-8 byte(2000)+ 行數(20)雙預算。行數上限是必要的另一半:
   reviewer 實測 475 個換行可把真錯誤捲出 TTY 視窗,而 newline 正是我在
   doc comment 裡寫「不帶終端控制能力」而刻意保留的字元 —— 那句 rationale
   是假的,已改寫成「保留是為了可讀性,其版面能力由行數上限約束」。

2. 新增的「newline / tab 保留」測試是套套邏輯(mutation 實證)

   三個子字串斷言對「保留」與「剝除」不可分辨:剝除後的 line1line2end 仍
   同時包含 line1、line2、end。reviewer 刪掉保留子句後全套件仍 9/9 綠。
   改為斷言分隔符本身:[ "$output" = $'line1\nline2\tend' ]。

   我這輪自己也跑了 mutation 驗新測試有分辨力(而非再次宣稱):
     換回 grapheme cap  → case 12(組合記號)、case 13(行數)轉紅
     移除 newline 保留  → case 8(分隔符)轉紅
   測試 9 → 13。

3. CHANGELOG 停留在前一個 commit 的狀態

   寫「5 case」而檔案有 13 個 @test;且完全沒記錄 sanitize/cap 這個**使用者
   可見的行為改變**(所有後端錯誤訊息自此會被截斷、控制字元會被移除)。已補
   `### Changed` 一節說明預算語意與為何不用 Character 計數,並更新 Known
   limitations 列出 sanitize 尚未覆蓋的類別(bidi/Tags/FEFF/U+2028-9)與另外
   兩個仍用 String.prefix(500) 的 sibling sink,全部歸 #28。

依 R6 devil's-advocate 的裁決收斂:byte cap 現在改對,其餘 sanitize 議題
(bidi、Tags、行數以外的版面問題、sibling 一致性、marker 可偽造、以及
`accumulated` 那條更大的未設防通道)併入 #28 一次處理,不再逐輪片段修補。

Refs #28
@kiki830621
kiki830621 merged commit 4f110d0 into main Aug 1, 2026
2 checks passed
@kiki830621
kiki830621 deleted the idd/25-codex-error-message branch August 1, 2026 07:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant