v0.26.1
[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:unit6 連続で 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は設定値から導出するようにして、定数と実際に測る対象が乖離しないようにした)