Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/specifications/cross-refactoring-time-budget.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) | — |
Expand Down Expand Up @@ -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` |
Expand Down
2 changes: 2 additions & 0 deletions docs/specifications/ndf-supervise-step-budgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
同じ値を課題ごとの実装のプランと検査のプランのすべてへ引き継ぐ。**`refactor` の `timeout` は常に `with_margin(M × 60)` で、
M は想定最大時間、無ければ cross-refactoring の既定の予算(`DEFAULT_BUDGET_MINUTES`)である。** NDF は実装の上限・想定最大時間の
固定値を新しく持たない。
実行の時点では、cross-refactoring の駆動がステップの延長(`NDF_STEP_EXTEND_FILE`)で締め切りを後ろへずらしうる。
プランに書く `timeout` は変えない。

この文書は、その形に**なぜしたか**(背景・決定と理由・常に成り立つ条件・境界・既知の限界・テスト観点)を残す。
**宣言の形と引数の正はスクリプトにある。**
Expand Down
9 changes: 8 additions & 1 deletion plugins/ndf/scripts/check-trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")

Expand Down
43 changes: 42 additions & 1 deletion plugins/ndf/scripts/lib/drive_pause.py
Original file line number Diff line number Diff line change
Expand Up @@ -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` は必ずある。任意の項目は次の通り。

Expand All @@ -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

Expand All @@ -46,6 +53,7 @@
}
EXIT_DONE = 0
EXIT_STOPPED = 1
EXTEND_ENV = "NDF_STEP_EXTEND_FILE"


class Stop(Exception):
Expand Down Expand Up @@ -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})

Expand Down
8 changes: 4 additions & 4 deletions plugins/ndf/scripts/lib/test_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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` は先に走った全体テストの
Expand All @@ -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
Expand Down
14 changes: 8 additions & 6 deletions plugins/ndf/scripts/supervise_lib/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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
Expand Down
36 changes: 17 additions & 19 deletions plugins/ndf/scripts/supervise_lib/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""認証の切り替え・利用上限・遅れの行。記録があるものだけを出す。"""
Expand Down Expand Up @@ -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
Expand All @@ -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("フェーズ")}
Expand All @@ -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}
Expand Down
32 changes: 31 additions & 1 deletion plugins/ndf/scripts/supervise_lib/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 も無い"
Expand All @@ -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(
Expand All @@ -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:
Expand Down
Loading
Loading