diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 65d5432..3bd1840 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,8 +8,8 @@ { "name": "parallel-ai-agents", "source": "./plugins/parallel-ai-agents", - "description": "v2.20.0: first-party codex-pro governance deep-integration (#23). v2.19.0: codexModel/codexEffort contract args (#22, caller-governed cross-model leg). 平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", - "version": "2.20.0", + "description": "v2.20.1: codex-call 補上 SSE error 事件的 message 提取路徑 (#25) — 直接呼叫 codex-call 時,HTTP 200 stream 內帶 message 的後端錯誤(如 server_is_overloaded)會顯示真實原因而非籠統的 \"Codex error\";經 ensemble 使用時仍受 #27 限制(消費端硬編碼失敗訊息)。v2.20.0: first-party codex-pro governance deep-integration (#23). v2.19.0: codexModel/codexEffort contract args (#22, caller-governed cross-model leg). 平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", + "version": "2.20.1", "author": { "name": "Che Cheng" }, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5b40c53..46f6e6b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,3 +33,54 @@ jobs: - name: node tests run: for t in test/*.test.mjs; do echo "$t"; node "$t"; done + + # codex-call 是 `#!/usr/bin/swift` script(釘 Xcode CLT swift,見 plugin CLAUDE.md), + # 其 bats 測試在 setup() 以 Darwin + CLT swift guard 自我 skip。沒有這個 job,那些 + # 測試會在 ubuntu job 上被 skip 而**永遠不執行** —— CI 全綠但 #25 的 regression 錨點 + # 零覆蓋(green-but-vacuous,#25 R2 verify HIGH)。macOS runner 內建 Xcode,故 guard + # 會通過、測試真正跑到。 + macos-swift-bats: + runs-on: macos-latest + defaults: + run: + working-directory: plugins/parallel-ai-agents + steps: + - uses: actions/checkout@v4 + + - name: Install bats + run: brew install bats-core + + - name: Assert swift toolchain is usable (the real gate — a present shim is not enough) + run: swift --version + + # Glob rather than an explicit list, so a future macOS-only bats file is picked up + # instead of silently reverting to zero coverage. Then assert on the run's OUTPUT, + # not on the guard's inputs: `bats` exits 0 when every test SKIPS, which is exactly + # the vacuous green this job exists to prevent (#25 verify). + - name: bats (codex-call, macOS-only) — fail on failure OR on skip + # `shell: bash` is load-bearing: Actions' DEFAULT shell is `bash -e {0}` + # — errexit WITHOUT pipefail — so `bats | tee` would report tee's status + # (always 0) and a failing test would exit the step green. Naming the shell + # explicitly gets `-eo pipefail`. Belt-and-braces: also assert no `not ok`, + # since `grep -q '^ok'` alone is satisfied by any single passing sibling. + shell: bash + run: | + set -o pipefail + TAP="${RUNNER_TEMP:-/tmp}/codex-call-bats.tap" + # Capture bats' status instead of letting errexit abort here: with pipefail + # active a failing run would kill the step before any check below could emit + # its `::error::` line, so the three checks would be decorative and only the + # generic "Process completed with exit code 1" would surface. + rc=0 + bats --formatter tap test/codex-call-*.bats | tee "$TAP" || rc=$? + if grep -q '^not ok' "$TAP"; then + echo "::error::a codex-call test failed"; exit 1 + fi + # -i because the TAP directive's casing is bats' choice and bats is unpinned + # (`brew install bats-core`); a casing change must not silently reopen the + # vacuous-green hole this check exists to close. + if grep -qi '# skip' "$TAP"; then + echo "::error::tests skipped on the macOS job — the regression anchor is vacuous"; exit 1 + fi + grep -q '^ok' "$TAP" || { echo "::error::no tests ran (empty glob or bats bail-out)"; exit 1; } + exit "$rc" diff --git a/.gitignore b/.gitignore index 864e0a6..abc57d0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ venv/ # remember plugin session artifacts .remember/ + +# IDD tree-lock (idd-tree-lock.sh #183) — per-machine session state, not tracked +.claude/.idd/tree-lock diff --git a/plugins/parallel-ai-agents/.claude-plugin/plugin.json b/plugins/parallel-ai-agents/.claude-plugin/plugin.json index 41e8eed..df04eef 100644 --- a/plugins/parallel-ai-agents/.claude-plugin/plugin.json +++ b/plugins/parallel-ai-agents/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "parallel-ai-agents", - "description": "v2.20.0: first-party skills deep-integrate codex-pro governance (#23, mirroring issue-driven-dev#264) — new references/codex-governance.md (canonical resolution: MIN_CODEX_PRO 0.7.0 gate, defaults.json base + two profile.yaml layers, fail-fast with install instruction when codexEnabled and codex-pro absent); ensemble-code-review / ensemble-academic-review / ensemble-compose(--codex) resolve and pass codexModel/codexEffort explicitly; engine + bin/codex-call baked defaults become release-time governance SNAPSHOTS (bumped to gpt-5.6-sol) — authoritative source is codex-pro's defaults.json; all first-party prose generation-neutral. v2.19.0: codexModel / codexEffort engine args (#22) — the cross-model codex leg's model and effort become caller-governed contract args (defaults gpt-5.5 / xhigh preserve pre-#22 behavior byte-identically). First consumer: issue-driven-dev passing codex-pro-resolved governance. 平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", - "version": "2.20.0", + "description": "v2.20.1: codex-call 補上 SSE error 事件的 message 提取路徑 (#25) — 直接呼叫 codex-call 時,HTTP 200 stream 內帶 message 的後端錯誤(如 server_is_overloaded)會顯示真實原因而非籠統的 \"Codex error\";經 ensemble 使用時仍受 #27 限制(消費端硬編碼失敗訊息)。v2.20.0: first-party skills deep-integrate codex-pro governance (#23, mirroring issue-driven-dev#264) — new references/codex-governance.md (canonical resolution: MIN_CODEX_PRO 0.7.0 gate, defaults.json base + two profile.yaml layers, fail-fast with install instruction when codexEnabled and codex-pro absent); ensemble-code-review / ensemble-academic-review / ensemble-compose(--codex) resolve and pass codexModel/codexEffort explicitly; engine + bin/codex-call baked defaults become release-time governance SNAPSHOTS (bumped to gpt-5.6-sol) — authoritative source is codex-pro's defaults.json; all first-party prose generation-neutral. v2.19.0: codexModel / codexEffort engine args (#22) — the cross-model codex leg's model and effort become caller-governed contract args (defaults gpt-5.5 / xhigh preserve pre-#22 behavior byte-identically). First consumer: issue-driven-dev passing codex-pro-resolved governance. 平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", + "version": "2.20.1", "author": { "name": "Che Cheng" } diff --git a/plugins/parallel-ai-agents/CHANGELOG.md b/plugins/parallel-ai-agents/CHANGELOG.md index 0b608dc..86b5eea 100644 --- a/plugins/parallel-ai-agents/CHANGELOG.md +++ b/plugins/parallel-ai-agents/CHANGELOG.md @@ -11,6 +11,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.20.1] - 2026-07-31 + +### Fixed + +- **`bin/codex-call` 吞掉 SSE `error` 事件的訊息 (#25)** — 後端在 HTTP 200 stream 內以 `{"type":"error","error":{...,"message":...}}` 回報時(實測觸發:`server_is_overloaded`),原有的兩條提取路徑皆不匹配,塌成 fallback 字面值 `"Codex error"`,使該類失敗無法區分原因。抽出 `extractErrorMessage(_:)` 並補上 `json["error"]["message"]` 路徑。HTTP 4xx 類(實測 model 400 / auth 401 / rate-limit 429)走既有 HTTP 錯誤路徑、本來就正確報告,不受影響。 + +### Changed + +- **後端錯誤訊息現在會被淨化並設上限(使用者可見的行為改變)** — 新增 `sanitizeBackendText`:剝除 C0/DEL/C1 控制字元(保留 newline 與 tab),並以 **UTF-8 byte(2000)+ 行數(20)** 為預算截斷,超出時附加 `…(truncated)`。 + 預算刻意**不用** `String.count` —— 它數的是 extended grapheme cluster,長度無上限(一個基底字元加 N 個組合記號是**一個** Character),因此 Character-based cap 實際上不約束任何東西。實測前一版:500 個 CJK 字元以 1,500 bytes 通過、500 個各含 20 個組合記號的 cluster 以 20,500 bytes 通過,兩者皆無截斷標記。TTY 版面與 agent context 都是以 byte/行計價,故以此為準。 + +### Added + +- **`--selftest-error-extract ` hidden flag** — 餵一則 SSE 事件 payload 給 `extractErrorMessage` 並印出結果,不發 HTTP、不列於 `--help`。`CODEX_URL` 是 hardcoded 常數、無注入點,沒有這個 hook 該提取邏輯結構上無法自動化回歸。 +- **`test/codex-call-error-extract.bats`** — **13 case**:提取路徑 5 個(含實測 payload 作 regression 錨點)+ sanitize/budget 8 個。後者以 mutation 驗證有分辨力(換回 grapheme cap → 組合記號與行數兩個 case 轉紅;移除 newline 保留子句 → 分隔符 case 轉紅)。**測試標題須為 ASCII** —— macOS runner 的 `/bin/bash` 3.2 在 `printf '%02x'` 上做 signed-char 符號延伸,會 mangle CJK 標題導致 bats 宣告 N 個卻執行 0 個(見 CI job `macos-swift-bats`)。 + +### Known limitations + +- **經 ensemble 使用時,本修正對使用者尚不可見(#27)** — `workflows/ensemble-workflow.js` 的 codex lens prompt 以硬編碼字串回報失敗,不帶 `codex-call` 的 stderr。本版真正改善的是**直接呼叫 `codex-call`** 的情境。 +- **sanitize 尚未覆蓋的類別,與另兩個 sink(#28)** — bidi override(U+202A–U+202E、U+2066–U+2069)、Unicode Tags block、U+FEFF、U+2028/U+2029 仍會通過;同檔另外兩處後端文字(`HTTP : `)仍用 `String.prefix(500)`,與本版修掉的 Character-counting 缺陷相同且未被 sanitize;截斷標記為 in-band、可被後端偽造。這些需要對所有 backend-text sink 做一次整體處理,不宜再以片段修補累加。 +- 分幀與終端事件語意 —— UTF-8 切在 byte 邊界導致整個 chunk 被丟棄、殘留 buffer 從不 flush、多個終端事件時的勝出政策、CRLF 分幀 —— 同樣追蹤於 **#28**,該處會先蒐集後端 teardown 的真實 trace 再定政策。 +- `extractErrorMessage` 的每條路徑接受任意 String(含 `""` 及 sanitize 後變空者),故空的 top-level `message` 會遮蔽真實的巢狀值;修法需要「資訊量謂詞」而非「存在性檢查」,一併歸 #28。 + + +## [2.20.0] - 2026-07-18 + +### Added + +- **first-party skills 深度整合 codex-pro governance (#23)** — 五個 ensemble-* skill 的 codex leg 不再於 pai 樹內 pin model/effort,改由 codex-pro 的 EXTERNAL-CONSUMER CONTRACT(`references/profile-contract.md` + `references/defaults.json`,0.7.0+)解析;解析流程的 canonical 落在 `references/codex-governance.md`,skills 引用該檔而不內嵌分歧複本。 + +## [2.19.0] - 2026-07-18 + +### Added + +- **`codexModel` / `codexEffort` engine args (#22)** — `ensemble-workflow.js` 新增這兩個 caller-governed args,讓跨模型 leg 的 model/effort 由呼叫端治理契約決定,而非引擎內部寫死。消費端(如 issue-driven-development 的 idd-verify)據此把 codex-pro 的治理值 thread 進來;引擎若靜默忽略這兩個 arg,canonical tier 的治理鏈會斷(故 consumer 端以最低版本閘門把關)。 + ## [2.18.0] - 2026-07-02 ### Added diff --git a/plugins/parallel-ai-agents/bin/codex-call b/plugins/parallel-ai-agents/bin/codex-call index 93b2850..c2418f5 100755 --- a/plugins/parallel-ai-agents/bin/codex-call +++ b/plugins/parallel-ai-agents/bin/codex-call @@ -192,6 +192,94 @@ func refreshIfNeeded(_ auth: inout [String: Any]) throws { log("token refreshed") } +// MARK: - SSE error message extraction + +/// The information-free fallback, named rather than inlined so #28 can key off it. +let CODEX_FALLBACK_MESSAGE = "Codex error" + +/// Pull the human-readable message out of an SSE `error` / `response.failed` event. +/// +/// The backend puts it in different places depending on the event shape: +/// `{"type":"error","error":{"code":…,"message":…}}` → json["error"]["message"] +/// `{"type":"response.failed","response":{"error":{"message":…}}}` → json["response"]["error"]["message"] +/// (defensive: a hypothetical top-level form) → json["message"] +/// +/// #25 — the `json["error"]["message"]` path was missing, so a real backend error +/// like `server_is_overloaded` matched none of the paths and collapsed to the +/// "Codex error" fallback. Every HTTP-200-stream failure then looked identical, +/// making "retry now vs stop" undecidable. Order matters: the top-level path stays +/// first so the new one cannot shadow it. +/// +/// KNOWN GAP (#28): each path accepts any String, including "" — an empty top-level +/// `message` therefore shadows a real nested one. Fixing that needs an informativeness +/// predicate rather than a presence check, which is tracked with the rest of the SSE +/// terminal-event semantics in #28. Deliberately NOT fixed here: #25 is scoped to the +/// missing path, which has a reproducible payload. +func extractErrorMessage(_ json: [String: Any]) -> String { + let raw = (json["message"] as? String) + ?? ((json["error"] as? [String: Any])?["message"] as? String) + ?? ((json["response"] as? [String: Any])?["error"] as? [String: Any])?["message"] as? String + ?? CODEX_FALLBACK_MESSAGE + return sanitizeBackendText(raw) +} + +/// Cap and de-fang backend-controlled text before it becomes an error message. +/// +/// Why this ships with #25 instead of joining #28's deferral pile: before this change the +/// shape the backend actually emits (`error.message`) matched none of the paths and +/// collapsed to the constant — the sink was dead in practice. Adding the path is what +/// makes it live, so the cap belongs to the same change. The two sibling external-text +/// sites already truncate identically (`body.prefix(500)`); this makes the third +/// consistent instead of leaving the newest one as the only uncapped route. +/// +/// C0/DEL/C1 are stripped because this text is written straight to a TTY and also reaches +/// an agent's context via the Bash tool. Newline and tab are kept for readability — NOT +/// because they are harmless. They are not: 500 blank lines will scroll the real failure +/// out of a terminal just as effectively as a CSI erase. What actually bounds that is the +/// budget below, not the character filter. +/// +/// The budget is measured in UTF-8 BYTES and LINES, not Characters. `String.count` counts +/// extended grapheme clusters, which are unbounded in scalar length — a base character +/// plus N combining marks is ONE Character — so a Character-based cap does not bound +/// anything. Measured on the previous grapheme-based version: 500 CJK characters passed +/// as 1,500 bytes and 500 clusters of 20 combining marks passed as 20,500 bytes, both +/// with no truncation marker. Bytes and lines are what a TTY and a token budget actually +/// spend, so those are what we cap (#25 R6). +/// +/// Remaining sanitization gaps — bidi overrides (U+202A–U+202E, U+2066–U+2069), the +/// Unicode Tags block, U+FEFF, U+2028/U+2029, the two sibling `body.prefix(500)` sites +/// that share the Character-counting flaw, and the forgeable in-band marker — are tracked +/// in #28. They need one coherent pass over every backend-text sink, not a fourth +/// piecemeal edit here. +func sanitizeBackendText(_ raw: String) -> String { + let stripped = String(String.UnicodeScalarView(raw.unicodeScalars.filter { s in + if s.value == 0x0A || s.value == 0x09 { return true } // keep \n, \t + if s.value < 0x20 || s.value == 0x7F { return false } // C0 + DEL + if s.value >= 0x80 && s.value <= 0x9F { return false } // C1 + return true + })) + return clampToBudget(stripped) +} + +/// Byte + line budget. Truncates on whichever limit is hit first. +func clampToBudget(_ s: String, maxBytes: Int = 2000, maxLines: Int = 20) -> String { + var out = String.UnicodeScalarView() + var bytes = 0 + var lines = 1 + var clipped = false + for scalar in s.unicodeScalars { + let w = String(scalar).utf8.count + if scalar.value == 0x0A { + if lines + 1 > maxLines { clipped = true; break } + lines += 1 + } + if bytes + w > maxBytes { clipped = true; break } + out.append(scalar) + bytes += w + } + return clipped ? String(out) + "…(truncated)" : String(out) +} + // MARK: - SSE streaming final class StreamCollector: NSObject, URLSessionDataDelegate { @@ -215,12 +303,22 @@ final class StreamCollector: NSObject, URLSessionDataDelegate { firstBytes = (firstBytes ?? Data()) + data return } + // KNOWN GAP (#28): `didReceive` delivers Data on TCP availability, with no + // alignment to UTF-8 codepoint boundaries. A cut mid-character makes this + // decode return nil and drops the WHOLE chunk — every complete event in it + // included — and the bytes are not retained for the next chunk. With this + // plugin defaulting to CJK output that is routine, not a corner case. Fixing + // it needs byte-level buffering; tracked in #28 with the rest of the framing. guard let chunk = String(data: data, encoding: .utf8) else { return } buffer += chunk while let range = buffer.range(of: "\n\n") { let event = String(buffer[buffer.startIndex..