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/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/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/test_check_trigger.py b/plugins/ndf/scripts/tests/test_check_trigger.py index 92a2dc574..51bc230c0 100644 --- a/plugins/ndf/scripts/tests/test_check_trigger.py +++ b/plugins/ndf/scripts/tests/test_check_trigger.py @@ -439,6 +439,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_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_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index a77bb6b61..0c7beec43 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -327,6 +327,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 を引数の解析で拒む @@ -885,6 +948,7 @@ def _report_state_rich(st): - 理由: 無し - 通ったステップ: {empty} - 件数: 無し +- 欠けを残して進んだステップ: 無し - LLM の使用量: 入力 0 / cache read 0 / cache write 0 / 出力 0 / $0.000 - CLI(drive・work の中): 無し - 記録: {dir} @@ -912,6 +976,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/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..318d70deb 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -2148,3 +2148,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/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}