Skip to content

v0.26.1

Choose a tag to compare

@Kewton Kewton released this 21 Aug 07:15
· 39 commits to main since this release
6540d7b

[0.26.1] - 2026-08-21

Highlight: 高負荷環境でのみ落ちるテストを 3 件、実測で機構を特定してから設計ごと直したリリース。いずれも「上限を緩める」「閾値を上げる」ではなく検出力を保つ形に作り替え、変異注入で検出力が落ちていないことを証明している(#1849 は計測窓を絶対時間から関係へ、#1869 は競合の staging を 1→8 writer にしたうえでスケジューリングに依存しない決定的経路を追加、#1873 は共通 setup で採番の漏れを封じた)。3 件とも Issue 本文に書かれた原因仮説が実測で覆されており、とくに #1869 は「writer が飢餓する」ではなく「高負荷が奪うのは CPU ではなく連続性」だった(赤いラウンドでも writer は 101ms の walk 中に 462 回書き込めていた)。あわせて Epic #1848 で唯一未達だった「並列 worktree の e2e ポート衝突が env 注入で消える」ことを実機で記録し(導出ポート 3219/3220 で両方 PASS、対照の固定 3177 は衝突して FAIL)、v0.26.0 で出荷済みだった #1771 の配線を playwright.config.ts に入れた。製品コード(src/)の変更は 1 バイトも無い。

Changed

  • test(verification): 並列 worktree の e2e ポート衝突が env 注入で消えることを実機で記録し、導出を playwright.config.ts に配線する (#1871): Issue #1771 の env 注入(CM_WORKTREE_ID / CM_WORKTREE_INDEX)は v0.26.0 で出荷済みだが、それを使う配線がどのリポジトリにも入っておらず、症状が消えたことが一度も確認されていなかった(Epic #1848 の受入条件で唯一未達で残っていた 1 件)。2 つの linked worktree で e2e ゲートを同時に走らせ、実 exit code と実際に LISTEN したポートで記録した: 導出ポートでは commandmate-e2e-probe-b が 3219(index 42)・commandmate-e2e-probe-a が 3220(index 43)で両方 PASS(gate exit=0 / CLI exit=0。両ポートが同時に LISTEN していた秒を 11 秒観測しており、重なりは仮定ではない)。対照実験として同じ同時実行を CM_E2E_PORT=3177 固定で行うと後発が [WebServer] Port 3177 is already in use → GATE e2e-fixed FAIL (exit=1, 3.9s) / CLI exit=20 で落ちる = 変更の欠陥とまったく同じ綴りの偽の赤になることも記録した(両方緑では「注入が効いた」のか「そもそも重ならなかった」のか区別できないため、対照が落ちて初めて主張が成立する)。同じ機会に mutex: の直列化も確認し、waited=0.0s / waited=14.3s の 2 本と duration=14.2s / 12.8s が別々に立つこと(=待ちを duration に足さない #1771 の契約)を実機で固定した。3 ラウンドの wall-clock 比較(導出 20s/20s / 固定 15s/5s(fail) / mutex 15s/30s)が「並列度を保てるのは env 注入だけ」という設計上の主張をそのまま数字にしている。配線は Issue が挙げた 2 案のうち (b) playwright.config.ts 側で導出を採った — verify 経由でなくても効くこと、$((3177+${CM_WORKTREE_INDEX:-0})) のシェル算術が不正値を黙って 0 に潰す(=全 worktree が 3177 に戻り、まさに直そうとしている衝突が再発する)のを型のある場所で例外にできること、規則を tests/e2e/fixtures/e2e-port.ts へ分離すれば単体テストで固定できること(playwright.config.ts 自体は import 時に ~/.commandmate-e2e を mkdir し git を起動するためテストから読めない)。優先順位は CM_E2E_PORT(明示)> CM_WORKTREE_INDEX(導出)> 3177(既定)で、未設定・空文字だけがオフセット 0 = #1871 以前の挙動と同一。e2e ゲートは常設しないと判断した: 宣言したゲートは既定で毎回走る(スキーマに「宣言はするが既定では走らない」フラグは無く、gateIds 省略時は work-evidence + verify.yaml の全ゲートが選ばれる)ため、常設は wait --verify を 1 ワーカーあたり 5 分以上伸ばして並列オーケストレーションの裁定時間に直結する一方(CI 実績 E2E 5m16s〜5m39s)、フル e2e は ci-pr.yml が PR ごとに回しており verify ゲートはその複製ではない — 代わりにコストゼロの配線だけを常設し、必要なときの足し方を .commandmate/verify.yaml のコメントと設計書 §9.1 に残した。実測記録と再現手順は docs/qa/1871-parallel-e2e-port-collision.md。計測用 worktree(commandmate-e2e-probe-a / -b)は削除せず残してある。src/ は 1 バイトも変更していない(#1771 の実装は出荷済みで、本件は配線と記録のみ)。副次観測として ~/.commandmate/worktree-index/ の 42 件中 40 件が実在しない wt-* = gate-runner.test.ts / gate-runner-timestamps.test.ts / hooks-agent-event.test.ts が CM_VERIFY_WORKTREE_INDEX_ROOT を stub せず開発者の HOME のレジストリに直接採番していることを確認したが、本 Issue のスコープ外として別 Issue 化を推奨する(本記録のポートが 3177+0,1 ではなく 3219/3220 になった理由でもある)

Fixed

  • test(setup): unit スイートが開発者の実 ~/.commandmate/worktree-index/ に幻の採番を書き込むのを止める (#1873): executeRun はコマンドゲートへ CM_WORKTREE_INDEX を渡すために resolveWorktreeIndex(worktreeId) を root 無しで呼ぶ(src/lib/verification/gate-runner.ts)ため、CM_VERIFY_WORKTREE_INDEX_ROOT を stub しないテストの wt-* フィクスチャがマシン共有・意図的に恒久のレジストリに枠を取っていた(枠は削除しても解放しない設計なので、一度走ったフィクスチャの分だけ実在 worktree が使える番号が永久に減る)。実測: 起票時点の実レジストリは 45 件中 40 件が実在しない wt-*(0 -> wt-window 〜 39 -> wt-counters-15。実在は 40 -> commandmate-issue-1849 以降の 5 件のみ)。受入条件は「test:unit の前後でエントリ数が変わらないこと」では成立しない — resolveWorktreeIndex は worktreeId で冪等(走査して owner が一致すれば既存枠を返す)なので、既に汚染済みのレジストリでは修正の有無にかかわらず 2 回目以降は増えない。そこで HOME を使い捨てディレクトリに向けた子プロセスで走らせ、実行後の $HOME/.commandmate/worktree-index/ のエントリ数を数えて判定した: 修正前は 4 ファイル(gate-runner / gate-runner-timestamps / hooks-agent-event / require-commit-conformance)で 26 件、修正後は 0 件(どちらも vitest exit=0)。対照実験として setup の 3 行を外して同じ測定を行うと 20 件(gate-runner と hooks-agent-event の 2 ファイルだけで)が再びクリーンな HOME に書かれ、新設ガードも 4 件中 3 件が赤になる = 塞いだことが空振りでないことを確認している。塞ぎ方は個別テストではなく共通 tests/setup.ts(#1760 の CODEX_HOME と同型)に置いた — 危険なのは既定値のほうで、来月書かれるテストが穴を知らないまま継承できる場所でなければ再発するため。??= ではなく未設定または空白のときだけ埋める: resolveWorktreeIndexRoot 自身が空文字・空白を未設定として扱いhome へフォールバックするので、??= だと「export はされているが空」のシェルで塞いだつもりのまま実レジストリへ戻る。逆に明示的に値が入っているときは尊重する(この env は隔離ランナーのためにも存在する)。Issue 本文の「どのテストも stub していない」は実測と食い違う: 本文の grep -rln "CM_VERIFY_WORKTREE_INDEX_ROOT" tests/ が 0 件だったのはリテラル文字列で検索したためで、gate-mutex.test.ts / gate-flaky.test.ts はエクスポート定数 WORKTREE_INDEX_ROOT_ENV 経由で vi.stubEnv 済み、worktree-index.test.ts は { root } を明示的に渡している(いずれも今回の pin より優先されるので挙動は変わらない)。逆に本文が挙げていない tests/unit/skills/cmate-verify/require-commit-conformance.test.ts が 4 本目の漏らし元だった(実レジストリの wt-conformance-* / wt-counters-* がこれ)。個別 stub を配って回る案を採らなかった理由でもある。再発防止に tests/unit/verification/worktree-index-isolation.test.ts を追加し、setup の綴りではなくproduction コードが実際に読む実効ルート(resolveWorktreeIndexRoot() を引数無しで)が home の外にあること・その主張が恒真でないこと(pin を外した既定は home 配下だと同時に検査する)・root 無しの実際の claim が pin 先に落ちて実レジストリの件数を変えないことを固定した。実レジストリの既存エントリは 1 件も削除・変更していない(幻エントリの掃除は別件。全測定は子プロセスの env としてのみ HOME を渡し、測定前後で実レジストリの全 45 行がバイト一致することを diff で確認済み)。src/ は 1 バイトも変更していない
  • test(helpers): temp-dir のレース検証ハーネスを「負荷で成立しなかった競合」で赤にしない設計へ直す (#1869): tests/unit/helpers/temp-dir.test.ts の the writer never collided with the walk in 4 rounds が高負荷環境のフル npm run test:unit でのみ落ちていた(実測: pristine な HEAD に 24 本の CPU バーナーを重ねてフル実行 3 連続 → 2 回赤。単独実行・無負荷フル実行・GitHub CI は緑)。Issue 本文の仮説「writer worker がコアを取れず、main スレッドの walk が先に走り切る」は実測と食い違う: ハーネスを計測して回すと、赤いラウンドでも writer は 101ms の walk の最中に 462 回の書き込みを成功させており(4 ラウンドとも delta=462/672/913/571)、飢餓ではなかった。startRacer のフラグ待ちが先にタイムアウトしていたのでもなく(別メッセージになる)、テスト自体のタイムアウトでもない(3.3 秒で終わっていた)。真の機構は Node の再帰 rmSync 自身が走査をやり直して再生成分を回収してしまうことで、ENOTEMPTY が外へ漏れるのは内部リトライ梯子の間じゅう木が汚れ続けていたときだけ = writer が 1 スレッドだと、コアを失った 1 ミリ秒が walk にちょうど必要な隙間を与える。同一条件の probe(同じ木・12 試行)での ENOTEMPTY 脱出率は writer 1 本が 無負荷 9/10・24 バーナー 11/12 に対し 64 バーナーで 0/12、writer 8 本なら 12/12・12/12・10/12(96 バーナーでも木を 16→32→64 dir と大きくすれば 7/12→11/12→12/12)。したがって対処は閾値(ラウンド数・木のサイズ)の引き上げではなく、(1) 競合を独立した 8 本の writer で staging する(隙間を開けるには 8 本が同時にコアを失う必要がある)、(2) ラウンドは木を大きくしながら wall-clock 予算内で回す、(3) それでも一度も重ならなかったら赤にせず、理由を stderr に出して skip する(競合が成立しなかったことは被テストコードの欠陥ではない)。あわせて スケジューリングに一切依存しない決定的テストを 2 本追加し、PR #1660 が直した欠陥(rmSync の maxRetries は同じパスに rmdir() を再発行するだけで走査をやり直さないため、walk 中に再生成された子には空振りする)の検出力を負荷から切り離した: 読めない子ディレクトリ(chmod 0o000)で 1 回目の走査を確実に失敗させ(macOS/Node 24 では実機で親の ENOTEMPTY = #1660 と同じコードが出る)、onRetry で障害物を外した 2 回目の走査だけが完了できること・障害物を外さなければ leak として stderr と getLeakedTempDirs() に載ることを検査する(0o000 を読めてしまう環境=root 実行は probe して skip)。検出力は隔離 worktree での変異注入で確認済み(無変異=緑 7/7 / attempts を実質 1 に潰して再walkループを殺す変異=赤。64 バーナー下 5 連続でも 5/5 赤で、レース側が staging に失敗しても決定的テスト側が必ず捕まえる)。検証は 24 バーナー下で当該ファイル 10 連続 10/10 exit 0(skip 発生 0)・フル test:unit 6 連続で temp-dir は 6/6 緑(app-version-display / tmux-capture-invalidation の 5000ms タイムアウトは修正前の pristine HEAD でも同条件で落ちる別件の負荷飢餓であり本件の scope 外)。旧ハーネスと新ハーネスを同一負荷(24 バーナー+フル test:unit 併走)で交互に 10 往復させた A/B では、旧 1/10 が the writer never collided with the walk in 4 rounds で exit 1、新は 10/10 exit 0(skip 0 = 競合は毎回実際に成立している)。無負荷では npm run lint / npx tsc --noEmit / npm run test:unit(907 files / 16912 tests)すべて exit 0。tests/helpers/temp-dir.ts(removeTempDir 本体)は 1 バイトも変更していない
  • test(verification): gate-runner-timestamps の計測窓アサーションからスケジューリングジッタ依存を外す (#1849): does not let the cost of writing the row leak into the window が単独実行では通るのにフル npm run test:unit でのみ落ちていた(実測: 24 本の CPU バーナー下で当該ファイルを 10 連続 → 3 回赤、expected 463/470/482 to be less than 460)。原因はロジックの回帰ではなく、上限 GATE_SLEEP_MS + insertDelayMs = 400 + 60 = 460 が**sleep 0.4 の spawn/wake/reap にかかる OS スケジューリングのジッタ 61〜82ms を許容できていなかったこと(このテストは暗黙に「sleep が 15% 以上遅延しない」ことを前提にしていた)。上限を単に緩めるとこのテストの検出力そのものが消えるため、書き込みコストの混入を絶対時間ではなく関係で表すように直した: INSERT のモックが自身の開始・終了時刻を観測し、started_at がその書き込みが終わった後であることを検査する(時間予算をまったく必要とせず、ジッタより小さい 1ms の混入でも赤くなる)。長さ側の混入を捕まえる上限は残したうえで注入コストと一緒に設計し直し**、INSERT_DELAY_MS を 60 → 400ms(=許容ジッタも 400ms、実測最悪値の約 5 倍)に引き上げた — 上限は注入コストそのものなので、どう大きくしても混入は必ず全量が検出される。注入待ちは busy-wait を Atomics.wait に変え(自分が測っている まさにそのプロセスと CPU を奪い合わないため)、対象ゲートの行だけが遅延を払うようにした。空振り防止として「注入が実際に払われたこと」も検査する。検出力は隔離 worktree での変異注入で確認済み(無変異=緑/started_at を書き込み時刻のまま残す・計測窓を INSERT 前から取る・duration_ms だけ膨らませる の 3 変異=いずれも赤。修正前のテストと同一の結果)。検証は 24 バーナー下 10 連続で 10/10 緑(修正前は同条件で 3/10 赤)。同ファイルの他の時間依存アサーション(GATE_SLEEP_MS - 50 の 2 箇所・timeout ゲートの下限)はいずれも下限=負荷が強くするほど成立が固くなる向きなので値は据え置き、その根拠をコメントに残した(timeout ゲートの 900 と sleep 0.4 は設定値から導出するようにして、定数と実際に測る対象が乖離しないようにした)