diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fdd26324b..930b397a9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -26,6 +26,15 @@ on: - "pytest.ini" - "pyproject.toml" +# 同じ Pull Request で新しい push が来たら、古い実行を取り消す(#2154・#2291 の決定 5)。 +# push(main / develop)は実行ごとに別の組へ入れて最後まで回す。同じ組に新しい実行が入ると、 +# 待っている前の実行は cancel-in-progress が false でも取り消され、コミットごとの記録が欠けるため。 +# 取り消した実行でも集約のジョブ pytest は always() で走り、古いコミットに失敗を付ける。 +# head のコミットのチェックには新しい実行の結果だけが付く +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: # 配布の PR(版数と説明だけの差分、または develop で通ったコミットを進めるだけの main 宛の # PR)では重いジョブを省く(#1055)。判定は scripts/ci-heavy-skip.py が差分と develop の @@ -75,8 +84,11 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" - - name: Install uv - run: python3 -m pip install --upgrade uv + # uv と依存の取得を uv.lock をキーにしたキャッシュで縮める(#2291 の決定 6) + - uses: astral-sh/setup-uv@v10.3.3 + with: + enable-cache: true + cache-dependency-glob: uv.lock # 起点はリポジトリの根に置く。環境は根の pyproject.toml と uv.lock が固定する(#1142 の決定 22)。 # playwright_kit・plugins/ndf の全グループ(--all-extras)・pytest と pytest-xdist が入り、 # 包みを呼ぶ側のテストも同じプロセスで外部パッケージを import できる。 diff --git a/conftest.py b/conftest.py index 79a4edaf8..ef6f8edb1 100644 --- a/conftest.py +++ b/conftest.py @@ -16,6 +16,8 @@ シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678) 6. `SHARD_TOTAL` と `SHARD_INDEX` が与えられたら、収集した項目をファイル単位で分け、 自分の分だけを残す。継続的統合がジョブを分けて回すために使う(#882) +7. pytest が SIGTERM で止められたとき・終わったときに、テストが起こした子孫のプロセスを止める + (`scripts/pytest_child_cleanup.py`。#2291) どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は 根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。 @@ -37,9 +39,11 @@ # `pytester` は playwright-kit の pytest plugin の自己テストが使う。pytest はこの宣言を # 最上位の conftest.py でしか受け付けない。 -pytest_plugins = ["pytester"] - +# `pytest_child_cleanup` は、テストが起こした子孫のプロセスを、pytest が SIGTERM で止められたとき・ +# 終わったときに止める(#2291)。親と xdist の各 worker で働く。 ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "scripts")) +pytest_plugins = ["pytester", "pytest_child_cleanup"] # `plugins//dev.agy/skills/` は配布 Skill への symlink である。実体は # `plugins//skills/` にあり、そちらを収集する。両方をたどると同じテストが 2 度 diff --git a/docs/ndf-experiments.md b/docs/ndf-experiments.md index f39a13bd0..b5000698e 100644 --- a/docs/ndf-experiments.md +++ b/docs/ndf-experiments.md @@ -16,6 +16,7 @@ | `runner-trial.py` | #1142 の不足 i の設計の前に、プランの実行とキューを置き換える候補(LangGraph の SqliteSaver・Burr の SQLitePersister・DBOS Transact の SQLite)で、`supervise.py new impl` の雛形(ステップを sleep と決めた終了コードの偽物にしたもの)・キュー・kill -9 からの再開・進捗ログと `supervise.py wait` を保てるかを確かめる | 2026-09-26・`exp/runner-trial` | 2026-09-26: 3 候補ともサーバーを立てず(プロセスの待ち受けのポート 0)SQLite のファイルだけで動き、確かめた 5 つが成り立った(`check <候補>` の結果がすべて ok)。表せるか: `on_fail`・judge の選択・上限 20・承認ゲート(止まり、承認の無い打ち直しは同じ所で止まったまま、承認で続く)を 5 つのシナリオで supervise.py の規則どおりの経路で流した。LangGraph の `interrupt()` は続けるときノードを頭から流し直すため、止める前の副作用はノードの外に置く。`recursion_limit` は invoke ごとに数え直すため、上限は状態で数える。Burr は遷移を `when(cur=...)` で行き先ごとに並べ、承認ゲートは `halt_before` で止める。DBOS は承認ゲートで `DBOS.recv` の待ちのままプロセスを抜け(`os._exit`)、打ち直しの `DBOS.launch()` が記録から続ける(落ちた後は状態が ENQUEUED に見える)。キュー: 6 本(ステージ 4 本 → 2 本)を同時 2 本・GraphQL の枠 1 本で流し、同時は最大 2 本・GraphQL は最大 1 本(枠の待ちが 1〜8 回起きた)で、後のステージは前のステージがすべて終わってから流れ、前のステージの 1 本が止まると後のステージは流れなかった。キューを持つのは DBOS だけ(`register_queue` の `worker_concurrency`。資源のタグは別のキューの `concurrency`)。LangGraph と Burr はキューを持たないため、子プロセスと flock の枠で自前にした(約 60 行)。6 本の所要は LangGraph 6.9 秒・Burr 6.0 秒・DBOS 12.4 秒(DBOS はキューの問い合わせの間隔 0.1 秒の分だけ遅い)。再開: 3 候補とも kill -9 のときに流れていたステップ(test-limited)から流し直し、済んだステップは流し直さなかった。費用(uv 0.12.19・キャッシュの冷えた状態。LangGraph / Burr / DBOS の順): 解決 0.28 / 0.45 / 0.21 秒、導入 0.32 / 0.20 / 0.45 秒(キャッシュが温まって 0.03 / 0.01 / 0.01 秒)、大きさ 54.9 / 20.2 / 60.6 MB、依存 41 / 2 / 11 件(LangGraph は langchain-core・langsmith・pydantic-core を、DBOS は SQLite でも SQLAlchemy・psycopg-binary を入れる)、import 冷えて 0.82 / 0.06 / 0.78 秒・温まって 0.26 / 0.04 / 0.24 秒、起動し直しを含むエントリポイント(温まって)0.31 / 0.08 / 0.31 秒。進捗ログと wait: 進捗ログの行(step・attention)はライブラリでなく NDF の部品がステップの中で書き、今の `supervise.py wait` は手を入れずに attention で 20、done で 0 を返した | 本体へ移した(DBOS Transact を SQLite で `lib/durable.py` の包みから使う。#1142 のスプリント 2c: 依存と包み #1538・プランの実行 #1548・投稿キュー #1545・cross-review の drive #1540・cross-refactoring の drive #1541・キューと重なりと資源の枠 #1551)。試行は Q6 で消した | | `hook-trial.py` | #1142 の決定 20(hook を 1 本の Python のエントリポイントへまとめる)の前に、成り立ちと退行を確かめる。今の hook のテストの入力のすべてを tree-sitter-bash(py-tree-sitter)で判定して今の判定と比べる・hook 1 回の所要・環境が無いときに判定をせずに通るか・claude-agent-sdk が `supervise_lib/claude.py` の `claude_cmd`・`call_claude` の契約を満たすか・bump-my-version が `release-steps.py` の `cmd_bump` の 15 箇所の書き換えを満たすか | 2026-09-26・`exp/hook-trial` | 2026-09-26: 5 つのうち 3 つが成り立ち、2 つが成り立たなかった。判定: hook のテスト(3121 件)が渡した入力を集めた。書き込み先の推定 552 件・コマンド 546 件のうち、前景の sleep とプランを起こす副命令は全件で一致した。書き込み先は 2 件が食い違った。`! case … esac` は tree-sitter-bash が読めず(否定した複合コマンドは ERROR になる)、worktree の側と取り違える。`cp a.txt $(basename b).txt` は今の実装が `b).txt` を出し、テストがそれを期待として固定している(tree-sitter の側は展開を含む語として出さない)。構文木の癖が 5 つあり、包みの側で直した: 並びとパイプの末尾のリダイレクトが並び全体に付く・リダイレクトの後ろの語が destination として並ぶ・`<>` が ERROR になる・`time` がコマンド名になる・ヒアドキュメントの本文の `$((…))` をコマンド置換と読み、バッククォートは読まない。所要(40 回ずつの中央値。宣言のあるリポジトリ): 今の `worktree-guard.sh` は 26.8〜76.1 ms、1 本のエントリポイント(用意済みの環境の `bin/python` を直に起動)は 20.1〜21.6 ms で、どの入力でも速かった。内訳は python の起動 6.9 ms・tree-sitter の import 込み 16.9 ms。環境が無いとき: hook の command を `sh -c '[ -x "$0" ] || exit 0; exec "$0" "$1"'` の形にすれば、出力なし・終了コード 0 で通る。python を直に指す形は終了コード 127 で終わる。tree-sitter を import できない python でも終了コード 0 で通る。claude-agent-sdk 0.2.160: 3 つの形(最小構成・work の Tool と Serena・Skill を回す形と `--resume`)の引数を今の引数と同じに組めた。打ち切ると子のプロセスは残らない。結果の値(本文・使用量・modelUsage・費用・往復・セッション)の形も同じだった。ただし 2 つが成り立たない。1 つ目は、今のテストの偽物の CLI(JSON を 1 つ書く)が SDK の下で動かないこと。2 つ目は、同じ依頼(haiku)で入力が 281 → 1039 トークン、費用が 2.73 倍になり、`usage` の入力(281)が modelUsage(1039)と合わないこと。同梱の CLI でも手元の CLI(どちらも 2.1.283)でも同じだった。bump-my-version 1.5.1: `10.17.30-dev.1` → `-dev.2` と → `10.17.31` の両方で、`cmd_bump` と同じ差分(7 と 8 ファイル)を書いた。手で直す箇所の報告と `check-doc-staleness.py` の実行は NDF の側に残る。2026-09-26(T2b): SDK の 2 点はどちらも SDK の設定で揃えられた。費用: SDK は子の CLI へ `CLAUDE_AGENT_SDK_VERSION` を必ず立て、CLI はこれを見て会話のタイトルを作る呼び出し(haiku・`source=generate_session_title`)を本体の前に 1 回足す。`usage` は本体の呼び出しだけを、modelUsage と費用は両方を数えるため合わない。`claude -p` の直の起動(`CLAUDE_CODE_ENTRYPOINT=sdk-cli`)では足さない。引数と環境を 1 つずつ入れ替えて測ると、`--input-format stream-json`・`CLAUDECODE` を外すこと・`CLAUDE_CODE_SDK_READS_SESSION_STATE` は入力を変えず、`CLAUDE_AGENT_SDK_VERSION` か `CLAUDE_CODE_ENTRYPOINT=sdk-py` を立てたときだけ 275 → 1033 トークンになった。`ClaudeAgentOptions(env={"CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1"})` で呼び出しが止まり、`hook-trial.py sdk --live --runs 3` で入力 280 トークン(usage と modelUsage が一致)・費用 1.01 倍(止めない形は 1038 トークン・2.77 倍)になった。`CLAUDE_CODE_ENTRYPOINT` を `env` で上書きしても `CLAUDE_AGENT_SDK_VERSION` が残るため止まらない。偽物の CLI: SDK は起動の直後に stream-json の `control_request`(`initialize`)を書いて `control_response` を待つ。今の偽物は標準入力の EOF を待つため双方が待ち合って止まる。制御の要求へ答えて `user` を 1 つ読み `result` を 1 つ書く偽物(`cli_path`・約 15 行)と、プロセスを起こさない `query(transport=...)` の差し替えは、どちらも ResultMessage を返した | 本体へ: tree-sitter-bash と hook の 1 本化はスプリント 2b の D5、bump-my-version は D7。claude-agent-sdk は採らない(`issues/issue-1142-design-libraries.md` の決定 24) | | `training-optout.py` | LLM へ入力を渡す前に、そのアカウントが入力を学習に使わない設定かを 1 行の JSON で返す。claude は OAuth の `account/settings` の `grove_enabled`、他は `unsupported` | 2026-10-02・`exp/training-optout` | 2026-10-02: Claude Max の実物のアカウントで `training: false`(`grove_updated_at` 2026-08-13)・終了コード 0。`--runtime kiro` を足すと `unsupported` で 3 | 2026-10-02: 本体へ昇格(`plugins/ndf/scripts/training-optout.py`。`check` に codex の読み取り、`session-start` で SessionStart の書き換えを足した。#1597) | +| `whole-test-timing.py` | 全体テストを宣言の順に同じ条件(開始時の load average・junit の集計)で測り、件数・各テストの所要の合計・遅いテストの上位 30 件・ファイルの上位 10 本を出す。着手前後のテストの集合を台帳と照らし、台帳に無い消えたテストと skip に変わったテストを出す | 2026-10-31・`feat/issue-2291-l20261031-5` | 2026-10-31: #2291 の着手前後の計測と台帳の照合に使った(結果は `issues/issue-2291-test-ledger.md`) | 未定 | ## 実験版の仕組みそのものの直し diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index c693a8a39..205cfd2e4 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -294,6 +294,29 @@ uv run --with pytest --with pytest-xdist pytest scripts/tests -q -n auto 対象プロジェクト(`pyproject.toml`)が無く、`pytest` が環境にも入っていないためです。 `plugins/ndf/skills/*/tests/` の既存のテストも同じ形で実行します。 +### テストを削る基準 + +全体テスト(`.ndf/project.json` の `test.suites` の全部)は手元で 180 秒以内に終わる大きさに保つ。テストの数がそのまま全体テストの長さと保守の費用になるため、守る振る舞いを別のテストが確かめているテストは残さない。次のどれかに当たるテストは消すか、1 件にまとめる。 + +| # | 基準 | 例 | +| --- | --- | --- | +| 1 | 重複: 同じ経路を、別の名前で同じ入力から確かめている | 同じ CLI を同じ引数で起動し、同じ終了コードを見るテストが 2 件ある | +| 2 | 消えた機能の残骸: 廃止した機能・オプションを確かめている | 外した副命令が「無い」ことだけを確かめる | +| 3 | `.md` の文言の固定 | Skill の本文に決まった文があるかを照合する | +| 4 | 実装の細部の固定: 内部の関数名・呼び出しの回数・中間の値を固定し、利用者に見える振る舞いを確かめていない | 判定は NDF の `tdd-cycle` の `references/test-quality.md` の「判定の基準」に従う | +| 5 | 総当たり: 1 つの振る舞いを、全テンプレート・全組み合わせで確かめている | 代表は「分岐ごとに 1 つ・境界の両側」で選び、残りを消す | +| 6 | suite・CI のジョブが既に走らせる検査を、テストの中でもう一度走らせる | `script-structure` の suite と同じ検査を、テストがリポジトリ全体へ流す | + +消す・まとめるときは、1 件ごとに、当たった基準の番号と、守っていた振る舞いを今後どのテストが守るか(守らなくてよいならその理由)を PR から追える形で残す。落ちるから消すのは基準に当たらない。 + +遅いテストを速くするときは、確かめる振る舞いを弱めずに次の 3 つの手段を使う。テストを速くするためにテストの対象のコードを変えない。 + +- 待ちの値を、対象のコードが既に持つ環境変数で縮める +- 1 つの CLI の配線(引数・終了コード・結果 JSON)は代表の数件の起動で確かめ、分岐はプロセスの中で関数を呼んで確かめる。スクリプトの起動だけで 1 件 0.2〜0.4 秒かかる +- git のリポジトリのような重い準備は、プロセスごとに 1 度作って複製する + +テストが起こした子孫のプロセスは、pytest が SIGTERM で止められたときと終わったときに、根の `conftest.py` が読み込む `scripts/pytest_child_cleanup.py` が止める。テストの中で止め忘れても次のテストへ持ち越さないが、テストは自分で起こしたプロセスを自分で止める。 + ローカル hook は任意で導入できます。 ```bash diff --git a/docs/specifications/cross-refactoring-init-test-and-phase-windows.md b/docs/specifications/cross-refactoring-init-test-and-phase-windows.md index bbf123686..f58633b76 100644 --- a/docs/specifications/cross-refactoring-init-test-and-phase-windows.md +++ b/docs/specifications/cross-refactoring-init-test-and-phase-windows.md @@ -6,9 +6,11 @@ 提案とリファクタリング計画の枠は着手前のテストの終わりから数え、全体の締め切り(`started_at + B`)は動かさない。 伸びた分はリファクタリング計画で採る件数が減ることで吸収する - **枠が想定最大時間に収まらないときは、提案者を起動せずに止まる。** 要る想定最大時間の下限を出し、予算を広げて - `init` を打ち直すと、着手前のテストを走らせ直さずに続く -- **CI に任せる戦略の着手前のテストの上限は、範囲テストの所要が分かればそれを下回らない。** 所要は同じ範囲の履歴の実測か、 - 範囲が全体テストの suite を覆うときの w から取る。範囲の広いスプリントでも、予算を広げずに init を通る + `init` を打ち直すと、着手前のテストを走らせ直さずに続く。止まるのは想定最大時間を明示した実行だけで、省いた実行は + `init` が想定最大時間の下限まで上げて続ける +- **着手前のテストの上限は、所要の見込み s が分かればそれを下回らない。** s は CI に任せる戦略なら同じ範囲の履歴の実測を先に採り、 + 無ければ(ほかの戦略も)手元の全体テストの直近の実測 m、無ければ宣言の w から取る(戦略を問わない。w は置き場所が suite を覆うかを問わない)。 + 範囲の広いスプリントでも、予算を広げずに init を通る 共通の原則は「**着手前のテストの上限と、その後の手順の枠を、同じ算術の層(`test_strategy.limits` と `timeline`)で入力だけから決める**」である。 @@ -87,13 +89,16 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 #1385 と同じ打ち切りに戻る - **枠が想定最大時間に収まらなければ、`init` で状態を保存してから止める。** 枠を残りの時間へ縮めて起動すると、縮めた枠 (90 秒)で提案 0 件になった実測がある。予算を自動で広げないのは、想定最大時間が利用者の与えた上限だからである。 - 保存しておけば、予算を広げた打ち直しは着手前のテストを走らせ直さずに続く + 保存しておけば、予算を広げた打ち直しは着手前のテストを走らせ直さずに続く。自動で広げないのは明示した + 想定最大時間(出所 `explicit`)だけにした。省いた実行(出所 `default`)は利用者の上限を持たないため、`init_test.settle_window` が + 下限まで上げて出所を `floor` にする。明示で収まらないときは `SKIP=window` を出して止まり、駆動はリファクタリングを飛ばした + 欠けの残る結果(終了コード 6)で返す。理由は [#2047 の設計](../../issues/issue-2047-design.md) の決定 2・決定 3 にある - **止めた後の打ち直しでだけ、枠の起点を打ち直した時刻へずらす。** 起点を `started_at + x` のまま残すと、止まってから人が 打ち直すまでの数分が提案の枠から引かれ、下限どおりの予算で提案者が余裕だけで起動される。提案や計画の途中で落ちた再開まで ずらすと、提案が揃っているのに枠が想定最大時間を越えて止まる。計画を取り込んだ後は採った件数が期限に合わせてあるため、 保存した期限で続ける - **要る想定最大時間の下限には、人が打ち直すまでの 300 秒(`RESUME_GRACE_SECONDS`)を足す。** 足さないと、出した下限で - 打ち直したときに経過の分だけ o が伸びて再び収まらない + 打ち直したときに経過の分だけ o が伸びて再び収まらない。打ち直さずにその場で上げる `settle_window` は足さない(`grace_seconds=0`。#2047 の決定 2) - **範囲テストの所要は、着手前のテストの行を履歴に別の種類(`kind: init_test`)として足して実測から引く。** 実行の行は 最終ゲートを通った実行だけが書くため、中断した実行と init で打ち切られた実行が残らず、範囲が suite の `paths` を覆わない リポジトリではいつまでも所要が分からない。打ち切りの行は上限の秒を下限の実測として残し、次の実行は `3·上限` で走らせる @@ -118,10 +123,10 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | I2 | `propose_end_at = started_at + o + 0.20·B`、`plan_end_at = propose_end_at + 0.10·B`。o は `resumed_at` が無ければ x(`baseline_test.seconds`。無ければ 0)、あれば `max(x, resumed_at − started_at)` | 単体テストで落とす | | I3 | `resumed_at` を書くのは、止めた印(`window_stopped_at`)を持つ状態をリファクタリング計画の前に打ち直したときだけである。印の無い再開と計画を取り込んだ後の再開は `resumed_at` を変えない | `init` のテストで落とす | | I4 | `fix_end_at`・`final_end_at`・実装の終わりは `started_at` から数え、x に依らない(項目ごとの期限は持たない) | 単体テストで落とす | -| I5 | `plan_end_at` に計画の後の見込み(`after_plan_minutes`。バッファの見込みと最短の項目 1 件)を足した時刻が `final_end_at` を越える状態で提案者を起動しない。`init`(新規と、計画の前の再開)は止めた印を残して状態を保存し、要る想定最大時間の下限を出して終了コード 4 で止まる。収まれば印を消す | `init` のテストで落とす | +| I5 | `plan_end_at` に計画の後の見込み(`after_plan_minutes`。バッファの見込みと最短の項目 1 件)を足した時刻が `final_end_at` を越える状態で提案者を起動しない。`init`(新規と、計画の前の再開)は、想定最大時間の出所が `explicit`(`budget_source` を持たない旧い状態も同じ)なら `SKIP=window` を出し、止めた印を残して状態を保存し、要る想定最大時間の下限を出して終了コード 4 で止まる。`default` / `floor` なら下限まで上げて続ける。収まれば印を消す | `init` のテストで落とす | | I6 | 計画を取り込んだ後の再開は `limits` を組み直さず、`window_problem` も見ない | `init` のテストで落とす | -| I7 | CI に任せる戦略の `init_test_timeout` は、範囲テストの所要 s が分かれば `max(0.10·B, 3·s)`、分からなければ `0.10·B`。ほかの戦略と予算なしの値は s を渡しても変わらない | 単体テストで落とす | -| I8 | s は、同じ戦略・同じ置き場所の集合の `init_test` 行の直近 10 行の秒の最大 → 置き場所が全体テストの suite の `paths` を覆うときの w → 無し、の順に採る。CI に任せる戦略でなければ履歴を読まない | 単体テストで落とす | +| I7 | `init_test_timeout` は、戦略を問わず着手前のテストの所要の見込み s が分かれば `max(0.10·B, 3·s)`、分からなければ今の値(CI に任せる戦略は `0.10·B`、ほかは `max(0.10·B, 3·w)`)。予算なしの値は s を渡しても変わらない | 単体テストで落とす | +| I8 | s は、CI に任せる戦略なら同じ戦略・同じ置き場所の集合の `init_test` 行の直近 10 行の秒の最大 → 手元の全体テストの直近の実測 m(`recent`)→ 宣言の w(`whole`。置き場所が suite を覆うかは問わない)→ 無し、の順に採る。CI に任せる戦略でなければ履歴を読まず m → w を採る | 単体テストで落とす | | I9 | 新規の `init` では、着手前のテストに使った上限と、状態から組み直した `init_test_timeout` が一致する(s は `baseline_test` に残る)。再開で予算を置き換えたときは置き換えた後の予算から組み直す | 単体テストで落とす | | I10 | 着手前のテストは成否と打ち切りを問わず `init_test` 行を 1 行足してから次へ進むか止まる | `init` のテストで落とす | | I11 | `init_test` 行は配分テーブルにも `ndf_record` にも数えない。`schema` が 2 未満の行と `kind` を持たない行は実行の行として読み、範囲の一致に使わない。読んでも落ちない | 単体テストで落とす | @@ -130,8 +135,8 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | 関数 | 契約 | | --- | --- | -| `limits(strategy, budget_minutes, ..., scope_seconds=None, scope_source=None)` | キーワード引数 2 つを足した(既定 `None` で、今の呼び出しはそのまま通る)。予算があるときだけ `basis` に `scope_seconds` / `scope_source` を書く(`scope_seconds` が `None` なら両方 `None`)。予算が無いとき(supervise)は表も `basis` の形も変えない。`test_timeout` と `whole_timeout` の代わりの値(x・w が無いとき)は s を入れない着手前の上限のまま | -| `covers_whole(strategy, locations, kind=TEST)` | `kind` の suite の `paths` の全要素を、置き場所のどれかが同じか祖先として覆えば真(純粋)。置き場所は正規化して比べる。`.` と glob の要素は置き場所 `.` だけが覆う。`kind` の suite が無いか置き場所が無ければ偽 | +| `limits(strategy, budget_minutes, ..., scope_seconds=None, scope_source=None)` | キーワード引数 2 つを足した(既定 `None` で、今の呼び出しはそのまま通る)。`scope_seconds` は戦略を問わず着手前の上限に効く。予算があるときだけ `basis` に `scope_seconds` / `scope_source` を書く(`scope_seconds` が `None` なら両方 `None`)。予算が無いとき(supervise)は表も `basis` の形も変えない。`test_timeout` と `whole_timeout` の代わりの値(x・w が無いとき)は s を入れない着手前の上限のまま | +| `covers_whole(strategy, locations, kind=TEST)` | `kind` の suite の `paths` の全要素を、置き場所のどれかが同じか祖先として覆えば真(純粋)。置き場所は正規化して比べる。`.` と glob の要素は置き場所 `.` だけが覆う。`kind` の suite が無いか置き場所が無ければ偽。s の決め方には使わない | ### 手順の枠(`refactor_lib/timeline.py`) @@ -141,7 +146,7 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | `window_offset(state)` | o(秒)。I2 の式で、状態の `baseline_test.seconds`・`started_at`・`resumed_at` を読み、締め切りをずらした止まっていた時間(`pause.shifted_seconds`。#1944)を足す | | `of_state(state)` | `window_offset(state)` を `compute` へ渡す。`test_limits(state)` は `baseline_test.scope_seconds` / `scope_source` を `limits` へ渡す(I9) | | `RESUME_GRACE_SECONDS` | 300。人が文を読んで打ち直すまでの見込み | -| `required_budget_minutes(offset_seconds, after_plan=0)` | `ceil(((o + RESUME_GRACE_SECONDS) / 60 + after_plan) / (1 − PROPOSE_SHARE − PLAN_SHARE))`(分)。見込みが 0 なら o = 1300 で 39、1700 で 48 | +| `required_budget_minutes(offset_seconds, after_plan=0, grace_seconds=RESUME_GRACE_SECONDS)` | `ceil(((o + grace_seconds) / 60 + after_plan) / (1 − PROPOSE_SHARE − PLAN_SHARE))`(分)。見込みが 0 なら o = 1300 で 39、1700 で 48。`settle_window` の上げる経路だけ `grace_seconds=0` | | `window_problem(limits)` | `plan_end_at + after_plan_minutes > final_end_at` なら止める理由の文(`limits` から o を逆算し、下限を `required_budget_minutes` で出す)、収まれば `None`(純粋)。見込みの出し方は [実装の時間と採り直しの確定仕様](cross-refactoring-implementation-window-and-readopt.md) にある | ### 着手前のテスト(`refactor_lib/baseline.py`・`refactor_lib/init_test.py`) @@ -150,18 +155,20 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | --- | --- | | `baseline.locations_of(strategy, scope, work)` | CI に任せる戦略(`round-only` を除く)で着手前に走らせる範囲テストの置き場所を、正規化して並べ替えた集合で返す。ほかの戦略は空。`commands_of` も同じ関数で置き場所を求める | | `baseline.run_baseline(...)` | 上限を超えたら止めずに記録を返す(`timed_out: true`・`seconds` は上限の秒。既存失敗の読み取りは行わない)。止めるのは起動の失敗だけ。記録に `locations`(`scope` のときだけ)と `timed_out` を足した | -| `init_test.resolve_scope_seconds(prep, scope, w)` | `(s, 出所)`。I8 の順で `history` / `whole` / `(None, None)`。CI に任せる戦略でなければ履歴を読まずに `(None, None)` | +| `init_test.resolve_scope_seconds(prep, scope, w, m=None)` | `(s, 出所)`。I8 の順で `history` / `recent` / `whole` / `(None, None)`。CI に任せる戦略でなければ履歴を読まずに m → w を採る | +| `init_test.recent_whole_seconds(prep)` | 手元の全体テストの直近の実測 m(`whole_runs.recent_whole`)。無ければ `None` | +| `init_test.extend_for_init_test(budget_minutes, timeout)` | 上限 T が `0.10·B` を超えるときだけ、テストの前にステップの延長へ `B·60 + (T − 0.10·B·60)` 秒を書く(`NDF_STEP_EXTEND_FILE` が無ければ何もしない。#2047 の決定 4) | | `init_test.append_init_test(prep, budget_minutes, pr, baseline)` | `init_test` 行を 1 行追記する。書けなければ知らせて続ける | -| `init_test.abort_timed_out(strategy, budget_minutes, baseline, timeout)` | 打ち切りの文で終了コード 4 にする。`scope` の CI に任せる戦略なら、次の実行の上限の見込み(`3·上限` を s とした `limits`)を添える | -| `init_test.stop_if_window_short(state_file, state)` | `window_problem` が文を返せば `window_stopped_at` を書いて保存し、終了コード 4。`None` なら印を消し、消したときだけ保存する | +| `init_test.abort_timed_out(strategy, budget_minutes, baseline, timeout)` | `SKIP=init-timeout` と `SKIP_DETAIL` を出し、打ち切りの文で終了コード 4 にする。`scope` の CI に任せる戦略なら、次の実行の上限の見込み(`3·上限` を s とした `limits`)を添える | +| `init_test.settle_window(state_file, state)` | `window_problem` が `None` なら印を消し、消したときだけ保存する。文を返し、出所(`budget_source`)が `explicit` なら `SKIP=window` と `SKIP_DETAIL` を出し、`window_stopped_at` を書いて保存し、終了コード 4。`default` / `floor` なら `budget_minutes` を `grace_seconds=0` の下限へ上げ、`limits` を組み直して出所を `floor`・`budget_floor` を書いて保存し、`ℹ` の 1 行を出して続ける | `refactor.py init` の順序(`commands/setup.py`): -1. `_verify_init`: w と c を読み、`resolve_scope_seconds` で s を求めて `limits` から上限を出す → `run_baseline` → +1. `_verify_init`: w と c と m を読み、`resolve_scope_seconds` で s を求めて `limits` から上限を出す → `extend_for_init_test` → `run_baseline` → `baseline_test` に `scope_seconds` / `scope_source` を残す → `append_init_test` → 打ち切りなら `abort_timed_out` -2. `cmd_init`: `_save_initial_state` で状態を保存した後に `stop_if_window_short`。止まらなければ出力(`PHASE=` など)へ進む +2. `cmd_init`: 既定を埋める前に出所(`explicit` / `default`)を決め、`_save_initial_state` で状態を保存した後に `settle_window`。止まらなければ出力(`PHASE=`・`BUDGET_SOURCE=` など)へ進む 3. `_resume`: 計画の前後を問わず先に `pause.resume_after_pause` で止まっていた時間を測る。計画の前(`state.plan` が無い)なら、 - 印があるときだけ `resumed_at` を今にし、`limits` を `of_state` で組み直して `stop_if_window_short`。計画の後は組み直さない(I6) + 引数で想定最大時間を渡せば出所を `explicit` にし、印があるときだけ `resumed_at` を今にし、`limits` を `of_state` で組み直して `settle_window`。計画の後は組み直さない(I6) ### 履歴の行(`refactor_lib/allocation.py`) @@ -199,9 +206,10 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | --- | --- | --- | --- | --- | | `baseline_test` | `locations` | 着手前に走らせた範囲テストの置き場所(`scope` 以外は `null`) | `baseline.run_baseline` | `allocation.init_test_row` | | `baseline_test` | `timed_out` | 上限で打ち切ったか | 同上 | `setup._verify_init`・`allocation.init_test_row` | -| `baseline_test` | `scope_seconds` / `scope_source` | 上限に使った s と出所(`history` / `whole`。分からなければ両方 `null`) | `setup._verify_init` | `timeline.test_limits` | +| `baseline_test` | `scope_seconds` / `scope_source` | 上限に使った s と出所(`history` / `recent` / `whole`。分からなければ両方 `null`) | `setup._verify_init` | `timeline.test_limits` | | `limits.basis` | `scope_seconds` / `scope_source` | 同上の写し | `test_strategy.limits` | `plan.limits_section`(計画のコメントの「入力:」の行) | -| 状態の根 | `window_stopped_at` | 枠が収まらずに止めた時刻(止めた印) | `init_test.stop_if_window_short` | `setup._resume` | +| 状態の根 | `window_stopped_at` | 枠が収まらずに止めた時刻(止めた印) | `init_test.settle_window` | `setup._resume` | +| 状態の根 | `budget_source` / `budget_floor` | 想定最大時間の出所(`explicit` / `default` / `floor`)と、上げたときの前後の分 | `setup.cmd_init`・`setup._resume`・`init_test.settle_window` | `settle_window`・`_emit_init`・`allocation.build_row`・`report` | | 状態の根 | `resumed_at` | 止めた後に打ち直した時刻 | `setup._resume` | `timeline.window_offset` | `limits` を持つ旧い状態ファイルは書き出した値を使い、持たないものは `of_state` が新しい式で組む。`baseline_test.scope_seconds` が @@ -230,12 +238,16 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 `baseline_test` から組み直した `init_test_timeout` も 960(`test_the_scope_test_seconds_feed_the_next_init_limit`) - 打ち切りでも `seconds: 180.0`・`timed_out: true` の行を足してから終了コード 4 で止まり、次の上限は 540 (`test_a_timed_out_init_test_is_recorded_before_stopping`) -- 置き場所が suite の `paths` を覆い履歴に一致が無ければ s = w、w が無ければ無し(`test_the_whole_test_covers_the_scope`) +- 置き場所が suite の `paths` を覆い履歴に一致が無ければ s = w、w が無ければ無し(`test_the_whole_test_covers_the_scope`)。 + #2047 の振る舞い(m を w より先に採る・明示で収まらなければ `SKIP=window`・省けば下限へ上げる・打ち切りの `SKIP=init-timeout`・ + テストの前のステップの延長)は `test_the_init_estimate_order`・`test_the_first_init_limit_reads_the_recent_whole_test`・ + `test_an_omitted_budget_rises_to_the_lower_bound`・`test_an_explicit_budget_short_of_the_floor_emits_skip`・ + `test_a_timed_out_init_test_emits_skip`・`test_a_long_init_limit_extends_the_step_before_the_test` が縛る (以上 `plugins/ndf/skills/cross-refactoring/tests/test_init.py`) - `limits` が CI に任せる戦略・B 30 で s 320 → 960、424 → 1272、無し → 180、30 → 180 を返し、`basis` に s と出所が残る (`test_the_ci_strategy_init_limit_does_not_fall_below_the_scope_test`)。s で `test_timeout`・`whole_timeout`・`ci_wait_timeout` は変わらない (`test_the_scope_test_does_not_change_the_other_limits`) -- `local-full` / `round-only` は s を渡しても `max(0.10·B, 3·w)`(`test_other_strategies_ignore_the_scope_test`)。予算なしの表は +- `local-full` / `round-only` も s を渡せば `max(0.10·B, 3·s)`、渡さなければ `max(0.10·B, 3·w)`(`test_other_strategies_take_the_init_test_estimate`。#2047)。予算なしの表は s を渡しても変わらず、`basis` に `scope_seconds` を持たない(`test_supervise_limits_stay_the_same`) - `covers_whole` が祖先・正規化・片方だけ・子・`.`・glob・空の置き場所・テストの suite が無いときを区別する (`test_covers_whole`・`test_covers_whole_needs_a_test_suite`)(以上 `plugins/ndf/scripts/tests/test_lib_test_strategy.py`) diff --git a/docs/specifications/cross-refactoring-time-budget.md b/docs/specifications/cross-refactoring-time-budget.md index 4b034061b..c63427dc0 100644 --- a/docs/specifications/cross-refactoring-time-budget.md +++ b/docs/specifications/cross-refactoring-time-budget.md @@ -163,7 +163,7 @@ | 引数 | 扱い | 既定 | | --- | --- | --- | -| `--budget-minutes N` | 1 以上の整数。それ以外は `init` が終了コード 4(argparse の 2 にしない) | 30(`vocabulary.DEFAULT_BUDGET_MINUTES`) | +| `--budget-minutes N` | 1 以上の整数。それ以外は `init` が終了コード 4(argparse の 2 にしない)。明示した値は上げない | 30(`vocabulary.DEFAULT_BUDGET_MINUTES`)。省いて手順の枠が収まらなければ想定最大時間の下限まで上げる | | `--implementer NAME` | 参加者の中の 1 者。参加者に無ければ終了コード 4 | 下の決め方 | | `--round-test CMD` | 範囲テストを組み立てる元。**`--baseline-test` の実行器が既知でなければ必須**(無ければ提案の前に終了コード 4) | `--baseline-test` を元にする | | `--max-test-rounds` / `--max-outer-rounds` / `--max-items-per-round` / `--max-fix-rounds` / `--test-timeout` | 廃止。受け取ると標準エラーへ `⚠ <引数> は廃止しました(#933)。--budget-minutes で所要を決めます` を出し、値を使わずに続ける(`setup.DEPRECATED_ARGS`) | — | @@ -251,7 +251,7 @@ | 値 | 要点 | 係数・関数 | | --- | --- | --- | | 余裕 | 0.05·B | `MARGIN_SHARE` | -| 着手前のテスト 1 回の上限 | 0.10·B(w はまだ無い) | `INIT_TEST_SHARE` | +| 着手前のテスト 1 回の上限 | max(0.10·B, 3·s)。s(着手前のテストの所要の見込み)が分からなければ 0.10·B か max(0.10·B, 3·w) | `INIT_TEST_SHARE` | | テスト 1 回の上限 | max(3·w, 0.01·B) | `TEST_FACTOR` / `TEST_FLOOR_SHARE` | | 提案の枠の終わり / リファクタリング計画の枠の終わり | 開始 + 0.20·B / さらに + 0.10·B(開始 + 0.30·B) | `PROPOSE_SHARE` / `PLAN_SHARE` | | テストの追加・実装の終わり | 実装の終わり = 最終ゲート修正の打ち切り − R − Σ(採っていて未検証の項目の verify)。テストの追加の終わりはそこから同じ項目の implement を引く | `budget.implement_end` / `budget.add_tests_end` | diff --git a/docs/specifications/ndf-supervise-step-budgets.md b/docs/specifications/ndf-supervise-step-budgets.md index 068bff483..6d52035af 100644 --- a/docs/specifications/ndf-supervise-step-budgets.md +++ b/docs/specifications/ndf-supervise-step-budgets.md @@ -7,6 +7,8 @@ 同じ値を課題ごとの実装のプランと検査のプランのすべてへ引き継ぐ。**`refactor` の `timeout` は常に `with_margin(M × 60)` で、 M は想定最大時間、無ければ cross-refactoring の既定の予算(`DEFAULT_BUDGET_MINUTES`)である。** NDF は実装の上限・想定最大時間の 固定値を新しく持たない。 +実行の時点では、cross-refactoring の駆動がステップの延長(`NDF_STEP_EXTEND_FILE`)で締め切りを後ろへずらしうる。 +プランに書く `timeout` は変えない。 この文書は、その形に**なぜしたか**(背景・決定と理由・常に成り立つ条件・境界・既知の限界・テスト観点)を残す。 **宣言の形と引数の正はスクリプトにある。** diff --git a/issues/issue-2291-test-ledger.md b/issues/issue-2291-test-ledger.md new file mode 100644 index 000000000..6cdd39db2 --- /dev/null +++ b/issues/issue-2291-test-ledger.md @@ -0,0 +1,94 @@ +# 全体テストの台帳(#2291・#2204) + +削る基準の番号は `docs/plugin-development-guide.md` の「テストを削る基準」の表の番号である。計測は実験版の +`plugins/ndf/scripts/experimental/whole-test-timing.py`(`measure` と `diff`)で取った。 + +## 着手前後の実測 + +計測のマシンはこの開発コンテナ(CPU 8 個・メモリ 30 GB)、並列の数は宣言のまま(pytest の `-n 4`)。 + +| 計測 | 日時(JST) | 開始時の load average | 壁時計 | suite ごとの秒 | 件数(skip) | 各テストの所要の合計 | +| --- | --- | --- | --- | --- | --- | --- | +| 着手前 | 2026-10-31 23:21 | 7.63 | 962.9 秒 | pytest 932.5 秒・factory 15.8 秒・script-structure 8.9 秒・lint 5.7 秒(pytest は 1 件が落ちた。根の conftest の文字列を固定していた `scripts/tests/test_root_conftest.py::test_the_root_conftest_declares_the_plugin` で、後始末の部品を根の conftest に足した後に測ったため。下の表で消した) | 10777(4) | 3454.6 秒 | +| 着手後 | 2026-11-01 01:46 | 1.33 | 635.8 秒 | pytest 607.5 秒・factory 14.8 秒・script-structure 8.4 秒・lint 5.1 秒 | 10781(4) | 2273.8 秒 | + +- 着手前と着手後で開始時の load average が違う(7.63 と 1.33。どちらも CPU の数 8 以下)。所要の差には負荷の差も入っている。後始末の部品とテストの書き直しの前半を入れた後の途中の計測(load average 1.31)は壁時計 623.0 秒・合計 2,295.0 秒で、着手後とほぼ同じだった +- 180 秒に届いていない。着手後の壁時計 635.8 秒のうち pytest が 607.5 秒で、残りの 3 つの suite は計 28.3 秒である。180 秒に入れるには pytest を約 150 秒(各テストの所要の合計で約 550 秒)にする必要があり、今の 2,273.8 秒から約 1,700 秒を減らす差が残る +- 0.3 秒未満のテスト(途中の計測で 9,028 件)だけで合計 481 秒ある。残りの時間は、本物のスクリプトと子のプロセスを起こして待つ結合テスト(中継・supervise の queue と再開・cross-review と cross-refactoring の drive)に集まっている +- 照合(`whole-test-timing.py diff`): 台帳に無い消えたテスト 0 件・skip に変わったテスト 0 件。着手後の件数が増えたのは、後始末の部品と計測の実験版のテストを足したため + +## 遅いテストの表 + +「速くした」は、ファイルを書き換え、着手後の秒が着手前の 8 割未満になったもの。「そのまま」は書き換えていないか、縮みが 2 割に届かないもの(負荷の差による縮みを含む)。 + +### 1 本あたりの秒の長い順の上位 30 件(着手前) + +| # | テスト | 着手前の秒 | 処置 | 着手後の秒 | +| --- | --- | --- | --- | --- | +| 1 | `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_template_plan_gets_through_each_fix_loop_three_times` | 78.52 | まとめた | 14.39 | +| 2 | `scripts/tests/test_root_conftest.py::test_a_bundle_outside_the_table_is_not_checked` | 41.70 | 速くした | 0.48 | +| 3 | `plugins/ndf/skills/cross-refactoring/tests/test_fix_rules_git.py::test_items_failing_the_same_structure_check_are_dropped_after_one_fix` | 18.72 | そのまま | 12.20 | +| 4 | `plugins/ndf/scripts/tests/test_supervise_contract.py::test_results_keep_their_keys_and_exit_codes` | 16.82 | 速くした | 7.49 | +| 5 | `plugins/ndf/scripts/tests/test_drive_refactor.py::test_refactor_drive_workflow_step_finishes_with_counts` | 12.91 | そのまま | 5.04 | +| 6 | `scripts/tests/test_check_script_structure.py::test_repository_matches_its_allow_list` | 12.76 | 消した | — | +| 7 | `plugins/ndf/scripts/tests/test_relay.py::test_run_waits_for_user_input_quiet` | 11.45 | 速くした | 6.51 | +| 8 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_the_release_without_a_gap_says_nothing_more` | 10.66 | そのまま | 5.78 | +| 9 | `plugins/ndf/scripts/tests/test_drive_refactor.py::test_refactor_drive_pauses_for_cross_review_then_finalizes` | 9.99 | そのまま | 4.95 | +| 10 | `plugins/ndf/scripts/tests/test_relay.py::test_slow_usage_endpoint_does_not_stall_relay` | 9.88 | 速くした | 4.82 | +| 11 | `plugins/ndf/scripts/tests/test_supervise_contract.py::test_subcommands_keep_their_arguments` | 9.13 | 速くした | 0.10 | +| 12 | `plugins/ndf/scripts/tests/test_base_sync.py::test_impl_route_goes_back_three_times_and_merges` | 8.42 | そのまま | 6.84 | +| 13 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py::test_the_same_head_returns_the_previous_result` | 7.77 | そのまま | 3.12 | +| 14 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_a_conditional_stage_without_a_record_is_shown_apart` | 7.76 | そのまま | 4.95 | +| 15 | `plugins/ndf/scripts/tests/test_supervise_queue.py::test_a_needed_plan_of_an_earlier_stage_or_with_a_gate_record` | 7.52 | そのまま | 5.48 | +| 16 | `plugins/ndf/scripts/tests/test_base_sync.py::test_check_route_goes_back_and_reaches_record` | 7.52 | 速くした | 5.99 | +| 17 | `scripts/tests/test_agy_distribution.py::test_generated_links_are_not_collected_twice` | 7.50 | そのまま | 5.75 | +| 18 | `plugins/ndf/scripts/tests/test_relay.py::test_run_spin_broken_by_long_section` | 7.21 | そのまま | 6.25 | +| 19 | `plugins/ndf/scripts/tests/test_relay.py::test_guard_question_blocks_exit` | 6.88 | 速くした | 3.05 | +| 20 | `plugins/ndf/scripts/tests/test_supervise.py::test_queue_tells_only_the_files_of_plans_running_when_the_worker_starts` | 6.50 | そのまま | 6.05 | +| 21 | `plugins/ndf/skills/cross-refactoring/tests/test_whole_test_triage_git.py::test_a_failure_caused_by_the_change_goes_to_fix_and_is_kept_when_fixed` | 6.34 | そのまま | 4.51 | +| 22 | `plugins/ndf/scripts/tests/test_supervise_queue.py::test_tagged_steps_do_not_exceed_the_resource_limit` | 6.13 | そのまま | 5.81 | +| 23 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py::test_a_record_without_head_returns_the_previous_result` | 6.10 | そのまま | 2.98 | +| 24 | `plugins/ndf/scripts/tests/test_supervise_queue_continue.py::test_resume_continues_from_the_stopped_step_and_runs_the_next_stage` | 6.09 | そのまま | 5.58 | +| 25 | `scripts/tests/test_lock_common.py::test_the_timeout_override_reaches_only_the_stage_state` | 5.96 | そのまま | 5.94 | +| 26 | `plugins/ndf/skills/cross-refactoring/tests/test_stop_revert_git.py::test_plan_a_reverts_only_the_culprit_and_the_rechecked_gate_passes` | 5.90 | そのまま | 3.11 | +| 27 | `plugins/ndf/skills/cross-refactoring/tests/test_whole_test_triage_git.py::test_without_time_to_fix_the_newest_flagged_items_are_reverted_until_it_passes` | 5.83 | そのまま | 5.09 | +| 28 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_the_record_stage_of_supervise_is_recorded` | 5.78 | そのまま | 2.10 | +| 29 | `plugins/ndf/scripts/tests/test_drive_review.py::test_review_drive_pauses_for_fix_then_sweep_then_finishes` | 5.72 | そのまま | 4.45 | +| 30 | `plugins/ndf/skills/cross-refactoring/tests/test_stop_revert_git.py::test_every_child_limit_from_the_cut_off_to_the_recheck_ends_by_the_end` | 5.61 | そのまま | 3.07 | + +### ファイルごとの合計の上位 10 本(着手前) + +| # | ファイル | 着手前(件・秒) | 処置 | 着手後(件・秒) | +| --- | --- | --- | --- | --- | +| 1 | `plugins/ndf/scripts/tests/test_relay.py` | 423 件・303.0 | 速くした | 423 件・205.5 | +| 2 | `plugins/ndf/scripts/tests/test_supervise.py` | 184 件・181.6 | 速くした | 184 件・143.1 | +| 3 | `plugins/ndf/scripts/tests/test_supervise_limits.py` | 15 件・103.2 | 速くした | 15 件・31.4 | +| 4 | `plugins/ndf/scripts/tests/test_supervise_queue.py` | 35 件・99.4 | そのまま | 35 件・85.7 | +| 5 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py` | 25 件・71.7 | そのまま | 25 件・48.7 | +| 6 | `plugins/ndf/scripts/tests/test_glossary.py` | 105 件・71.0 | 速くした | 105 件・7.9 | +| 7 | `plugins/ndf/scripts/tests/test_progress_record_stages.py` | 36 件・65.4 | そのまま | 36 件・32.2 | +| 8 | `plugins/ndf/scripts/tests/test_supervise_new.py` | 70 件・52.8 | 速くした | 70 件・11.1 | +| 9 | `plugins/ndf/scripts/tests/test_relay_account.py` | 54 件・51.4 | そのまま | 54 件・38.8 | +| 10 | `scripts/tests/test_root_conftest.py` | 18 件・48.9 | 速くした・消した | 15 件・5.2 | + + +## 消した・まとめたテスト + +| テスト | 処置 | 基準 | 守るテスト・守らなくてよい理由 | +| --- | --- | --- | --- | +| `scripts/tests/test_root_conftest.py::test_the_root_conftest_declares_the_plugin` | 消した | 4 | 根の conftest の文字列を固定していた。`pytester` が読まれなければ `plugins/playwright-kit/skills/playwright-kit-ops/tests` の `pytester` を使うテストが落ちる | +| `scripts/tests/test_root_conftest.py::test_the_failure_names_the_bundle_and_the_command` | まとめた | 1 | 同じ起動を `scripts/tests/test_root_conftest.py::test_a_missing_command_fails_the_collection` が行い、束とコマンドの名前も確かめる | +| `scripts/tests/test_root_conftest.py::test_the_prefix_is_declared_once` | 消した | 4 | 根の conftest の文字列を固定していた。接頭辞の除去は `scripts/tests/test_root_conftest.py::test_no_monitor_variable_survives_into_a_test` が守る | +| `scripts/tests/test_check_script_structure.py::test_repository_matches_its_allow_list` | 消した | 6 | suite `script-structure`(`python3 scripts/check-script-structure.py`)と CI の `.github/workflows/script-structure.yml` が同じ検査をリポジトリ全体へ流す | +| `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_template_plan_gets_through_each_fix_loop_three_times` | まとめた | 5 | 17 本の雛形のプランのうち、修正のループの入口の組が同じものは同じ遷移を通る。組ごとに 1 本(impl・check・release・release-prod の形)を流す `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_fix_loop_shape_gets_through_each_fix_loop_three_times` が守る。修正のループの無いプランは上限に届く経路が無い | + +## 打ち切りの確かめ(受け入れ条件 6・7) + +2026-11-01 02:00 JST ごろ に手元で確かめた。数えたのは、作業場所のパスをコマンドラインに含むプロセスのうち、打ち切る前から動いていた作業者のセッションと言語サーバーを除いたもの。 + +| 打ち切り方 | 打ち切る前 | 打ち切りから 10 秒後 | +| --- | --- | --- | +| `test_triage.run_command` に宣言の pytest のコマンドを上限 30 秒で渡す(cross-refactoring の着手前のテストと同じ打ち切る側。30.2 秒で打ち切った) | — | 0 件 | +| `pytest -n 4`(`test_relay.py`・`test_supervise_queue.py`)を 25 秒流し、親だけへ `kill -TERM` | 11 件 | 0 件 | + +`python3 plugins/ndf/scripts/test-run.py whole --limit 30` は、PR の無いこのブランチでは CI のチェックの結論を待つ側へ進み、手元のテストを流さなかったため、上の 1 行目で打ち切る側を直に呼んで確かめた。 diff --git a/plugins/ndf/scripts/check-trigger.py b/plugins/ndf/scripts/check-trigger.py index 5b77d7de7..b1de1410f 100755 --- a/plugins/ndf/scripts/check-trigger.py +++ b/plugins/ndf/scripts/check-trigger.py @@ -495,9 +495,16 @@ def findings_of(state: Path) -> tuple[dict, str]: **({"unconfirmed": ref["unconfirmed"]} if "unconfirmed" in ref else {}), # 最終ゲートを経ていない実行だけ(#1652) # drive が返したキーだけを写す(古い drive の `findings` はコメントの数で、stats が読むときに読み替える) **{k: rev[k] for k in REVIEW_COUNTS if k in rev}, + # 時間の枠に収まらずリファクタリングを飛ばした(#2047 の I8)。種類と理由だけを写す + **( + {"refactor_skipped": {"kind": sk.get("kind"), "reason": sk.get("reason")}} if isinstance(sk := ref.get("skipped"), dict) else {} + ), } + # 欠けを残して後続へ進んだステップ(終了コード 6 と gaps)は落ちたステップに数えない failed = [ - e.get("id") for e in log if e.get("exit") not in (0, None) and not e.get("gate") and not str(e.get("id", "")).startswith("abort") + e.get("id") + for e in log + if e.get("exit") not in (0, None) and not e.get("gate") and not e.get("gaps") and not str(e.get("id", "")).startswith("abort") ] return findings, (failed[-1] if failed else "") diff --git a/plugins/ndf/scripts/experimental/whole-test-timing.py b/plugins/ndf/scripts/experimental/whole-test-timing.py new file mode 100644 index 000000000..75ee0f1cf --- /dev/null +++ b/plugins/ndf/scripts/experimental/whole-test-timing.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""whole-test-timing.py(試行): 全体テストを同じ条件で測り、着手前後のテストの集合を照らす(#2291)。 + + python3 whole-test-timing.py measure --root --out + python3 whole-test-timing.py diff --before --after --ledger + +measure は `.ndf/project.json` の `test.suites` の `command` を宣言の順に 1 本ずつ流し、最初の起動から最後の +終了までを壁時計とする。開始時の 1 分の load average が CPU の数を超えたら `reference`(判定に使わない参考の値)を +立てる。junit を宣言した suite は、junit の `time`(準備・本体・後始末の和)から件数・合計・遅いテストの上位 30 件・ +ファイルごとの合計の上位 10 本を出し、`--out` へ nodeid ごとの結果と秒を書く。 + +diff は着手前にあって着手後に無い nodeid のうち台帳に書かれていないものと、着手前に走っていて着手後に skip に +なった nodeid を出す。nodeid はパラメータの部分まで含めて文字どおりに照らす。 + +結果は lib/step_result.py の形の 1 行の JSON(`tool: "whole-test-timing"`)。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) +from step_result import EXIT_UNREADABLE, StepError, emit, main_with, result # noqa: E402 + +TOOL = "whole-test-timing" +TOP_TESTS = 30 +TOP_FILES = 10 + + +def load_suites(root: Path) -> list[dict]: + try: + data = json.loads((root / ".ndf" / "project.json").read_text(encoding="utf-8")) + suites = data["test"]["suites"] + except (OSError, ValueError, KeyError, TypeError) as e: + raise StepError(f".ndf/project.json の test.suites を読めない: {e}", EXIT_UNREADABLE) + if not isinstance(suites, list) or not all(isinstance(s, dict) and s.get("command") for s in suites): + raise StepError("test.suites の各要素に command が要る", EXIT_UNREADABLE) + return suites + + +def _nodeid(case: ET.Element) -> str: + """xunit1 の testcase から pytest の nodeid を組む(file と classname の差がクラスの部分)。""" + file = case.get("file") or "" + name = case.get("name") or "" + classname = case.get("classname") or "" + module = file[:-3].replace("/", ".") if file.endswith(".py") else "" + rest = classname[len(module) :].lstrip(".") if module and classname.startswith(module) else "" + parts = [file or classname, *(p for p in rest.split(".") if p), name] + return "::".join(parts) + + +def _outcome(case: ET.Element) -> str: + for tag in ("failure", "error", "skipped"): + if case.find(tag) is not None: + return {"failure": "failed", "error": "error", "skipped": "skipped"}[tag] + return "passed" + + +def read_junit(path: Path) -> dict[str, dict]: + """nodeid → {"outcome", "seconds"}。同じ nodeid が 2 度出たら(準備と後始末の誤り)秒を足し、悪い方の結果を残す。""" + try: + tree = ET.parse(path) + except (OSError, ET.ParseError) as e: + raise StepError(f"junit を読めない: {path}: {e}", EXIT_UNREADABLE) + tests: dict[str, dict] = {} + for case in tree.iter("testcase"): + nid = _nodeid(case) + sec = float(case.get("time") or 0.0) + out = _outcome(case) + if nid in tests: + prev = tests[nid] + prev["seconds"] = round(prev["seconds"] + sec, 3) + if out in ("failed", "error"): + prev["outcome"] = out + continue + tests[nid] = {"outcome": out, "seconds": round(sec, 3)} + return tests + + +def summarize_tests(tests: dict[str, dict]) -> tuple[dict, list[dict]]: + ran = {k: v for k, v in tests.items() if v["outcome"] != "skipped"} + metrics = { + "tests": len(ran), + "skipped": len(tests) - len(ran), + "sum_seconds": round(sum(v["seconds"] for v in tests.values()), 1), + } + slow = sorted(tests.items(), key=lambda kv: (-kv[1]["seconds"], kv[0]))[:TOP_TESTS] + files: dict[str, list] = {} + for nid, v in tests.items(): + f = files.setdefault(nid.split("::", 1)[0], [0.0, 0]) + f[0] += v["seconds"] + f[1] += 1 + top_files = sorted(files.items(), key=lambda kv: (-kv[1][0], kv[0]))[:TOP_FILES] + items = [{"kind": "test", "name": nid, "seconds": v["seconds"], "result": v["outcome"]} for nid, v in slow] + items += [{"kind": "file", "name": f, "seconds": round(s, 1), "count": n, "result": "sum"} for f, (s, n) in top_files] + return metrics, items + + +def run_suites(root: Path, suites: list[dict], log_dir: Path) -> tuple[list[dict], float]: + log_dir.mkdir(parents=True, exist_ok=True) + runs = [] + first = time.monotonic() + for s in suites: + name = str(s.get("name") or len(runs)) + start = time.monotonic() + with (log_dir / f"{name}.log").open("w", encoding="utf-8") as log: + p = subprocess.run(["bash", "-c", s["command"]], cwd=root, stdout=log, stderr=subprocess.STDOUT) + runs.append({"name": name, "seconds": round(time.monotonic() - start, 1), "exit": p.returncode}) + return runs, round(time.monotonic() - first, 1) + + +def cmd_measure(a) -> None: + root = Path(a.root or ".").resolve() + suites = load_suites(root) + load1 = os.getloadavg()[0] + cpus = os.cpu_count() or 1 + out = Path(a.out).resolve() + runs, wall = run_suites(root, suites, out.with_suffix(".logs")) + tests: dict[str, dict] = {} + for s in suites: + if s.get("junit"): + tests.update(read_junit(root / s["junit"])) + counted, items = summarize_tests(tests) + metrics = { + "load_average_1m": round(load1, 2), + "cpus": cpus, + "reference": load1 > cpus, + "wall_seconds": wall, + "suites": runs, + **counted, + } + out.write_text(json.dumps({"metrics": metrics, "tests": tests}, ensure_ascii=False, indent=1), encoding="utf-8") + failed = [r["name"] for r in runs if r["exit"] != 0] + status = "stopped" if failed else "ok" + summary = f"壁時計 {wall} 秒・{counted['tests']} 件・合計 {counted['sum_seconds']} 秒・load average {load1:.2f}" + if failed: + summary += f"。落ちた suite: {', '.join(failed)}" + emit(result(TOOL, status, summary, items, metrics)) + + +def ledger_nodeids(text: str) -> set[str]: + """台帳の表の行にバッククォートで書かれた nodeid(`::` を含むもの)。""" + ids = set() + for line in text.splitlines(): + if line.lstrip().startswith("|"): + ids.update(m for m in re.findall(r"`([^`]+)`", line) if "::" in m) + return ids + + +def _read_out(path: str) -> dict: + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return data["tests"] + except (OSError, ValueError, KeyError, TypeError) as e: + raise StepError(f"計測の記録を読めない: {path}: {e}", EXIT_UNREADABLE) + + +def compare_sets(before: dict, after: dict, ledger: set[str]) -> dict: + ran_before = {k for k, v in before.items() if v["outcome"] != "skipped"} + missing = sorted(k for k in ran_before if k not in after and k not in ledger) + skipped = sorted(k for k in ran_before if k in after and after[k]["outcome"] == "skipped") + return {"missing": missing, "now_skipped": skipped} + + +def cmd_diff(a) -> None: + before, after = _read_out(a.before), _read_out(a.after) + try: + ledger = ledger_nodeids(Path(a.ledger).read_text(encoding="utf-8")) + except OSError as e: + raise StepError(f"台帳を読めない: {a.ledger}: {e}", EXIT_UNREADABLE) + found = compare_sets(before, after, ledger) + b, f = summarize_tests(before)[0], summarize_tests(after)[0] + metrics = {"before": b, "after": f, "missing": len(found["missing"]), "now_skipped": len(found["now_skipped"])} + items = [{"kind": "missing", "name": n, "result": "not-in-ledger"} for n in found["missing"]] + items += [{"kind": "now_skipped", "name": n, "result": "skipped"} for n in found["now_skipped"]] + status = "stopped" if items else "ok" + summary = ( + f"着手前 {b['tests']} 件・{b['sum_seconds']} 秒 → 着手後 {f['tests']} 件・{f['sum_seconds']} 秒。" + f"台帳に無い消えたテスト {len(found['missing'])} 件・skip に変わったテスト {len(found['now_skipped'])} 件" + ) + emit(result(TOOL, status, summary, items, metrics)) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + m = sub.add_parser("measure") + m.add_argument("--root", default=".") + m.add_argument("--out", required=True) + m.set_defaults(func=cmd_measure) + d = sub.add_parser("diff") + d.add_argument("--before", required=True) + d.add_argument("--after", required=True) + d.add_argument("--ledger", required=True) + d.set_defaults(func=cmd_diff) + return main_with(ap, lambda a: TOOL, argv) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/lib/drive_pause.py b/plugins/ndf/scripts/lib/drive_pause.py index d789b5978..5811ea14f 100644 --- a/plugins/ndf/scripts/lib/drive_pause.py +++ b/plugins/ndf/scripts/lib/drive_pause.py @@ -18,6 +18,7 @@ | 1 | stopped | — | 中断(`metrics.exit` に元の終了コード) | 理由(summary)を報告する | | 1 | stopped | — | 収束ループがレビューの成立しないまま終わった(`metrics.final` が `error`。報告は `items[0].report`・`gaps` 無し) | 理由と件数を報告し、後続へ進まない | | 6 | stopped | — | 最後まで回ったが収束しなかった(`metrics.final` が `max_rounds`・`oscillation`。報告は `items[0].report`・`gaps` に `kind: review`) | 理由と件数を報告する。プランは `on_exit` の `"6"` で行き先を分けられる(無ければ `on_fail`) | +| 6 | stopped | — | 時間の枠に収まらずリファクタリングを飛ばした(`metrics.skipped` に種類と理由。`gaps` に `kind: refactor`。#2047) | 同上 | `items[0]` の項目: `pause`・`prompt_file`・`result_file`・`round` は必ずある。任意の項目は次の通り。 @@ -28,12 +29,18 @@ 再開は同じコマンドを打ち直すだけである。進みは各 drive が一時ディレクトリの状態ファイルに持つ。 件数(metrics)の中身は drive ごとに決め、各 drive の docstring が並べる。 + +ステップの延長(#2047 の決定 4): drive のステップを流すエンジンは、環境変数 `NDF_STEP_EXTEND_FILE` に延長のファイルの +パスを渡す。駆動とその子は、この試行の開始からの所要の見込み(秒)を `{"seconds": <秒>, "reason": "<1 行>"}` で書き、 +エンジンは締め切りを後ろへだけずらす。書くのは `write_extension`、読むのは `read_extension` だけである。 """ from __future__ import annotations +import json +import os from pathlib import Path -from typing import Callable +from typing import Callable, Optional import step_result as sr @@ -46,6 +53,7 @@ } EXIT_DONE = 0 EXIT_STOPPED = 1 +EXTEND_ENV = "NDF_STEP_EXTEND_FILE" class Stop(Exception): @@ -89,6 +97,39 @@ def unconverged(tool: str, summary: str, report: Path | str, metrics: dict) -> d return sr.result(tool, "stopped", summary, [{"report": str(report)}], {**metrics, "exit": sr.EXIT_INCOMPLETE}, gaps=[gap]) +def incomplete(tool: str, summary: str, metrics: dict, gap: dict) -> dict: + """欠けを 1 件持つ中断の結果(終了コード 6)。プランは `on_exit` の `"6"` で後続へ進められる(#2047 の I5)。""" + return sr.result(tool, "stopped", summary, [], metrics, gaps=[gap]) + + +def write_extension(seconds: float, reason: str) -> bool: + """延長のファイルへ見込みの秒を書く。`NDF_STEP_EXTEND_FILE` が無い(エンジンの外で打った)か書けなければ False。 + 今のファイルの秒より小さければ書き換えない(締め切りは前へずらさない。#2047 の I7)。""" + target = os.environ.get(EXTEND_ENV) + if not target: + return False + got = read_extension(target) + if got is not None and got[0] >= float(seconds): + return True + try: + Path(target).write_text(json.dumps({"seconds": int(seconds), "reason": str(reason)}, ensure_ascii=False), encoding="utf-8") + except OSError: + return False + return True + + +def read_extension(path: Path | str) -> Optional[tuple[float, str]]: + """延長のファイルの (秒, 理由)。無い・読めない・秒が正の数でなければ None。""" + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + seconds = data.get("seconds") if isinstance(data, dict) else None + if isinstance(seconds, bool) or not isinstance(seconds, (int, float)) or seconds <= 0: + return None + return float(seconds), str(data.get("reason") or "") + + def stopped(tool: str, msg: str, metrics: dict, code: int) -> dict: return sr.result(tool, "stopped", msg, [], {**metrics, "exit": code}) diff --git a/plugins/ndf/scripts/lib/test_strategy.py b/plugins/ndf/scripts/lib/test_strategy.py index d460234a1..4c4bf1585 100644 --- a/plugins/ndf/scripts/lib/test_strategy.py +++ b/plugins/ndf/scripts/lib/test_strategy.py @@ -43,7 +43,7 @@ CI_WHOLE_THRESHOLD_SECONDS = 600.0 # 時間の係数(決定 8)。B は予算(分)、w は全体テストの所要、x は着手前に手元で走らせたテストの実測、c は CI の壁時計。 -INIT_TEST_SHARE = 0.10 # 着手前のテスト 1 回の上限 = max(0.10·B, 3·w)(CI に任せる戦略は max(0.10·B, 3·s)。s が分からなければ 0.10·B) +INIT_TEST_SHARE = 0.10 # 着手前のテスト 1 回の上限 = max(0.10·B, 3·s)(s は着手前のテストの所要の見込み。分からなければ 0.10·B) TEST_FACTOR = 3.0 # テスト 1 回の上限 = max(3·x, 0.01·B) TEST_FLOOR_SHARE = 0.01 CI_WAIT_SHARE = 0.05 # CI の待ちの上限 = max(3·c, 0.05·B) @@ -820,8 +820,8 @@ def limits( ) -> dict[str, Any]: """時間の上限(秒)の表(決定 8)。`basis` に入力を並べる。 - `scope_seconds` は CI に任せる戦略で着手前に走らせる範囲テストの所要の見込み s(`scope_source` はその出所。 - `history` / `whole`)。分かれば着手前の上限を `max(0.10·B, 3·s)` にする(#1555)。ほかの戦略と予算なしでは使わない。 + `scope_seconds` は着手前のテストの所要の見込み s(`scope_source` はその出所。`history` / `recent` / `whole`)。 + 分かれば戦略を問わず着手前の上限を `max(0.10·B, 3·s)` にする(#1555・#2047 の I1)。予算なしでは使わない。 予算が無い(supervise)ときは `3·max(w, m)` と `3·c` を上限にし、所要も無ければ `UNKNOWN_DURATION_LIMITS` を使って `basis.unknown_duration` を真にする(`3·m` が上回るときだけ広げる)。`recent_seconds` は先に走った全体テストの @@ -848,7 +848,7 @@ def limits( else: base_timeout = _ceil(max(seconds * INIT_TEST_SHARE, TEST_FACTOR * w)) init_timeout = base_timeout - if strategy.whole_on_ci and scope_seconds is not None: + if scope_seconds is not None: init_timeout = _ceil(max(seconds * INIT_TEST_SHARE, TEST_FACTOR * float(scope_seconds))) if x is None: test_timeout = base_timeout diff --git a/plugins/ndf/scripts/supervise_lib/claude.py b/plugins/ndf/scripts/supervise_lib/claude.py index 0822ee16c..770f6dbbe 100644 --- a/plugins/ndf/scripts/supervise_lib/claude.py +++ b/plugins/ndf/scripts/supervise_lib/claude.py @@ -77,10 +77,8 @@ def kill_group(p: subprocess.Popen) -> None: """子をプロセスグループごと止める(shell=True のステップの孫も残さない。lib/procs.py)。止まらなければ 5 秒で見切る。""" procs.stop_tree(p.pid, grace=0) # 先頭が先に終わった(ゾンビの)グループの残りを procs.stop_tree は止めない。孫を残さないためグループへも送る - try: + with contextlib.suppress(OSError): os.killpg(p.pid, signal.SIGKILL) - except OSError: - pass try: p.communicate(timeout=5) except (subprocess.TimeoutExpired, ValueError, OSError): @@ -102,7 +100,9 @@ def run_ticking( err_path を渡すと stderr をそのファイルへ書かせる(待ちの間に最後の行を読めるように)。 子は新しいセッションで起こし、`watch_children` の知らせる先へ渡す(孤児の片付け)。打ち切りは子のプロセスグループを止めて subprocess.TimeoutExpired を投げる。 - tick() が例外を投げたら(遅れの見張りの打ち切り)、子のプロセスグループを止めてから投げ直す。""" + tick() が例外を投げたら(遅れの見張りの打ち切り)、子のプロセスグループを止めてから投げ直す。 + kw の extend(開始の時刻) は待ちの周期ごとに呼び、返した時刻が締め切りより後なら締め切りを後ろへずらす(#2047 の I7)。""" + extend = kw.pop("extend", None) with open(err_path, "w", encoding="utf-8") if err_path else contextlib.nullcontext() as errf: # 閉じ済みでも閉じ直してよい p = subprocess.Popen( cmd, @@ -118,13 +118,13 @@ def run_ticking( note(p.pid, True) deadline = time.time() + timeout if timeout else None try: - return _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd, timeout) + return _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd, timeout, extend) finally: if note: note(p.pid, False) -def _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd, timeout) -> subprocess.CompletedProcess: +def _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd, timeout, extend=None) -> subprocess.CompletedProcess: """every 秒ごとに tick() を呼びながら p の終わりを待つ。打ち切りと tick() の例外では子のプロセスグループを止める。""" first = True while True: @@ -137,6 +137,8 @@ def _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd return subprocess.CompletedProcess(cmd, p.returncode, out, err) except subprocess.TimeoutExpired: first = False + if extend and deadline is not None and (later := extend(deadline - timeout)) and later > deadline: + deadline, timeout = later, timeout + later - deadline if deadline is not None and time.time() >= deadline: kill_group(p) raise subprocess.TimeoutExpired(cmd, timeout) from None diff --git a/plugins/ndf/scripts/supervise_lib/state.py b/plugins/ndf/scripts/supervise_lib/state.py index b4d997de4..4be8ac900 100644 --- a/plugins/ndf/scripts/supervise_lib/state.py +++ b/plugins/ndf/scripts/supervise_lib/state.py @@ -403,15 +403,13 @@ def retell_gates(self, recorded: list[dict]) -> None: def _step_rows(self) -> str: """LLM を使ったステップの表の行。無ければ「無し」の 1 行。""" - return ( - "\n".join( - f"| {e['id']} | {e['llm']['turns']} | {e.get('seconds', '')} | {e['llm']['cache_read']} | " - f"{e['llm']['cache_write']} | {e['llm']['output']} | ${e['llm']['cost']:.3f} |" - for e in self.log - if e.get("llm") - ) - or "| 無し | | | | | | |" - ) + rows = [ + f"| {e['id']} | {e['llm']['turns']} | {e.get('seconds', '')} | {e['llm']['cache_read']} | " + f"{e['llm']['cache_write']} | {e['llm']['output']} | ${e['llm']['cost']:.3f} |" + for e in self.log + if e.get("llm") + ] + return "\n".join(rows) or "| 無し | | | | | | |" def _extra_lines(self) -> str: """認証の切り替え・利用上限・遅れの行。記録があるものだけを出す。""" @@ -450,7 +448,7 @@ def _extra_lines(self) -> str: return extra def write_report(self, plan: dict, result: str, reason: str) -> str: - """`## フェーズの報告` を組み、`report.md` へ書いて返す。""" + """`## フェーズの報告` を組み、`report.md` へ書いて返す。欠けを残して進んだステップは欠けの理由を写す(#2047 の決定 5)。""" l = self.llm self.read_worker_lines() pc = self.pcount @@ -460,19 +458,18 @@ def write_report(self, plan: dict, result: str, reason: str) -> str: if "counts" in e: counts[e["id"]] = e["counts"] counts_line = "; ".join(f"{k}: {counts_text(v)}" for k, v in counts.items()) or "無し" + gaps = [ + f"{e['id']}(" + ", ".join(f"{g.get('name') or g.get('kind')}: {g.get('reason')}" for g in e["gaps"]) + ")" + for e in self.log + if e.get("gaps") + ] if self.gates: gate_line = "; ".join(f"ステップ {g['id']}(exit={g['exit']})" for g in self.gates) else: gate_line = "本番の系へ届く操作" if result == "関門" else "無し" - presented = ( - ", ".join( - [ - *(g["presentation"] for g in self.gates if g.get("presentation")), - *(e["presentation"] for e in self.log if e.get("gate_as_ok") and e.get("presentation")), - ] - ) - or "無し" - ) + shown = [g["presentation"] for g in self.gates if g.get("presentation")] + shown += [e["presentation"] for e in self.log if e.get("gate_as_ok") and e.get("presentation")] + presented = ", ".join(shown) or "無し" text = f"""## フェーズの報告 - フェーズ: {plan.get("フェーズ")} @@ -488,6 +485,7 @@ def write_report(self, plan: dict, result: str, reason: str) -> str: - 理由: {reason} - 通ったステップ: {steps} - 件数: {counts_line} +- 欠けを残して進んだステップ: {"; ".join(gaps) or "無し"} - LLM の使用量: 入力 {l["input"]} / cache read {l["cache_read"]} / cache write {l["cache_write"]} / 出力 {l["output"]} / ${l["cost"]:.3f} - CLI(drive・work の中): {usage_aggregate.runtimes_text(self.log)} - 記録: {self.dir} diff --git a/plugins/ndf/scripts/supervise_lib/steps.py b/plugins/ndf/scripts/supervise_lib/steps.py index b6635947d..e156d0894 100644 --- a/plugins/ndf/scripts/supervise_lib/steps.py +++ b/plugins/ndf/scripts/supervise_lib/steps.py @@ -181,7 +181,30 @@ def no_reports(self, ctx) -> str: return "" return str(test.get("no_reports") or "") if isinstance(test, dict) else "" - def run_cmd(self, ctx, step: dict) -> tuple[int, str]: + @staticmethod + def extension(ctx, step: dict, path: Path, timeout) -> int | None: + """延長のファイルの秒から、この呼び出しの開始からの締め切り(`with_margin`)を返す。今の `timeout` 以下なら None。 + 延ばしたら `cur.extended_timeout` と進捗ログの 1 行に残す(#2047 の決定 4)。""" + import drive_pause + from supervise_lib.verify_steps import with_margin + + got = drive_pause.read_extension(path) + if got is None: + return None + longer = with_margin(int(got[0])) + if longer <= int(timeout): + return None + cur = ctx.state.cur + if isinstance(cur, dict) and int((cur.get("extended_timeout") or {}).get("timeout") or 0) < longer: + cur["extended_timeout"] = {"seconds": int(got[0]), "timeout": longer, "planned": int(timeout), "reason": got[1]} + write = getattr(ctx.state, "progress_write", None) + if write: + write({"kind": "extend", "step": step.get("id"), "timeout": longer, "planned": int(timeout), "reason": got[1]}) + return longer + + def run_cmd(self, ctx, step: dict, extend_file: Path | None = None) -> tuple[int, str]: + """コマンドを締め切りまで待つ。`extend_file` を渡すと(drive のステップ)、子へ `NDF_STEP_EXTEND_FILE` で置き場を渡し、 + 子が書いた見込みの秒で締め切りを後ろへずらす(#2047 の決定 4)。""" cmd = step.get("cmd") or paths.PRESETS.get(step.get("preset", ""), "") if not cmd: return 2, f"ステップ {step['id']} に cmd も知っている preset も無い" @@ -205,6 +228,12 @@ def run_cmd(self, ctx, step: dict) -> tuple[int, str]: env["PYTEST_ADDOPTS"] = " ".join(a for a in addopts if a) # 子が起動する CLI の使用量の帳簿の行にプランとステップを付ける(#1953 の決定 7・決定 11) env.update(NDF_USAGE_PLAN=getattr(ctx, "plan_path", None) or "", NDF_USAGE_STEP=str(step.get("id") or "")) + extend = None + if extend_file is not None: + import drive_pause + + env[drive_pause.EXTEND_ENV] = str(extend_file) + extend = lambda started: (t := self.extension(ctx, step, Path(extend_file), timeout)) and started + t # noqa: E731 ctx.state.run_log = ctx.state.dir / "run-stderr.log" try: p = run_ticking( @@ -216,6 +245,7 @@ def run_cmd(self, ctx, step: dict) -> tuple[int, str]: timeout=timeout, env=env, err_path=ctx.state.run_log, + extend=extend, ) return p.returncode, p.stdout + p.stderr except subprocess.TimeoutExpired as e: diff --git a/plugins/ndf/scripts/supervise_lib/templates.py b/plugins/ndf/scripts/supervise_lib/templates.py index e44741704..195d6c034 100644 --- a/plugins/ndf/scripts/supervise_lib/templates.py +++ b/plugins/ndf/scripts/supervise_lib/templates.py @@ -254,6 +254,9 @@ def _inspect_steps(a, pr_ref: str, scope: str) -> list[dict]: "timeout": with_margin((budget or default_refactor_budget_minutes()) * 60), "cmd": f"ndf_scope={scope} || exit 2; python3 {DRIVES['cross-refactoring']} {pr_ref} --workflow-step --scope $ndf_scope" f"{budget_arg}{refactor_template_arg(a)}", + # 時間の枠に収まらずリファクタリングを飛ばした終わり(終了コード 6 と gaps)はコードレビューへ進む(#2047 の I6)。 + # 想定最大時間を省いて下限まで上げたときの締め切りは、駆動が書くステップの延長で実行の時点に延ばす + "on_exit": {"6": "review"}, "on_fail": "abort", "next": "review", }, diff --git a/plugins/ndf/scripts/supervise_lib/worker_steps.py b/plugins/ndf/scripts/supervise_lib/worker_steps.py index 8b69178b0..01931f013 100644 --- a/plugins/ndf/scripts/supervise_lib/worker_steps.py +++ b/plugins/ndf/scripts/supervise_lib/worker_steps.py @@ -173,7 +173,9 @@ def drive_cmd(self, step: dict) -> str: script = paths.DRIVES.get(step.get("drive", "")) return f"python3 {script} {step.get('args', '')}".strip() if script else "" - def drive_loop(self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict] | None = None) -> tuple[bool, dict | None, str]: + def drive_loop( + self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict] | None = None, extend_file: Path | None = None + ) -> tuple[bool, dict | None, str]: """駆動を打ち、pause のたびに worker へ渡して打ち直す。(成功, 最後の結果, 出力) を返す。 入れ子の駆動(最終ゲートの item.command)の metrics は `inner` へ足す(外側の結果は内側の件数を持たない)。""" @@ -181,7 +183,7 @@ def drive_loop(self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict texts = [] max_pauses = step.get("max_pauses", DEFAULT_MAX_PAUSES) for _ in range(max_pauses + 1): - code, text = self.run.run_cmd(ctx, {**step, "cmd": cmd}) + code, text = self.run.run_cmd(ctx, {**step, "cmd": cmd}, extend_file) texts.append(text) out = last_json(text) if out is None: @@ -195,7 +197,7 @@ def drive_loop(self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict ctx.state.cur.setdefault("pauses", []).append(kind) res_file = Path(item["result_file"]) if item.get("command") and depth == 0: - ok, sub = self._drive_nested(ctx, step, item, res_file, depth, inner, texts) + ok, sub = self._drive_nested(ctx, step, item, res_file, depth, inner, texts, extend_file) if not ok: return False, sub, "\n".join(texts) + f"\n{kind} の駆動が止まった" continue @@ -205,10 +207,18 @@ def drive_loop(self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict return False, None, "\n".join(texts) + f"\npause が上限 {max_pauses} を超えた" def _drive_nested( - self, ctx, step: dict, item: dict, res_file: Path, depth: int, inner: list[dict] | None, texts: list[str] + self, + ctx, + step: dict, + item: dict, + res_file: Path, + depth: int, + inner: list[dict] | None, + texts: list[str], + extend_file: Path | None = None, ) -> tuple[bool, dict | None]: """item.command の駆動を回し、通れば件数から答えを結果ファイルへ書く。(成功, 内側の最後の結果) を返す。""" - ok, sub, sub_text = self.drive_loop(ctx, step, item["command"], depth + 1, inner) + ok, sub, sub_text = self.drive_loop(ctx, step, item["command"], depth + 1, inner, extend_file) if inner is not None and sub and isinstance(sub.get("metrics"), dict): inner.append(sub["metrics"]) texts.append(sub_text) @@ -243,7 +253,10 @@ def execute(self, ctx, step: dict) -> tuple[bool, str]: return False, err # 入れ子の駆動(最終ゲートの item.command)の metrics を控える。外側の結果は内側の件数を持たない inner: list[dict] = [] - ok, out, text = self.drive_loop(ctx, step, cmd, inner=inner) + # ステップの延長の置き場(#2047 の決定 4)。試行ごとに分け、前の試行のファイルは読まない + extend_file = Path(ctx.state.dir) / f"{len(getattr(ctx.state, 'log', []) or []) + 1:02d}-{step['id']}.extend.json" + extend_file.unlink(missing_ok=True) + ok, out, text = self.drive_loop(ctx, step, cmd, inner=inner, extend_file=extend_file) note_runtimes(ctx, step, started) if out or inner: ctx.state.cur["counts"] = dict((out or {}).get("metrics") or {}) @@ -251,5 +264,7 @@ def execute(self, ctx, step: dict) -> tuple[bool, str]: ctx.state.cur["counts"]["inner"] = inner[-1] # 欠けの残る終わり(stopped と gaps。収束しなかったなど)は終了コード 6 として控え、on_exit で分けられるようにする code = 0 if ok else (6 if out and out.get("status") == "stopped" and out.get("gaps") else 1) + if code == 6: + ctx.state.cur["gaps"] = list(out.get("gaps") or []) # フェーズレポートの「欠けを残して進んだステップ」(#2047 の決定 5) ctx.state.cur.update(exit=code, text=text, seconds=round(time.time() - started, 1)) return ok, text diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/check.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/check.json index e853f6377..5aa0713a2 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/check.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/check.json @@ -26,6 +26,9 @@ "stage": "構造改善", "timeout": 1980, "cmd": "ndf_scope=$(python3 /scripts/refactor-scope.py --pr 5) || exit 2; python3 /skills/cross-refactoring/scripts/drive.py 5 --workflow-step --scope $ndf_scope", + "on_exit": { + "6": "review" + }, "on_fail": "abort", "next": "review" }, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/1-check.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/1-check.json index 13395b806..6588bdf4e 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/1-check.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/1-check.json @@ -53,6 +53,9 @@ "stage": "構造改善", "timeout": 1980, "cmd": "ndf_scope=$(python3 /scripts/check-trigger.py scope --id m9-final --state {state_dir} --root .) || exit 2; python3 /skills/cross-refactoring/scripts/drive.py {pr} --workflow-step --scope $ndf_scope", + "on_exit": { + "6": "review" + }, "on_fail": "abort", "next": "review" }, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/7-check.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/7-check.json index ca40da0f0..4205d0846 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/7-check.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/7-check.json @@ -54,6 +54,9 @@ "stage": "構造改善", "timeout": 1980, "cmd": "ndf_scope=$(python3 /scripts/refactor-scope.py --pr {pr}) || exit 2; python3 /skills/cross-refactoring/scripts/drive.py {pr} --workflow-step --scope $ndf_scope", + "on_exit": { + "6": "review" + }, "on_fail": "abort", "next": "review" }, diff --git a/plugins/ndf/scripts/tests/inproc.py b/plugins/ndf/scripts/tests/inproc.py new file mode 100644 index 000000000..48ca67430 --- /dev/null +++ b/plugins/ndf/scripts/tests/inproc.py @@ -0,0 +1,67 @@ +"""スクリプトの `main(argv)` を、テストのプロセスの中で呼ぶ(#2291 の決定 3 の (2))。 + +スクリプトを子のプロセスで起動すると、起動と import だけで 1 回 0.2〜0.4 秒かかる。CLI の配線(引数・終了コード・ +結果 JSON)は代表の数件の起動で確かめ、分岐はこの関数で確かめる。引数を取らない `main()` は `sys.argv` から読む。返す値は `subprocess.run` と同じ形 +(`returncode`・`stdout`・`stderr`)で、起動を置き換えても読み手を変えずに済む。 + +作業ディレクトリ・環境変数・`sys.argv`・標準入出力は呼ぶ間だけ差し替え、戻す。モジュールは 1 度だけ読み込む。 +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import inspect +import io +import os +import subprocess +import sys +from pathlib import Path + +_modules: dict[str, object] = {} + + +def load(script: Path | str): + key = str(Path(script).resolve()) + if key not in _modules: + name = "inproc_" + Path(key).stem.replace("-", "_") + spec = importlib.util.spec_from_file_location(name, key) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod # dataclass などがモジュールを sys.modules から引く + spec.loader.exec_module(mod) + _modules[key] = mod + return _modules[key] + + +def _code(e: SystemExit, err: io.StringIO) -> int: + if e.code is None: + return 0 + if isinstance(e.code, int): + return e.code + err.write(f"{e.code}\n") + return 1 + + +def run(script: Path | str, *args: str, cwd: Path | str | None = None, env: dict | None = None, input: str | None = None): + """`[python, script, *args]` を子のプロセスで起動したのと同じ形の結果を返す。""" + mod = load(script) + argv = [str(a) for a in args] + out, err = io.StringIO(), io.StringIO() + saved = (dict(os.environ), sys.argv, sys.stdin) + try: + if env is not None: + os.environ.clear() + os.environ.update({k: str(v) for k, v in env.items()}) + sys.argv = [str(script), *argv] + sys.stdin = io.StringIO(input or "") + with contextlib.chdir(cwd or os.getcwd()), contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + r = mod.main(argv) if inspect.signature(mod.main).parameters else mod.main() + code = r if isinstance(r, int) else 0 + except SystemExit as e: + code = _code(e, err) + finally: + os.environ.clear() + os.environ.update(saved[0]) + sys.argv, sys.stdin = saved[1], saved[2] + return subprocess.CompletedProcess([sys.executable, str(script), *argv], code, out.getvalue(), err.getvalue()) diff --git a/plugins/ndf/scripts/tests/test_base_sync.py b/plugins/ndf/scripts/tests/test_base_sync.py index c1d758a82..05c49b2a0 100644 --- a/plugins/ndf/scripts/tests/test_base_sync.py +++ b/plugins/ndf/scripts/tests/test_base_sync.py @@ -15,6 +15,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + sys.path.insert(0, str(SCRIPTS)) sys.path.insert(0, str(SCRIPTS / "lib")) from step_result import validate_result # noqa: E402 @@ -99,7 +102,7 @@ def merging(self): def merged(args, cwd, env=None): - p = subprocess.run([PY, str(MERGED), *args], capture_output=True, text=True, cwd=cwd, env=env) + p = inproc.run(MERGED, *args, cwd=cwd, env=env) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None if out is not None: diff --git a/plugins/ndf/scripts/tests/test_check_trigger.py b/plugins/ndf/scripts/tests/test_check_trigger.py index 92a2dc574..b110bd4d6 100644 --- a/plugins/ndf/scripts/tests/test_check_trigger.py +++ b/plugins/ndf/scripts/tests/test_check_trigger.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SCRIPT = SCRIPTS / "check-trigger.py" sys.path.insert(0, str(SCRIPTS / "lib")) from step_result import validate_result # noqa: E402 @@ -132,7 +135,7 @@ def merge_pr(root: Path, n: int, branch: str, files: dict[str, int]) -> str: def call(root: Path, env: dict, *args: str) -> tuple[int, dict | None, str]: - p = subprocess.run([sys.executable, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, env=env, cwd=root) + p = inproc.run(SCRIPT, *args, "--root", str(root), env=env, cwd=root) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines and lines[-1].startswith("{") else None if out is not None: @@ -439,6 +442,16 @@ def test_findings_have_no_unconfirmed_when_the_refactor_step_did_not_return_it(t assert "unconfirmed" not in findings +def test_findings_copy_a_skipped_refactor_without_failing_it(tmp_path): + """#2047 の I8: 飛ばした refactor(6 と gaps)は理由を写し、落ちたステップに数えない。後の review が落ちればそれを返す。""" + skipped = {"kind": "window", "reason": "要る下限 61 分 > 想定最大時間 30 分"} + ref = {"id": "refactor", "exit": 6, "gaps": [{"kind": "refactor"}], "counts": {"skipped": skipped, "exit": 4}} + findings, failed = _check_trigger_module().findings_of(state_dir(tmp_path, [ref, {"id": "review", "exit": 0}])) + assert (findings["refactor_skipped"], failed) == (skipped, "") + findings, failed = _check_trigger_module().findings_of(state_dir(tmp_path, [ref, {"id": "review", "exit": 1}])) + assert (findings["refactor_skipped"], failed) == (skipped, "review") + + def test_review_fires_on_one_pr_without_the_other_triggers(repo, env): write_decl(repo, {**TRIGGERS, "score": 99, "lines": 10_000}) assert call(repo, env, "eval", "--review")[0] == 3 diff --git a/plugins/ndf/scripts/tests/test_glossary.py b/plugins/ndf/scripts/tests/test_glossary.py index cb81479a1..31fa8f27d 100644 --- a/plugins/ndf/scripts/tests/test_glossary.py +++ b/plugins/ndf/scripts/tests/test_glossary.py @@ -14,6 +14,9 @@ import pytest +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SCRIPTS = Path(__file__).resolve().parents[1] SCRIPT = SCRIPTS / "glossary.py" PY = sys.executable @@ -37,12 +40,20 @@ def write_json(root, rel, obj): return write(root, rel, json.dumps(obj, ensure_ascii=False, indent=2) + "\n") -def run(root, *args): - p = subprocess.run([PY, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, cwd=root) +def _parsed(p): lines = p.stdout.strip().splitlines() return p.returncode, (json.loads(lines[-1]) if lines else None), p.stderr +def run(root, *args): + """分岐はプロセスの中で main を呼んで確かめる(起動の配線は `run_cli` の代表の数件)。""" + return _parsed(inproc.run(SCRIPT, *args, "--root", str(root), cwd=root)) + + +def run_cli(root, *args): + return _parsed(subprocess.run([PY, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, cwd=root)) + + def commit(root, msg="c"): git(root, "add", "-A") git(root, "commit", "-q", "-m", msg) @@ -816,7 +827,7 @@ def test_init_places_outside_root_are_2(bare, tmp_path, idx): def test_command_lines_are_step_results(repo): for args in (["gate", "--mode", "standard"], ["render", "--check"], ["check"], ["diff", "--base", "develop"]): - code, out, _ = run(repo, *args) + code, out, _ = run_cli(repo, *args) assert code == 0 and out["tool"] == "glossary" and out["status"] == "ok" assert os.access(SCRIPT, os.X_OK) diff --git a/plugins/ndf/scripts/tests/test_instructions_check.py b/plugins/ndf/scripts/tests/test_instructions_check.py index 48cd2cfd1..e3d524ec9 100644 --- a/plugins/ndf/scripts/tests/test_instructions_check.py +++ b/plugins/ndf/scripts/tests/test_instructions_check.py @@ -18,6 +18,8 @@ import pytest SCRIPT = Path(__file__).resolve().parents[1] / "instructions-check.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 # --- 一時リポジトリの組み立て ------------------------------------------------ @@ -60,12 +62,8 @@ def declare(root: Path, decl: dict) -> None: def run(root: Path, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess: - return subprocess.run( - [sys.executable, str(SCRIPT), "--root", str(root), *args], - capture_output=True, - text=True, - cwd=str(cwd) if cwd else None, - ) + """分岐はプロセスの中で main を呼ぶ(起動の配線は `test_entry_point_runs_from_a_copy_without_lib_on_sys_path` などの起動のテスト)。""" + return inproc.run(SCRIPT, "--root", str(root), *args, cwd=cwd) def errors(proc: subprocess.CompletedProcess) -> list[str]: diff --git a/plugins/ndf/scripts/tests/test_lib_test_strategy.py b/plugins/ndf/scripts/tests/test_lib_test_strategy.py index 5b39ee9be..0a2913337 100644 --- a/plugins/ndf/scripts/tests/test_lib_test_strategy.py +++ b/plugins/ndf/scripts/tests/test_lib_test_strategy.py @@ -582,13 +582,15 @@ def test_the_scope_test_does_not_change_the_other_limits(): @pytest.mark.parametrize("name", [ts.LOCAL_FULL, ts.ROUND_ONLY]) -def test_other_strategies_ignore_the_scope_test(name): - """AC12: local-full / round-only は max(0.10·B, 3·w) のまま。s を渡しても使わない。""" +def test_other_strategies_take_the_init_test_estimate(name): + """#2047 の I1: 戦略を問わず、s を渡せば着手前の上限は max(0.10·B, 3·s)。渡さなければ max(0.10·B, 3·w) のまま。""" strategy = ts.Strategy( name, "args", [ts.Suite("unit", "pytest .", paths=["."])], round_command="pytest ." if name == ts.ROUND_ONLY else None ) - assert ts.limits(strategy, 30, whole_seconds_value=424.0, scope_seconds=900.0, scope_source="history")["init_test_timeout"] == 1272 - assert ts.limits(strategy, 30, scope_seconds=900.0, scope_source="history")["init_test_timeout"] == 180 + # 手元の全体テストの直近の実測 m = 620 秒が w = 1,518 秒より先に効く(3·m = 1,860 秒) + assert ts.limits(strategy, 30, whole_seconds_value=1518.0, scope_seconds=620.0, scope_source="recent")["init_test_timeout"] == 1860 + assert ts.limits(strategy, 30, whole_seconds_value=424.0)["init_test_timeout"] == 1272 + assert ts.limits(strategy, 30)["init_test_timeout"] == 180 def test_supervise_limits_stay_the_same(): diff --git a/plugins/ndf/scripts/tests/test_pr_steps.py b/plugins/ndf/scripts/tests/test_pr_steps.py index 352f64705..eca922d8c 100644 --- a/plugins/ndf/scripts/tests/test_pr_steps.py +++ b/plugins/ndf/scripts/tests/test_pr_steps.py @@ -17,6 +17,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable FAKE_GH = """#!{py} @@ -123,7 +126,7 @@ def repo(tmp_path): def call(args, env, cwd): - p = subprocess.run([PY, str(SCRIPTS / "pr-steps.py"), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / "pr-steps.py", *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None return p.returncode, out, p.stderr diff --git a/plugins/ndf/scripts/tests/test_project_decl.py b/plugins/ndf/scripts/tests/test_project_decl.py index 1662f51e0..93a1e1431 100644 --- a/plugins/ndf/scripts/tests/test_project_decl.py +++ b/plugins/ndf/scripts/tests/test_project_decl.py @@ -18,6 +18,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable SECRET = "AKIAABCDEFGHIJKLMNOP" DB_URL = "mysql://app:hunter2secret@db.internal:3306/app" @@ -142,7 +145,7 @@ def plain(tmp_path): def run(env, *args, cwd=None): - p = subprocess.run([PY, str(SCRIPTS / "project-decl.py"), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / "project-decl.py", *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() try: out = json.loads(lines[-1]) if lines else None diff --git a/plugins/ndf/scripts/tests/test_project_mvv.py b/plugins/ndf/scripts/tests/test_project_mvv.py index 6891bc24d..99adbf097 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv.py +++ b/plugins/ndf/scripts/tests/test_project_mvv.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + REPO = SCRIPTS.parents[2] MVV_PY = SCRIPTS / "project-mvv.py" GATE_PY = SCRIPTS / "mvv-gate.py" @@ -111,7 +114,7 @@ def calls(env: dict, what: str = "") -> int: def run(env: dict, *args: str, script: Path = MVV_PY) -> tuple[int, dict, str]: - p = subprocess.run([PY, str(script), *args], capture_output=True, text=True, env=env["env"], cwd=env["root"], timeout=60) + p = inproc.run(script, *args, env=env["env"], cwd=env["root"]) lines = [ln for ln in p.stdout.splitlines() if ln.startswith("{")] out = json.loads(lines[-1]) if lines else {} if out: diff --git a/plugins/ndf/scripts/tests/test_relay.py b/plugins/ndf/scripts/tests/test_relay.py index 9abe1a696..aec663950 100644 --- a/plugins/ndf/scripts/tests/test_relay.py +++ b/plugins/ndf/scripts/tests/test_relay.py @@ -486,8 +486,10 @@ def child_input(self, n): def wait_start(self, n, timeout=15): self.wait(lambda: len(self.starts()) >= n, timeout, f"{n} 回目の起動") - # 子が端末を raw にするまで少し待つ(raw の前の入力は行の編集に掛かる) - time.sleep(0.3) + # 子が端末を raw にするまで待つ(raw の前の入力は行の編集に掛かる)。偽の claude は raw にした直後に + # 入力の記録のファイルを作る + raw = self.fake_dir / f"input-{self.starts()[n - 1]['pid']}" + self.wait(raw.exists, timeout, f"{n} 回目の子が端末を raw にする") def finish(self, timeout=20): try: @@ -643,13 +645,16 @@ def test_run_chain_three_sections(term): def test_run_waits_for_user_input_quiet(term): - """利用者の入力から静止の秒数がたつまで /exit を送らない(AC6)。""" - t = term(env={"NDF_RELAY_QUIET": "1.5"}) + """利用者の入力から静止の秒数がたつまで /exit を送らない(AC6)。 + + 打ちかけの文字の後ろに /exit が付くため子は終わらず、終わりの待ちの後に止められる。待ちは 1 秒に縮める。""" + t = term(env={"NDF_RELAY_QUIET": "1.5", "NDF_RELAY_EXIT_WAIT": "1"}) t.wait_start(1) t.type("mark x\r") for _ in range(6): time.sleep(0.4) t.type("a") # 打っている途中 + assert len(t.starts()) == 1 assert b"/exit" not in t.child_input(0) t.wait_start(2) @@ -2136,7 +2141,7 @@ def question_rows(t, n=0): def test_guard_question_blocks_exit(term): """G1: 合図の後に質問が出たら /exit を書かない。答えた後の Stop がブロックを出せば切り替わる。""" - t = term(env={"NDF_RELAY_QUIET": "1.5"}) + t = term(env={"NDF_RELAY_QUIET": "0.5"}) t.wait_start(1) t.type("mark 次\r") # 合図の後に応答が再開して質問が出た形 t.type("q open\r") @@ -2144,7 +2149,7 @@ def test_guard_question_blocks_exit(term): assert question_rows(t)[0] == {"action": "open", "stdout": "", "code": 0} d = pathlib.Path(t.starts()[0]["relay_dir"]) assert (d / "question").exists() and (d / "next.json").exists() - time.sleep(3) + time.sleep(1.2) # 静止の待ちの 2 倍を超えても書かない assert len(t.starts()) == 1 and b"/exit" not in t.child_input(0) t.type("q close\r") t.wait(lambda: not (d / "question").exists(), what="合図が消える") @@ -2222,10 +2227,10 @@ def test_guard_lock_held_then_question_appears(term): fcntl.flock(f, fcntl.LOCK_EX) t.type("mark 次\r") t.wait(lambda: (d / "next.json").exists(), what="合図") - time.sleep(1.5) + time.sleep(0.8) # 静止の待ち(0.3 秒)の 2 倍を超えても書かない assert b"/exit" not in t.child_input(0) (d / "question").write_text("") - time.sleep(1.5) + time.sleep(0.8) assert len(t.starts()) == 1 and b"/exit" not in t.child_input(0) t.type("q close\r") t.type("mark 次\r") @@ -3515,7 +3520,7 @@ def test_slow_usage_endpoint_does_not_stall_relay(term, accounts): accounts.add("b", util5=30) t = account_term(term, accounts) t.wait_start(1) - accounts.fake.delay = 8 # 定期の確認(1 秒ごと)の取得が待たされる + accounts.fake.delay = 3 # 定期の確認(1 秒ごと)の取得が、下で打つ時点(1.5 秒後)にまだ待たされている time.sleep(1.5) pid = t.starts()[0]["pid"] sent = time.time() diff --git a/plugins/ndf/scripts/tests/test_release_steps.py b/plugins/ndf/scripts/tests/test_release_steps.py index 515ddc6cf..d1f6b5a99 100644 --- a/plugins/ndf/scripts/tests/test_release_steps.py +++ b/plugins/ndf/scripts/tests/test_release_steps.py @@ -14,6 +14,9 @@ import pytest SCRIPT = Path(__file__).resolve().parents[1] / "release-steps.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable @@ -497,7 +500,7 @@ def test_notes_and_changelog_stop_when_no_pr_is_merged(repo, tmp_path): before = [f.read_bytes() for f in files] base = ["--root", str(repo), "--version", "1.2.3-dev.1", "--prs", "13"] for args in (["notes", *base], ["notes", *base, "--approval", "issues/approval.md"], ["changelog", *base]): - p = subprocess.run([PY, str(SCRIPT), *args], capture_output=True, text=True, env=env) + p = inproc.run(SCRIPT, *args, env=env) assert p.returncode == 3, args assert [f.read_bytes() for f in files] == before, args @@ -767,7 +770,7 @@ def m744_repo(root: Path) -> tuple[Path, str]: def others_run(root: Path, env: dict, *args: str) -> tuple[int, dict]: - p = subprocess.run([PY, str(SCRIPT), "changed-plugins", "--root", str(root), *args], capture_output=True, text=True, env=env) + p = inproc.run(SCRIPT, "changed-plugins", "--root", str(root), *args, env=env) return p.returncode, json.loads(p.stdout.strip().splitlines()[-1]) diff --git a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py index 07a52ce24..3e9992a14 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py @@ -18,6 +18,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + sys.path.insert(0, str(SCRIPTS / "lib")) import approved_commit # noqa: E402 from step_result import validate_result # noqa: E402 @@ -177,7 +180,7 @@ def get(self): def call(script, args, env, cwd): - p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / script, *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None if out is not None: diff --git a/plugins/ndf/scripts/tests/test_sprint_procedures.py b/plugins/ndf/scripts/tests/test_sprint_procedures.py index 16a557711..78fecdfa0 100644 --- a/plugins/ndf/scripts/tests/test_sprint_procedures.py +++ b/plugins/ndf/scripts/tests/test_sprint_procedures.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" SPRINT_STATE = SCRIPTS / "sprint-state.py" REPO = SCRIPTS.parents[2] @@ -70,7 +73,7 @@ def new_sprint(tmp_path: Path, pace: str, design: bool = True) -> dict: args += ["--design", "11"] if design else [] if pace != "normal": args += ["--pace", pace, "--state", str(sprint_state(tmp_path, pace))] - p = subprocess.run([PY, str(SUPERVISE), *args, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, "--out", str(out), cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return {w["name"]: w for w in load(out / "sprint.json")["ステージ"]} @@ -87,7 +90,7 @@ def test_sprint_waves_does_not_rebuild_the_shared_procedures(): def new_single(tmp_path: Path, kind: str, *extra: str) -> dict: repo = tmp_path / "repo" if (tmp_path / "repo").is_dir() else make_repo(tmp_path) out = tmp_path / f"{kind}-{len(list(tmp_path.glob('*.json')))}.json" - p = subprocess.run([PY, str(SUPERVISE), "new", kind, *extra, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, "new", kind, *extra, "--out", str(out), cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return load(out) diff --git a/plugins/ndf/scripts/tests/test_step_scripts.py b/plugins/ndf/scripts/tests/test_step_scripts.py index e2d6281b8..7edb4e4b4 100644 --- a/plugins/ndf/scripts/tests/test_step_scripts.py +++ b/plugins/ndf/scripts/tests/test_step_scripts.py @@ -14,6 +14,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable FAKE_GH = """#!{py} @@ -72,8 +75,12 @@ def repo(tmp_path): return root -def call(script, args, env, cwd=None): - p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) +def call(script, args, env, cwd=None, proc=False): + """分岐はプロセスの中で main を呼ぶ。子のプロセスへ渡す入口(phase-steps.py)は `proc=True` で起動して確かめる。""" + if proc: + p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) + else: + p = inproc.run(SCRIPTS / script, *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None return p.returncode, out, p.stderr @@ -518,13 +525,13 @@ def test_phase_steps_forwards_with_root_before_or_after(repo, env, root_first): spec_repo(repo) sub = ["spec-finalize", "--spec", "docs/specifications/x.md", "--design", "docs/design/x-design.md"] args = ["--root", str(repo), *sub] if root_first else [*sub, "--root", str(repo)] - code, out, err = call("phase-steps.py", args, env, cwd=str(repo.parent)) + code, out, err = call("phase-steps.py", args, env, cwd=str(repo.parent), proc=True) assert code == 0, err assert out["tool"] == "plan-to-spec" and out["status"] == "ok" def test_phase_steps_forwards_exit_code(repo, env): - code, out, _ = call("phase-steps.py", [f"--root={repo}", "bump", "--plugin", "nope", "--to", "1.1.0"], env) + code, out, _ = call("phase-steps.py", [f"--root={repo}", "bump", "--plugin", "nope", "--to", "1.1.0"], env, proc=True) assert code == 3 and out["tool"] == "release" diff --git a/plugins/ndf/scripts/tests/test_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index a77bb6b61..6e7b408a2 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -16,6 +16,9 @@ SCRIPTS = Path(__file__).resolve().parents[1] SUPERVISE = SCRIPTS / "supervise.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable sys.path.insert(0, str(SCRIPTS / "lib")) @@ -327,6 +330,69 @@ def test_an_unconverged_review_stops_the_plan(tmp_path, monkeypatch, final, ran) assert [e["id"] for e in s.state.log] == ["review", "after"], text +def _skipping_drive(tmp_path, monkeypatch): + drive = tmp_path / "skip-drive.py" + drive.write_text( + f"import json, sys\nsys.path.insert(0, {str(SCRIPTS / 'lib')!r})\nimport drive_pause as dp\n" + "gap = {'kind': 'refactor', 'name': 'window', 'reason': '時間の枠に収まらず飛ばした: 要る下限 61 分 > 想定最大時間 30 分'}\n" + "out = dp.incomplete('d', 's', {'skipped': {'kind': 'window', 'reason': 'r'}, 'exit': 4}, gap)\n" + "print(json.dumps(out)); sys.exit(dp.exit_code(out))\n" + ) + monkeypatch.setitem(paths.DRIVES, "skip", drive) + return [ + {"id": "refactor", "type": "drive", "drive": "skip", "on_exit": {"6": "review"}, "on_fail": "abort", "next": "review"}, + {"id": "review", "type": "run", "cmd": "true", "next": "end"}, + {"id": "abort", "type": "run", "cmd": "true", "next": "end"}, + ] + + +def test_a_skipped_refactor_goes_on_to_the_review(tmp_path, monkeypatch): + """#2047 の I6・決定 5: 時間の枠の不足で飛ばした refactor(6 と gaps)は review へ進み、報告に欠けの理由が出る。""" + steps = _skipping_drive(tmp_path, monkeypatch) + s, text = run_plan(tmp_path, steps) + assert [e["id"] for e in s.state.log] == ["refactor", "review"], text + assert s.state.log[0]["exit"] == 6 and s.state.log[0]["gaps"][0]["name"] == "window" + assert "- 欠けを残して進んだステップ: refactor(window: 時間の枠に収まらず飛ばした: 要る下限 61 分 > 想定最大時間 30 分)" in text + # on_exit を持たない旧いプランは今どおり abort へ行く(移行性) + del steps[0]["on_exit"] + s, text = run_plan(tmp_path, steps) + assert [e["id"] for e in s.state.log] == ["refactor", "abort"], text + + +def _extending_drive(tmp_path, monkeypatch, seconds): + """延長のファイルへ `seconds` を書いてから 2 秒眠る駆動。""" + drive = tmp_path / "slow-drive.py" + drive.write_text( + f"import json, os, sys, time\nsys.path.insert(0, {str(SCRIPTS / 'lib')!r})\nimport drive_pause as dp\n" + f"dp.write_extension({seconds}, '想定最大時間を下限の 40 分へ上げた')\ntime.sleep(2)\n" + "out = dp.done('d', 's', 'r.md', {})\nprint(json.dumps(out)); sys.exit(dp.exit_code(out))\n" + ) + monkeypatch.setitem(paths.DRIVES, "slow", drive) + return [{"id": "refactor", "type": "drive", "drive": "slow", "timeout": 1, "on_fail": "end", "next": "end"}] + + +def test_the_drive_extends_the_step_deadline(tmp_path, monkeypatch): + """#2047 の決定 4・I7: 子が書いた秒に余裕を足した締め切りまで待ち、延ばしたことを試行と進捗ログに残す。""" + s, text = run_plan(tmp_path, _extending_drive(tmp_path, monkeypatch, 1)) + assert s.state.log[0]["exit"] == 0, text + assert s.state.log[0]["extended_timeout"] == {"seconds": 1, "timeout": 31, "planned": 1, "reason": "想定最大時間を下限の 40 分へ上げた"} + rows = [json.loads(line) for line in (s.state.dir / "progress.jsonl").read_text().splitlines()] + assert any(r["kind"] == "extend" and r["timeout"] == 31 for r in rows) + + +def test_a_drive_without_the_extension_is_cut_at_the_plan_timeout(tmp_path, monkeypatch): + steps = _extending_drive(tmp_path, monkeypatch, 0) + s, _ = run_plan(tmp_path, steps) + assert s.state.log[0]["exit"] == 1 and "extended_timeout" not in s.state.log[0] + + +def test_run_ticking_moves_the_deadline_only_later(): + """I7: extend の時刻が締め切りより前なら使わない。後ろなら延ばす。""" + assert claude.run_ticking("sleep 1.5", shell=True, timeout=0.5, every=0.1, extend=lambda started: started + 5).returncode == 0 + with pytest.raises(subprocess.TimeoutExpired): + claude.run_ticking("sleep 3", shell=True, timeout=0.5, every=0.1, extend=lambda started: started + 0.1) + + @pytest.mark.parametrize("drive", ["cross-review", "cross-refactoring"]) def test_real_drives_take_pr_number_not_url(drive): # drive の args の {pr} は番号になる。2 つの駆動は URL を引数の解析で拒む @@ -447,7 +513,7 @@ def test_queue_creates_worktrees_in_order_before_run(tmp_path, monkeypatch): def cli(*args, cwd=None): """既定ではこのリポジトリの根で打つ(作業場所に宣言が無いとき、今のディレクトリの .ndf/ を読む)。""" - return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd or REPO) + return inproc.run(SUPERVISE, *args, cwd=cwd or REPO) AI_PLUGINS_WORDS = ("plugins/", "playwright-kit", "build-runtime-plugins", "claude,codex,kiro", "develop", "scripts/check-") @@ -885,6 +951,7 @@ def _report_state_rich(st): - 理由: 無し - 通ったステップ: {empty} - 件数: 無し +- 欠けを残して進んだステップ: 無し - LLM の使用量: 入力 0 / cache read 0 / cache write 0 / 出力 0 / $0.000 - CLI(drive・work の中): 無し - 記録: {dir} @@ -912,6 +979,7 @@ def _report_state_rich(st): - 理由: 承認を待つ - 通ったステップ: impl(exit=0) → judge[続ける] → show(exit=12) → quiet(exit=12) → wait(exit=0) → wait2(exit=None) - 件数: impl: passed 3 / skipped 0; judge: 無し +- 欠けを残して進んだステップ: 無し - LLM の使用量: 入力 56 / cache read 200 / cache write 40 / 出力 14 / $1.012 - CLI(drive・work の中): 無し - 記録: {dir} diff --git a/plugins/ndf/scripts/tests/test_supervise_contract.py b/plugins/ndf/scripts/tests/test_supervise_contract.py index cf12e44e5..4478b05ab 100644 --- a/plugins/ndf/scripts/tests/test_supervise_contract.py +++ b/plugins/ndf/scripts/tests/test_supervise_contract.py @@ -26,6 +26,9 @@ PLUGIN = SCRIPTS.parent SUPERVISE = SCRIPTS / "supervise.py" PY = sys.executable +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + FIXTURES = Path(__file__).resolve().parent / "fixtures" / "supervise-contract" SUBCOMMANDS = ["run", "history", "expected", "example", "new", "queue", "wait", "design-glossary", "note", "sync-check"] KINDS = ["sprint", "impl", "fix", "check", "release", "close"] @@ -37,6 +40,11 @@ def cli(*args: str, cwd: Path, timeout: float = 120) -> subprocess.CompletedProcess: + """分岐と結果の形はプロセスの中で main を呼んで確かめる(起動の配線は `cli_proc` の代表の 1 件)。""" + return inproc.run(SUPERVISE, *args, cwd=cwd, env=dict(os.environ, COLUMNS="200")) + + +def cli_proc(*args: str, cwd: Path, timeout: float = 120) -> subprocess.CompletedProcess: env = dict(os.environ, COLUMNS="200") return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd, env=env, timeout=timeout) @@ -214,6 +222,6 @@ def test_the_fixture_plans_run_without_claude(tmp_path, monkeypatch): stub.write_text("#!/bin/sh\necho called >&2\nexit 99\n") stub.chmod(0o755) monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}") - p = cli("run", str(runnable(tmp_path, "ok")), cwd=tmp_path) + p = cli_proc("run", str(runnable(tmp_path, "ok")), cwd=tmp_path) assert p.returncode == 0 and "called" not in p.stderr, p.stderr assert shutil.which("claude") == str(bindir / "claude") diff --git a/plugins/ndf/scripts/tests/test_supervise_limits.py b/plugins/ndf/scripts/tests/test_supervise_limits.py index 6a0c24c2e..e3661a2a3 100644 --- a/plugins/ndf/scripts/tests/test_supervise_limits.py +++ b/plugins/ndf/scripts/tests/test_supervise_limits.py @@ -120,11 +120,22 @@ def reason_of(text: str) -> str: # --- L1・I1: 雛形ごとに、修正のループのそれぞれを 3 回ずつ通しても上限で止まらない --- -def test_every_template_plan_gets_through_each_fix_loop_three_times(plans, tmp_path): +def _failing_sets(plans: dict[str, dict]) -> dict[frozenset, tuple[str, dict]]: + """修正のループの入口の組ごとに、最初のプランを 1 つ(総当たりを代表の組に絞る。修正のループの無いプランは除く)。""" + reps: dict[frozenset, tuple[str, dict]] = {} for name, plan in plans.items(): fixes, _ = limits.loops(plan) failing = {k.split("→")[0] for k in fixes} - failing = {c for c in failing if any(s["id"] == c and s.get("on_fail") for s in plan["steps"])} + failing = frozenset(c for c in failing if any(s["id"] == c and s.get("on_fail") for s in plan["steps"])) + if failing: + reps.setdefault(failing, (name, plan)) + return reps + + +def test_every_fix_loop_shape_gets_through_each_fix_loop_three_times(plans, tmp_path): + reps = _failing_sets(plans) + assert len(reps) >= 4, sorted(n for n, _ in reps.values()) # impl・check・release・release-prod の形 + for failing, (name, plan) in reps.items(): visits: dict[str, int] = {} def decide(sid, step, failing=failing, visits=visits): diff --git a/plugins/ndf/scripts/tests/test_supervise_new.py b/plugins/ndf/scripts/tests/test_supervise_new.py index 8668ebbf1..108b3317b 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -11,6 +11,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" PY = sys.executable @@ -23,7 +26,7 @@ def cli(*args, cwd): - return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd) + return inproc.run(SUPERVISE, *args, cwd=cwd) def git(cwd, *args): diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_auto.py b/plugins/ndf/scripts/tests/test_supervise_pace_auto.py index bfee70a33..46dc36e01 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_auto.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_auto.py @@ -15,6 +15,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" SPRINT_STATE = SCRIPTS / "sprint-state.py" REPO = SCRIPTS.parents[2] @@ -61,7 +64,7 @@ def new_sprint(tmp_path, repo, state, *extra, pace="auto", design=True): args = ["new", "sprint", "--name", "m27", "--worktree", str(repo), "--issue", "11", "12"] args += ["--design", "11"] if design else [] args += ["--version", "10.18.0-dev.1", "--pace", pace, "--state", str(state), "--out", str(out), *extra] - p = subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, cwd=repo) return p, out diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index 90de1fe16..678f7cc7a 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -14,6 +14,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" STEPS = SCRIPTS / "release-steps.py" PY = sys.executable @@ -53,7 +56,7 @@ def new_sprint(tmp_path, repo, pace: str | None = "auto"): args = ["new", "sprint", "--name", "m2", "--worktree", str(repo), "--issue", "11", "--out", str(out)] if pace: args += ["--pace", pace, "--state", str(sprint_state(tmp_path, "auto"))] - p = subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return load(out / "sprint.json")["ステージ"] @@ -61,7 +64,7 @@ def new_sprint(tmp_path, repo, pace: str | None = "auto"): def new_close(tmp_path, repo): out = tmp_path / "c" args = ["new", "close", "--name", "m2", "--worktree", str(repo), "--issue", "11", "--state", str(sprint_state(tmp_path, "auto"))] - p = subprocess.run([PY, str(SUPERVISE), *args, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, "--out", str(out), cwd=repo) return p, out diff --git a/plugins/ndf/scripts/tests/test_whole_test_timing.py b/plugins/ndf/scripts/tests/test_whole_test_timing.py new file mode 100644 index 000000000..af428c357 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_whole_test_timing.py @@ -0,0 +1,102 @@ +"""実験版 `whole-test-timing.py` の計測と照合(#2291 の決定 7)。 + +- measure は宣言の順に suite を流し、最初の起動から最後の終了までを壁時計にする。負荷が CPU の数を超えたら参考の値にする +- junit の skip を件数から外し、遅いテストを秒の長い順に、ファイルを秒の合計の順に並べる +- diff は台帳に無い消えたテストと、skip に変わったテストを、nodeid を丸めずに出す +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "experimental" / "whole-test-timing.py" +_spec = importlib.util.spec_from_file_location("whole_test_timing", SCRIPT) +wtt = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(wtt) + +JUNIT = """ + + + + +""" + + +def _repo(tmp_path: Path, suites: list[dict]) -> Path: + (tmp_path / ".ndf").mkdir() + (tmp_path / ".ndf" / "project.json").write_text(json.dumps({"test": {"suites": suites}})) + return tmp_path + + +def _measure(monkeypatch, tmp_path, suites, load): + monkeypatch.setattr(wtt.os, "getloadavg", lambda: (load, 0.0, 0.0)) + monkeypatch.setattr(wtt.os, "cpu_count", lambda: 8) + root = _repo(tmp_path, suites) + out = tmp_path / "out.json" + with pytest.raises(SystemExit) as e: + wtt.main(["measure", "--root", str(root), "--out", str(out)]) + return e.value.code, json.loads(out.read_text()) + + +def test_measure_runs_suites_in_order_and_reads_junit(monkeypatch, tmp_path, capsys): + (tmp_path / "j.xml").write_text(JUNIT) + suites = [ + {"name": "first", "command": "echo 1 >> order", "junit": "j.xml"}, + {"name": "second", "command": "sleep 0.2; echo 2 >> order"}, + ] + code, out = _measure(monkeypatch, tmp_path, suites, load=3.0) + assert code == 0 + assert (tmp_path / "order").read_text().split() == ["1", "2"] + m = out["metrics"] + assert [s["name"] for s in m["suites"]] == ["first", "second"] + assert m["wall_seconds"] >= 0.2 and m["reference"] is False + assert (m["tests"], m["skipped"], m["sum_seconds"]) == (3, 1, 4.1) + assert out["tests"]["pkg/tests/test_a.py::TestK::test_slow[x-1]"] == {"outcome": "passed", "seconds": 3.0} + items = json.loads(capsys.readouterr().out)["items"] + tests = [i["name"] for i in items if i["kind"] == "test"] + assert tests[:2] == ["pkg/tests/test_a.py::TestK::test_slow[x-1]", "pkg/tests/test_b.py::test_mid"] + assert [(i["name"], i["seconds"]) for i in items if i["kind"] == "file"] == [ + ("pkg/tests/test_a.py", 3.1), + ("pkg/tests/test_b.py", 1.0), + ] + + +def test_measure_marks_high_load_as_reference_and_fails_on_a_failed_suite(monkeypatch, tmp_path): + code, out = _measure(monkeypatch, tmp_path, [{"name": "bad", "command": "exit 3"}], load=9.5) + assert code == 1 + assert out["metrics"]["reference"] is True + assert out["metrics"]["suites"][0]["exit"] == 3 + + +def _diff(tmp_path, before, after, ledger): + for name, tests in (("b.json", before), ("a.json", after)): + (tmp_path / name).write_text(json.dumps({"tests": tests})) + (tmp_path / "ledger.md").write_text(ledger) + with pytest.raises(SystemExit) as e: + wtt.main( + ["diff", "--before", str(tmp_path / "b.json"), "--after", str(tmp_path / "a.json"), "--ledger", str(tmp_path / "ledger.md")] + ) + return e.value.code + + +def test_diff_lists_removed_tests_missing_from_the_ledger_without_rounding_params(tmp_path, capsys): + ran = {"outcome": "passed", "seconds": 1.0} + before = {"t.py::a[1]": ran, "t.py::a[2]": ran, "t.py::b": ran} + after = {"t.py::b": ran} + code = _diff(tmp_path, before, after, "| `t.py::a[1]` | 消した | 1 | `t.py::b` |\n| `t.py::a` | 消した |\n") + assert code == 1 + out = json.loads(capsys.readouterr().out) + assert [i["name"] for i in out["items"]] == ["t.py::a[2]"] + + +def test_diff_lists_tests_that_became_skipped(tmp_path, capsys): + before = {"t.py::a": {"outcome": "passed", "seconds": 1.0}, "t.py::s": {"outcome": "skipped", "seconds": 0.0}} + after = {"t.py::a": {"outcome": "skipped", "seconds": 0.0}} + assert _diff(tmp_path, before, after, "") == 1 + out = json.loads(capsys.readouterr().out) + assert [(i["kind"], i["name"]) for i in out["items"]] == [("now_skipped", "t.py::a")] + assert _diff(tmp_path, before, before, "") == 0 diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index bab60f56f..5ff3a7196 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -14,7 +14,7 @@ allowed-tools: # 多ランタイム・リファクタリング(想定最大時間に収める 1 回のリファクタリング計画の実行) **参加者の全員が 1 度だけ多面的に提案し、選ばれた 1 者が想定最大時間に収まる分を -リファクタリング計画・テスト追加・実装・検証する。** 所要の上限は `--budget-minutes`(既定 30 分)で利用者が決める。 +リファクタリング計画・テスト追加・実装・検証する。** 所要の上限は `--budget-minutes` で利用者が決める。省けば 30 分から始め、着手前のテストが長く手順の枠が収まらなければ想定最大時間の下限まで上げる。 `refactoring` Skill は「テストで守りながら 1 手ずつ直す」手順を持つが、 **何を直すかの発見**と**直した結果の検証**を持たない。この Skill がその 2 つを補う。 @@ -70,7 +70,7 @@ allowed-tools: | --- | --- | --- | | `[PR番号]` | 対象の Pull Request | 必須 | | `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須。** 検証にも効くので、現状固定テストの置き場所も含める | 必須 | -| `--budget-minutes N` | 想定最大時間(分)。1 以上の整数でなければ `init` が止める(終了コード 4) | `30` | +| `--budget-minutes N` | 想定最大時間(分)。1 以上の整数でなければ `init` が止める(終了コード 4)。明示した値は上げない | 省けば `30`。手順の枠が収まらなければ想定最大時間の下限まで上げる(想定最大時間の出所 `floor`) | | `--implementer NAME` | リファクタリング計画以降を担う 1 者。参加者に無ければ `init` が止める | ホスト → 先頭 | | `--host claude\|codex\|agy\|kiro` | ホストの明示指定。未指定時は環境変数から推定(agy は推定できないため明示する) | 推定 | | `--exclude NAMES` | 参加者から外す者(カンマ区切り・繰り返し可)。再開で `none` を渡すと空へ戻す | なし | @@ -255,7 +255,8 @@ bash "$LIB/bg-wait.sh" wait "$RC" # 1 回 540 秒以内。124 = まだ終わ | --- | --- | --- | | 0 | finalize まで終わった | `items[0].report`(`refactor.py report` の出力)と `metrics` を「完了報告」へ写す | | 23(`cross-review`) | 単独起動の最終ゲート | `prompt_file` を読み、`items[0].cwd`(対象のリポジトリの根)で `items[0].command`(cross-review の駆動)を回す。最終ステータスは cross-review の駆動が `result_file` へ書き、自分では書かない。同じコマンドを打ち直し、結果 JSON の `metrics.review_status` が `approved` のときだけ Draft を解除する。それ以外は Draft のまま最終ステータスを報告して止まる | -| 1 | 中断(`metrics.exit` に元の終了コード。4 = 対象の Pull Request が閉じている・マージ済み・状態を判定できない(作業ディレクトリを作らず、担当の CLI を起動しない)・予算の指定の誤り・着手前のテストが長く手順の枠が想定最大時間に収まらない(要る `--budget-minutes` の下限を出す。下限以上で `init` を打ち直すと着手前のテストを走らせずに続く)・使える時間が 0 分以下で候補がすべて時間で見送られた(計画を置かず、打ち直しで止まっていた時間を除いて作り直す。#1944)・着手前のテストの打ち切り・範囲テストのテンプレートが `{paths}` に入るテストのファイルを受けない(着手前のテストの後に試走で見分けた。受けるかを確かめられないときも止め、直す箇所と記録 `template-check.json` を出す)・`--round-test` が要る・旧い状態ファイル・取り消しの失敗(項目に属さないコミットの積み直しか公開済みの revert が衝突した)・打ち切りの後の取り消しで起点へ戻した後でも最終ゲートが落ちた・push の直前に残すコミットでないもの(見放した担当の残留コミットなど)がある・範囲を確定できない など。最終ゲートを経ずに終わった実行もここで、`metrics.adopted` は 0・`metrics.unconfirmed` に残った項目の数) | `summary` を報告して止まる。**握り潰さない**(検証を通っていない変更が残る) | +| 6 | 時間の枠の不足でリファクタリングを飛ばした(着手前のテストの打ち切りか、明示した想定最大時間が手順の枠の下限に届かない。`metrics.skipped` に種類 `init-timeout` / `window` と理由、`gaps` に 1 件。`metrics.exit` は 4) | 理由を報告する。検査のプランは `on_exit` の `"6"` でコードレビューへ進む。単独で打ったときは、要る `--budget-minutes` の下限以上で `init` を打ち直すと着手前のテストを走らせずに続く | +| 1 | 中断(`metrics.exit` に元の終了コード。4 = 対象の Pull Request が閉じている・マージ済み・状態を判定できない(作業ディレクトリを作らず、担当の CLI を起動しない)・予算の指定の誤り・使える時間が 0 分以下で候補がすべて時間で見送られた(計画を置かず、打ち直しで止まっていた時間を除いて作り直す。#1944)・範囲テストのテンプレートが `{paths}` に入るテストのファイルを受けない(着手前のテストの後に試走で見分けた。受けるかを確かめられないときも止め、直す箇所と記録 `template-check.json` を出す)・`--round-test` が要る・旧い状態ファイル・取り消しの失敗(項目に属さないコミットの積み直しか公開済みの revert が衝突した)・打ち切りの後の取り消しで起点へ戻した後でも最終ゲートが落ちた・push の直前に残すコミットでないもの(見放した担当の残留コミットなど)がある・範囲を確定できない など。最終ゲートを経ずに終わった実行もここで、`metrics.adopted` は 0・`metrics.unconfirmed` に残った項目の数) | `summary` を報告して止まる。**握り潰さない**(検証を通っていない変更が残る) | 再開は同じコマンドを打ち直すだけである。進みは耐久の記録(既定の置き場は `~/.local/state/ndf/dbos/refactor-<鍵>.sqlite` )が持ち、 記録のある手順は流し直さない。`metrics` は状態ファイルから数えた件数(`items` / `adopted` / `reverted` / diff --git a/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md b/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md index b0648e07c..7d2442bd1 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md +++ b/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md @@ -63,11 +63,19 @@ 13. **着手前のテスト** — 戦略ごとに走らせる(`local-full` は全体テスト、`local-scoped-ci-whole` は `--scope` のテストの 置き場所の範囲テスト、`round-only` は全体テストとラウンドテスト)。**落ちても止めない。** 落ちたテストは JUnit から読んで `baseline_test.existing_failures`(既存失敗)に書き、最終ゲートは既存失敗の外で新しく落ちたテストが無ければ通る。 - JUnit を読めなければ `null` と理由を書く。上限(`limits.init_test_timeout`)を超えたときだけ止める。 + JUnit を読めなければ `null` と理由を書く。上限(`limits.init_test_timeout`)を超えたときだけ止める(`SKIP=init-timeout` を出して終了コード 4)。 + 上限は `max(0.10·B, 3·s)`。s は着手前のテストの所要の見込みで、CI に任せる戦略の同じ範囲の配分の履歴 → 手元の全体テストの直近の実測 + (`whole_runs`)→ 宣言の所要の順に採り(`baseline_test.scope_source` は `history` / `recent` / `whole`)、どれも無ければ `0.10·B` にする。 + 上限が `0.10·B` を超えるときは、走らせる前にステップの延長(環境変数 `NDF_STEP_EXTEND_FILE` の指すファイル)へ `B·60 + (上限 − 0.10·B·60)` 秒を書く。 実測の秒は `baseline_test.seconds`、宣言の所要は `whole_seconds`・`ci_seconds` に残し、時間の上限とバッファに使う 14. **測定の設定の読み込み** — 書き込み用の作業ディレクトリの `.ndf/code-metrics.json` を読み、 既定の対応と重ねて状態の `code_metrics.config` に書く。`status` は `pending` から始まる。 `limits.measure_timeout` もここで書く(下の「指標の測定」) +15. **手順の枠の確かめ**(`init_test.settle_window`)— 提案とリファクタリング計画の枠と計画の後の見込みが想定最大時間に収まるかを見る。 + 収まらないとき、想定最大時間の出所(状態の `budget_source`)が `default` / `floor` なら、打ち直しの見込みを足さない下限まで + `budget_minutes` を上げ、出所を `floor`・上げる前と後を `budget_floor` に残して上限の表を組み直し、`ℹ` の 1 行を出して続ける。 + `explicit`(引数か宣言で渡した。`budget_source` を持たない旧い状態ファイルも同じ)なら上げず、`SKIP=window` と要る下限を出して + 止めた印(`window_stopped_at`)を残し、終了コード 4 で止まる。出力の `BUDGET_SOURCE` に出所を出す ### 再開 @@ -85,7 +93,7 @@ | --- | --- | | `--max-fix-rounds` / `--test-timeout` | 廃止を知らせて無視する(決定 24。修正は締め切りまで、テストの上限は予算から導く) | | `--baseline-test` / `--round-test` | 知らせるだけ(戦略は `init` で解いたまま) | -| `--budget-minutes` | **リファクタリング計画の前**(`phase` が `propose` か `plan` でリファクタリング計画が無い)だけ置き換え、上限の表(`limits`)を組み直す。後は知らせるだけ。採用の件数・実装の終わり・バッファはリファクタリング計画の時点の予算で固定されている | +| `--budget-minutes` | **リファクタリング計画の前**(`phase` が `propose` か `plan` でリファクタリング計画が無い)だけ置き換え、想定最大時間の出所を `explicit` にして上限の表(`limits`)を組み直す。渡さなければ出所は状態のまま。後は知らせるだけ。採用の件数・実装の終わり・バッファはリファクタリング計画の時点の予算で固定されている | | `--implementer` | 知らせるだけ(置き換えない) | | `--exclude` / `--include` / `--require-all` | 参加者を作り直す。**実装担当が外れたら**、リファクタリング計画の前は決め方を当て直し、リファクタリング計画の後は止める | | そのほか状態に載る引数 | 状態と違えば「反映しない」と知らせる | diff --git a/plugins/ndf/skills/cross-refactoring/docs/02-plan-and-implement.md b/plugins/ndf/skills/cross-refactoring/docs/02-plan-and-implement.md index d11669868..58e382f48 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/02-plan-and-implement.md +++ b/plugins/ndf/skills/cross-refactoring/docs/02-plan-and-implement.md @@ -119,9 +119,9 @@ rf_eval merge-plan "$ID" # TESTS_NEEDED=0|1。2 = 項目 0 件(readop **時間に関わる数値は、この節の式だけで決まる**(決定 23・24、#1334 決定 8)。B は想定最大時間 (`--budget-minutes`)、w は宣言の全体テストの所要(`test_duration`。`ndf-record` → `ci-junit` → `ci-steps` の順)、 x は着手前に手元で走らせたテストの実測秒(`baseline_test.seconds`)、c は CI の壁時計(`ci.workflows[].wall_seconds` のうち -`test.ci.check` を持つもの、分からなければ最大)、s は CI に任せる戦略で着手前に走らせる範囲テストの所要の見込み -(配分の履歴の同じ戦略・同じテストの置き場所の集合の着手前のテストの行の直近 10 行の最大 → 置き場所が全体テストの suite の -`paths` を覆うときの w → 無し、の順。出所は `history` / `whole`)、o は手順の枠の起点のずれ(x。無ければ 0)、開始は `started_at`、R はバッファ(最終ゲートの全体テスト、 +`test.ci.check` を持つもの、分からなければ最大)、s は着手前のテストの所要の見込み +(CI に任せる戦略だけ配分の履歴の同じ戦略・同じテストの置き場所の集合の着手前のテストの行の直近 10 行の最大を先に採り、無ければ +手元の全体テストの直近の実測 m → 宣言の w → 無し、の順。出所は `history` / `recent` / `whole`。#2047)、o は手順の枠の起点のずれ(x。無ければ 0)、開始は `started_at`、R はバッファ(最終ゲートの全体テスト、 修正 1 回、最終ゲート修正 1 回と、`--ci-check` を付けた手元の戦略だけ危険フラグの全体テスト)、L は 1 件の長さ(配分テーブルの 手法のうち最も短い `implement` + `verify`。テストの追加を除く)である。テストと CI の待ちの係数は共通層 `scripts/lib/test_strategy.py` に、手順の枠の係数は `refactor_lib/timeline.py` に置き、実行の途中で数値を決めるために LLM へ問わない。入力は `limits.basis` に並べる。 @@ -129,7 +129,7 @@ x は着手前に手元で走らせたテストの実測秒(`baseline_test.sec | 値 | 式 | 書き出す時点 | | --- | --- | --- | | 余裕 | `0.05·B` | `init` | -| 着手前のテスト 1 回の上限 | `local-full` / `round-only` は `max(0.10·B, 3·w)`、`local-scoped-ci-whole` は s が分かれば `max(0.10·B, 3·s)`、分からなければ `0.10·B`(走らせるのは置き場所の範囲テストだけ) | `init` | +| 着手前のテスト 1 回の上限 | 戦略を問わず s が分かれば `max(0.10·B, 3·s)`。分からなければ `local-full` / `round-only` は `max(0.10·B, 3·w)`、`local-scoped-ci-whole` は `0.10·B`(走らせるのは置き場所の範囲テストだけ)。`0.10·B` を超えるときは、走らせる前にステップの延長へ `B·60 + (上限 − 0.10·B·60)` 秒を書く | `init` | | 範囲テスト 1 回の上限(`test_timeout`) | `max(3·x, 0.01·B)`。x が無ければ着手前の上限(s を入れない式) | `init` | | 手元の全体テスト 1 回の上限(`whole_timeout`) | `max(3·w, 0.01·B)`。w が無ければ着手前の上限(s を入れない式) | `init` | | CI の待ちの上限(`ci_wait_timeout`) | `max(3·c, 0.05·B)`。c が無ければ `0.20·B` | `init` | @@ -153,8 +153,11 @@ x は着手前に手元で走らせたテストの実測秒(`baseline_test.sec 収まる件数が減ることで吸収する(直しの試行の打ち切りと H は開始から数える)。 **枠の後に 1 件も入らなければ、提案者を起動せずに止める。** リファクタリング計画の枠の終わりに計画の後に要る時間の見込み -(R + L)を足した時刻が `開始 + B` を越えると、`init` は状態を保存して止めた印(`window_stopped_at`)を残し、終了コード 4 で +(R + L)を足した時刻が `開始 + B` を越えると、想定最大時間を明示した実行(出所 `explicit`)では、`init` は `SKIP=window` を出し、 +状態を保存して止めた印(`window_stopped_at`)を残し、終了コード 4 で 止まる。文は要る想定最大時間の下限 `ceil(((o + 300) / 60 + R + L) / 0.70)` 分を出す(300 秒は打ち直すまでの見込み)。 +想定最大時間を省いた実行(出所 `default` / `floor`)は止めず、`init_test.settle_window` が B を 300 秒を足さない下限 +`ceil((o / 60 + R + L) / 0.70)` 分まで上げ、出所を `floor` にして上限の表を組み直して続ける。 下限で打ち直すと 1 件だけが入る。`--budget-minutes` を下限以上にして `init` を 打ち直すと、着手前のテストを走らせ直さずに続き、枠は打ち直した時刻から数える(o = `max(x, resumed_at − 開始)`)。打ち直しが 遅れて再び収まらなければ、その時点の o から同じ式で下限を出して止まる。止めた印の無い再開は、計画の前も後も diff --git a/plugins/ndf/skills/cross-refactoring/scripts/drive.py b/plugins/ndf/skills/cross-refactoring/scripts/drive.py index 0b16e734c..153895163 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/drive.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/drive.py @@ -14,7 +14,7 @@ 耐久の記録の実行の鍵は `refactor-<状態の置き場の sha256 の先頭 12 字>`(置き場が求まらなければ `<作業ディレクトリ>#`)。done で終わり状態ファイルが残る実行の回は、PR の head と結果を変える引数が同じなら記録した結果を返す(#1263)。 -止まるときの JSON の形と終了コードの表はライブラリの `scripts/lib/drive_pause.py` にある(使うのは 23 だけ。中断の `metrics.exit` が 4 なら refactor.py の中断)。 +止まるときの JSON の形と終了コードの表はライブラリの `scripts/lib/drive_pause.py` にある(止まりは 23 だけ、欠けの残る結果は 6。中断の `metrics.exit` が 4 なら refactor.py の中断)。 子の起動・KEY=VALUE の読み取りは `scripts/lib/loop_drive.py` にある。最終ステータスは決めない。最終ゲートの cross-review の 駆動が `--result-file` で回答ファイルへ書いた値(`loop_drive.review_status` が決めたもの)をそのまま finalize へ渡す(#1656)。 対象のリポジトリは打った場所(現在のディレクトリが属する git の作業ツリーの根)で決まる。決められなければ耐久の記録を開かずに @@ -25,6 +25,9 @@ unpublished は手元の HEAD が公開した地点より進んでいるか(plan-comment が判定できなければ null)。 done・stopped・pause の各出口で、結果 JSON を組む前に `refactor.py plan-comment` を 1 度だけ打ち、リファクタリング計画のコメントを書き直す(#1692)。 最終ゲートを経ずに終わった実行は完了にせず、中断(stopped・metrics.exit に最終ゲートの終了コード)で終える(#1482)。 +`init` が時間の枠の不足(`SKIP=init-timeout` / `SKIP=window`)で止まったときだけ、リファクタリングを飛ばした欠けの残る結果 +(stopped・`gaps` 1 件・`metrics.skipped`・終了コード 6)で返す(#2047)。`init` が想定最大時間を下限まで上げた(`BUDGET_SOURCE=floor`)ら、 +ステップの延長(`drive_pause.write_extension`)へ想定最大時間の秒を書く。 """ from __future__ import annotations @@ -122,6 +125,14 @@ def _target_root() -> Path: raise Stop(str(e), e.code) from None +class Skip(Stop): + """時間の枠の不足でリファクタリングを飛ばす(`init` の `SKIP`)。捕まえ損ねても `Stop` として中断に落ちる(#2047)。""" + + def __init__(self, kind: str, detail: str, code: int): + super().__init__(f"時間の枠に収まらずリファクタリングを飛ばした: {detail}", code) + self.kind, self.detail = kind, detail + + class Drive(AssigneeLaunch): def __init__(self, pr: int, init_args: list[str], root: str | None = None): self.pr = pr @@ -152,6 +163,8 @@ def launch(self, seat: str, phase: str) -> int: def rf(self, *args: str, ok=(0,)) -> tuple[int, dict]: rc, out = self.call(_refactor_cmd(*args)) vs = parse_vars(out) + if rc == ABORT and (kind := vs.get("SKIP")): # 時間の枠の不足(#2047 の I5)。リファクタリングを飛ばして返す + raise Skip(kind, vs.get("SKIP_DETAIL") or "", rc) if rc == ABORT or rc not in ok: # 子が出した中断の理由の 1 行(ABORT_DETAIL)を添える(#1944 の決定 6) raise Stop(f"refactor.py {args[0]} が終了コード {rc} で止まった" + (f": {why}" if (why := vs.get("ABORT_DETAIL")) else ""), rc) self.v.update(vs) @@ -271,6 +284,9 @@ def start(self) -> None: if "TMP_DIR" not in self.v or "ID" not in self.v: raise Stop("refactor.py init が TMP_DIR / ID を返さない", 2) self.own("touch") + if self.v.get("BUDGET_SOURCE") == "floor": # 下限まで上げた想定最大時間で、親のステップの締め切りを延ばす(#2047 の決定 4) + minutes = int(self.v.get("BUDGET_MINUTES") or 0) + dp.write_extension(minutes * 60, f"想定最大時間を下限の {minutes} 分へ上げた") self.extra_env["CROSS_REFACTORING_TMP_DIR"] = self.v["TMP_DIR"] self.sh("prepare-worktrees.sh", ["bash", str(HERE / "prepare-worktrees.sh"), self.v["ID"]]) @@ -385,6 +401,15 @@ def done(self, extra: dict | None = None) -> dict: c, ) + def skipped(self, e: Skip) -> dict: + """時間の枠の不足で飛ばした結果(欠けの残る結果・終了コード 6)。状態ファイルが無ければ(打ち切りは保存の前)件数は持たない。""" + if not self.comment_done: + self.refresh_plan_comment() + skipped = {"kind": e.kind, "reason": e.detail} + metrics = {**(self.counts() if self.has_state() else {}), "skipped": skipped, "exit": e.code} + gap = {"kind": "refactor", "name": e.kind, "reason": f"時間の枠に収まらず飛ばした: {e.detail}"} + return dp.incomplete(TOOL, str(e), metrics, gap) + def stopped(self, e: Stop) -> dict: if not self.comment_done: self.refresh_plan_comment() @@ -484,6 +509,8 @@ def refactor_drive(pr: int, init_args: list[str], root: str | None = None) -> di result = d.done() result["items"] = [*result.get("items", []), *json.loads(d.v.get("SWEPT") or "[]")] # 開始の片付けの結果 d.own("finish") + except Skip as e: + result = d.skipped(e) except Stop as e: result = d.stopped(e) head = pr_head(d.call, pr) if result.get("status") == "ok" else None diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/allocation.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/allocation.py index 4fea1637b..2100f5286 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/allocation.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/allocation.py @@ -307,6 +307,7 @@ def build_row(state: dict[str, Any]) -> dict[str, Any]: "pr": state.get("pr", state.get("current_pr")), "implementer": state.get("implementer"), "budget_minutes": state.get("budget_minutes"), + "budget_source": state.get("budget_source"), # 想定最大時間の出所(#2047)。旧い行は持たない(集計には使わない) "elapsed_seconds": elapsed, "phases": phase_seconds(state), "kinds": kinds, diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py index f17c06bb0..cee2cb983 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py @@ -204,6 +204,14 @@ def _reassigned_lines(state: dict[str, Any]) -> list[str]: return lines +def _budget_source_text(state: dict[str, Any]) -> str: + """想定最大時間の出所の添え書き(#2047)。下限まで上げた実行だけ「下限から決めた」と上げる前の分を添える。""" + if state.get("budget_source") != "floor": + return "" + before = (state.get("budget_floor") or {}).get("from_minutes") + return f"(下限から決めた。既定 {before} 分)" + + def _print_header(state: dict[str, Any]) -> None: budget_seconds = int(state.get("budget_minutes") or 0) * 60 # 止まっていた時間の分だけ締め切りをずらしたなら、所要からその秒を除いて想定最大時間と比べる(#1491・決定 8) @@ -217,7 +225,7 @@ def _print_header(state: dict[str, Any]) -> None: print(f"- 件数: {counts_line(state)}") print(f"- 対象範囲: {', '.join(state['target_scope']) or '(未指定)'}") print( - f"- 想定最大時間: {state.get('budget_minutes')} 分 / 所要: {elapsed / 60:.1f} 分" + f"- 想定最大時間: {state.get('budget_minutes')} 分{_budget_source_text(state)} / 所要: {elapsed / 60:.1f} 分" f"(差 {(budget_seconds - elapsed) / 60:+.1f} 分。{paused}cross-review を除く)" ) print(f"- 実装担当: {state.get('implementer')}({state.get('implementer_reason')}) / モデル: {_implementer_model_text(state)}") diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index e1c52cfdc..423ab1778 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -317,6 +317,8 @@ def _build_initial_state(args: argparse.Namespace, ctx: InitialContext) -> dict[ "id": args.pr, "started_at": ctx.started_at, "budget_minutes": args.budget_minutes, + # 想定最大時間の出所(#2047)。`explicit` / `default` / `floor`(`init_test.settle_window` が下限まで上げた) + "budget_source": getattr(args, "budget_source", None) or "explicit", "repo": ctx.repo, "current_pr": args.pr, "base_branch": ctx.base_branch, @@ -407,6 +409,8 @@ def cmd_init(args: argparse.Namespace) -> None: _emit_run(prep) return + # 想定最大時間の出所(#2047)。既定を埋める前に決める。省いた実行だけ、手順の枠が収まらなければ下限まで上げる + args.budget_source = "explicit" if getattr(args, "budget_minutes", None) is not None else "default" for key, value in NEW_RUN_DEFAULTS.items(): if getattr(args, key, None) is None: setattr(args, key, value) @@ -414,7 +418,8 @@ def cmd_init(args: argparse.Namespace) -> None: participants, baseline, round_record = _verify_init(args, inputs, prep) state = _save_initial_state(args, inputs, prep, participants, baseline, round_record, started_at) # **手順の枠と計画の後の見込みが想定最大時間に収まらなければ、提案者を起動せずに止める**(#1385 I4・#1743 I8)。 - init_test.stop_if_window_short(prep.state_file, state) + # 想定最大時間を省いた実行は止めずに下限まで上げる(#2047 の I3)。 + init_test.settle_window(prep.state_file, state) # **出力は入口から直接呼ぶ。** 手順書の変数の出所のチェック(`scripts/check-skill-shell-vars.py`)は 1 階層だけたどる。 _emit_init(state) _emit_run(prep) @@ -605,14 +610,16 @@ def _verify_init( ) _warn_unmeasurable_models(inputs.model_spec, participants["available"]) - # 着手前のテストの上限は予算と宣言の所要から導く(決定 8)。CI に任せる戦略は範囲テストの所要 s も入れる(#1555)。 + # 着手前のテストの上限は予算と、着手前のテストの所要の見込み s(履歴 → 手元の全体テストの直近の実測 m → 宣言の w)から導く + # (決定 8・#1555・#2047 の I1)。 w, w_source = ts.whole_seconds(prep.decl) c = ts.ci_wall_seconds(prep.decl, (prep.strategy.ci or {}).get("check") if prep.strategy.ci else None) - s, s_source = init_test.resolve_scope_seconds(prep, list(args.scope), w) + s, s_source = init_test.resolve_scope_seconds(prep, list(args.scope), w, init_test.recent_whole_seconds(prep)) kw: dict[str, Any] = {"whole_seconds_value": w, "whole_source": w_source, "ci_seconds": c, "scope_seconds": s, "scope_source": s_source} timeout = ts.limits(prep.strategy, int(args.budget_minutes), **kw)["init_test_timeout"] if s is not None: - info(f" 着手前のテストの上限: {timeout} 秒(範囲テストの所要 {s} 秒 / {s_source})") + info(f" 着手前のテストの上限: {timeout} 秒(着手前のテストの所要の見込み {s} 秒 / {s_source})") + init_test.extend_for_init_test(int(args.budget_minutes), timeout) # 親のステップの締め切りで先に切られない(#2047 の決定 4) baseline = baseline_lib.run_baseline(prep.strategy, prep.work, timeout, list(args.scope), prep.tmp_dir) baseline.update({"whole_seconds": w, "whole_source": w_source, "ci_seconds": c, "scope_seconds": s, "scope_source": s_source}) init_test.append_init_test(prep, int(args.budget_minutes), args.pr, baseline) @@ -753,6 +760,8 @@ def _resume( info(f"↻ 前回中断した状態から再開します(手順 {state.get('phase')})") ensure_record(state, getattr(args, "code_metrics", None)) budget_spec = RESUME_BUDGET_REPLACE if _before_plan(state) else RESUME_BUDGET_NOTIFY + if getattr(args, "budget_minutes", None) is not None and _before_plan(state): + state["budget_source"] = "explicit" # 渡した想定最大時間は利用者の上限として守る(#2047 の I2) for line in statefile.apply_resume_args(state, args, budget_spec): info(line) view, given = _notify_view(state, args, inputs.model_spec) @@ -772,7 +781,7 @@ def _resume( if state.get("window_stopped_at"): state["resumed_at"] = statefile.now() state["limits"] = timeline.of_state(state, forecast.after_plan(state)) - init_test.stop_if_window_short(state_file, state) + init_test.settle_window(state_file, state) statefile.save(state_file, state) _emit_init(state) @@ -853,6 +862,7 @@ def _emit_init(state: dict[str, Any]) -> None: # 再開の地点。駆動は終わった手順を飛ばす(AC24)。 PHASE=state.get("phase") or "propose", BUDGET_MINUTES=state["budget_minutes"], + BUDGET_SOURCE=init_test.budget_source(state), # 想定最大時間の出所(#2047)。駆動は floor ならステップの延長を書く WORKTREE_ROOT=state["worktree_root"], WORK=state["worktrees"]["work"], TMP_DIR=state["tmp_dir"], diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py index cce63b36f..525f3eb80 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py @@ -1,13 +1,15 @@ -"""`init` の着手前のテストの周り: 範囲テストの所要の見込み・配分の履歴への追記・打ち切りと手順の枠での停止(#1385 #1555)。 +"""`init` の着手前のテストの周り: 着手前のテストの所要の見込み・配分の履歴への追記・打ち切りと手順の枠での停止か想定最大時間の引き上げ(#1385 #1555 #2047)。 `commands/setup.py` から呼ぶ。`prep` は `setup._InitPreparation`(`strategy`・`work`・`repo` を持つもの)を受ける。 """ from __future__ import annotations +import math import pathlib from typing import Any, Optional +import drive_pause import run_metrics import statefile import test_strategy as ts @@ -21,22 +23,41 @@ def history_file(repo: str) -> pathlib.Path: return allocation.history_path(run_metrics.metrics_dir(), repo) -def resolve_scope_seconds(prep: Any, scope: list[str], w: Optional[float]) -> tuple[Optional[float], Optional[str]]: - """CI に任せる戦略の範囲テストの所要 s と出所(I7・決定 5)。履歴の同じ戦略・同じ置き場所の実測(`history`)→ - 置き場所が全体テストの suite のパスを覆うときの w(`whole`)→ 無し。ほかの戦略では履歴を読まない。""" - if not prep.strategy.whole_on_ci: - return None, None - locations = baseline_lib.locations_of(prep.strategy, scope, prep.work) - if not locations: - return None, None - measured = allocation.scope_seconds(allocation.read_history(history_file(prep.repo)), prep.strategy.name, locations) - if measured is not None: - return measured, "history" - if w is not None and ts.covers_whole(prep.strategy, locations): +def resolve_scope_seconds( + prep: Any, scope: list[str], w: Optional[float], m: Optional[float] = None +) -> tuple[Optional[float], Optional[str]]: + """着手前のテストの所要の見込み s と出所(#1555 の I7・#2047 の I1)。CI に任せる戦略だけ、履歴の同じ戦略・同じ置き場所の + 実測(`history`)を先に採る。無ければ手元の全体テストの直近の実測 m(`recent`)→ 宣言の w(`whole`)→ 無し。 + 範囲テストは全体テストの部分なので、全体テストの所要は上限の見込みとして上側に倒れる。""" + if prep.strategy.whole_on_ci: + locations = baseline_lib.locations_of(prep.strategy, scope, prep.work) + if locations: + measured = allocation.scope_seconds(allocation.read_history(history_file(prep.repo)), prep.strategy.name, locations) + if measured is not None: + return measured, "history" + if m is not None: + return float(m), "recent" + if w is not None: return float(w), "whole" return None, None +def recent_whole_seconds(prep: Any) -> Optional[float]: + """手元の全体テストの直近の実測 m(`whole_runs`)。無ければ None。""" + got = whole_runs.recent_whole(prep.work, ts.whole_key(prep.strategy)) + return float(got[0]) if got else None + + +def extend_for_init_test(budget_minutes: int, timeout: int) -> None: + """着手前のテストの上限 T が `0.10·B` を超えるなら、テストを始める前にステップの延長を書く(#2047 の決定 4)。 + 見込みは `B·60 + (T − 0.10·B·60)`。エンジンの外で打ったときは何もしない。""" + base = math.ceil(float(budget_minutes) * 60 * ts.INIT_TEST_SHARE) + if timeout <= base: + return + seconds = int(budget_minutes) * 60 + (int(timeout) - base) + drive_pause.write_extension(seconds, f"着手前のテストの上限 {timeout} 秒が想定最大時間の 10%({base} 秒)を超える") + + def append_init_test(prep: Any, budget_minutes: int, pr: Any, baseline: dict[str, Any]) -> None: """着手前のテストの行を履歴へ 1 行足す(E9)。打ち切りでも足す。書けなければ知らせて続ける。""" try: @@ -56,7 +77,8 @@ def record_whole_run(prep: Any, baseline: dict[str, Any]) -> None: def abort_timed_out(strategy: ts.Strategy, budget_minutes: int, baseline: dict[str, Any], timeout: int) -> None: - """着手前のテストの打ち切りで止める。履歴に残したことと、次の実行の上限の見込みを添える。""" + """着手前のテストの打ち切りで止める。時間の枠の不足(`SKIP=init-timeout`)を出し、履歴に残したことと、次の実行の上限の見込みを添える。""" + statefile.emit(SKIP="init-timeout", SKIP_DETAIL=f"着手前のテストが上限 {timeout} 秒で終わらなかった") lines = [f"着手前のテストが {timeout} 秒で終わりませんでした({baseline.get('command')})。打ち切りました"] if baseline.get("mode") == "scope" and strategy.whole_on_ci: following = ts.limits(strategy, budget_minutes, scope_seconds=float(timeout), scope_source="history")["init_test_timeout"] @@ -66,13 +88,51 @@ def abort_timed_out(strategy: ts.Strategy, budget_minutes: int, baseline: dict[s die("\n".join(lines)) -def stop_if_window_short(state_file: pathlib.Path, state: dict[str, Any]) -> None: - """手順の枠が想定最大時間に収まらなければ、止めた印を残して保存し、終了コード 4 で止める。収まれば印を消す。""" +def budget_source(state: dict[str, Any]) -> str: + """想定最大時間の出所。持たない状態ファイル(この変更より前の実行)は `explicit`(止まる側)として読む。""" + return str(state.get("budget_source") or "explicit") + + +def settle_window(state_file: pathlib.Path, state: dict[str, Any]) -> None: + """手順の枠が想定最大時間に収まるかを確かめ、出所に応じて上げるか止める(#2047 の I3・I4)。 + + 収まれば止めた印を消す。出所が `default` / `floor` なら想定最大時間を下限(打ち直しの見込みを足さない)へ上げ、 + 出所を `floor` にして上限の表を組み直す。`explicit` なら `SKIP=window` を出し、止めた印を残して保存し、終了コード 4 で止める。""" problem = timeline.window_problem(state["limits"]) if problem is None: if state.pop("window_stopped_at", None) is not None: statefile.save(state_file, state) return - state["window_stopped_at"] = statefile.now() + offset = timeline.window_offset(state) + after = float(state["limits"].get("after_plan_minutes") or 0.0) + floor = timeline.required_budget_minutes(offset, after, grace_seconds=0) + before = int(state["budget_minutes"]) + x = (state.get("baseline_test") or {}).get("seconds") + spent = f"着手前のテスト {x} 秒" if x is not None else f"枠の起点 {math.ceil(offset)} 秒" + if budget_source(state) == "explicit": + statefile.emit(SKIP="window", SKIP_DETAIL=f"要る下限 {floor} 分 > 想定最大時間 {before} 分({spent})") + state["window_stopped_at"] = statefile.now() + statefile.save(state_file, state) + die(problem) + return + minutes = max(floor, before) + state["budget_minutes"] = minutes + state["limits"] = timeline.of_state(state) + while timeline.window_problem(state["limits"]) is not None: # 時刻の丸めで 1 分届かないときだけ回る + minutes += 1 + state["budget_minutes"] = minutes + state["limits"] = timeline.of_state(state) + state["budget_source"] = "floor" + state["budget_floor"] = { + "from_minutes": before, + "minutes": minutes, + "offset_seconds": round(offset, 1), + "after_plan_minutes": after, + "at": statefile.now(), + } + state.pop("window_stopped_at", None) statefile.save(state_file, state) - die(problem) + info( + f"ℹ 想定最大時間を省いたため、下限の {minutes} 分へ上げました({before} 分では提案とリファクタリング計画の枠と" + f"計画の後の見込み {after} 分が収まらない。{spent})" + ) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py index 0308b2cdc..af7b7a50e 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py @@ -315,7 +315,7 @@ def limits_section(limits: dict[str, Any]) -> list[str]: "", f"入力: 予算 {basis.get('budget_minutes')} 分 / 全体テストの所要 w {basis.get('w')}({basis.get('w_source') or '—'})" f" / 着手前の実測 x {basis.get('x')} / CI の壁時計 c {basis.get('c')}" - f" / 範囲テストの所要 s {basis.get('scope_seconds')}({basis.get('scope_source') or '—'}) / 戦略 {basis.get('strategy')}", + f" / 着手前のテストの所要の見込み s {basis.get('scope_seconds')}({basis.get('scope_source') or '—'}) / 戦略 {basis.get('strategy')}", ] ) lines.extend(["", "無音の打ち切りは手順の監視の上限と同じ値である。項目ごとの期限は持たない(実装を止めるのは実装の終わりだけ)。", ""]) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/timeline.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/timeline.py index 6f03a3ea3..ce7fd0e2b 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/timeline.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/timeline.py @@ -229,11 +229,12 @@ def of_state(state: dict[str, Any], after_plan: Optional[dict[str, Any]] = None) ) -def required_budget_minutes(offset_seconds: float, after_plan: float = 0.0) -> int: +def required_budget_minutes(offset_seconds: float, after_plan: float = 0.0, grace_seconds: float = RESUME_GRACE_SECONDS) -> int: """枠の起点が o のとき、提案と改修計画の枠の後に見込み(R + L)が収まる想定最大時間の下限(分)。 - 人が打ち直すまでの見込みを足す(I8)。見込みが 0 なら枠だけが収まる下限である。""" + 人が打ち直すまでの見込み `grace_seconds` を足す(I8)。打ち直さずにその場で上げる経路(`init_test.settle_window`)は 0 を渡す + (#2047 の決定 2)。見込みが 0 なら枠だけが収まる下限である。""" share = round(1.0 - PROPOSE_SHARE - PLAN_SHARE, 9) - return math.ceil(round(((float(offset_seconds) + RESUME_GRACE_SECONDS) / 60 + float(after_plan or 0.0)) / share, 9)) + return math.ceil(round(((float(offset_seconds) + float(grace_seconds)) / 60 + float(after_plan or 0.0)) / share, 9)) def window_problem(limits: dict[str, Any]) -> Optional[str]: diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_drive_contract.py b/plugins/ndf/skills/cross-refactoring/tests/test_drive_contract.py index 95f570d87..538458b38 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_drive_contract.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_drive_contract.py @@ -106,3 +106,64 @@ def test_readopt_round_launches_the_implement_cli_for_tested_items(): d, calls = _readopt_drive(["verified", "tested"]) d.readopt_rounds() assert calls == ["readopt", "cli:add-tests", "merge-tests", "cli:implement", "merge-implement", "verify", "readopt"] + + +# ---------- #2047: 時間の枠の不足は飛ばした欠けの残る結果、下限まで上げたらステップの延長 ---------- + + +@pytest.mark.parametrize( + "kind, detail", + [ + ("window", "要る下限 61 分 > 想定最大時間 30 分(着手前のテスト 495.0 秒)"), + ("init-timeout", "着手前のテストが上限 180 秒で終わらなかった"), + ], +) +def test_a_window_shortage_returns_the_skipped_gap(tmp_path, monkeypatch, capsys, kind, detail): + """I5: init の SKIP だけを、gaps 1 件の stopped・終了コード 6 で返す。summary と理由に「落ちた」「失敗」を入れない。""" + + def fake(cmd, env=None, cwd=None): + return 4, f"SKIP={kind}\nSKIP_DETAIL='{detail}'\n" + + monkeypatch.setattr(rf, "call", fake) + with pytest.raises(SystemExit) as e: + rf.main(["5"]) + out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert e.value.code == 6 + assert out["status"] == "stopped" + assert out["metrics"] == {"skipped": {"kind": kind, "reason": detail}, "exit": 4} + assert out["gaps"] == [{"kind": "refactor", "name": kind, "reason": f"時間の枠に収まらず飛ばした: {detail}"}] + for text in (out["summary"], out["gaps"][0]["reason"]): + assert "落ちた" not in text and "失敗" not in text + + +def _started_drive(tmp_path, monkeypatch, source: str): + """init が BUDGET_SOURCE を返す start を、子を呼ばずに打つ。延長のファイルのパスを返す。""" + target = tmp_path / "01-refactor.extend.json" + monkeypatch.setenv(drive_pause.EXTEND_ENV, str(target)) + d = rf.Drive(5, []) + out = f"ID=7\nTMP_DIR={tmp_path}\nBUDGET_MINUTES=40\nBUDGET_SOURCE={source}\n" + monkeypatch.setattr(rf, "call", lambda cmd, env=None, cwd=None: (0, out if "init" in cmd else "")) + d.start() + return target + + +def test_a_floor_budget_extends_the_step(tmp_path, monkeypatch): + """決定 4: 下限まで上げた想定最大時間(40 分)の秒を延長のファイルへ書く。""" + target = _started_drive(tmp_path, monkeypatch, "floor") + assert drive_pause.read_extension(target) == (2400.0, "想定最大時間を下限の 40 分へ上げた") + + +@pytest.mark.parametrize("source", ["default", "explicit"]) +def test_other_budget_sources_do_not_extend_the_step(tmp_path, monkeypatch, source): + assert not _started_drive(tmp_path, monkeypatch, source).exists() + + +def test_the_extension_never_moves_back(tmp_path, monkeypatch): + """I7: 後から小さい秒を書いても、延長のファイルの秒は前へ戻らない。""" + target = tmp_path / "x.extend.json" + monkeypatch.setenv(drive_pause.EXTEND_ENV, str(target)) + assert drive_pause.write_extension(6174, "着手前のテスト") + assert drive_pause.write_extension(2400, "下限") + assert drive_pause.read_extension(target) == (6174.0, "着手前のテスト") + monkeypatch.delenv(drive_pause.EXTEND_ENV) + assert not drive_pause.write_extension(9000, "エンジンの外") diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index dcf286e37..d123e5814 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -10,6 +10,7 @@ import json import os import shlex +import shutil import subprocess import types @@ -25,15 +26,10 @@ def _git(*args, cwd): return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True) -@pytest.fixture -def origin_repo(tmp_path): - """origin にだけ head ブランチがあるリポジトリ。 - - `git worktree add ` はローカルにブランチが無いと失敗するため、 - その経路を踏ませる形で用意する。 - """ - origin = tmp_path / "origin.git" - repo = tmp_path / "repo" +def _build_origin_repo(base): + """origin にだけ head ブランチがあるリポジトリを `base` の下に作る。""" + origin = base / "origin.git" + repo = base / "repo" subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True) subprocess.run(["git", "clone", "-q", str(origin), str(repo)], check=True, capture_output=True) _git("config", "user.email", "t@e.st", cwd=repo) @@ -52,6 +48,26 @@ def origin_repo(tmp_path): # ローカルの head ブランチを消し、origin にだけある状態にする _git("checkout", "-q", "main", cwd=repo) _git("branch", "-qD", HEAD_BRANCH, cwd=repo) + + +@pytest.fixture(scope="session") +def _origin_template(tmp_path_factory): + base = tmp_path_factory.mktemp("origin-template") + _build_origin_repo(base) + return base + + +@pytest.fixture +def origin_repo(tmp_path, _origin_template): + """origin にだけ head ブランチがあるリポジトリ。 + + `git worktree add ` はローカルにブランチが無いと失敗するため、 + その経路を踏ませる形で用意する。git の組み立てはプロセスごとに 1 度だけ行い、テストごとに複製する。 + """ + for name in ("origin.git", "repo"): + shutil.copytree(_origin_template / name, tmp_path / name, symlinks=True) + repo = tmp_path / "repo" + _git("remote", "set-url", "origin", str(tmp_path / "origin.git"), cwd=repo) return repo @@ -2148,3 +2164,131 @@ def test_the_pull_request_check_adds_no_gh_call(run_init, tmp_path, capsys): run_init(_args(tmp_path)) assert stopped == run_init.gh_calls assert sum(1 for c in stopped if c[2:3] and c[2].startswith("repos/")) == 1 + + +# ---------- #2047: 想定最大時間を省いたら下限まで上げ、明示したら飛ばす合図を出す ---------- + + +def _floor_minutes(state, x): + """打ち直しの見込みを足さない下限(決定 2)。""" + import math + + return math.ceil(round((x / 60 + float(state["limits"]["after_plan_minutes"])) / 0.70, 9)) + + +def test_an_omitted_budget_rises_to_the_lower_bound(run_init, tmp_path, init_clock, slow_baseline, capsys): + """受け入れ条件 3・I3: x = 495 秒で既定 30 分の枠が収まらなくても止まらず、下限まで上げて続ける。""" + timeline = sys.modules["refactor_lib.timeline"] + slow_baseline["seconds"] = 495.0 + init_clock(0) + run_init(_args(tmp_path)) + captured = capsys.readouterr() + _, state = _state_of(tmp_path) + floor = _floor_minutes(state, 495.0) + assert floor > 30 and floor < timeline.required_budget_minutes(495.0, state["limits"]["after_plan_minutes"]) + assert (state["budget_minutes"], state["budget_source"]) == (floor, "floor") + assert state["budget_floor"]["from_minutes"] == 30 and state["budget_floor"]["minutes"] == floor + assert state["limits"]["budget_minutes"] == floor and timeline.window_problem(state["limits"]) is None + assert f"下限の {floor} 分へ上げました" in captured.err + assert "BUDGET_SOURCE=floor" in captured.out and f"BUDGET_MINUTES={floor}" in captured.out + assert "PHASE=" in captured.out and "SKIP=" not in captured.out + + +def test_a_resume_keeps_or_replaces_the_budget_source(run_init, tmp_path, init_clock, slow_baseline): + """受け入れ条件 3(再開): 引数なしの再開は floor のまま、引数を渡すと explicit になる。""" + slow_baseline["seconds"] = 495.0 + init_clock(0) + run_init(_args(tmp_path)) + raised = _state_of(tmp_path)[1]["budget_minutes"] + run_init(_args(tmp_path)) + _, state = _state_of(tmp_path) + assert (state["budget_minutes"], state["budget_source"]) == (raised, "floor") + run_init(_args(tmp_path, budget_minutes="120")) + _, state = _state_of(tmp_path) + assert (state["budget_minutes"], state["budget_source"]) == (120, "explicit") + + +def test_an_explicit_budget_short_of_the_floor_emits_skip(run_init, tmp_path, init_clock, slow_baseline, capsys): + """受け入れ条件 5・I2・I4: 明示した 30 分は上げず、SKIP=window と下限(打ち直しの見込みなし)を出して 4 で止まる。""" + slow_baseline["seconds"] = 495.0 + captured = _stopped_init(run_init, tmp_path, init_clock, capsys) + _, state = _state_of(tmp_path) + floor = _floor_minutes(state, 495.0) + assert (state["budget_minutes"], state["budget_source"]) == (30, "explicit") and state["window_stopped_at"] + assert "SKIP=window" in captured.out + assert f"要る下限 {floor} 分 > 想定最大時間 30 分(着手前のテスト 495.0 秒)" in captured.out + assert "以上にして" in captured.err, "人へ出す文は今どおり" + + +def test_an_explicit_budget_that_fits_does_not_change(run_init, tmp_path, init_clock, slow_baseline, capsys): + """受け入れ条件 6: 明示した想定最大時間が下限以上なら、想定最大時間と手順の枠は今と同じ。""" + slow_baseline["seconds"] = 495.0 + init_clock(0) + run_init(_args(tmp_path, budget_minutes="120")) + _, state = _state_of(tmp_path) + assert (state["budget_minutes"], state["budget_source"]) == (120, "explicit") and "budget_floor" not in state + assert _window_seconds(state, "final_end_at") == 120 * 60 + assert _window_seconds(state, "propose_end_at") == 495 + 120 * 60 * 0.20 + assert "SKIP=" not in capsys.readouterr().out + + +def test_a_state_without_the_source_reads_as_explicit(refactor): + assert sys.modules["refactor_lib.init_test"].budget_source({"budget_minutes": 30}) == "explicit" + + +def test_a_timed_out_init_test_emits_skip(verify_init, capsys): + """受け入れ条件 5・I4: 着手前のテストの打ち切りは SKIP=init-timeout と上限の秒を出す。""" + verify_init.run["timed_out"] = True + with pytest.raises(SystemExit): + verify_init.call() + out = capsys.readouterr().out + assert "SKIP=init-timeout" in out and "上限 180 秒で終わらなかった" in out + + +def _record_recent(verify_init, seconds): + whole_runs = sys.modules["whole_runs"] + ts = sys.modules["test_strategy"] + work = verify_init.history.parents[2] / "work" + whole_runs.record_whole(work, ts.whole_key(verify_init.strategy), seconds, "test") + + +def test_the_first_init_limit_reads_the_recent_whole_test(verify_init): + """受け入れ条件 1・I1: 履歴が無くても、手元の全体テストの直近の実測 m = 620 秒なら上限は 3·m = 1,860 秒。""" + _record_recent(verify_init, 620.0) + _, baseline, _ = verify_init.call() + assert verify_init.given == [1860] + assert (baseline["scope_seconds"], baseline["scope_source"]) == (620.0, "recent") + + +@pytest.mark.parametrize("name", ["local-full", "local-scoped-ci-whole"]) +def test_the_init_estimate_order(refactor, tmp_path, name): + """受け入れ条件 2・I1: m → w → 無し。w は置き場所が全体テストを覆うかを問わない。""" + ts = sys.modules["test_strategy"] + init_test = sys.modules["refactor_lib.init_test"] + strategy = ts.Strategy(name, "args", [ts.Suite("unit", "pytest .", scope_command="pytest {paths}", paths=["elsewhere"])]) + prep = types.SimpleNamespace(strategy=strategy, work=tmp_path, repo="acme/demo") + assert init_test.resolve_scope_seconds(prep, ["tests"], 1518.0, 620.0) == (620.0, "recent") + assert init_test.resolve_scope_seconds(prep, ["tests"], 1518.0) == (1518.0, "whole") + assert init_test.resolve_scope_seconds(prep, ["tests"], None) == (None, None) + + +def test_a_long_init_limit_extends_the_step_before_the_test(verify_init, monkeypatch, tmp_path): + """決定 4: 既定 30 分・s = 1,518 秒なら、着手前のテストを始める前に延長のファイルへ 6,174 秒を書く。""" + drive_pause = sys.modules["drive_pause"] + target = tmp_path / "01-refactor.extend.json" + monkeypatch.setenv(drive_pause.EXTEND_ENV, str(target)) + seen = [] + real = verify_init.setup.baseline_lib.run_baseline + monkeypatch.setattr( + verify_init.setup.baseline_lib, "run_baseline", lambda *a, **k: (seen.append(drive_pause.read_extension(target)), real(*a, **k))[1] + ) + _record_recent(verify_init, 1518.0) + verify_init.call() + assert verify_init.given == [4554] and seen[0][0] == 6174.0 + + +def test_a_short_init_limit_does_not_extend_the_step(verify_init, monkeypatch, tmp_path): + target = tmp_path / "01-refactor.extend.json" + monkeypatch.setenv(sys.modules["drive_pause"].EXTEND_ENV, str(target)) + verify_init.call() + assert verify_init.given == [180] and not target.exists() diff --git a/scripts/pytest_child_cleanup.py b/scripts/pytest_child_cleanup.py new file mode 100644 index 000000000..02e64dea0 --- /dev/null +++ b/scripts/pytest_child_cleanup.py @@ -0,0 +1,195 @@ +"""テストが起こした子孫のプロセスを、pytest が止められたとき・終わったときに残さない(#2291 の決定 4)。 + +根の `conftest.py` が `pytest_plugins` で読み込む。pytest の親と xdist の各 worker で、次の 3 つを行う。 + +1. 子の回収役(Linux の `PR_SET_CHILD_SUBREAPER`)になる。テストが自分の session で起こした子の親が先に + 終わっても、孫は init ではなく最も近い pytest のプロセスへ付け替わり、止める対象から漏れない +2. SIGCHLD を受けるたびに、自分が起こした覚えの無い直接の子のゾンビだけを回収する。付け替わった孫の + ゾンビが残ると、打ち切る側のグループの存否の判定が成功し続け、猶予を毎回待ち切る。 + 自分が起こした pid は `subprocess.Popen` と `os.fork` を包んで記録し、その終了コードは横取りしない。 + 起動から記録までの間(起動中の数が 1 以上の間)は回収を見送り、数が 0 に戻った時点でやり直す +3. SIGTERM を受けたとき・pytest が終わるとき(`pytest_unconfigure`)に、直接の子の木を止める。 + 止めるのは `lib/procs.py` の `stop_tree` で、自分の pid には向けない + +子の回収役にできない環境(Linux 以外・psutil が無い)では何もしない。テストの結果は変えない。 +""" + +from __future__ import annotations + +import ctypes +import os +import signal +import subprocess +import sys +import threading +from pathlib import Path + +PR_SET_CHILD_SUBREAPER = 36 +GRACE = 2.0 + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugins" / "ndf" / "scripts" / "lib")) +try: + import procs +except ImportError: # psutil が無い環境では何もしない + procs = None + +_lock = threading.Lock() +_own: set[int] = set() +_state = {"starting": 0, "deferred": False, "config": None} +_saved: dict = {} + + +def _become_subreaper() -> bool: + if procs is None or not sys.platform.startswith("linux"): + return False + try: + libc = ctypes.CDLL(None, use_errno=True) + return libc.prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 + except (OSError, AttributeError): + return False + + +def _reap_unknown() -> None: + """直接の子のうち、ゾンビで、自分が起こした記録に無いものだけを 1 つずつ回収する(ロックを持って呼ぶ)。""" + for pid in procs.child_pids(os.getpid(), recursive=False): + if pid in _own or not procs.pid_is_zombie(pid): + continue + try: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + pass + + +def _reap_while_deferred() -> None: + """見送った回収を、起動中の数が 0 の間にやり直す。""" + while _state["deferred"]: + with _lock: + if _state["starting"]: + return + _state["deferred"] = False + _reap_unknown() + + +def _begin() -> None: + with _lock: + _state["starting"] += 1 + + +def _end(pid: int | None) -> None: + with _lock: + if pid: + _own.add(pid) + _state["starting"] -= 1 + _reap_while_deferred() + + +def _reset_in_forked_child() -> None: + global _lock + _lock = threading.Lock() + _own.clear() + _state["starting"] = 0 + _state["deferred"] = False + + +def _on_sigchld(signum, frame) -> None: + # 主スレッドで走り、主スレッドの包んだ呼び出しの途中にも割り込むため、ロックを待たない + if not _lock.acquire(blocking=False): + _state["deferred"] = True + return + try: + if _state["starting"]: + _state["deferred"] = True + else: + _state["deferred"] = False + _reap_unknown() + finally: + _lock.release() + + +def stop_children() -> None: + """直接の子の各 pid へ `stop_tree` を並べてかけ、全部の終わりをまとめて待つ。自分の pid には送らない。""" + me = os.getpid() + pids = [p for p in procs.child_pids(me, recursive=False) if p != me] + threads = [threading.Thread(target=procs.stop_tree, args=(p, GRACE), daemon=True) for p in pids] + for t in threads: + t.start() + for t in threads: + t.join() + + +def _on_sigterm(signum, frame) -> None: + stop_children() + signal.signal(signal.SIGTERM, signal.SIG_DFL) + os.kill(os.getpid(), signal.SIGTERM) + + +def _wrap_popen_init(orig): + def __init__(self, *args, **kwargs): + _begin() + pid = None + try: + orig(self, *args, **kwargs) + pid = getattr(self, "pid", None) + finally: + _end(pid) + + return __init__ + + +def _wrap_fork(orig): + def fork(): + _begin() + pid = None + try: + pid = orig() + finally: + if pid == 0: + _reset_in_forked_child() + else: + _end(pid) + return pid + + return fork + + +def install() -> bool: + """子の回収役になり、包みとシグナルの扱いを置く。置けたら真。""" + if _saved or not _become_subreaper(): + return False + try: + _saved["sigchld"] = signal.signal(signal.SIGCHLD, _on_sigchld) + signal.siginterrupt(signal.SIGCHLD, False) + _saved["sigterm"] = signal.signal(signal.SIGTERM, _on_sigterm) + except ValueError: # 主スレッドでないとシグナルの扱いを置けない + if "sigchld" in _saved: + signal.signal(signal.SIGCHLD, _saved.pop("sigchld")) + return False + _saved["popen_init"] = subprocess.Popen.__init__ + _saved["fork"] = os.fork + subprocess.Popen.__init__ = _wrap_popen_init(_saved["popen_init"]) + os.fork = _wrap_fork(_saved["fork"]) + return True + + +def uninstall() -> None: + """残った子孫を止め、記録に無いゾンビを回収し、包みとシグナルの扱いを元へ戻す。""" + if not _saved: + return + stop_children() + with _lock: + _reap_unknown() + subprocess.Popen.__init__ = _saved.pop("popen_init") + os.fork = _saved.pop("fork") + signal.signal(signal.SIGCHLD, _saved.pop("sigchld")) + signal.signal(signal.SIGTERM, _saved.pop("sigterm")) + + +def pytest_configure(config) -> None: + if _state["config"] is None and install(): + _state["config"] = config + + +def pytest_unconfigure(config) -> None: + if _state["config"] is config: + uninstall() + _state["config"] = None diff --git a/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__drive.py--lines.json b/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__drive.py--lines.json index 67bf5f245..16d899e55 100644 --- a/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__drive.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__drive.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/skills/cross-refactoring/scripts/drive.py", "name": "", "kind": "lines", "reason": "#2229 の前の時点で 500 行(上限ちょうど)。#2229(rf の持ち主の記録を動いた時刻・終わりで更新する)で増えた。共有の処理は lib/run_owner.py へ置き、呼び出しだけが残る。触って 500 行以下にしたら消す", "lines": 509} +{"path": "plugins/ndf/skills/cross-refactoring/scripts/drive.py", "name": "", "kind": "lines", "reason": "#2229 の前の時点で 500 行(上限ちょうど)。#2229(rf の持ち主の記録を動いた時刻・終わりで更新する)で増えた。共有の処理は lib/run_owner.py へ置き、呼び出しだけが残る。触って 500 行以下にしたら消す。#2047(時間の枠の不足で飛ばした結果・想定最大時間を下限へ上げたときのステップの延長)で増えた。延長の書き出しは lib/drive_pause.py へ置いた", "lines": 536} diff --git a/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__refactor_lib__commands__setup.py--lines.json b/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__refactor_lib__commands__setup.py--lines.json index 17d314a98..dc0a7b642 100644 --- a/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__refactor_lib__commands__setup.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__refactor_lib__commands__setup.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py", "name": "", "kind": "lines", "reason": "L0 の時点で 913 行。C4 は重複だけをライブラリへ置き換える。触って 500 行以下にしたら消す。#1290(参加の確認へ --model の明示を渡す)で増えた。#1658(入口で Pull Request の状態を確かめる)で増えた。#2229(開始の片付けと持ち主の記録。共有の処理は lib/run_owner.py・lib/run_state.py へ置いた)で増えた", "lines": 943} +{"path": "plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py", "name": "", "kind": "lines", "reason": "L0 の時点で 913 行。C4 は重複だけをライブラリへ置き換える。触って 500 行以下にしたら消す。#1290(参加の確認へ --model の明示を渡す)で増えた。#1658(入口で Pull Request の状態を確かめる)で増えた。#2229(開始の片付けと持ち主の記録。共有の処理は lib/run_owner.py・lib/run_state.py へ置いた)で増えた。#2047(想定最大時間の出所の記録と下限への引き上げ。判定は refactor_lib/init_test.py へ置いた)で増えた", "lines": 953} diff --git a/scripts/tests/test_check_script_structure.py b/scripts/tests/test_check_script_structure.py index 1b4c60e8f..00dfccae4 100644 --- a/scripts/tests/test_check_script_structure.py +++ b/scripts/tests/test_check_script_structure.py @@ -227,12 +227,6 @@ def run_repo(repo: Path) -> tuple[int, dict]: return p.returncode, json.loads(p.stdout.strip().splitlines()[-1]) -def test_repository_matches_its_allow_list(): - """実物の木は例外リストと合う。直した移行ステップは、同じ PR で例外リストの行を消す。""" - p = subprocess.run([sys.executable, str(CHECK)], capture_output=True, text=True, cwd=REPO) - assert p.returncode == 0, p.stdout[-2000:] + p.stderr[-2000:] - - def test_structure_baseline_runs_on_a_given_root(tmp_path: Path): put(tmp_path, "scripts/a.py", "def f():\n return 1\n") put(tmp_path, "scripts/b.py", "def f():\n return 1\n") diff --git a/scripts/tests/test_pytest_child_cleanup.py b/scripts/tests/test_pytest_child_cleanup.py new file mode 100644 index 000000000..b21a3ee8d --- /dev/null +++ b/scripts/tests/test_pytest_child_cleanup.py @@ -0,0 +1,145 @@ +"""テストが起こした子孫のプロセスを、pytest を止めたときに残さない(#2291 の受け入れ条件 6・7、決定 4)。 + +- 打ち切る側(`test_triage.stop_group`)がグループを止めても、自分の session の子と、親が先に終わった孫が残らない +- 親だけに SIGTERM を送っても、xdist の worker とその子孫が残らない +- 子の回収役にしても、`subprocess` と `os.fork` の呼び手が受け取る終了コードは変わらない +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import textwrap +import threading +import time +from pathlib import Path + +import psutil +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) +sys.path.insert(0, str(ROOT / "plugins" / "ndf" / "scripts" / "lib")) + +import pytest_child_cleanup # noqa: E402 +import test_triage # noqa: E402 + +pytestmark = pytest.mark.skipif(not sys.platform.startswith("linux"), reason="子の回収役は Linux だけ") + + +def _bundle(tmp_path: Path) -> Path: + """自分の session の子と、親が先に終わる孫を起こして待つテスト 2 件の束。""" + b = tmp_path / "bundle" + b.mkdir() + (b / "pytest.ini").write_text("[pytest]\n") + (b / "conftest.py").write_text(f"import sys\nsys.path.insert(0, {str(SCRIPTS)!r})\npytest_plugins = ['pytest_child_cleanup']\n") + sleeper = f"{sys.executable} -c 'import time; time.sleep(60)' {b}" + (b / "test_spawn.py").write_text( + textwrap.dedent(f""" + import subprocess, sys, time, pytest + @pytest.mark.parametrize("n", [0, 1]) + def test_spawn(n): + subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)", {str(b)!r}], start_new_session=True) + subprocess.Popen(["setsid", "sh", "-c", {sleeper + " &"!r}]) + open({str(b)!r} + f"/ready-{{n}}", "w").close() + time.sleep(60) + """) + ) + return b + + +def _start(b: Path) -> subprocess.Popen: + p = subprocess.Popen( + [sys.executable, "-m", "pytest", str(b), "-q", "-n", "2", "-p", "no:cacheprovider"], + cwd=b, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + deadline = time.monotonic() + 60 + while not all((b / f"ready-{n}").exists() for n in (0, 1)): + assert time.monotonic() < deadline, "束のテストが子を起こすところまで進まない" + time.sleep(0.1) + time.sleep(0.5) # setsid の親が終わり、孫が付け替わるのを待つ + return p + + +def _left(b: Path) -> list[int]: + found = [] + for p in psutil.process_iter(["cmdline", "status"]): + try: + if p.info["status"] != psutil.STATUS_ZOMBIE and str(b) in " ".join(p.info["cmdline"] or []): + found.append(p.pid) + except psutil.Error: + pass + return found + + +def _assert_none_left(b: Path) -> None: + deadline = time.monotonic() + 10 + while (left := _left(b)) and time.monotonic() < deadline: + time.sleep(0.2) + for pid in left: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + assert left == [], f"止めた後も残っている: {left}" + + +def test_stopping_the_group_leaves_no_test_children(tmp_path): + b = _bundle(tmp_path) + p = _start(b) + test_triage.stop_group(p) + _assert_none_left(b) + + +def test_sigterm_to_the_parent_alone_stops_workers_and_their_children(tmp_path): + b = _bundle(tmp_path) + p = _start(b) + os.kill(p.pid, signal.SIGTERM) + p.wait(timeout=10) + _assert_none_left(b) + + +@pytest.mark.filterwarnings("ignore:This process:DeprecationWarning") +def test_exit_codes_reach_their_callers_under_the_reaper(): + assert pytest_child_cleanup._saved, "根の conftest が後始末の部品を置いていない" + codes: list[int] = [] + + def run(): + codes.extend(subprocess.run(["false"]).returncode for _ in range(200)) + + threads = [threading.Thread(target=run) for _ in range(4)] + for t in threads: + t.start() + run() + for t in threads: + t.join() + assert codes == [1] * 1000 + + pid = os.fork() + if pid == 0: + os._exit(7) + assert os.waitstatus_to_exitcode(os.waitpid(pid, 0)[1]) == 7 + + +def test_stopping_children_spares_itself_and_waits_for_them_together(tmp_path): + script = textwrap.dedent(f""" + import subprocess, sys, time + sys.path.insert(0, {str(SCRIPTS)!r}) + import pytest_child_cleanup as c + kids = [subprocess.Popen(["bash", "-c", "trap '' TERM; sleep 60"]) for _ in range(2)] + time.sleep(0.3) + start = time.monotonic() + c.stop_children() + print(round(time.monotonic() - start, 1), [k.poll() is not None for k in kids]) + """) + out = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + seconds, stopped = out.stdout.split(" ", 1) + assert float(seconds) < 5 + assert stopped.strip() == "[True, True]" diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 817fa5a7a..8fa10ed91 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -37,14 +37,14 @@ def _read_root_conftest() -> str: return ROOT_CONFTEST.read_text(encoding="utf-8") -def _run_pytest(target: str, *, path: str | None = None, env_extra: dict | None = None): +def _run_pytest(target: str, *args: str, path: str | None = None, env_extra: dict | None = None): env = dict(os.environ) env.pop("NDF_TESTS_ALLOW_MISSING_COMMANDS", None) if path is not None: env["PATH"] = path env.update(env_extra or {}) return subprocess.run( - [sys.executable, "-m", "pytest", target, "-q", "--no-header", "-p", "no:cacheprovider"], + [sys.executable, "-m", "pytest", target, *args, "-q", "--no-header", "-p", "no:cacheprovider"], cwd=str(REPO_ROOT), capture_output=True, text=True, @@ -71,11 +71,6 @@ def _path_without(tmp_path: Path, *names: str) -> str: return str(bin_dir) -def test_the_root_conftest_declares_the_plugin() -> None: - """`pytest_plugins` は最上位の conftest.py でしか宣言できない。""" - assert 'pytest_plugins = ["pytester"]' in _read_root_conftest() - - def test_the_skill_conftest_no_longer_declares_the_plugin() -> None: body = (REPO_ROOT / "plugins/playwright-kit/skills/playwright-kit-ops/tests/conftest.py").read_text(encoding="utf-8") assert 'pytest_plugins = ["pytester"]' not in body @@ -98,18 +93,11 @@ def test_no_bundle_skips_itself_when_a_command_is_missing() -> None: @pytest.mark.skipif(shutil.which("jq") is None, reason="jq を外した PATH を組み立てられない") def test_a_missing_command_fails_the_collection(tmp_path: Path) -> None: - """前提のコマンドが無ければ、読み飛ばさずに 0 以外の終了コードで終わる。""" - result = _run_pytest(BUNDLE, path=_path_without(tmp_path, "jq")) - - assert result.returncode != 0 - assert "jq" in result.stdout + result.stderr - - -@pytest.mark.skipif(shutil.which("jq") is None, reason="jq を外した PATH を組み立てられない") -def test_the_failure_names_the_bundle_and_the_command(tmp_path: Path) -> None: + """前提のコマンドが無ければ、読み飛ばさずに 0 以外の終了コードで終わり、束とコマンドの名前を出す。""" result = _run_pytest(BUNDLE, path=_path_without(tmp_path, "jq")) out = result.stdout + result.stderr + assert result.returncode != 0 assert BUNDLE in out assert "jq" in out @@ -138,8 +126,8 @@ def test_the_outside_bundle_is_really_outside_the_table() -> None: def test_a_bundle_outside_the_table_is_not_checked(tmp_path: Path) -> None: - """一覧に無い束だけを収集したときは、前提を確かめない。""" - result = _run_pytest(OUTSIDE_BUNDLE, path=_path_without(tmp_path, "jq")) + """一覧に無い束だけを収集したときは、前提を確かめない(確かめるのは収集の時点なので、収集だけで足りる)。""" + result = _run_pytest(OUTSIDE_BUNDLE, "--collect-only", path=_path_without(tmp_path, "jq")) assert result.returncode == 0, result.stdout + result.stderr @@ -269,14 +257,6 @@ def test_the_isolation_holds_from_a_bundle_directory() -> None: assert result.returncode == 0, result.stdout + result.stderr -def test_the_prefix_is_declared_once() -> None: - """接頭辞は根の設定だけが持つ。テストの側へ書き戻すと、同じ除去がまた散る。""" - body = _read_root_conftest() - - assert 'MONITOR_ENV_PREFIX = "MONITOR_"' in body - assert "def pytest_unconfigure" in body - - # ジョブの分割(#882)。**分けても項目が欠けず、重ならず、ファイルが割れないこと**を、 # 実際の収集で確かめる。分割が項目を落としても各ジョブと集約のジョブは成功し得るため、 # 継続的統合の結果だけでは退行に気づけない。