From be1cd02321a8bf303780bcd645b9339cc8dc1753 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:17:00 +0000 Subject: [PATCH 01/57] =?UTF-8?q?Docs:=20cross-refactoring=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E5=BE=8C=E3=81=AE=E5=86=8D=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E8=A1=8C=E6=9D=A1=E4=BB=B6=E3=82=92=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不具合 9 件の修正(v8.2.0)を実機で確かめる。この PR が対象。 確かめることと、前回まったく実行できていない範囲を先に書き出す。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GSwBvT9CH8mKfgyFn2JWfS --- issues/issue-113-cross-refactoring-retrial.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 issues/issue-113-cross-refactoring-retrial.md diff --git a/issues/issue-113-cross-refactoring-retrial.md b/issues/issue-113-cross-refactoring-retrial.md new file mode 100644 index 00000000..8ed34f87 --- /dev/null +++ b/issues/issue-113-cross-refactoring-retrial.md @@ -0,0 +1,62 @@ +# cross-refactoring 修正後の再検証 + +不具合 9 件の修正([issue-113-cross-refactoring-defect-fixes.md](issue-113-cross-refactoring-defect-fixes.md)、 +NDF v8.2.0)を実機で確かめる。**この Pull Request がその対象**である。 + +前回(PR #118)は適用結果の検証で失敗した項目を取り消す経路が破綻し、 +レビューフェーズより先へ一度も進めなかった。今回の目的は**その先を通すこと**にある。 + +## 実行条件 + +| 項目 | 値 | +| --- | --- | +| ホスト | Claude Code(提案・レビューには不参加) | +| 提案・レビュー | codex / gemini / kiro | +| 適用の母集合 | claude / codex / kiro | +| 使用する版 | リポジトリ内の `plugins/ndf-claude`(v8.2.0)。プラグインキャッシュ(8.1.0)は使わない | +| ラウンド上限 | 3 | + +```bash +PLUGIN_ROOT=/work/ai-plugins/plugins/ndf-claude + +/ndf:cross-refactoring <この PR 番号> \ + --scope plugins/ndf-shared/skills/cross-refactoring/scripts \ + plugins/ndf-shared/skills/cross-refactoring/tests \ + plugins/ndf-shared/skills/cross-review/scripts/lib \ + --baseline-test "uv run --with pytest python -m pytest \ + plugins/ndf-shared/skills/cross-refactoring/tests \ + plugins/ndf-shared/skills/cross-review/tests -q" +``` + +`--scope` に**テストの置き場所を含める**。範囲は適用結果の検証にも効くようになったため、 +含めないと `test_gap` が真の項目で「テストを先に足せ」と「範囲外を触るな」が両立しない。 + +## 確かめること + +修正した 9 件が実機で成立するか。 + +| # | 直したこと | 何が観測できれば通ったと言えるか | +| --- | --- | --- | +| 1 | 取り消しの巻き戻しと積み直し | 検証に失敗した項目だけが消え、合意済みの項目が残る。分離できない位置関係なら `rounds[].drops[].mode = round` が記録される | +| 2 | 中断と全件失敗の区別 | 取り消しに失敗したら終了コード 4 で進行が止まる(次の提案ラウンドへ進まない) | +| 3 | 判定の逐次記録 | `rounds[].apply_progress` に項目ごとの判定が残る | +| 4 | 再送信の印 | 取り消しが Pull Request へ反映される。`pending_push` が残らない | +| 5 | 範囲の検査 | 配布物 3 系統が実装担当の差分に現れない | +| 6 | 提案結果のラウンド別保存 | `<ランタイム>-propose-rf-r<ラウンド>-result.json` が巡ごとに残る | +| 7 | gemini の読み取り | gemini の提案が語彙内で返る(`read_file` が拒否されない) | +| 8 | 語彙の列挙 | 3 ランタイムとも `smell` / `technique` が英字の識別子で返る | +| 9 | 認証の確認 | 初期化時に 4 CLI の認証状態が出力される | + +## 前回まったく実行できていない範囲 + +ここを通すことが今回の主目的である。 + +- レビュー担当 2 者の並列実行と指摘の投稿、承認判定 +- 指摘の修正と再レビューの繰り返し、上限到達時の項目単位の見送り +- 実装担当の輪番 +- 提案の重複率による収束判定 +- 集計値(`report --metrics`)の出力 + +## 結果 + +(実行後に追記する) From df445e8afc83e376159bf935d049dfa0d5a7977d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:24:49 +0000 Subject: [PATCH 02/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コミット所有権の検証を独立した関数へ抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 69 +++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 3ad3134b..995bd4a5 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1078,6 +1078,45 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _validate_commit_ownership( + work: str, + ordered_range: list[str], + payload: dict[str, Any], + round_items: set[str], +) -> tuple[ + dict[str, dict[str, Any]], dict[str, str], list[str], list[str], list[str] +]: + """申告されたコミットがラウンド内で一意に所有されているか調べる。""" + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for reported_item in raw_items: + if not isinstance(reported_item, dict): + continue + item_id = reported_item.get("item_id") + if item_id in round_items: + reported[item_id] = reported_item + elif item_id is not None: + unknown_ids.append(str(item_id)) + + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, reported_item in reported.items(): + for sha in _reported_shas(reported_item): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(set(ordered_range) - set(owner_of)) + return reported, owner_of, unassigned, unknown_ids, duplicated + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1152,21 +1191,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id in round_items: - reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1176,18 +1200,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: From d1919676c05804c6fcff29516da22e3100898155 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:26:00 +0000 Subject: [PATCH 03/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 項目別の検証と進捗記録を独立した関数へ抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 98 +++++++++++-------- 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 995bd4a5..ef62be3a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1117,6 +1117,60 @@ def _validate_commit_ownership( return reported, owner_of, unassigned, unknown_ids, duplicated +def _verify_and_record_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + reported: dict[str, dict[str, Any]], + in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str]]: + """適用項目を検証し、項目と進捗に判定を記録する。""" + applied: list[str] = [] + failed: list[str] = [] + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not dry_run: + statefile.save(path, state) + return applied, failed + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1265,51 +1319,15 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: statefile.save(path, state) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - progress: list[dict[str, Any]] = [] - entry["apply_progress"] = progress - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts, scope) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - else: - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - progress.append({ - "item_id": item_id, "at": statefile.now(), - "result": "failed" if problem else "ok", - "reason": problem, "commits": list(item.get("commits") or []), - }) - if not args.dry_run: - statefile.save(path, state) + applied, failed = _verify_and_record_items( + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + ) entry["apply"] = { "applied": applied, From d3a34bca761bebeb6f645a0955edf0fc6abe80ff Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:27:19 +0000 Subject: [PATCH 04/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当の指標更新を独立した関数へ抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 82 +++++++++++-------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index c2b576b4..24866554 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,50 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _update_impl_metrics( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドの実装担当指標とモデル警告を更新する。""" + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + reviews = _round_reviews(entry) + if reviews: + approved_first = all( + _verdict(reviews[0], reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,47 +96,13 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + _update_impl_metrics(entry, items_by_id, impl, unmeasured) + round_no = entry.get("round") reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} From ef8970cc794fe2da1a308d59426e6d34a5e4e5c8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:28:25 +0000 Subject: [PATCH 05/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー担当ごとの指標更新を独立した関数へ抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 80 ++++++++++--------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 24866554..4cc4e661 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -83,6 +83,49 @@ def _update_impl_metrics( bucket["first_review_approved"] += 1 if approved_first else 0 +def _update_reviewer_metrics( + entry: dict[str, Any], + name: str, + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドのレビュー担当 1 名分の指標と警告を更新する。""" + spec = (entry.get("reviewer_models") or {}).get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {entry.get('round')}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {entry.get('round')}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (candidate for candidate in entry.get("reviewers", []) if candidate != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += int( + other_verdict == _verdict(review, name) + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -101,44 +144,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: continue _update_impl_metrics(entry, items_by_id, impl, unmeasured) - round_no = entry.get("round") reviews = _round_reviews(entry) - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _update_reviewer_metrics(entry, name, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, From 17a1b8ca9f7dbeab9442ab774a67ab29b7920b3c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:29:27 +0000 Subject: [PATCH 06/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正コミットの範囲と手順の検証を独立した関数へ抽出する。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ef62be3a..1fe49d82 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1659,6 +1659,39 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def _validate_fix_commits( + work: str, + ordered_range: list[str], + reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, +) -> tuple[list[tuple[str, str]], list[str], list[str]]: + """修正コミットの申告漏れと手順適合を検証する。""" + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, unassigned, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1738,35 +1771,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) if unassigned: info( From 5a6d1d606088abb9bb607b9aea6750a47d7ca291 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:30:21 +0000 Subject: [PATCH 07/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証失敗時の修正範囲取り消しと起点更新を独立した関数へ抽出する。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1fe49d82..913715e2 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1692,6 +1692,28 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _revert_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + work: str, +) -> None: + """検証失敗した修正範囲を戻し、次の起点を記録する。""" + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + { + "item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range), + }, + dry_run=False, + ) + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1794,19 +1816,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) + _revert_fix_range(path, state, entry, ordered_range, work) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True From a8ed8cee49b200229ad3b4f424cdfa3bfc138ea0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:31:33 +0000 Subject: [PATCH 08/57] =?UTF-8?q?Test:=20monitor=5Fagent=20=E3=81=AE?= =?UTF-8?q?=E7=B5=82=E4=BA=86=E5=88=A4=E5=AE=9A=E3=82=92=E7=8F=BE=E7=8A=B6?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タイムアウト、致命エラー、結果なし終了、stall の観測結果を固定する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../tests/test_monitor_termination.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py new file mode 100644 index 00000000..e5013f44 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py @@ -0,0 +1,58 @@ +"""monitor_agent の終了判定を固定するテスト。""" +from __future__ import annotations + +from unittest import mock + + +def _prepare(tmp_path, agent: str, pr: int) -> None: + base = tmp_path / f"{agent}-review-pr{pr}" + (tmp_path / f"{base.name}.pid").write_text("12345") + (tmp_path / f"{base.name}-err.log").write_text("") + (tmp_path / f"{base.name}-stdout.log").write_text("") + + +def _monitor(monitor_mod, tmp_path, *, alive=True, timeout=420, stall_timeout=480): + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=alive), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid") as kill, + mock.patch("time.sleep"), + ): + status = monitor_mod.monitor_agent( + agent="gemini", pr=901, timeout=timeout, stall_timeout=stall_timeout, + poll=1, require_result=True, + ) + return status, kill + + +def test_hard_timeout_kills_live_process(monitor_mod, tmp_path): + _prepare(tmp_path, "gemini", 901) + status, kill = _monitor(monitor_mod, tmp_path, timeout=0) + assert (status.status, status.exit_code) == ("TIMEOUT", 2) + kill.assert_called_once_with(12345) + + +def test_fatal_log_kills_live_process(monitor_mod, tmp_path): + _prepare(tmp_path, "gemini", 901) + (tmp_path / "gemini-review-pr901-err.log").write_text("Error: quota exceeded\n") + status, kill = _monitor(monitor_mod, tmp_path) + assert (status.status, status.exit_code) == ("EARLY_ERROR", 4) + assert "err.log" in status.detail + kill.assert_called_once_with(12345) + + +def test_exited_process_without_result_is_reported(monitor_mod, tmp_path): + _prepare(tmp_path, "gemini", 901) + status, kill = _monitor(monitor_mod, tmp_path, alive=False) + assert (status.status, status.exit_code) == ("NO_RESULT", 3) + kill.assert_not_called() + + +def test_stall_kills_live_process(monitor_mod, tmp_path): + _prepare(tmp_path, "gemini", 901) + status, kill = _monitor(monitor_mod, tmp_path, stall_timeout=0) + assert (status.status, status.exit_code) == ("STALLED", 5) + assert status.err_log_size == 0 + assert status.stdout_log_size == 0 + kill.assert_called_once_with(12345) From 0661832c818272399b41e756edad429f9870dfca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:32:29 +0000 Subject: [PATCH 09/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sentinel と result age による早期完了判定を抽出する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 93 +++++++++++-------- 1 file changed, 55 insertions(+), 38 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 401a760f..fabe0640 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,56 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_early_completion( + agent: str, + paths: AgentPaths, + status: AgentStatus, + pid: int, + alive: bool, + cmdline_validated: bool, + started_wall: float, + log_prefix: str, +) -> Optional[AgentStatus]: + """sentinel または result.json の安定を根拠に常駐プロセスを完了させる。""" + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" + _emit_log(log_prefix, agent, status) + return status + + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status + return None + + def monitor_agent( agent: str, pr: int, @@ -676,22 +726,11 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + completed = _check_early_completion( + agent, paths, status, pid, alive, cmdline_validated, started_wall, log_prefix, + ) + if completed is not None: + return completed # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け @@ -700,28 +739,6 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status - if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと # ファイル不在で None 扱いになり判定不能のため。 From 64882a9d88046bec69fd8a9f491a041094bb998d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:34:00 +0000 Subject: [PATCH 10/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タイムアウト、異常、終了、停滞の判定を独立した関数へ抽出する。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 182 +++++++++--------- 1 file changed, 96 insertions(+), 86 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index fabe0640..6ecf4585 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -663,6 +663,94 @@ def _check_early_completion( return None +def _check_termination_conditions( + agent: str, + paths: AgentPaths, + status: AgentStatus, + pid: int, + alive: bool, + elapsed: float, + timeout: int, + stall_timeout: int, + last_progress: float, + last_progress_size: int, + no_early_error: bool, + warned_early_error: bool, + require_result: bool, + log_prefix: str, +) -> tuple[Optional[AgentStatus], bool, float, int]: + """タイムアウト、異常、終了、停滞の順で終了条件を判定する。""" + if elapsed >= timeout: + if alive: + _kill_pid(pid) + status.status = "TIMEOUT" + status.exit_code = 2 + status.detail = f"hard timeout {timeout}s reached (pid {pid})" + _emit_log(log_prefix, agent, status) + return status, warned_early_error, last_progress, last_progress_size + + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + _emit_log(log_prefix, agent, status) + return status, warned_early_error, last_progress, last_progress_size + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True + + if not alive: + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status, warned_early_error, last_progress, last_progress_size + + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = status.err_log_size + status.stdout_log_size + status.progress_log_size + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status, warned_early_error, last_progress, last_progress_size + return None, warned_early_error, last_progress, last_progress_size + + def monitor_agent( agent: str, pr: int, @@ -753,93 +841,15 @@ def monitor_agent( if cmdline_ok is True: cmdline_validated = True - # 2. hard timeout - if elapsed >= timeout: - if alive: - _kill_pid(pid) - status.status = "TIMEOUT" - status.exit_code = 2 - status.detail = f"hard timeout {timeout}s reached (pid {pid})" - _emit_log(log_prefix, agent, status) - return status - - # 3. early error - # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を - # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status - - if not warned_early_error: - warn_err = _scan_early_warn(paths.err_log) - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True - - if not alive: - # プロセス終了 — result.json を確認 - status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 - if status.result_exists or not require_result: - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"process exited; sentinel={status.sentinel_seen}; " - f"result_exists={status.result_exists}" - ) - else: - status.status = "NO_RESULT" - status.exit_code = 3 - status.detail = f"process exited but result.json missing: {paths.result}" - _emit_log(log_prefix, agent, status) - return status - - # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 - # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には - # launcher が要求した短いフェーズマーカーが出るため、いずれかが - # 更新されれば progress として扱う) - status.err_log_size = _safe_size(paths.err_log) - status.stdout_log_size = _safe_size(paths.stdout_log) - status.progress_log_size = _safe_size(paths.progress_log) - status.progress_tail = _tail_last_nonempty_line(paths.progress_log) - progress_size = ( - status.err_log_size + status.stdout_log_size + status.progress_log_size - ) - if progress_size != last_progress_size: - last_progress_size = progress_size - last_progress = time.monotonic() - status.idle_seconds = time.monotonic() - last_progress - if status.idle_seconds >= stall_timeout: - if alive: - _kill_pid(pid) - status.status = "STALLED" - status.exit_code = 5 - status.detail = ( - f"no log progress for {stall_timeout}s " - f"(pid {pid}, last size {last_progress_size}B)" + terminated, warned_early_error, last_progress, last_progress_size = ( + _check_termination_conditions( + agent, paths, status, pid, alive, elapsed, timeout, stall_timeout, + last_progress, last_progress_size, no_early_error, + warned_early_error, require_result, log_prefix, ) - _emit_log(log_prefix, agent, status) - return status + ) + if terminated is not None: + return terminated # poll 中の進捗ログ _emit_progress(log_prefix, agent, status) From a1e318677c4b3bdd0c3b1dbb8710230d982e2def Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:34:55 +0000 Subject: [PATCH 11/57] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20refactor.py#commit=5Fvalidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 申告SHAを完全SHAへ解決する処理を適用・修正経路で共通化する。 Item-Id: R1-005 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 913715e2..0632ee60 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1078,6 +1078,16 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: + """申告 SHA のうち実在するコミットを完全 SHA に正規化する。""" + return { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + + def _validate_commit_ownership( work: str, ordered_range: list[str], @@ -1105,10 +1115,7 @@ def _validate_commit_ownership( owner_of: dict[str, str] = {} duplicated: list[str] = [] for item_id, reported_item in reported.items(): - for sha in _reported_shas(reported_item): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue + for full in _resolve_reported_to_full(work, _reported_shas(reported_item)): if full in owner_of and owner_of[full] != item_id: duplicated.append(full) owner_of.setdefault(full, item_id) @@ -1669,12 +1676,7 @@ def _validate_fix_commits( timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - for sha in reported_shas - ) if full - } + reported_full = _resolve_reported_to_full(work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) facts = collect_commit_facts( work, reported_shas, set(ordered_range), test_command, head_branch, timeout, From b981a06be26a6c90b594560931105cd0edc1ec0f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:36:05 +0000 Subject: [PATCH 12/57] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20refactor.py#range=5Frecovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 範囲取り消し時の push 印、revert、起点更新、保存を共通化する。 Item-Id: R1-005 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 68 +++++++++---------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0632ee60..f4093f23 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,6 +1088,31 @@ def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: } +def _revert_range_with_recovery( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + base_sha_key: str, + dry_run: bool = False, +) -> None: + """範囲を取り消し、中断復帰用の push 印と新しい起点を保存する。""" + if base_sha_key == "apply_base_sha": + item_id = f"R{entry['round']}-range" + else: + item_id = f"R{entry['round']}-fix{entry['fix_rounds'] + 1}" + if not dry_run: + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, {"item_id": item_id, "commits": list(ordered_range)}, dry_run=dry_run, + ) + if not dry_run: + entry[base_sha_key] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + + def _validate_commit_ownership( work: str, ordered_range: list[str], @@ -1294,20 +1319,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - if not args.dry_run: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1694,28 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _revert_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - work: str, -) -> None: - """検証失敗した修正範囲を戻し、次の起点を記録する。""" - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - { - "item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range), - }, - dry_run=False, - ) - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1818,7 +1810,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - _revert_fix_range(path, state, entry, ordered_range, work) + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True From 8fad6dd798c23aa3ece2dde3321d55cf7813f54f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 13/57] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20refactor.py#range=5Frecovery"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b981a06be26a6c90b594560931105cd0edc1ec0f. --- .../cross-refactoring/scripts/refactor.py | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f4093f23..0632ee60 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,31 +1088,6 @@ def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: } -def _revert_range_with_recovery( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - ordered_range: list[str], - base_sha_key: str, - dry_run: bool = False, -) -> None: - """範囲を取り消し、中断復帰用の push 印と新しい起点を保存する。""" - if base_sha_key == "apply_base_sha": - item_id = f"R{entry['round']}-range" - else: - item_id = f"R{entry['round']}-fix{entry['fix_rounds'] + 1}" - if not dry_run: - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, {"item_id": item_id, "commits": list(ordered_range)}, dry_run=dry_run, - ) - if not dry_run: - entry[base_sha_key] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - - def _validate_commit_ownership( work: str, ordered_range: list[str], @@ -1319,9 +1294,20 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, - ) + whole_round = { + "item_id": f"R{entry['round']}-range", + "commits": list(ordered_range), + } + if not args.dry_run: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits(state, whole_round, args.dry_run) + if not args.dry_run: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1708,6 +1694,28 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _revert_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + work: str, +) -> None: + """検証失敗した修正範囲を戻し、次の起点を記録する。""" + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + { + "item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range), + }, + dry_run=False, + ) + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1810,9 +1818,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) + _revert_fix_range(path, state, entry, ordered_range, work) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True From 5cadf52db7852b14383e7dc1c4e6a7191caacca3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 14/57] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20refactor.py#commit=5Fvalidation"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a1e318677c4b3bdd0c3b1dbb8710230d982e2def. --- .../cross-refactoring/scripts/refactor.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0632ee60..913715e2 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1078,16 +1078,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: - """申告 SHA のうち実在するコミットを完全 SHA に正規化する。""" - return { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - for sha in reported_shas - ) if full - } - - def _validate_commit_ownership( work: str, ordered_range: list[str], @@ -1115,7 +1105,10 @@ def _validate_commit_ownership( owner_of: dict[str, str] = {} duplicated: list[str] = [] for item_id, reported_item in reported.items(): - for full in _resolve_reported_to_full(work, _reported_shas(reported_item)): + for sha in _reported_shas(reported_item): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue if full in owner_of and owner_of[full] != item_id: duplicated.append(full) owner_of.setdefault(full, item_id) @@ -1676,7 +1669,12 @@ def _validate_fix_commits( timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(work, reported_shas) + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } unassigned = sorted(set(ordered_range) - reported_full) facts = collect_commit_facts( work, reported_shas, set(ordered_range), test_command, head_branch, timeout, From e7ea3e4f21215645f727bdc2c0de33da668182a2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 15/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 64882a9d88046bec69fd8a9f491a041094bb998d. --- .../cross-review/scripts/lib/monitor.py | 182 +++++++++--------- 1 file changed, 86 insertions(+), 96 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 6ecf4585..fabe0640 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -663,94 +663,6 @@ def _check_early_completion( return None -def _check_termination_conditions( - agent: str, - paths: AgentPaths, - status: AgentStatus, - pid: int, - alive: bool, - elapsed: float, - timeout: int, - stall_timeout: int, - last_progress: float, - last_progress_size: int, - no_early_error: bool, - warned_early_error: bool, - require_result: bool, - log_prefix: str, -) -> tuple[Optional[AgentStatus], bool, float, int]: - """タイムアウト、異常、終了、停滞の順で終了条件を判定する。""" - if elapsed >= timeout: - if alive: - _kill_pid(pid) - status.status = "TIMEOUT" - status.exit_code = 2 - status.detail = f"hard timeout {timeout}s reached (pid {pid})" - _emit_log(log_prefix, agent, status) - return status, warned_early_error, last_progress, last_progress_size - - if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - _emit_log(log_prefix, agent, status) - return status, warned_early_error, last_progress, last_progress_size - if not warned_early_error: - warn_err = _scan_early_warn(paths.err_log) - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True - - if not alive: - status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 - if status.result_exists or not require_result: - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"process exited; sentinel={status.sentinel_seen}; " - f"result_exists={status.result_exists}" - ) - else: - status.status = "NO_RESULT" - status.exit_code = 3 - status.detail = f"process exited but result.json missing: {paths.result}" - _emit_log(log_prefix, agent, status) - return status, warned_early_error, last_progress, last_progress_size - - status.err_log_size = _safe_size(paths.err_log) - status.stdout_log_size = _safe_size(paths.stdout_log) - status.progress_log_size = _safe_size(paths.progress_log) - status.progress_tail = _tail_last_nonempty_line(paths.progress_log) - progress_size = status.err_log_size + status.stdout_log_size + status.progress_log_size - if progress_size != last_progress_size: - last_progress_size = progress_size - last_progress = time.monotonic() - status.idle_seconds = time.monotonic() - last_progress - if status.idle_seconds >= stall_timeout: - _kill_pid(pid) - status.status = "STALLED" - status.exit_code = 5 - status.detail = ( - f"no log progress for {stall_timeout}s " - f"(pid {pid}, last size {last_progress_size}B)" - ) - _emit_log(log_prefix, agent, status) - return status, warned_early_error, last_progress, last_progress_size - return None, warned_early_error, last_progress, last_progress_size - - def monitor_agent( agent: str, pr: int, @@ -841,15 +753,93 @@ def monitor_agent( if cmdline_ok is True: cmdline_validated = True - terminated, warned_early_error, last_progress, last_progress_size = ( - _check_termination_conditions( - agent, paths, status, pid, alive, elapsed, timeout, stall_timeout, - last_progress, last_progress_size, no_early_error, - warned_early_error, require_result, log_prefix, - ) + # 2. hard timeout + if elapsed >= timeout: + if alive: + _kill_pid(pid) + status.status = "TIMEOUT" + status.exit_code = 2 + status.detail = f"hard timeout {timeout}s reached (pid {pid})" + _emit_log(log_prefix, agent, status) + return status + + # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True + + if not alive: + # プロセス終了 — result.json を確認 + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status + + # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 + # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には + # launcher が要求した短いフェーズマーカーが出るため、いずれかが + # 更新されれば progress として扱う) + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size ) - if terminated is not None: - return terminated + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status # poll 中の進捗ログ _emit_progress(log_prefix, agent, status) From c60a4a426d5448052d25480d5e347d203bfdc84b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 16/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0661832c818272399b41e756edad429f9870dfca. --- .../cross-review/scripts/lib/monitor.py | 93 ++++++++----------- 1 file changed, 38 insertions(+), 55 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index fabe0640..401a760f 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,56 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_early_completion( - agent: str, - paths: AgentPaths, - status: AgentStatus, - pid: int, - alive: bool, - cmdline_validated: bool, - started_wall: float, - log_prefix: str, -) -> Optional[AgentStatus]: - """sentinel または result.json の安定を根拠に常駐プロセスを完了させる。""" - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = f"codex sentinel + result.json detected; killed lingering pid {pid}" - _emit_log(log_prefix, agent, status) - return status - - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status - return None - - def monitor_agent( agent: str, pr: int, @@ -726,11 +676,22 @@ def monitor_agent( # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - completed = _check_early_completion( - agent, paths, status, pid, alive, cmdline_validated, started_wall, log_prefix, - ) - if completed is not None: - return completed + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け @@ -739,6 +700,28 @@ def monitor_agent( # 安全条件: # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status + if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと # ファイル不在で None 扱いになり判定不能のため。 From 53be3424281c364a56a801d441357ebf182bc0d5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 17/57] =?UTF-8?q?Revert=20"Test:=20monitor=5Fagent=20?= =?UTF-8?q?=E3=81=AE=E7=B5=82=E4=BA=86=E5=88=A4=E5=AE=9A=E3=82=92=E7=8F=BE?= =?UTF-8?q?=E7=8A=B6=E5=9B=BA=E5=AE=9A"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a8ed8cee49b200229ad3b4f424cdfa3bfc138ea0. --- .../tests/test_monitor_termination.py | 58 ------------------- 1 file changed, 58 deletions(-) delete mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py deleted file mode 100644 index e5013f44..00000000 --- a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py +++ /dev/null @@ -1,58 +0,0 @@ -"""monitor_agent の終了判定を固定するテスト。""" -from __future__ import annotations - -from unittest import mock - - -def _prepare(tmp_path, agent: str, pr: int) -> None: - base = tmp_path / f"{agent}-review-pr{pr}" - (tmp_path / f"{base.name}.pid").write_text("12345") - (tmp_path / f"{base.name}-err.log").write_text("") - (tmp_path / f"{base.name}-stdout.log").write_text("") - - -def _monitor(monitor_mod, tmp_path, *, alive=True, timeout=420, stall_timeout=480): - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=alive), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid") as kill, - mock.patch("time.sleep"), - ): - status = monitor_mod.monitor_agent( - agent="gemini", pr=901, timeout=timeout, stall_timeout=stall_timeout, - poll=1, require_result=True, - ) - return status, kill - - -def test_hard_timeout_kills_live_process(monitor_mod, tmp_path): - _prepare(tmp_path, "gemini", 901) - status, kill = _monitor(monitor_mod, tmp_path, timeout=0) - assert (status.status, status.exit_code) == ("TIMEOUT", 2) - kill.assert_called_once_with(12345) - - -def test_fatal_log_kills_live_process(monitor_mod, tmp_path): - _prepare(tmp_path, "gemini", 901) - (tmp_path / "gemini-review-pr901-err.log").write_text("Error: quota exceeded\n") - status, kill = _monitor(monitor_mod, tmp_path) - assert (status.status, status.exit_code) == ("EARLY_ERROR", 4) - assert "err.log" in status.detail - kill.assert_called_once_with(12345) - - -def test_exited_process_without_result_is_reported(monitor_mod, tmp_path): - _prepare(tmp_path, "gemini", 901) - status, kill = _monitor(monitor_mod, tmp_path, alive=False) - assert (status.status, status.exit_code) == ("NO_RESULT", 3) - kill.assert_not_called() - - -def test_stall_kills_live_process(monitor_mod, tmp_path): - _prepare(tmp_path, "gemini", 901) - status, kill = _monitor(monitor_mod, tmp_path, stall_timeout=0) - assert (status.status, status.exit_code) == ("STALLED", 5) - assert status.err_log_size == 0 - assert status.stdout_log_size == 0 - kill.assert_called_once_with(12345) From ea2e4a403233230b6f5b460935f2a5c7399ffe7a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 18/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 5a6d1d606088abb9bb607b9aea6750a47d7ca291. --- .../cross-refactoring/scripts/refactor.py | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 913715e2..1fe49d82 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1692,28 +1692,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _revert_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - work: str, -) -> None: - """検証失敗した修正範囲を戻し、次の起点を記録する。""" - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - { - "item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range), - }, - dry_run=False, - ) - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1816,7 +1794,19 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - _revert_fix_range(path, state, entry, ordered_range, work) + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True From 8d29a6f8d6f849d046d621aaf5cde9a2ce9b0c72 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 19/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 17a1b8ca9f7dbeab9442ab774a67ab29b7920b3c. --- .../cross-refactoring/scripts/refactor.py | 62 +++++++------------ 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1fe49d82..ef62be3a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1659,39 +1659,6 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def _validate_fix_commits( - work: str, - ordered_range: list[str], - reported_shas: list[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, -) -> tuple[list[tuple[str, str]], list[str], list[str]]: - """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - for sha in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), test_command, head_branch, timeout, - ) - accepted: list[tuple[str, str]] = [] - problems: list[str] = [] - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, scope) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) - return accepted, unassigned, problems - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1771,16 +1738,35 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) needs_push = False - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, state.get("target_scope") or []) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) if unassigned: info( From f21f96c419c3619be457e74e752f74c01350098f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 20/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20metrics.py#aggregate"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ef8970cc794fe2da1a308d59426e6d34a5e4e5c8. --- .../cross-review/scripts/lib/metrics.py | 80 +++++++++---------- 1 file changed, 36 insertions(+), 44 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 4cc4e661..24866554 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -83,49 +83,6 @@ def _update_impl_metrics( bucket["first_review_approved"] += 1 if approved_first else 0 -def _update_reviewer_metrics( - entry: dict[str, Any], - name: str, - reviews: list[dict[str, Any]], - reviewer: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - """ラウンドのレビュー担当 1 名分の指標と警告を更新する。""" - spec = (entry.get("reviewer_models") or {}).get(name) or {} - requested = spec.get("requested") - observed = spec.get("observed") - warning = _models.mismatch_warning(name, requested, observed) - if warning: - unmeasured.append(f"round {entry.get('round')}: {warning}") - if not _models.is_measurable(name, requested): - unmeasured.append( - f"round {entry.get('round')}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) - bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - bucket["reviews"] += 1 - findings = [ - finding for finding in review.get("findings", []) - if isinstance(finding, dict) and finding.get("reviewer") == name - ] - bucket["findings"] += len(findings) - bucket["findings_resolved"] += sum( - 1 for finding in findings if finding.get("resolved") - ) - for other in (candidate for candidate in entry.get("reviewers", []) if candidate != name): - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - bucket["verdict_pairs"] += 1 - bucket["verdict_agreements"] += int( - other_verdict == _verdict(review, name) - ) - - def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -144,9 +101,44 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: continue _update_impl_metrics(entry, items_by_id, impl, unmeasured) + round_no = entry.get("round") reviews = _round_reviews(entry) + reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): - _update_reviewer_metrics(entry, name, reviews, reviewer, unmeasured) + spec = reviewer_models.get(name) or {} + r_requested = spec.get("requested") + r_observed = spec.get("observed") + r_warning = _models.mismatch_warning(name, r_requested, r_observed) + if r_warning: + unmeasured.append(f"round {round_no}: {r_warning}") + if not _models.is_measurable(name, r_requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, From 7aa2c105235642efa0cc0bc049159b57d315bf35 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 21/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20metrics.py#aggregate"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d3a34bca761bebeb6f645a0955edf0fc6abe80ff. --- .../cross-review/scripts/lib/metrics.py | 82 ++++++++----------- 1 file changed, 36 insertions(+), 46 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 24866554..c2b576b4 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -39,50 +39,6 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None -def _update_impl_metrics( - entry: dict[str, Any], - items_by_id: dict[str, dict[str, Any]], - impl: dict[str, dict[str, Any]], - unmeasured: list[str], -) -> None: - """ラウンドの実装担当指標とモデル警告を更新する。""" - round_no = entry.get("round") - impl_runtime = entry["impl"] - impl_model = entry.get("impl_model") or {} - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - - reviews = _round_reviews(entry) - if reviews: - approved_first = all( - _verdict(reviews[0], reviewer) == "APPROVE" - for reviewer in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -96,13 +52,47 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): + round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - _update_impl_metrics(entry, items_by_id, impl, unmeasured) + impl_model = (entry.get("impl_model") or {}) + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - round_no = entry.get("round") reviews = _round_reviews(entry) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} From 675e0a1015ae49dfc7c7d77d2ef29b8371abdc0f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 22/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d1919676c05804c6fcff29516da22e3100898155. --- .../cross-refactoring/scripts/refactor.py | 98 ++++++++----------- 1 file changed, 40 insertions(+), 58 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ef62be3a..995bd4a5 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1117,60 +1117,6 @@ def _validate_commit_ownership( return reported, owner_of, unassigned, unknown_ids, duplicated -def _verify_and_record_items( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - reported: dict[str, dict[str, Any]], - in_range: set[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, - dry_run: bool, -) -> tuple[list[str], list[str]]: - """適用項目を検証し、項目と進捗に判定を記録する。""" - applied: list[str] = [] - failed: list[str] = [] - progress: list[dict[str, Any]] = [] - entry["apply_progress"] = progress - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, timeout, - ) - problem = verify_apply_item(item, facts, scope) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - else: - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - progress.append({ - "item_id": item_id, "at": statefile.now(), - "result": "failed" if problem else "ok", - "reason": problem, "commits": list(item.get("commits") or []), - }) - if not dry_run: - statefile.save(path, state) - return applied, failed - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1319,15 +1265,51 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: statefile.save(path, state) sys.exit(2) + applied: list[str] = [] + failed: list[str] = [] scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - applied, failed = _verify_and_record_items( - path, state, entry, work, reported, in_range, scope, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, - ) + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + problem = verify_apply_item(item, facts, scope) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not args.dry_run: + statefile.save(path, state) entry["apply"] = { "applied": applied, From 89334425f92d5dc1aaeebb25c7c8e555618b4ed9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:42:57 +0000 Subject: [PATCH 23/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit df445e8afc83e376159bf935d049dfa0d5a7977d. --- .../cross-refactoring/scripts/refactor.py | 69 ++++++++----------- 1 file changed, 27 insertions(+), 42 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 995bd4a5..3ad3134b 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1078,45 +1078,6 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) -def _validate_commit_ownership( - work: str, - ordered_range: list[str], - payload: dict[str, Any], - round_items: set[str], -) -> tuple[ - dict[str, dict[str, Any]], dict[str, str], list[str], list[str], list[str] -]: - """申告されたコミットがラウンド内で一意に所有されているか調べる。""" - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for reported_item in raw_items: - if not isinstance(reported_item, dict): - continue - item_id = reported_item.get("item_id") - if item_id in round_items: - reported[item_id] = reported_item - elif item_id is not None: - unknown_ids.append(str(item_id)) - - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, reported_item in reported.items(): - for sha in _reported_shas(reported_item): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(set(ordered_range) - set(owner_of)) - return reported, owner_of, unassigned, unknown_ids, duplicated - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1191,6 +1152,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id in round_items: + reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1200,9 +1176,18 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) - ) + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(in_range - set(owner_of)) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: From 41508488518a1145da86b26d40dbc5da9a539387 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:24:49 +0000 Subject: [PATCH 24/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コミット所有権の検証を独立した関数へ抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 69 +++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 3ad3134b..995bd4a5 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1078,6 +1078,45 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _validate_commit_ownership( + work: str, + ordered_range: list[str], + payload: dict[str, Any], + round_items: set[str], +) -> tuple[ + dict[str, dict[str, Any]], dict[str, str], list[str], list[str], list[str] +]: + """申告されたコミットがラウンド内で一意に所有されているか調べる。""" + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for reported_item in raw_items: + if not isinstance(reported_item, dict): + continue + item_id = reported_item.get("item_id") + if item_id in round_items: + reported[item_id] = reported_item + elif item_id is not None: + unknown_ids.append(str(item_id)) + + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, reported_item in reported.items(): + for sha in _reported_shas(reported_item): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(set(ordered_range) - set(owner_of)) + return reported, owner_of, unassigned, unknown_ids, duplicated + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1152,21 +1191,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id in round_items: - reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1176,18 +1200,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: From 3c983daf9a60df0ffdf06b8281ff49b347aa2c77 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:26:00 +0000 Subject: [PATCH 25/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 項目別の検証と進捗記録を独立した関数へ抽出する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 98 +++++++++++-------- 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 995bd4a5..ef62be3a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1117,6 +1117,60 @@ def _validate_commit_ownership( return reported, owner_of, unassigned, unknown_ids, duplicated +def _verify_and_record_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + reported: dict[str, dict[str, Any]], + in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str]]: + """適用項目を検証し、項目と進捗に判定を記録する。""" + applied: list[str] = [] + failed: list[str] = [] + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not dry_run: + statefile.save(path, state) + return applied, failed + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1265,51 +1319,15 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: statefile.save(path, state) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - progress: list[dict[str, Any]] = [] - entry["apply_progress"] = progress - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts, scope) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - else: - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - progress.append({ - "item_id": item_id, "at": statefile.now(), - "result": "failed" if problem else "ok", - "reason": problem, "commits": list(item.get("commits") or []), - }) - if not args.dry_run: - statefile.save(path, state) + applied, failed = _verify_and_record_items( + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + ) entry["apply"] = { "applied": applied, From b5ea994c3db978d257311fbc91b72eeda848cbb1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:27:19 +0000 Subject: [PATCH 26/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当の指標更新を独立した関数へ抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 82 +++++++++++-------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index c2b576b4..24866554 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,50 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _update_impl_metrics( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドの実装担当指標とモデル警告を更新する。""" + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + reviews = _round_reviews(entry) + if reviews: + approved_first = all( + _verdict(reviews[0], reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,47 +96,13 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + _update_impl_metrics(entry, items_by_id, impl, unmeasured) + round_no = entry.get("round") reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} From 61d2bd40458bffbd7f0daeb7b802370a86d04c9b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:28:25 +0000 Subject: [PATCH 27/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー担当ごとの指標更新を独立した関数へ抽出する。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 80 ++++++++++--------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 24866554..4cc4e661 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -83,6 +83,49 @@ def _update_impl_metrics( bucket["first_review_approved"] += 1 if approved_first else 0 +def _update_reviewer_metrics( + entry: dict[str, Any], + name: str, + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドのレビュー担当 1 名分の指標と警告を更新する。""" + spec = (entry.get("reviewer_models") or {}).get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {entry.get('round')}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {entry.get('round')}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (candidate for candidate in entry.get("reviewers", []) if candidate != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += int( + other_verdict == _verdict(review, name) + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -101,44 +144,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: continue _update_impl_metrics(entry, items_by_id, impl, unmeasured) - round_no = entry.get("round") reviews = _round_reviews(entry) - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _update_reviewer_metrics(entry, name, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, From cfd7e6285d5f55dd2ea926e51e750d0efb84661f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:29:27 +0000 Subject: [PATCH 28/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正コミットの範囲と手順の検証を独立した関数へ抽出する。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ef62be3a..1fe49d82 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1659,6 +1659,39 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def _validate_fix_commits( + work: str, + ordered_range: list[str], + reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, +) -> tuple[list[tuple[str, str]], list[str], list[str]]: + """修正コミットの申告漏れと手順適合を検証する。""" + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, unassigned, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1738,35 +1771,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) if unassigned: info( From 33882602537729c29a9e4f6dd7bb98bb20c87d9f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:30:21 +0000 Subject: [PATCH 29/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証失敗時の修正範囲取り消しと起点更新を独立した関数へ抽出する。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 1fe49d82..913715e2 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1692,6 +1692,28 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _revert_fix_range( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], + work: str, +) -> None: + """検証失敗した修正範囲を戻し、次の起点を記録する。""" + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, + { + "item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", + "commits": list(ordered_range), + }, + dry_run=False, + ) + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1794,19 +1816,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) + _revert_fix_range(path, state, entry, ordered_range, work) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True From b4b05760c134f8350b09a725c3e1d17935970b32 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:34:55 +0000 Subject: [PATCH 30/57] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20refactor.py#commit=5Fvalidation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 申告SHAを完全SHAへ解決する処理を適用・修正経路で共通化する。 Item-Id: R1-005 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 913715e2..0632ee60 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1078,6 +1078,16 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: + """申告 SHA のうち実在するコミットを完全 SHA に正規化する。""" + return { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + + def _validate_commit_ownership( work: str, ordered_range: list[str], @@ -1105,10 +1115,7 @@ def _validate_commit_ownership( owner_of: dict[str, str] = {} duplicated: list[str] = [] for item_id, reported_item in reported.items(): - for sha in _reported_shas(reported_item): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue + for full in _resolve_reported_to_full(work, _reported_shas(reported_item)): if full in owner_of and owner_of[full] != item_id: duplicated.append(full) owner_of.setdefault(full, item_id) @@ -1669,12 +1676,7 @@ def _validate_fix_commits( timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - for sha in reported_shas - ) if full - } + reported_full = _resolve_reported_to_full(work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) facts = collect_commit_facts( work, reported_shas, set(ordered_range), test_command, head_branch, timeout, From cebd1ef5472f9c3b204009801368f9db34621526 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:36:05 +0000 Subject: [PATCH 31/57] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20refactor.py#range=5Frecovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 範囲取り消し時の push 印、revert、起点更新、保存を共通化する。 Item-Id: R1-005 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 68 +++++++++---------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0632ee60..f4093f23 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1088,6 +1088,31 @@ def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: } +def _revert_range_with_recovery( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + base_sha_key: str, + dry_run: bool = False, +) -> None: + """範囲を取り消し、中断復帰用の push 印と新しい起点を保存する。""" + if base_sha_key == "apply_base_sha": + item_id = f"R{entry['round']}-range" + else: + item_id = f"R{entry['round']}-fix{entry['fix_rounds'] + 1}" + if not dry_run: + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, {"item_id": item_id, "commits": list(ordered_range)}, dry_run=dry_run, + ) + if not dry_run: + entry[base_sha_key] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + + def _validate_commit_ownership( work: str, ordered_range: list[str], @@ -1294,20 +1319,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - if not args.dry_run: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1694,28 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _revert_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - work: str, -) -> None: - """検証失敗した修正範囲を戻し、次の起点を記録する。""" - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - { - "item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range), - }, - dry_run=False, - ) - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1818,7 +1810,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - _revert_fix_range(path, state, entry, ordered_range, work) + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True From 8ae57584129500ceddc51b3ed4530db7cfaa7a8e Mon Sep 17 00:00:00 2001 From: cross-refactoring orchestrator Date: Sun, 16 Aug 2026 04:45:40 +0000 Subject: [PATCH 32/57] =?UTF-8?q?Chore:=20=E7=94=9F=E6=88=90=E7=89=A9?= =?UTF-8?q?=E3=82=92=E5=90=8C=E6=9C=9F=E3=81=99=E3=82=8B=EF=BC=88=E9=80=B2?= =?UTF-8?q?=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当は対象範囲(編集元)だけを変更するため、配布物が同期されない。 このリポジトリは pre-push で同期を検査するので、進行側がここで生成する。 --- .../cross-refactoring/scripts/refactor.py | 287 +++++++++++------- .../cross-review/scripts/lib/metrics.py | 160 +++++----- .../cross-refactoring/scripts/refactor.py | 287 +++++++++++------- .../cross-review/scripts/lib/metrics.py | 160 +++++----- .../cross-refactoring/scripts/refactor.py | 287 +++++++++++------- .../cross-review/scripts/lib/metrics.py | 160 +++++----- 6 files changed, 777 insertions(+), 564 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 3ad3134b..f4093f23 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1078,6 +1078,131 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: + """申告 SHA のうち実在するコミットを完全 SHA に正規化する。""" + return { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + + +def _revert_range_with_recovery( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + base_sha_key: str, + dry_run: bool = False, +) -> None: + """範囲を取り消し、中断復帰用の push 印と新しい起点を保存する。""" + if base_sha_key == "apply_base_sha": + item_id = f"R{entry['round']}-range" + else: + item_id = f"R{entry['round']}-fix{entry['fix_rounds'] + 1}" + if not dry_run: + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, {"item_id": item_id, "commits": list(ordered_range)}, dry_run=dry_run, + ) + if not dry_run: + entry[base_sha_key] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + + +def _validate_commit_ownership( + work: str, + ordered_range: list[str], + payload: dict[str, Any], + round_items: set[str], +) -> tuple[ + dict[str, dict[str, Any]], dict[str, str], list[str], list[str], list[str] +]: + """申告されたコミットがラウンド内で一意に所有されているか調べる。""" + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for reported_item in raw_items: + if not isinstance(reported_item, dict): + continue + item_id = reported_item.get("item_id") + if item_id in round_items: + reported[item_id] = reported_item + elif item_id is not None: + unknown_ids.append(str(item_id)) + + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, reported_item in reported.items(): + for full in _resolve_reported_to_full(work, _reported_shas(reported_item)): + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(set(ordered_range) - set(owner_of)) + return reported, owner_of, unassigned, unknown_ids, duplicated + + +def _verify_and_record_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + reported: dict[str, dict[str, Any]], + in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str]]: + """適用項目を検証し、項目と進捗に判定を記録する。""" + applied: list[str] = [] + failed: list[str] = [] + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not dry_run: + statefile.save(path, state) + return applied, failed + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1152,21 +1277,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id in round_items: - reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1176,18 +1286,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: @@ -1218,20 +1319,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - if not args.dry_run: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1250,51 +1340,15 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: statefile.save(path, state) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - progress: list[dict[str, Any]] = [] - entry["apply_progress"] = progress - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts, scope) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - else: - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - progress.append({ - "item_id": item_id, "at": statefile.now(), - "result": "failed" if problem else "ok", - "reason": problem, "commits": list(item.get("commits") or []), - }) - if not args.dry_run: - statefile.save(path, state) + applied, failed = _verify_and_record_items( + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + ) entry["apply"] = { "applied": applied, @@ -1626,6 +1680,34 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def _validate_fix_commits( + work: str, + ordered_range: list[str], + reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, +) -> tuple[list[tuple[str, str]], list[str], list[str]]: + """修正コミットの申告漏れと手順適合を検証する。""" + reported_full = _resolve_reported_to_full(work, reported_shas) + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, unassigned, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1705,35 +1787,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) if unassigned: info( @@ -1747,19 +1810,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py index c2b576b4..4cc4e661 100644 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,93 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _update_impl_metrics( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドの実装担当指標とモデル警告を更新する。""" + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + reviews = _round_reviews(entry) + if reviews: + approved_first = all( + _verdict(reviews[0], reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + +def _update_reviewer_metrics( + entry: dict[str, Any], + name: str, + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドのレビュー担当 1 名分の指標と警告を更新する。""" + spec = (entry.get("reviewer_models") or {}).get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {entry.get('round')}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {entry.get('round')}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (candidate for candidate in entry.get("reviewers", []) if candidate != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += int( + other_verdict == _verdict(review, name) + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,83 +139,14 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + _update_impl_metrics(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _update_reviewer_metrics(entry, name, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 3ad3134b..f4093f23 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1078,6 +1078,131 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: + """申告 SHA のうち実在するコミットを完全 SHA に正規化する。""" + return { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + + +def _revert_range_with_recovery( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + base_sha_key: str, + dry_run: bool = False, +) -> None: + """範囲を取り消し、中断復帰用の push 印と新しい起点を保存する。""" + if base_sha_key == "apply_base_sha": + item_id = f"R{entry['round']}-range" + else: + item_id = f"R{entry['round']}-fix{entry['fix_rounds'] + 1}" + if not dry_run: + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, {"item_id": item_id, "commits": list(ordered_range)}, dry_run=dry_run, + ) + if not dry_run: + entry[base_sha_key] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + + +def _validate_commit_ownership( + work: str, + ordered_range: list[str], + payload: dict[str, Any], + round_items: set[str], +) -> tuple[ + dict[str, dict[str, Any]], dict[str, str], list[str], list[str], list[str] +]: + """申告されたコミットがラウンド内で一意に所有されているか調べる。""" + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for reported_item in raw_items: + if not isinstance(reported_item, dict): + continue + item_id = reported_item.get("item_id") + if item_id in round_items: + reported[item_id] = reported_item + elif item_id is not None: + unknown_ids.append(str(item_id)) + + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, reported_item in reported.items(): + for full in _resolve_reported_to_full(work, _reported_shas(reported_item)): + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(set(ordered_range) - set(owner_of)) + return reported, owner_of, unassigned, unknown_ids, duplicated + + +def _verify_and_record_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + reported: dict[str, dict[str, Any]], + in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str]]: + """適用項目を検証し、項目と進捗に判定を記録する。""" + applied: list[str] = [] + failed: list[str] = [] + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not dry_run: + statefile.save(path, state) + return applied, failed + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1152,21 +1277,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id in round_items: - reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1176,18 +1286,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: @@ -1218,20 +1319,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - if not args.dry_run: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1250,51 +1340,15 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: statefile.save(path, state) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - progress: list[dict[str, Any]] = [] - entry["apply_progress"] = progress - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts, scope) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - else: - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - progress.append({ - "item_id": item_id, "at": statefile.now(), - "result": "failed" if problem else "ok", - "reason": problem, "commits": list(item.get("commits") or []), - }) - if not args.dry_run: - statefile.save(path, state) + applied, failed = _verify_and_record_items( + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + ) entry["apply"] = { "applied": applied, @@ -1626,6 +1680,34 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def _validate_fix_commits( + work: str, + ordered_range: list[str], + reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, +) -> tuple[list[tuple[str, str]], list[str], list[str]]: + """修正コミットの申告漏れと手順適合を検証する。""" + reported_full = _resolve_reported_to_full(work, reported_shas) + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, unassigned, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1705,35 +1787,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) if unassigned: info( @@ -1747,19 +1810,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py index c2b576b4..4cc4e661 100644 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,93 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _update_impl_metrics( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドの実装担当指標とモデル警告を更新する。""" + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + reviews = _round_reviews(entry) + if reviews: + approved_first = all( + _verdict(reviews[0], reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + +def _update_reviewer_metrics( + entry: dict[str, Any], + name: str, + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドのレビュー担当 1 名分の指標と警告を更新する。""" + spec = (entry.get("reviewer_models") or {}).get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {entry.get('round')}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {entry.get('round')}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (candidate for candidate in entry.get("reviewers", []) if candidate != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += int( + other_verdict == _verdict(review, name) + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,83 +139,14 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + _update_impl_metrics(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _update_reviewer_metrics(entry, name, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 3ad3134b..f4093f23 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1078,6 +1078,131 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: sys.exit(2) +def _resolve_reported_to_full(work: str, reported_shas: list[str]) -> set[str]: + """申告 SHA のうち実在するコミットを完全 SHA に正規化する。""" + return { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + for sha in reported_shas + ) if full + } + + +def _revert_range_with_recovery( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + base_sha_key: str, + dry_run: bool = False, +) -> None: + """範囲を取り消し、中断復帰用の push 印と新しい起点を保存する。""" + if base_sha_key == "apply_base_sha": + item_id = f"R{entry['round']}-range" + else: + item_id = f"R{entry['round']}-fix{entry['fix_rounds'] + 1}" + if not dry_run: + entry["pending_push"] = True + statefile.save(path, state) + _revert_item_commits( + state, {"item_id": item_id, "commits": list(ordered_range)}, dry_run=dry_run, + ) + if not dry_run: + entry[base_sha_key] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + + +def _validate_commit_ownership( + work: str, + ordered_range: list[str], + payload: dict[str, Any], + round_items: set[str], +) -> tuple[ + dict[str, dict[str, Any]], dict[str, str], list[str], list[str], list[str] +]: + """申告されたコミットがラウンド内で一意に所有されているか調べる。""" + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for reported_item in raw_items: + if not isinstance(reported_item, dict): + continue + item_id = reported_item.get("item_id") + if item_id in round_items: + reported[item_id] = reported_item + elif item_id is not None: + unknown_ids.append(str(item_id)) + + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, reported_item in reported.items(): + for full in _resolve_reported_to_full(work, _reported_shas(reported_item)): + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(set(ordered_range) - set(owner_of)) + return reported, owner_of, unassigned, unknown_ids, duplicated + + +def _verify_and_record_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + reported: dict[str, dict[str, Any]], + in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, + dry_run: bool, +) -> tuple[list[str], list[str]]: + """適用項目を検証し、項目と進捗に判定を記録する。""" + applied: list[str] = [] + failed: list[str] = [] + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress + for item_id in entry["items"]: + item = _find_item(state, item_id) + got = reported.get(item_id) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) + if problem: + item["status"] = "abandoned" + item["failure_reason"] = problem + item["test_failed"] = bool(got and "テストが成功していません" in problem) + item["budget_exceeded"] = bool(got and "差分予算" in problem) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not dry_run: + statefile.save(path, state) + return applied, failed + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1152,21 +1277,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 round_items = set(entry["items"]) - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id in round_items: - reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1176,18 +1286,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) if unassigned or unknown_ids or duplicated: causes = [] if unassigned: @@ -1218,20 +1319,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{entry['round']}-range", - "commits": list(ordered_range), - } - if not args.dry_run: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits(state, whole_round, args.dry_run) - if not args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) entry["apply"] = { "applied": [], "failed": list(entry["items"]), "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, @@ -1250,51 +1340,15 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: statefile.save(path, state) sys.exit(2) - applied: list[str] = [] - failed: list[str] = [] scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 - progress: list[dict[str, Any]] = [] - entry["apply_progress"] = progress - for item_id in entry["items"]: - item = _find_item(state, item_id) - got = reported.get(item_id) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - problem = verify_apply_item(item, facts, scope) - if problem: - item["status"] = "abandoned" - item["failure_reason"] = problem - item["test_failed"] = bool(got and "テストが成功していません" in problem) - item["budget_exceeded"] = bool(got and "差分予算" in problem) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - else: - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") - progress.append({ - "item_id": item_id, "at": statefile.now(), - "result": "failed" if problem else "ok", - "reason": problem, "commits": list(item.get("commits") or []), - }) - if not args.dry_run: - statefile.save(path, state) + applied, failed = _verify_and_record_items( + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + ) entry["apply"] = { "applied": applied, @@ -1626,6 +1680,34 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def _validate_fix_commits( + work: str, + ordered_range: list[str], + reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, +) -> tuple[list[tuple[str, str]], list[str], list[str]]: + """修正コミットの申告漏れと手順適合を検証する。""" + reported_full = _resolve_reported_to_full(work, reported_shas) + unassigned = sorted(set(ordered_range) - reported_full) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) + accepted: list[tuple[str, str]] = [] + problems: list[str] = [] + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return accepted, unassigned, problems + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1705,35 +1787,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) - - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) needs_push = False - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) if unassigned: info( @@ -1747,19 +1810,9 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - _revert_item_commits( - state, - {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - "commits": list(ordered_range)}, - dry_run=False, + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py index c2b576b4..4cc4e661 100644 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py @@ -39,6 +39,93 @@ def _verdict(review: dict[str, Any], reviewer: str) -> Optional[str]: return value if isinstance(value, str) else None +def _update_impl_metrics( + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドの実装担当指標とモデル警告を更新する。""" + round_no = entry.get("round") + impl_runtime = entry["impl"] + impl_model = entry.get("impl_model") or {} + requested = impl_model.get("requested") + observed = impl_model.get("observed") + + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) + + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum(1 for i in round_items if i.get("budget_exceeded")) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + reviews = _round_reviews(entry) + if reviews: + approved_first = all( + _verdict(reviews[0], reviewer) == "APPROVE" + for reviewer in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + +def _update_reviewer_metrics( + entry: dict[str, Any], + name: str, + reviews: list[dict[str, Any]], + reviewer: dict[str, dict[str, Any]], + unmeasured: list[str], +) -> None: + """ラウンドのレビュー担当 1 名分の指標と警告を更新する。""" + spec = (entry.get("reviewer_models") or {}).get(name) or {} + requested = spec.get("requested") + observed = spec.get("observed") + warning = _models.mismatch_warning(name, requested, observed) + if warning: + unmeasured.append(f"round {entry.get('round')}: {warning}") + if not _models.is_measurable(name, requested): + unmeasured.append( + f"round {entry.get('round')}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) + bucket = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + bucket["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + bucket["reviews"] += 1 + findings = [ + finding for finding in review.get("findings", []) + if isinstance(finding, dict) and finding.get("reviewer") == name + ] + bucket["findings"] += len(findings) + bucket["findings_resolved"] += sum( + 1 for finding in findings if finding.get("resolved") + ) + for other in (candidate for candidate in entry.get("reviewers", []) if candidate != name): + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + bucket["verdict_pairs"] += 1 + bucket["verdict_agreements"] += int( + other_verdict == _verdict(review, name) + ) + + def aggregate(state: dict[str, Any]) -> dict[str, Any]: """状態ファイルから実装担当・レビュー担当それぞれの指標を出す。 @@ -52,83 +139,14 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: unmeasured: list[str] = [] for entry in state.get("rounds", []): - round_no = entry.get("round") impl_runtime = entry.get("impl") if not impl_runtime: continue - impl_model = (entry.get("impl_model") or {}) - requested = impl_model.get("requested") - observed = impl_model.get("observed") - - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + _update_impl_metrics(entry, items_by_id, impl, unmeasured) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): - spec = reviewer_models.get(name) or {} - r_requested = spec.get("requested") - r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _update_reviewer_metrics(entry, name, reviews, reviewer, unmeasured) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, From 48f2ac954b689f29837bd14c07e185b9506b06a4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:59:22 +0000 Subject: [PATCH 33/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20monitor.py#monitor=5Fagent=20(characterization=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit monitor_agent の 5 つの終了経路(codex sentinel, result age fallback, timeout, early error, stall detection)と補助経路(正常終了, NO_RESULT, PIDFILE_BAD, cmdline不一致, WARN非kill, no_early_error抑制, claude stdout) を現状固定テストとして記録する。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../tests/test_monitor_termination.py | 463 ++++++++++++++++++ .../tests/test_monitor_termination.py | 463 ++++++++++++++++++ .../tests/test_monitor_termination.py | 463 ++++++++++++++++++ .../tests/test_monitor_termination.py | 463 ++++++++++++++++++ 4 files changed, 1852 insertions(+) create mode 100644 plugins/ndf-claude/skills/cross-review/tests/test_monitor_termination.py create mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_monitor_termination.py create mode 100644 plugins/ndf-kiro/skills/cross-review/tests/test_monitor_termination.py create mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_termination.py new file mode 100644 index 00000000..a0032dc5 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_termination.py @@ -0,0 +1,463 @@ +"""monitor_agent の 5 つの終了経路を固定する現状固定テスト。 + +リファクタリング R2-001 で monitor_agent を分割する前に、 +各終了経路の入出力と副作用を振る舞いとして記録する。 + +終了経路: + 1. codex sentinel + result.json → OK (kill + detail に "codex sentinel") + 2. result.json age fallback → OK (kill + detail に "result.json exists for") + 3. hard timeout → TIMEOUT (kill + exit_code=2) + 4. early error (FATAL) → EARLY_ERROR (kill + exit_code=4) + 5. stall detection → STALLED (kill + exit_code=5) + +追加で固定する経路: + 6. プロセス正常終了 + result.json あり → OK (exit_code=0) + 7. プロセス正常終了 + result.json なし → NO_RESULT (exit_code=3) + 8. pidfile 不在 → PIDFILE_BAD (exit_code=6) + 9. cmdline 不一致 → PIDFILE_BAD (kill + exit_code=6) +""" +from __future__ import annotations + +import json +import os +import pathlib +import time +from unittest import mock + + +# ---------- 1. codex sentinel + result.json ---------- + +def test_codex_sentinel_with_result_returns_ok_and_kills(monitor_mod, tmp_path): + """codex sentinel + result.json が揃ったら即 OK で pid を kill する。""" + pr = 800 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("11111") + (pathlib.Path(f"{base}-err.log")).write_text("processing...\ntokens used\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert st.sentinel_seen is True + assert "codex sentinel" in st.detail + assert 11111 in killed + + +# ---------- 2. result.json age fallback ---------- + +def test_result_age_fallback_returns_ok_and_kills(monitor_mod, tmp_path): + """result.json の age >= RESULT_AGE_GRACE で OK、pid を kill する。""" + pr = 801 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("22222") + (pathlib.Path(f"{base}-err.log")).write_text("working...\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + result = pathlib.Path(f"{base}-result.json") + result.write_text(json.dumps({"event": "APPROVE"})) + + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_calls = [0] + def fake_time(): + time_calls[0] += 1 + if time_calls[0] == 1: + return base_time + return base_time + 40 # age = 40 - 5 = 35s >= 30s + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep"), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "result.json exists for" in st.detail + assert 22222 in killed + + +# ---------- 3. hard timeout ---------- + +def test_timeout_returns_timeout_and_kills(monitor_mod, tmp_path): + """hard timeout に達したら TIMEOUT で pid を kill する。""" + pr = 802 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("33333") + (pathlib.Path(f"{base}-err.log")).write_text("working...\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + poll_count = [0] + # monotonic を進めて timeout に到達させる + mono_base = time.monotonic() + + def fake_pid_alive(pid): + return True + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 50: + raise RuntimeError("too many polls") + + def fake_monotonic(): + # 1 回目: start, 以降: timeout 超え + if poll_count[0] >= 1: + return mono_base + 500 # > timeout=420 + return mono_base + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.monotonic", side_effect=fake_monotonic), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "TIMEOUT" + assert st.exit_code == 2 + assert "hard timeout" in st.detail + assert 33333 in killed + + +# ---------- 4. early error (FATAL) ---------- + +def test_early_error_fatal_returns_early_error_and_kills(monitor_mod, tmp_path): + """致命エラーパターン検出で EARLY_ERROR、pid を kill する。""" + pr = 803 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("44444") + (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: token expired\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "EARLY_ERROR" + assert st.exit_code == 4 + assert "early error (fatal)" in st.detail + assert 44444 in killed + + +# ---------- 5. stall detection ---------- + +def test_stall_returns_stalled_and_kills(monitor_mod, tmp_path): + """ログ無進捗が stall_timeout に達したら STALLED、pid を kill する。""" + pr = 804 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("55555") + err_log = pathlib.Path(f"{base}-err.log") + err_log.write_text("initial\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + poll_count = [0] + mono_base = time.monotonic() + + def fake_pid_alive(pid): + return True + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 50: + raise RuntimeError("too many polls") + + def fake_monotonic(): + # elapsed < timeout (420), idle >= stall_timeout (60) + # poll_count で進める: idle が stall_timeout を超えるようにする + if poll_count[0] >= 2: + return mono_base + 100 # idle_seconds will exceed stall_timeout=60 + return mono_base + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.monotonic", side_effect=fake_monotonic), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=60, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "STALLED" + assert st.exit_code == 5 + assert "no log progress" in st.detail + assert 55555 in killed + + +# ---------- 6. プロセス正常終了 + result.json あり ---------- + +def test_process_exit_with_result_returns_ok(monitor_mod, tmp_path): + """プロセスが正常に終了し result.json があれば OK。""" + pr = 805 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("66666") + (pathlib.Path(f"{base}-err.log")).write_text("done\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "process exited" in st.detail + + +# ---------- 7. プロセス正常終了 + result.json なし ---------- + +def test_process_exit_without_result_returns_no_result(monitor_mod, tmp_path): + """プロセスが終了したが result.json がなければ NO_RESULT。""" + pr = 806 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("77777") + (pathlib.Path(f"{base}-err.log")).write_text("crashed\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "NO_RESULT" + assert st.exit_code == 3 + assert "result.json missing" in st.detail + + +# ---------- 8. pidfile 不在 ---------- + +def test_no_pidfile_returns_pidfile_bad(monitor_mod, tmp_path): + """pidfile が見つからなければ PIDFILE_BAD。""" + pr = 807 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "PIDFILE_BAD" + assert st.exit_code == 6 + assert "pidfile not found" in st.detail + + +# ---------- 9. cmdline 不一致 ---------- + +def test_cmdline_mismatch_returns_pidfile_bad_and_kills(monitor_mod, tmp_path): + """cmdline に agent 名がなければ PIDFILE_BAD で kill する。""" + pr = 808 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("88888") + (pathlib.Path(f"{base}-err.log")).write_text("") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=False), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "PIDFILE_BAD" + assert st.exit_code == 6 + assert "cmdline does not contain" in st.detail + assert 88888 in killed + + +# ---------- 10. early error WARN は kill しない ---------- + +def test_early_error_warn_does_not_kill(monitor_mod, tmp_path): + """WARN パターンは kill せず、プロセス終了後に OK で返る。""" + pr = 809 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("99999") + (pathlib.Path(f"{base}-err.log")).write_text("Error: something non-fatal\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 1: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert killed == [] # WARN does not kill + + +# ---------- 11. no_early_error フラグが FATAL を抑制する ---------- + +def test_no_early_error_suppresses_fatal_detection(monitor_mod, tmp_path): + """no_early_error=True のとき、FATAL パターンがあっても検知しない。""" + pr = 810 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("10101") + (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: bad token\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 1: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + + +# ---------- 12. claude stdout fatal ---------- + +def test_claude_stdout_fatal_returns_early_error(monitor_mod, tmp_path): + """claude の stdout.log に致命パターンがあれば EARLY_ERROR。""" + pr = 811 + base = tmp_path / f"claude-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("12121") + (pathlib.Path(f"{base}-err.log")).write_text("") + stdout = pathlib.Path(f"{base}-stdout.log") + stdout.write_text(json.dumps({ + "type": "result", "is_error": True, "permission_denials": [], + })) + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="claude", pr=pr, timeout=420, stall_timeout=900, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "EARLY_ERROR" + assert st.exit_code == 4 + assert "stdout.log" in st.detail + assert 12121 in killed diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_termination.py new file mode 100644 index 00000000..a0032dc5 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_termination.py @@ -0,0 +1,463 @@ +"""monitor_agent の 5 つの終了経路を固定する現状固定テスト。 + +リファクタリング R2-001 で monitor_agent を分割する前に、 +各終了経路の入出力と副作用を振る舞いとして記録する。 + +終了経路: + 1. codex sentinel + result.json → OK (kill + detail に "codex sentinel") + 2. result.json age fallback → OK (kill + detail に "result.json exists for") + 3. hard timeout → TIMEOUT (kill + exit_code=2) + 4. early error (FATAL) → EARLY_ERROR (kill + exit_code=4) + 5. stall detection → STALLED (kill + exit_code=5) + +追加で固定する経路: + 6. プロセス正常終了 + result.json あり → OK (exit_code=0) + 7. プロセス正常終了 + result.json なし → NO_RESULT (exit_code=3) + 8. pidfile 不在 → PIDFILE_BAD (exit_code=6) + 9. cmdline 不一致 → PIDFILE_BAD (kill + exit_code=6) +""" +from __future__ import annotations + +import json +import os +import pathlib +import time +from unittest import mock + + +# ---------- 1. codex sentinel + result.json ---------- + +def test_codex_sentinel_with_result_returns_ok_and_kills(monitor_mod, tmp_path): + """codex sentinel + result.json が揃ったら即 OK で pid を kill する。""" + pr = 800 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("11111") + (pathlib.Path(f"{base}-err.log")).write_text("processing...\ntokens used\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert st.sentinel_seen is True + assert "codex sentinel" in st.detail + assert 11111 in killed + + +# ---------- 2. result.json age fallback ---------- + +def test_result_age_fallback_returns_ok_and_kills(monitor_mod, tmp_path): + """result.json の age >= RESULT_AGE_GRACE で OK、pid を kill する。""" + pr = 801 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("22222") + (pathlib.Path(f"{base}-err.log")).write_text("working...\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + result = pathlib.Path(f"{base}-result.json") + result.write_text(json.dumps({"event": "APPROVE"})) + + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_calls = [0] + def fake_time(): + time_calls[0] += 1 + if time_calls[0] == 1: + return base_time + return base_time + 40 # age = 40 - 5 = 35s >= 30s + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep"), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "result.json exists for" in st.detail + assert 22222 in killed + + +# ---------- 3. hard timeout ---------- + +def test_timeout_returns_timeout_and_kills(monitor_mod, tmp_path): + """hard timeout に達したら TIMEOUT で pid を kill する。""" + pr = 802 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("33333") + (pathlib.Path(f"{base}-err.log")).write_text("working...\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + poll_count = [0] + # monotonic を進めて timeout に到達させる + mono_base = time.monotonic() + + def fake_pid_alive(pid): + return True + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 50: + raise RuntimeError("too many polls") + + def fake_monotonic(): + # 1 回目: start, 以降: timeout 超え + if poll_count[0] >= 1: + return mono_base + 500 # > timeout=420 + return mono_base + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.monotonic", side_effect=fake_monotonic), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "TIMEOUT" + assert st.exit_code == 2 + assert "hard timeout" in st.detail + assert 33333 in killed + + +# ---------- 4. early error (FATAL) ---------- + +def test_early_error_fatal_returns_early_error_and_kills(monitor_mod, tmp_path): + """致命エラーパターン検出で EARLY_ERROR、pid を kill する。""" + pr = 803 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("44444") + (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: token expired\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "EARLY_ERROR" + assert st.exit_code == 4 + assert "early error (fatal)" in st.detail + assert 44444 in killed + + +# ---------- 5. stall detection ---------- + +def test_stall_returns_stalled_and_kills(monitor_mod, tmp_path): + """ログ無進捗が stall_timeout に達したら STALLED、pid を kill する。""" + pr = 804 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("55555") + err_log = pathlib.Path(f"{base}-err.log") + err_log.write_text("initial\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + poll_count = [0] + mono_base = time.monotonic() + + def fake_pid_alive(pid): + return True + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 50: + raise RuntimeError("too many polls") + + def fake_monotonic(): + # elapsed < timeout (420), idle >= stall_timeout (60) + # poll_count で進める: idle が stall_timeout を超えるようにする + if poll_count[0] >= 2: + return mono_base + 100 # idle_seconds will exceed stall_timeout=60 + return mono_base + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.monotonic", side_effect=fake_monotonic), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=60, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "STALLED" + assert st.exit_code == 5 + assert "no log progress" in st.detail + assert 55555 in killed + + +# ---------- 6. プロセス正常終了 + result.json あり ---------- + +def test_process_exit_with_result_returns_ok(monitor_mod, tmp_path): + """プロセスが正常に終了し result.json があれば OK。""" + pr = 805 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("66666") + (pathlib.Path(f"{base}-err.log")).write_text("done\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "process exited" in st.detail + + +# ---------- 7. プロセス正常終了 + result.json なし ---------- + +def test_process_exit_without_result_returns_no_result(monitor_mod, tmp_path): + """プロセスが終了したが result.json がなければ NO_RESULT。""" + pr = 806 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("77777") + (pathlib.Path(f"{base}-err.log")).write_text("crashed\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "NO_RESULT" + assert st.exit_code == 3 + assert "result.json missing" in st.detail + + +# ---------- 8. pidfile 不在 ---------- + +def test_no_pidfile_returns_pidfile_bad(monitor_mod, tmp_path): + """pidfile が見つからなければ PIDFILE_BAD。""" + pr = 807 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "PIDFILE_BAD" + assert st.exit_code == 6 + assert "pidfile not found" in st.detail + + +# ---------- 9. cmdline 不一致 ---------- + +def test_cmdline_mismatch_returns_pidfile_bad_and_kills(monitor_mod, tmp_path): + """cmdline に agent 名がなければ PIDFILE_BAD で kill する。""" + pr = 808 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("88888") + (pathlib.Path(f"{base}-err.log")).write_text("") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=False), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "PIDFILE_BAD" + assert st.exit_code == 6 + assert "cmdline does not contain" in st.detail + assert 88888 in killed + + +# ---------- 10. early error WARN は kill しない ---------- + +def test_early_error_warn_does_not_kill(monitor_mod, tmp_path): + """WARN パターンは kill せず、プロセス終了後に OK で返る。""" + pr = 809 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("99999") + (pathlib.Path(f"{base}-err.log")).write_text("Error: something non-fatal\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 1: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert killed == [] # WARN does not kill + + +# ---------- 11. no_early_error フラグが FATAL を抑制する ---------- + +def test_no_early_error_suppresses_fatal_detection(monitor_mod, tmp_path): + """no_early_error=True のとき、FATAL パターンがあっても検知しない。""" + pr = 810 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("10101") + (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: bad token\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 1: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + + +# ---------- 12. claude stdout fatal ---------- + +def test_claude_stdout_fatal_returns_early_error(monitor_mod, tmp_path): + """claude の stdout.log に致命パターンがあれば EARLY_ERROR。""" + pr = 811 + base = tmp_path / f"claude-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("12121") + (pathlib.Path(f"{base}-err.log")).write_text("") + stdout = pathlib.Path(f"{base}-stdout.log") + stdout.write_text(json.dumps({ + "type": "result", "is_error": True, "permission_denials": [], + })) + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="claude", pr=pr, timeout=420, stall_timeout=900, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "EARLY_ERROR" + assert st.exit_code == 4 + assert "stdout.log" in st.detail + assert 12121 in killed diff --git a/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_termination.py new file mode 100644 index 00000000..a0032dc5 --- /dev/null +++ b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_termination.py @@ -0,0 +1,463 @@ +"""monitor_agent の 5 つの終了経路を固定する現状固定テスト。 + +リファクタリング R2-001 で monitor_agent を分割する前に、 +各終了経路の入出力と副作用を振る舞いとして記録する。 + +終了経路: + 1. codex sentinel + result.json → OK (kill + detail に "codex sentinel") + 2. result.json age fallback → OK (kill + detail に "result.json exists for") + 3. hard timeout → TIMEOUT (kill + exit_code=2) + 4. early error (FATAL) → EARLY_ERROR (kill + exit_code=4) + 5. stall detection → STALLED (kill + exit_code=5) + +追加で固定する経路: + 6. プロセス正常終了 + result.json あり → OK (exit_code=0) + 7. プロセス正常終了 + result.json なし → NO_RESULT (exit_code=3) + 8. pidfile 不在 → PIDFILE_BAD (exit_code=6) + 9. cmdline 不一致 → PIDFILE_BAD (kill + exit_code=6) +""" +from __future__ import annotations + +import json +import os +import pathlib +import time +from unittest import mock + + +# ---------- 1. codex sentinel + result.json ---------- + +def test_codex_sentinel_with_result_returns_ok_and_kills(monitor_mod, tmp_path): + """codex sentinel + result.json が揃ったら即 OK で pid を kill する。""" + pr = 800 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("11111") + (pathlib.Path(f"{base}-err.log")).write_text("processing...\ntokens used\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert st.sentinel_seen is True + assert "codex sentinel" in st.detail + assert 11111 in killed + + +# ---------- 2. result.json age fallback ---------- + +def test_result_age_fallback_returns_ok_and_kills(monitor_mod, tmp_path): + """result.json の age >= RESULT_AGE_GRACE で OK、pid を kill する。""" + pr = 801 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("22222") + (pathlib.Path(f"{base}-err.log")).write_text("working...\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + result = pathlib.Path(f"{base}-result.json") + result.write_text(json.dumps({"event": "APPROVE"})) + + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_calls = [0] + def fake_time(): + time_calls[0] += 1 + if time_calls[0] == 1: + return base_time + return base_time + 40 # age = 40 - 5 = 35s >= 30s + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep"), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "result.json exists for" in st.detail + assert 22222 in killed + + +# ---------- 3. hard timeout ---------- + +def test_timeout_returns_timeout_and_kills(monitor_mod, tmp_path): + """hard timeout に達したら TIMEOUT で pid を kill する。""" + pr = 802 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("33333") + (pathlib.Path(f"{base}-err.log")).write_text("working...\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + poll_count = [0] + # monotonic を進めて timeout に到達させる + mono_base = time.monotonic() + + def fake_pid_alive(pid): + return True + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 50: + raise RuntimeError("too many polls") + + def fake_monotonic(): + # 1 回目: start, 以降: timeout 超え + if poll_count[0] >= 1: + return mono_base + 500 # > timeout=420 + return mono_base + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.monotonic", side_effect=fake_monotonic), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "TIMEOUT" + assert st.exit_code == 2 + assert "hard timeout" in st.detail + assert 33333 in killed + + +# ---------- 4. early error (FATAL) ---------- + +def test_early_error_fatal_returns_early_error_and_kills(monitor_mod, tmp_path): + """致命エラーパターン検出で EARLY_ERROR、pid を kill する。""" + pr = 803 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("44444") + (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: token expired\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "EARLY_ERROR" + assert st.exit_code == 4 + assert "early error (fatal)" in st.detail + assert 44444 in killed + + +# ---------- 5. stall detection ---------- + +def test_stall_returns_stalled_and_kills(monitor_mod, tmp_path): + """ログ無進捗が stall_timeout に達したら STALLED、pid を kill する。""" + pr = 804 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("55555") + err_log = pathlib.Path(f"{base}-err.log") + err_log.write_text("initial\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + poll_count = [0] + mono_base = time.monotonic() + + def fake_pid_alive(pid): + return True + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 50: + raise RuntimeError("too many polls") + + def fake_monotonic(): + # elapsed < timeout (420), idle >= stall_timeout (60) + # poll_count で進める: idle が stall_timeout を超えるようにする + if poll_count[0] >= 2: + return mono_base + 100 # idle_seconds will exceed stall_timeout=60 + return mono_base + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.monotonic", side_effect=fake_monotonic), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=60, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "STALLED" + assert st.exit_code == 5 + assert "no log progress" in st.detail + assert 55555 in killed + + +# ---------- 6. プロセス正常終了 + result.json あり ---------- + +def test_process_exit_with_result_returns_ok(monitor_mod, tmp_path): + """プロセスが正常に終了し result.json があれば OK。""" + pr = 805 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("66666") + (pathlib.Path(f"{base}-err.log")).write_text("done\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "process exited" in st.detail + + +# ---------- 7. プロセス正常終了 + result.json なし ---------- + +def test_process_exit_without_result_returns_no_result(monitor_mod, tmp_path): + """プロセスが終了したが result.json がなければ NO_RESULT。""" + pr = 806 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("77777") + (pathlib.Path(f"{base}-err.log")).write_text("crashed\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "NO_RESULT" + assert st.exit_code == 3 + assert "result.json missing" in st.detail + + +# ---------- 8. pidfile 不在 ---------- + +def test_no_pidfile_returns_pidfile_bad(monitor_mod, tmp_path): + """pidfile が見つからなければ PIDFILE_BAD。""" + pr = 807 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "PIDFILE_BAD" + assert st.exit_code == 6 + assert "pidfile not found" in st.detail + + +# ---------- 9. cmdline 不一致 ---------- + +def test_cmdline_mismatch_returns_pidfile_bad_and_kills(monitor_mod, tmp_path): + """cmdline に agent 名がなければ PIDFILE_BAD で kill する。""" + pr = 808 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("88888") + (pathlib.Path(f"{base}-err.log")).write_text("") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=False), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "PIDFILE_BAD" + assert st.exit_code == 6 + assert "cmdline does not contain" in st.detail + assert 88888 in killed + + +# ---------- 10. early error WARN は kill しない ---------- + +def test_early_error_warn_does_not_kill(monitor_mod, tmp_path): + """WARN パターンは kill せず、プロセス終了後に OK で返る。""" + pr = 809 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("99999") + (pathlib.Path(f"{base}-err.log")).write_text("Error: something non-fatal\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 1: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert killed == [] # WARN does not kill + + +# ---------- 11. no_early_error フラグが FATAL を抑制する ---------- + +def test_no_early_error_suppresses_fatal_detection(monitor_mod, tmp_path): + """no_early_error=True のとき、FATAL パターンがあっても検知しない。""" + pr = 810 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("10101") + (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: bad token\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 1: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + + +# ---------- 12. claude stdout fatal ---------- + +def test_claude_stdout_fatal_returns_early_error(monitor_mod, tmp_path): + """claude の stdout.log に致命パターンがあれば EARLY_ERROR。""" + pr = 811 + base = tmp_path / f"claude-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("12121") + (pathlib.Path(f"{base}-err.log")).write_text("") + stdout = pathlib.Path(f"{base}-stdout.log") + stdout.write_text(json.dumps({ + "type": "result", "is_error": True, "permission_denials": [], + })) + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="claude", pr=pr, timeout=420, stall_timeout=900, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "EARLY_ERROR" + assert st.exit_code == 4 + assert "stdout.log" in st.detail + assert 12121 in killed diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py new file mode 100644 index 00000000..a0032dc5 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py @@ -0,0 +1,463 @@ +"""monitor_agent の 5 つの終了経路を固定する現状固定テスト。 + +リファクタリング R2-001 で monitor_agent を分割する前に、 +各終了経路の入出力と副作用を振る舞いとして記録する。 + +終了経路: + 1. codex sentinel + result.json → OK (kill + detail に "codex sentinel") + 2. result.json age fallback → OK (kill + detail に "result.json exists for") + 3. hard timeout → TIMEOUT (kill + exit_code=2) + 4. early error (FATAL) → EARLY_ERROR (kill + exit_code=4) + 5. stall detection → STALLED (kill + exit_code=5) + +追加で固定する経路: + 6. プロセス正常終了 + result.json あり → OK (exit_code=0) + 7. プロセス正常終了 + result.json なし → NO_RESULT (exit_code=3) + 8. pidfile 不在 → PIDFILE_BAD (exit_code=6) + 9. cmdline 不一致 → PIDFILE_BAD (kill + exit_code=6) +""" +from __future__ import annotations + +import json +import os +import pathlib +import time +from unittest import mock + + +# ---------- 1. codex sentinel + result.json ---------- + +def test_codex_sentinel_with_result_returns_ok_and_kills(monitor_mod, tmp_path): + """codex sentinel + result.json が揃ったら即 OK で pid を kill する。""" + pr = 800 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("11111") + (pathlib.Path(f"{base}-err.log")).write_text("processing...\ntokens used\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert st.sentinel_seen is True + assert "codex sentinel" in st.detail + assert 11111 in killed + + +# ---------- 2. result.json age fallback ---------- + +def test_result_age_fallback_returns_ok_and_kills(monitor_mod, tmp_path): + """result.json の age >= RESULT_AGE_GRACE で OK、pid を kill する。""" + pr = 801 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("22222") + (pathlib.Path(f"{base}-err.log")).write_text("working...\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + result = pathlib.Path(f"{base}-result.json") + result.write_text(json.dumps({"event": "APPROVE"})) + + base_time = time.time() + result_mtime = base_time + 5 + os.utime(result, (result_mtime, result_mtime)) + + time_calls = [0] + def fake_time(): + time_calls[0] += 1 + if time_calls[0] == 1: + return base_time + return base_time + 40 # age = 40 - 5 = 35s >= 30s + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), + mock.patch("time.sleep"), + mock.patch("time.time", side_effect=fake_time), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "result.json exists for" in st.detail + assert 22222 in killed + + +# ---------- 3. hard timeout ---------- + +def test_timeout_returns_timeout_and_kills(monitor_mod, tmp_path): + """hard timeout に達したら TIMEOUT で pid を kill する。""" + pr = 802 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("33333") + (pathlib.Path(f"{base}-err.log")).write_text("working...\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + poll_count = [0] + # monotonic を進めて timeout に到達させる + mono_base = time.monotonic() + + def fake_pid_alive(pid): + return True + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 50: + raise RuntimeError("too many polls") + + def fake_monotonic(): + # 1 回目: start, 以降: timeout 超え + if poll_count[0] >= 1: + return mono_base + 500 # > timeout=420 + return mono_base + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.monotonic", side_effect=fake_monotonic), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "TIMEOUT" + assert st.exit_code == 2 + assert "hard timeout" in st.detail + assert 33333 in killed + + +# ---------- 4. early error (FATAL) ---------- + +def test_early_error_fatal_returns_early_error_and_kills(monitor_mod, tmp_path): + """致命エラーパターン検出で EARLY_ERROR、pid を kill する。""" + pr = 803 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("44444") + (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: token expired\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "EARLY_ERROR" + assert st.exit_code == 4 + assert "early error (fatal)" in st.detail + assert 44444 in killed + + +# ---------- 5. stall detection ---------- + +def test_stall_returns_stalled_and_kills(monitor_mod, tmp_path): + """ログ無進捗が stall_timeout に達したら STALLED、pid を kill する。""" + pr = 804 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("55555") + err_log = pathlib.Path(f"{base}-err.log") + err_log.write_text("initial\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + poll_count = [0] + mono_base = time.monotonic() + + def fake_pid_alive(pid): + return True + + def fake_sleep(s): + poll_count[0] += 1 + if poll_count[0] > 50: + raise RuntimeError("too many polls") + + def fake_monotonic(): + # elapsed < timeout (420), idle >= stall_timeout (60) + # poll_count で進める: idle が stall_timeout を超えるようにする + if poll_count[0] >= 2: + return mono_base + 100 # idle_seconds will exceed stall_timeout=60 + return mono_base + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + mock.patch("time.monotonic", side_effect=fake_monotonic), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=60, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "STALLED" + assert st.exit_code == 5 + assert "no log progress" in st.detail + assert 55555 in killed + + +# ---------- 6. プロセス正常終了 + result.json あり ---------- + +def test_process_exit_with_result_returns_ok(monitor_mod, tmp_path): + """プロセスが正常に終了し result.json があれば OK。""" + pr = 805 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("66666") + (pathlib.Path(f"{base}-err.log")).write_text("done\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert st.result_exists is True + assert "process exited" in st.detail + + +# ---------- 7. プロセス正常終了 + result.json なし ---------- + +def test_process_exit_without_result_returns_no_result(monitor_mod, tmp_path): + """プロセスが終了したが result.json がなければ NO_RESULT。""" + pr = 806 + base = tmp_path / f"gemini-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("77777") + (pathlib.Path(f"{base}-err.log")).write_text("crashed\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "NO_RESULT" + assert st.exit_code == 3 + assert "result.json missing" in st.detail + + +# ---------- 8. pidfile 不在 ---------- + +def test_no_pidfile_returns_pidfile_bad(monitor_mod, tmp_path): + """pidfile が見つからなければ PIDFILE_BAD。""" + pr = 807 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "PIDFILE_BAD" + assert st.exit_code == 6 + assert "pidfile not found" in st.detail + + +# ---------- 9. cmdline 不一致 ---------- + +def test_cmdline_mismatch_returns_pidfile_bad_and_kills(monitor_mod, tmp_path): + """cmdline に agent 名がなければ PIDFILE_BAD で kill する。""" + pr = 808 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("88888") + (pathlib.Path(f"{base}-err.log")).write_text("") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=False), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "PIDFILE_BAD" + assert st.exit_code == 6 + assert "cmdline does not contain" in st.detail + assert 88888 in killed + + +# ---------- 10. early error WARN は kill しない ---------- + +def test_early_error_warn_does_not_kill(monitor_mod, tmp_path): + """WARN パターンは kill せず、プロセス終了後に OK で返る。""" + pr = 809 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("99999") + (pathlib.Path(f"{base}-err.log")).write_text("Error: something non-fatal\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 1: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + assert killed == [] # WARN does not kill + + +# ---------- 11. no_early_error フラグが FATAL を抑制する ---------- + +def test_no_early_error_suppresses_fatal_detection(monitor_mod, tmp_path): + """no_early_error=True のとき、FATAL パターンがあっても検知しない。""" + pr = 810 + base = tmp_path / f"codex-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("10101") + (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: bad token\n") + (pathlib.Path(f"{base}-stdout.log")).write_text("") + (pathlib.Path(f"{base}-result.json")).write_text( + json.dumps({"event": "APPROVE"}) + ) + + poll_count = [0] + + def fake_pid_alive(pid): + if poll_count[0] >= 1: + return False + return True + + def fake_sleep(s): + poll_count[0] += 1 + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), + mock.patch.object(monitor_mod, "_kill_pid"), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep", side_effect=fake_sleep), + ): + st = monitor_mod.monitor_agent( + agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, + require_result=True, no_early_error=True, + ) + + assert st.status == "OK" + assert st.exit_code == 0 + + +# ---------- 12. claude stdout fatal ---------- + +def test_claude_stdout_fatal_returns_early_error(monitor_mod, tmp_path): + """claude の stdout.log に致命パターンがあれば EARLY_ERROR。""" + pr = 811 + base = tmp_path / f"claude-review-pr{pr}" + + (pathlib.Path(f"{base}.pid")).write_text("12121") + (pathlib.Path(f"{base}-err.log")).write_text("") + stdout = pathlib.Path(f"{base}-stdout.log") + stdout.write_text(json.dumps({ + "type": "result", "is_error": True, "permission_denials": [], + })) + + killed = [] + + with ( + mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), + mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), + mock.patch("time.sleep"), + ): + st = monitor_mod.monitor_agent( + agent="claude", pr=pr, timeout=420, stall_timeout=900, poll=15, + require_result=True, no_early_error=False, + ) + + assert st.status == "EARLY_ERROR" + assert st.exit_code == 4 + assert "stdout.log" in st.detail + assert 12121 in killed From d5cae7c68d2bcfc4c47639d61a1ff97cf01eabee Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:00:57 +0000 Subject: [PATCH 34/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex sentinel + result.json の完了判定を _check_codex_sentinel_complete へ抽出する。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 54 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 54 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 54 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 54 ++++++++++++------- 4 files changed, 136 insertions(+), 80 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index 401a760f..e3278662 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,35 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_codex_sentinel_complete( + pid: int, + paths: AgentPaths, + status: AgentStatus, +) -> Optional[AgentStatus]: + """codex sentinel + result.json が揃っていれば OK を返す。 + + codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + """ + if not ( + paths.agent == "codex" + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return None + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + return status + + def monitor_agent( agent: str, pr: int, @@ -672,26 +701,11 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + if alive: + result = _check_codex_sentinel_complete(pid, paths, status) + if result is not None: + _emit_log(log_prefix, agent, result) + return result # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 401a760f..e3278662 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,35 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_codex_sentinel_complete( + pid: int, + paths: AgentPaths, + status: AgentStatus, +) -> Optional[AgentStatus]: + """codex sentinel + result.json が揃っていれば OK を返す。 + + codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + """ + if not ( + paths.agent == "codex" + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return None + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + return status + + def monitor_agent( agent: str, pr: int, @@ -672,26 +701,11 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + if alive: + result = _check_codex_sentinel_complete(pid, paths, status) + if result is not None: + _emit_log(log_prefix, agent, result) + return result # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 401a760f..e3278662 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,35 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_codex_sentinel_complete( + pid: int, + paths: AgentPaths, + status: AgentStatus, +) -> Optional[AgentStatus]: + """codex sentinel + result.json が揃っていれば OK を返す。 + + codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + """ + if not ( + paths.agent == "codex" + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return None + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + return status + + def monitor_agent( agent: str, pr: int, @@ -672,26 +701,11 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + if alive: + result = _check_codex_sentinel_complete(pid, paths, status) + if result is not None: + _emit_log(log_prefix, agent, result) + return result # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 401a760f..e3278662 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,35 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_codex_sentinel_complete( + pid: int, + paths: AgentPaths, + status: AgentStatus, +) -> Optional[AgentStatus]: + """codex sentinel + result.json が揃っていれば OK を返す。 + + codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + """ + if not ( + paths.agent == "codex" + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return None + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + return status + + def monitor_agent( agent: str, pr: int, @@ -672,26 +701,11 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + if alive: + result = _check_codex_sentinel_complete(pid, paths, status) + if result is not None: + _emit_log(log_prefix, agent, result) + return result # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け From d390be783df9f18e1822c13026066718b288a17e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:02:21 +0000 Subject: [PATCH 35/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit result.json age fallback の完了判定を _check_result_age_fallback へ抽出する。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 75 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 75 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 75 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 75 ++++++++++++------- 4 files changed, 188 insertions(+), 112 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index e3278662..523addce 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,47 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_result_age_fallback( + pid: int, + paths: AgentPaths, + status: AgentStatus, + started_wall: float, + cmdline_validated: bool, +) -> Optional[AgentStatus]: + """result.json の age fallback で完了判定する。 + + sentinel 機構を持たない agent (gemini) 向け。result.json が書かれた後も + プロセスがハングするケース (MCP サーバー切断待ち等で exit しない) の + fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + 完了とみなし、プロセスを kill → OK。 + + 安全条件: + - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + - mtime >= started_wall: 前 round の stale result.json を拾わない + """ + if status.sentinel_seen: + return None + if not cmdline_validated: + return None + if not (paths.result.exists() and paths.result.stat().st_size > 0): + return None + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return None + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return None + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + return status + + def _check_codex_sentinel_complete( pid: int, paths: AgentPaths, @@ -707,34 +748,12 @@ def monitor_agent( _emit_log(log_prefix, agent, result) return result - # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: - # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け - # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - # 完了とみなし、プロセスを kill → OK。 - # 安全条件: - # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + result = _check_result_age_fallback( + pid, paths, status, started_wall, cmdline_validated, + ) + if result is not None: + _emit_log(log_prefix, agent, result) + return result if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index e3278662..523addce 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,47 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_result_age_fallback( + pid: int, + paths: AgentPaths, + status: AgentStatus, + started_wall: float, + cmdline_validated: bool, +) -> Optional[AgentStatus]: + """result.json の age fallback で完了判定する。 + + sentinel 機構を持たない agent (gemini) 向け。result.json が書かれた後も + プロセスがハングするケース (MCP サーバー切断待ち等で exit しない) の + fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + 完了とみなし、プロセスを kill → OK。 + + 安全条件: + - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + - mtime >= started_wall: 前 round の stale result.json を拾わない + """ + if status.sentinel_seen: + return None + if not cmdline_validated: + return None + if not (paths.result.exists() and paths.result.stat().st_size > 0): + return None + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return None + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return None + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + return status + + def _check_codex_sentinel_complete( pid: int, paths: AgentPaths, @@ -707,34 +748,12 @@ def monitor_agent( _emit_log(log_prefix, agent, result) return result - # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: - # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け - # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - # 完了とみなし、プロセスを kill → OK。 - # 安全条件: - # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + result = _check_result_age_fallback( + pid, paths, status, started_wall, cmdline_validated, + ) + if result is not None: + _emit_log(log_prefix, agent, result) + return result if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index e3278662..523addce 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,47 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_result_age_fallback( + pid: int, + paths: AgentPaths, + status: AgentStatus, + started_wall: float, + cmdline_validated: bool, +) -> Optional[AgentStatus]: + """result.json の age fallback で完了判定する。 + + sentinel 機構を持たない agent (gemini) 向け。result.json が書かれた後も + プロセスがハングするケース (MCP サーバー切断待ち等で exit しない) の + fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + 完了とみなし、プロセスを kill → OK。 + + 安全条件: + - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + - mtime >= started_wall: 前 round の stale result.json を拾わない + """ + if status.sentinel_seen: + return None + if not cmdline_validated: + return None + if not (paths.result.exists() and paths.result.stat().st_size > 0): + return None + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return None + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return None + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + return status + + def _check_codex_sentinel_complete( pid: int, paths: AgentPaths, @@ -707,34 +748,12 @@ def monitor_agent( _emit_log(log_prefix, agent, result) return result - # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: - # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け - # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - # 完了とみなし、プロセスを kill → OK。 - # 安全条件: - # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + result = _check_result_age_fallback( + pid, paths, status, started_wall, cmdline_validated, + ) + if result is not None: + _emit_log(log_prefix, agent, result) + return result if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index e3278662..523addce 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,47 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_result_age_fallback( + pid: int, + paths: AgentPaths, + status: AgentStatus, + started_wall: float, + cmdline_validated: bool, +) -> Optional[AgentStatus]: + """result.json の age fallback で完了判定する。 + + sentinel 機構を持たない agent (gemini) 向け。result.json が書かれた後も + プロセスがハングするケース (MCP サーバー切断待ち等で exit しない) の + fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + 完了とみなし、プロセスを kill → OK。 + + 安全条件: + - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + - mtime >= started_wall: 前 round の stale result.json を拾わない + """ + if status.sentinel_seen: + return None + if not cmdline_validated: + return None + if not (paths.result.exists() and paths.result.stat().st_size > 0): + return None + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return None + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return None + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + return status + + def _check_codex_sentinel_complete( pid: int, paths: AgentPaths, @@ -707,34 +748,12 @@ def monitor_agent( _emit_log(log_prefix, agent, result) return result - # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: - # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け - # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - # 完了とみなし、プロセスを kill → OK。 - # 安全条件: - # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + result = _check_result_age_fallback( + pid, paths, status, started_wall, cmdline_validated, + ) + if result is not None: + _emit_log(log_prefix, agent, result) + return result if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと From 674086e0df3e67d8852899bfdbcd145c5484c439 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:03:51 +0000 Subject: [PATCH 36/57] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 早期エラー(FATAL)の検出と kill を _check_early_error へ抽出する。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 55 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 55 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 55 ++++++++++++------- .../cross-review/scripts/lib/monitor.py | 55 ++++++++++++------- 4 files changed, 136 insertions(+), 84 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index 523addce..8a11a13e 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,36 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_early_error( + pid: int, + paths: AgentPaths, + status: AgentStatus, + agent: str, + alive: bool, +) -> Optional[AgentStatus]: + """err.log (claude は stdout.log も) に致命パターンがあれば EARLY_ERROR を返す。 + + 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + この関数では扱わない(呼び出し側で WARN として警告ログのみ出す)。 + """ + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if not fatal_err: + return None + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + return status + + def _check_result_age_fallback( pid: int, paths: AgentPaths, @@ -780,28 +810,11 @@ def monitor_agent( return status # 3. early error - # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を - # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status + result = _check_early_error(pid, paths, status, agent, alive) + if result is not None: + _emit_log(log_prefix, agent, result) + return result if not warned_early_error: warn_err = _scan_early_warn(paths.err_log) diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 523addce..8a11a13e 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,36 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_early_error( + pid: int, + paths: AgentPaths, + status: AgentStatus, + agent: str, + alive: bool, +) -> Optional[AgentStatus]: + """err.log (claude は stdout.log も) に致命パターンがあれば EARLY_ERROR を返す。 + + 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + この関数では扱わない(呼び出し側で WARN として警告ログのみ出す)。 + """ + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if not fatal_err: + return None + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + return status + + def _check_result_age_fallback( pid: int, paths: AgentPaths, @@ -780,28 +810,11 @@ def monitor_agent( return status # 3. early error - # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を - # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status + result = _check_early_error(pid, paths, status, agent, alive) + if result is not None: + _emit_log(log_prefix, agent, result) + return result if not warned_early_error: warn_err = _scan_early_warn(paths.err_log) diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 523addce..8a11a13e 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,36 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_early_error( + pid: int, + paths: AgentPaths, + status: AgentStatus, + agent: str, + alive: bool, +) -> Optional[AgentStatus]: + """err.log (claude は stdout.log も) に致命パターンがあれば EARLY_ERROR を返す。 + + 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + この関数では扱わない(呼び出し側で WARN として警告ログのみ出す)。 + """ + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if not fatal_err: + return None + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + return status + + def _check_result_age_fallback( pid: int, paths: AgentPaths, @@ -780,28 +810,11 @@ def monitor_agent( return status # 3. early error - # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を - # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status + result = _check_early_error(pid, paths, status, agent, alive) + if result is not None: + _emit_log(log_prefix, agent, result) + return result if not warned_early_error: warn_err = _scan_early_warn(paths.err_log) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 523addce..8a11a13e 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,36 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_early_error( + pid: int, + paths: AgentPaths, + status: AgentStatus, + agent: str, + alive: bool, +) -> Optional[AgentStatus]: + """err.log (claude は stdout.log も) に致命パターンがあれば EARLY_ERROR を返す。 + + 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + この関数では扱わない(呼び出し側で WARN として警告ログのみ出す)。 + """ + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if not fatal_err: + return None + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + return status + + def _check_result_age_fallback( pid: int, paths: AgentPaths, @@ -780,28 +810,11 @@ def monitor_agent( return status # 3. early error - # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を - # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status + result = _check_early_error(pid, paths, status, agent, alive) + if result is not None: + _emit_log(log_prefix, agent, result) + return result if not warned_early_error: warn_err = _scan_early_warn(paths.err_log) From 9aaab34b1955307837a6ad49b4f27386d0bb0764 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:06:28 +0000 Subject: [PATCH 37/57] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正結果の読込と merge_key による再実行判定を入口段 _merge_fix_load_and_check_idempotent へ抽出する。 Item-Id: R2-002 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 52 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 52 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 52 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 52 +++++++++++-------- 4 files changed, 124 insertions(+), 84 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index f4093f23..c0016e43 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,33 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_load_and_check_idempotent( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + result: pathlib.Path, + payload: dict[str, Any], +) -> Optional[str]: + """修正結果の読込と merge_key による再実行判定。 + + 取り込み済みなら merge_key を返す(呼び出し側は return する)。 + 新規なら None を返す。 + """ + attempt = entry.get("fix_attempts", 0) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) + merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return merge_key + return None + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1717,21 +1744,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 - # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 - # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 - # 鍵は**試行番号と結果ファイルの内容**から作る。 - # - # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず - # 同じ申告を再処理してしまう。 - # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ - # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま - # 同じ修正を起動し続ける。 - # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に - # なりうる。 - # - # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が - # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 + # 冪等性チェック + if _merge_fix_load_and_check_idempotent(path, state, entry, result, payload) is not None: + return + work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) @@ -1740,12 +1756,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: + hashlib.sha256(result.read_bytes()).hexdigest() ) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) - return # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index f4093f23..c0016e43 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,33 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_load_and_check_idempotent( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + result: pathlib.Path, + payload: dict[str, Any], +) -> Optional[str]: + """修正結果の読込と merge_key による再実行判定。 + + 取り込み済みなら merge_key を返す(呼び出し側は return する)。 + 新規なら None を返す。 + """ + attempt = entry.get("fix_attempts", 0) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) + merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return merge_key + return None + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1717,21 +1744,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 - # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 - # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 - # 鍵は**試行番号と結果ファイルの内容**から作る。 - # - # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず - # 同じ申告を再処理してしまう。 - # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ - # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま - # 同じ修正を起動し続ける。 - # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に - # なりうる。 - # - # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が - # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 + # 冪等性チェック + if _merge_fix_load_and_check_idempotent(path, state, entry, result, payload) is not None: + return + work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) @@ -1740,12 +1756,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: + hashlib.sha256(result.read_bytes()).hexdigest() ) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) - return # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index f4093f23..c0016e43 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,33 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_load_and_check_idempotent( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + result: pathlib.Path, + payload: dict[str, Any], +) -> Optional[str]: + """修正結果の読込と merge_key による再実行判定。 + + 取り込み済みなら merge_key を返す(呼び出し側は return する)。 + 新規なら None を返す。 + """ + attempt = entry.get("fix_attempts", 0) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) + merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return merge_key + return None + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1717,21 +1744,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 - # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 - # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 - # 鍵は**試行番号と結果ファイルの内容**から作る。 - # - # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず - # 同じ申告を再処理してしまう。 - # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ - # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま - # 同じ修正を起動し続ける。 - # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に - # なりうる。 - # - # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が - # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 + # 冪等性チェック + if _merge_fix_load_and_check_idempotent(path, state, entry, result, payload) is not None: + return + work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) @@ -1740,12 +1756,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: + hashlib.sha256(result.read_bytes()).hexdigest() ) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) - return # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f4093f23..c0016e43 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,33 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_load_and_check_idempotent( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + result: pathlib.Path, + payload: dict[str, Any], +) -> Optional[str]: + """修正結果の読込と merge_key による再実行判定。 + + 取り込み済みなら merge_key を返す(呼び出し側は return する)。 + 新規なら None を返す。 + """ + attempt = entry.get("fix_attempts", 0) + merge_key = ( + f"{attempt}:" + + hashlib.sha256(result.read_bytes()).hexdigest() + ) + merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return merge_key + return None + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1717,21 +1744,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 - # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 - # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 - # 鍵は**試行番号と結果ファイルの内容**から作る。 - # - # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず - # 同じ申告を再処理してしまう。 - # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ - # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま - # 同じ修正を起動し続ける。 - # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に - # なりうる。 - # - # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が - # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 + # 冪等性チェック + if _merge_fix_load_and_check_idempotent(path, state, entry, result, payload) is not None: + return + work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) @@ -1740,12 +1756,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: + hashlib.sha256(result.read_bytes()).hexdigest() ) merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) - return # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の From c93228233d50beabdc01229cbdd9e9d5688c6770 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:07:54 +0000 Subject: [PATCH 38/57] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolved_thread_ids の正規化と GitHub 実状態との照合を _merge_fix_resolve_threads へ抽出する。 Item-Id: R2-002 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 56 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 56 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 56 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 56 +++++++++++-------- 4 files changed, 136 insertions(+), 88 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index c0016e43..cb321cfa 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,38 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_resolve_threads( + payload: dict[str, Any], + repo: str, + pr: int, +) -> set[str]: + """resolved_thread_ids の正規化と GitHub 実状態との照合。 + + 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を返す。 + """ + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(repo, pr) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + def _merge_fix_load_and_check_idempotent( path: pathlib.Path, state: dict[str, Any], @@ -1757,28 +1789,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) merged_keys = entry.setdefault("fix_merged_keys", []) - # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + # スレッド解決の照合 + resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index c0016e43..cb321cfa 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,38 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_resolve_threads( + payload: dict[str, Any], + repo: str, + pr: int, +) -> set[str]: + """resolved_thread_ids の正規化と GitHub 実状態との照合。 + + 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を返す。 + """ + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(repo, pr) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + def _merge_fix_load_and_check_idempotent( path: pathlib.Path, state: dict[str, Any], @@ -1757,28 +1789,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) merged_keys = entry.setdefault("fix_merged_keys", []) - # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + # スレッド解決の照合 + resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index c0016e43..cb321cfa 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,38 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_resolve_threads( + payload: dict[str, Any], + repo: str, + pr: int, +) -> set[str]: + """resolved_thread_ids の正規化と GitHub 実状態との照合。 + + 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を返す。 + """ + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(repo, pr) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + def _merge_fix_load_and_check_idempotent( path: pathlib.Path, state: dict[str, Any], @@ -1757,28 +1789,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) merged_keys = entry.setdefault("fix_merged_keys", []) - # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + # スレッド解決の照合 + resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index c0016e43..cb321cfa 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,38 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_resolve_threads( + payload: dict[str, Any], + repo: str, + pr: int, +) -> set[str]: + """resolved_thread_ids の正規化と GitHub 実状態との照合。 + + 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を返す。 + """ + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(repo, pr) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + def _merge_fix_load_and_check_idempotent( path: pathlib.Path, state: dict[str, Any], @@ -1757,28 +1789,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) merged_keys = entry.setdefault("fix_merged_keys", []) - # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + # スレッド解決の照合 + resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From 7f531cc006a76f04c48a4c22a4daa4eee7a03733 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:09:34 +0000 Subject: [PATCH 39/57] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正コミット範囲の確定・検証・不正時の取り消しを _merge_fix_verify_and_reconcile へ抽出する。 Item-Id: R2-002 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 106 +++++++++--------- .../cross-refactoring/scripts/refactor.py | 106 +++++++++--------- .../cross-refactoring/scripts/refactor.py | 106 +++++++++--------- .../cross-refactoring/scripts/refactor.py | 106 +++++++++--------- 4 files changed, 224 insertions(+), 200 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index cb321cfa..d1287cbd 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,57 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_verify_and_reconcile( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + head_now: str, + payload: dict[str, Any], +) -> tuple[bool, set[str]]: + """修正コミット範囲の確定・検証・不正時の取り消し。 + + Returns: + (needs_push, resolved): needs_push が True なら呼び出し側が push する。 + resolved が渡された resolved_threads を返す(失敗時は空集合にリセット)。 + """ + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) + return True, set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + return False, set() # sentinel — resolved is not reset here + + def _merge_fix_resolve_threads( payload: dict[str, Any], repo: str, @@ -1792,59 +1843,14 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) - # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 - # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} - # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は - # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** - # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける - # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 - # 適用フェーズの未割当コミットと同じ扱いにする。 - needs_push = False - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + # 修正コミットの検証と取り消し + needs_push, reset_resolved = _merge_fix_verify_and_reconcile( + path, state, entry, work, head_now, payload, ) - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - needs_push = True + if reset_resolved is not None and needs_push: + # 検証失敗: 解決の申告は採用しない info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index cb321cfa..d1287cbd 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,57 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_verify_and_reconcile( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + head_now: str, + payload: dict[str, Any], +) -> tuple[bool, set[str]]: + """修正コミット範囲の確定・検証・不正時の取り消し。 + + Returns: + (needs_push, resolved): needs_push が True なら呼び出し側が push する。 + resolved が渡された resolved_threads を返す(失敗時は空集合にリセット)。 + """ + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) + return True, set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + return False, set() # sentinel — resolved is not reset here + + def _merge_fix_resolve_threads( payload: dict[str, Any], repo: str, @@ -1792,59 +1843,14 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) - # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 - # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} - # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は - # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** - # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける - # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 - # 適用フェーズの未割当コミットと同じ扱いにする。 - needs_push = False - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + # 修正コミットの検証と取り消し + needs_push, reset_resolved = _merge_fix_verify_and_reconcile( + path, state, entry, work, head_now, payload, ) - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - needs_push = True + if reset_resolved is not None and needs_push: + # 検証失敗: 解決の申告は採用しない info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index cb321cfa..d1287cbd 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,57 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_verify_and_reconcile( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + head_now: str, + payload: dict[str, Any], +) -> tuple[bool, set[str]]: + """修正コミット範囲の確定・検証・不正時の取り消し。 + + Returns: + (needs_push, resolved): needs_push が True なら呼び出し側が push する。 + resolved が渡された resolved_threads を返す(失敗時は空集合にリセット)。 + """ + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) + return True, set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + return False, set() # sentinel — resolved is not reset here + + def _merge_fix_resolve_threads( payload: dict[str, Any], repo: str, @@ -1792,59 +1843,14 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) - # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 - # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} - # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は - # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** - # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける - # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 - # 適用フェーズの未割当コミットと同じ扱いにする。 - needs_push = False - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + # 修正コミットの検証と取り消し + needs_push, reset_resolved = _merge_fix_verify_and_reconcile( + path, state, entry, work, head_now, payload, ) - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - needs_push = True + if reset_resolved is not None and needs_push: + # 検証失敗: 解決の申告は採用しない info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index cb321cfa..d1287cbd 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,57 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_verify_and_reconcile( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + head_now: str, + payload: dict[str, Any], +) -> tuple[bool, set[str]]: + """修正コミット範囲の確定・検証・不正時の取り消し。 + + Returns: + (needs_push, resolved): needs_push が True なら呼び出し側が push する。 + resolved が渡された resolved_threads を返す(失敗時は空集合にリセット)。 + """ + baseline = state.get("baseline_test") or {} + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) + return True, set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + return False, set() # sentinel — resolved is not reset here + + def _merge_fix_resolve_threads( payload: dict[str, Any], repo: str, @@ -1792,59 +1843,14 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) - # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 - # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 - baseline = state.get("baseline_test") or {} - # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は - # `judge-review` が変更要求を返したときの HEAD である。 - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** - # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける - # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 - # 適用フェーズの未割当コミットと同じ扱いにする。 - needs_push = False - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + # 修正コミットの検証と取り消し + needs_push, reset_resolved = _merge_fix_verify_and_reconcile( + path, state, entry, work, head_now, payload, ) - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - needs_push = True + if reset_resolved is not None and needs_push: + # 検証失敗: 解決の申告は採用しない info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: From ec9caf548fde5b701d667d8073ba113d5277f2ff Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:11:09 +0000 Subject: [PATCH 40/57] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー解決状態の反映・ラウンド計数・保存・push を _merge_fix_finalize へ抽出する。 Item-Id: R2-002 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 50 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 50 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 50 ++++++++++++------- .../cross-refactoring/scripts/refactor.py | 50 ++++++++++++------- 4 files changed, 128 insertions(+), 72 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index d1287cbd..321d0158 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,36 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_finalize( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + resolved: set[str], + merge_key: str, + payload: dict[str, Any], + needs_push: bool, +) -> None: + """レビュー解決状態の反映・ラウンド計数・保存・push。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + merged_keys = entry.setdefault("fix_merged_keys", []) + merged_keys.append(merge_key) + entry["fix_rounds"] += 1 + + entry.setdefault("durations", {})["fix"] = ( + entry.get("durations", {}).get("fix", 0) + + _safe_int(payload.get("elapsed_seconds")) + ) + statefile.save(path, state) + if needs_push: + _push_with_retry_marker(path, state, entry) + info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") + + def _merge_fix_verify_and_reconcile( path: pathlib.Path, state: dict[str, Any], @@ -1838,7 +1868,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() ) - merged_keys = entry.setdefault("fix_merged_keys", []) # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) @@ -1852,23 +1881,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 - - entry.setdefault("durations", {})["fix"] = ( - entry.get("durations", {}).get("fix", 0) - + _safe_int(payload.get("elapsed_seconds")) - ) - statefile.save(path, state) - if needs_push: - _push_with_retry_marker(path, state, entry) - info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") + # 状態保存と push + _merge_fix_finalize(path, state, entry, resolved, merge_key, payload, needs_push) def cmd_advance(args: argparse.Namespace) -> None: diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index d1287cbd..321d0158 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,36 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_finalize( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + resolved: set[str], + merge_key: str, + payload: dict[str, Any], + needs_push: bool, +) -> None: + """レビュー解決状態の反映・ラウンド計数・保存・push。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + merged_keys = entry.setdefault("fix_merged_keys", []) + merged_keys.append(merge_key) + entry["fix_rounds"] += 1 + + entry.setdefault("durations", {})["fix"] = ( + entry.get("durations", {}).get("fix", 0) + + _safe_int(payload.get("elapsed_seconds")) + ) + statefile.save(path, state) + if needs_push: + _push_with_retry_marker(path, state, entry) + info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") + + def _merge_fix_verify_and_reconcile( path: pathlib.Path, state: dict[str, Any], @@ -1838,7 +1868,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() ) - merged_keys = entry.setdefault("fix_merged_keys", []) # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) @@ -1852,23 +1881,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 - - entry.setdefault("durations", {})["fix"] = ( - entry.get("durations", {}).get("fix", 0) - + _safe_int(payload.get("elapsed_seconds")) - ) - statefile.save(path, state) - if needs_push: - _push_with_retry_marker(path, state, entry) - info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") + # 状態保存と push + _merge_fix_finalize(path, state, entry, resolved, merge_key, payload, needs_push) def cmd_advance(args: argparse.Namespace) -> None: diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index d1287cbd..321d0158 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,36 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_finalize( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + resolved: set[str], + merge_key: str, + payload: dict[str, Any], + needs_push: bool, +) -> None: + """レビュー解決状態の反映・ラウンド計数・保存・push。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + merged_keys = entry.setdefault("fix_merged_keys", []) + merged_keys.append(merge_key) + entry["fix_rounds"] += 1 + + entry.setdefault("durations", {})["fix"] = ( + entry.get("durations", {}).get("fix", 0) + + _safe_int(payload.get("elapsed_seconds")) + ) + statefile.save(path, state) + if needs_push: + _push_with_retry_marker(path, state, entry) + info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") + + def _merge_fix_verify_and_reconcile( path: pathlib.Path, state: dict[str, Any], @@ -1838,7 +1868,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() ) - merged_keys = entry.setdefault("fix_merged_keys", []) # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) @@ -1852,23 +1881,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 - - entry.setdefault("durations", {})["fix"] = ( - entry.get("durations", {}).get("fix", 0) - + _safe_int(payload.get("elapsed_seconds")) - ) - statefile.save(path, state) - if needs_push: - _push_with_retry_marker(path, state, entry) - info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") + # 状態保存と push + _merge_fix_finalize(path, state, entry, resolved, merge_key, payload, needs_push) def cmd_advance(args: argparse.Namespace) -> None: diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index d1287cbd..321d0158 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1708,6 +1708,36 @@ def _validate_fix_commits( return accepted, unassigned, problems +def _merge_fix_finalize( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + resolved: set[str], + merge_key: str, + payload: dict[str, Any], + needs_push: bool, +) -> None: + """レビュー解決状態の反映・ラウンド計数・保存・push。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + merged_keys = entry.setdefault("fix_merged_keys", []) + merged_keys.append(merge_key) + entry["fix_rounds"] += 1 + + entry.setdefault("durations", {})["fix"] = ( + entry.get("durations", {}).get("fix", 0) + + _safe_int(payload.get("elapsed_seconds")) + ) + statefile.save(path, state) + if needs_push: + _push_with_retry_marker(path, state, entry) + info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") + + def _merge_fix_verify_and_reconcile( path: pathlib.Path, state: dict[str, Any], @@ -1838,7 +1868,6 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() ) - merged_keys = entry.setdefault("fix_merged_keys", []) # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) @@ -1852,23 +1881,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 - - entry.setdefault("durations", {})["fix"] = ( - entry.get("durations", {}).get("fix", 0) - + _safe_int(payload.get("elapsed_seconds")) - ) - statefile.save(path, state) - if needs_push: - _push_with_retry_marker(path, state, entry) - info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") + # 状態保存と push + _merge_fix_finalize(path, state, entry, resolved, merge_key, payload, needs_push) def cmd_advance(args: argparse.Namespace) -> None: From 6ffb03849b68ceb96cf43ad0bf9705262ae3e229 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:12:58 +0000 Subject: [PATCH 41/57] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コミット所有権の検証と不正時のラウンド取り消しを _merge_apply_validate_ownership へ抽出する。 Item-Id: R2-003 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 142 ++++++++++-------- .../cross-refactoring/scripts/refactor.py | 142 ++++++++++-------- .../cross-refactoring/scripts/refactor.py | 142 ++++++++++-------- .../cross-refactoring/scripts/refactor.py | 142 ++++++++++-------- 4 files changed, 308 insertions(+), 260 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 321d0158..66b4a191 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1203,6 +1203,77 @@ def _verify_and_record_items( return applied, failed +def _merge_apply_validate_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + payload: dict[str, Any], + head_sha: str, + dry_run: bool, +) -> Optional[tuple[dict[str, list[str]], dict[str, str], set[str]]]: + """所有権検証。不正時はラウンドを取り消して sys.exit(2) する。 + + Returns: + 正常時は (reported, owner_of, in_range) のタプル。 + 不正時はこの関数内で sys.exit(2) し、返らない。 + """ + round_items = set(entry["items"]) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) + if not (unassigned or unknown_ids or duplicated): + return reported, owner_of, set(ordered_range) + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", dry_run, + ) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1273,72 +1344,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: code=2, ) - # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた - # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 - # そのまま Pull Request に残せてしまう。 - round_items = set(entry["items"]) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) + # 申告は**このラウンドの改善項目のものだけ**を採る。 + ownership_result = _merge_apply_validate_ownership( + path, state, entry, work, ordered_range, payload, head_sha, args.dry_run, ) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, - ) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + # 不正時は _merge_apply_validate_ownership 内で sys.exit(2) するので、 + # ここに到達した時点で正常。 + reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 321d0158..66b4a191 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1203,6 +1203,77 @@ def _verify_and_record_items( return applied, failed +def _merge_apply_validate_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + payload: dict[str, Any], + head_sha: str, + dry_run: bool, +) -> Optional[tuple[dict[str, list[str]], dict[str, str], set[str]]]: + """所有権検証。不正時はラウンドを取り消して sys.exit(2) する。 + + Returns: + 正常時は (reported, owner_of, in_range) のタプル。 + 不正時はこの関数内で sys.exit(2) し、返らない。 + """ + round_items = set(entry["items"]) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) + if not (unassigned or unknown_ids or duplicated): + return reported, owner_of, set(ordered_range) + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", dry_run, + ) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1273,72 +1344,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: code=2, ) - # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた - # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 - # そのまま Pull Request に残せてしまう。 - round_items = set(entry["items"]) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) + # 申告は**このラウンドの改善項目のものだけ**を採る。 + ownership_result = _merge_apply_validate_ownership( + path, state, entry, work, ordered_range, payload, head_sha, args.dry_run, ) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, - ) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + # 不正時は _merge_apply_validate_ownership 内で sys.exit(2) するので、 + # ここに到達した時点で正常。 + reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 321d0158..66b4a191 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1203,6 +1203,77 @@ def _verify_and_record_items( return applied, failed +def _merge_apply_validate_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + payload: dict[str, Any], + head_sha: str, + dry_run: bool, +) -> Optional[tuple[dict[str, list[str]], dict[str, str], set[str]]]: + """所有権検証。不正時はラウンドを取り消して sys.exit(2) する。 + + Returns: + 正常時は (reported, owner_of, in_range) のタプル。 + 不正時はこの関数内で sys.exit(2) し、返らない。 + """ + round_items = set(entry["items"]) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) + if not (unassigned or unknown_ids or duplicated): + return reported, owner_of, set(ordered_range) + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", dry_run, + ) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1273,72 +1344,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: code=2, ) - # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた - # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 - # そのまま Pull Request に残せてしまう。 - round_items = set(entry["items"]) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) + # 申告は**このラウンドの改善項目のものだけ**を採る。 + ownership_result = _merge_apply_validate_ownership( + path, state, entry, work, ordered_range, payload, head_sha, args.dry_run, ) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, - ) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + # 不正時は _merge_apply_validate_ownership 内で sys.exit(2) するので、 + # ここに到達した時点で正常。 + reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 321d0158..66b4a191 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1203,6 +1203,77 @@ def _verify_and_record_items( return applied, failed +def _merge_apply_validate_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + work: str, + ordered_range: list[str], + payload: dict[str, Any], + head_sha: str, + dry_run: bool, +) -> Optional[tuple[dict[str, list[str]], dict[str, str], set[str]]]: + """所有権検証。不正時はラウンドを取り消して sys.exit(2) する。 + + Returns: + 正常時は (reported, owner_of, in_range) のタプル。 + 不正時はこの関数内で sys.exit(2) し、返らない。 + """ + round_items = set(entry["items"]) + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) + ) + if not (unassigned or unknown_ids or duplicated): + return reported, owner_of, set(ordered_range) + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", dry_run, + ) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1273,72 +1344,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: code=2, ) - # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた - # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 - # そのまま Pull Request に残せてしまう。 - round_items = set(entry["items"]) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) + # 申告は**このラウンドの改善項目のものだけ**を採る。 + ownership_result = _merge_apply_validate_ownership( + path, state, entry, work, ordered_range, payload, head_sha, args.dry_run, ) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, - ) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + # 不正時は _merge_apply_validate_ownership 内で sys.exit(2) するので、 + # ここに到達した時点で正常。 + reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した From ef60c8737457601b57b96f49a50c7328b8522091 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:14:33 +0000 Subject: [PATCH 42/57] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply 状態の構築・dry-run/部分失敗/全成功の分岐・終了処理を _merge_apply_build_state_and_finalize へ抽出する。 Item-Id: R2-003 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 77 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 77 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 77 +++++++++++-------- .../cross-refactoring/scripts/refactor.py | 77 +++++++++++-------- 4 files changed, 180 insertions(+), 128 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 66b4a191..2b7411f1 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1203,6 +1203,48 @@ def _verify_and_record_items( return applied, failed +def _merge_apply_build_state_and_finalize( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + applied: list[str], + failed: list[str], + head_sha: str, + payload: dict[str, Any], + dry_run: bool, +) -> None: + """apply 状態の構築と、失敗項目の取り消し・保存。 + + dry-run / 部分失敗 / 全成功で分岐し、適切に終了する。 + """ + entry["apply"] = { + "applied": applied, + "failed": failed, + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") + ) + state["phase"] = "review" if applied else "propose" + + if dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(entry["apply"]["applied"]) + elif failed: + applied = _apply_drop(path, state, entry, failed) + else: + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) + + def _merge_apply_validate_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1362,39 +1404,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) - entry["apply"] = { - "applied": applied, - "failed": failed, - # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに - # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 - # 検証を通っていない変更が Pull Request に残り続ける。 - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") + # 状態構築と失敗項目の取り消し + _merge_apply_build_state_and_finalize( + path, state, entry, applied, failed, head_sha, payload, args.dry_run, ) - state["phase"] = "review" if applied else "propose" - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - _drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(entry["apply"]["applied"]) - elif failed: - # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 - applied = _apply_drop(path, state, entry, failed) - else: - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) def _run_drop( diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 66b4a191..2b7411f1 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1203,6 +1203,48 @@ def _verify_and_record_items( return applied, failed +def _merge_apply_build_state_and_finalize( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + applied: list[str], + failed: list[str], + head_sha: str, + payload: dict[str, Any], + dry_run: bool, +) -> None: + """apply 状態の構築と、失敗項目の取り消し・保存。 + + dry-run / 部分失敗 / 全成功で分岐し、適切に終了する。 + """ + entry["apply"] = { + "applied": applied, + "failed": failed, + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") + ) + state["phase"] = "review" if applied else "propose" + + if dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(entry["apply"]["applied"]) + elif failed: + applied = _apply_drop(path, state, entry, failed) + else: + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) + + def _merge_apply_validate_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1362,39 +1404,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) - entry["apply"] = { - "applied": applied, - "failed": failed, - # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに - # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 - # 検証を通っていない変更が Pull Request に残り続ける。 - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") + # 状態構築と失敗項目の取り消し + _merge_apply_build_state_and_finalize( + path, state, entry, applied, failed, head_sha, payload, args.dry_run, ) - state["phase"] = "review" if applied else "propose" - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - _drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(entry["apply"]["applied"]) - elif failed: - # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 - applied = _apply_drop(path, state, entry, failed) - else: - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) def _run_drop( diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 66b4a191..2b7411f1 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1203,6 +1203,48 @@ def _verify_and_record_items( return applied, failed +def _merge_apply_build_state_and_finalize( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + applied: list[str], + failed: list[str], + head_sha: str, + payload: dict[str, Any], + dry_run: bool, +) -> None: + """apply 状態の構築と、失敗項目の取り消し・保存。 + + dry-run / 部分失敗 / 全成功で分岐し、適切に終了する。 + """ + entry["apply"] = { + "applied": applied, + "failed": failed, + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") + ) + state["phase"] = "review" if applied else "propose" + + if dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(entry["apply"]["applied"]) + elif failed: + applied = _apply_drop(path, state, entry, failed) + else: + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) + + def _merge_apply_validate_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1362,39 +1404,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) - entry["apply"] = { - "applied": applied, - "failed": failed, - # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに - # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 - # 検証を通っていない変更が Pull Request に残り続ける。 - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") + # 状態構築と失敗項目の取り消し + _merge_apply_build_state_and_finalize( + path, state, entry, applied, failed, head_sha, payload, args.dry_run, ) - state["phase"] = "review" if applied else "propose" - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - _drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(entry["apply"]["applied"]) - elif failed: - # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 - applied = _apply_drop(path, state, entry, failed) - else: - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) def _run_drop( diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 66b4a191..2b7411f1 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1203,6 +1203,48 @@ def _verify_and_record_items( return applied, failed +def _merge_apply_build_state_and_finalize( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + applied: list[str], + failed: list[str], + head_sha: str, + payload: dict[str, Any], + dry_run: bool, +) -> None: + """apply 状態の構築と、失敗項目の取り消し・保存。 + + dry-run / 部分失敗 / 全成功で分岐し、適切に終了する。 + """ + entry["apply"] = { + "applied": applied, + "failed": failed, + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") + ) + state["phase"] = "review" if applied else "propose" + + if dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(entry["apply"]["applied"]) + elif failed: + applied = _apply_drop(path, state, entry, failed) + else: + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) + + def _merge_apply_validate_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1362,39 +1404,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) - entry["apply"] = { - "applied": applied, - "failed": failed, - # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに - # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 - # 検証を通っていない変更が Pull Request に残り続ける。 - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") + # 状態構築と失敗項目の取り消し + _merge_apply_build_state_and_finalize( + path, state, entry, applied, failed, head_sha, payload, args.dry_run, ) - state["phase"] = "review" if applied else "propose" - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - _drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(entry["apply"]["applied"]) - elif failed: - # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 - applied = _apply_drop(path, state, entry, failed) - else: - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) def _run_drop( From f9247dac85f6305c73ea4feb7620e81e478914e5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:17:47 +0000 Subject: [PATCH 43/57] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20refactor.py#verify=5Fapply=5Fitem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_fix_commit と verify_apply_item の per-commit 検証(exists, trailers, scope, test_status)を _verify_single_commit へ共通化する。 Item-Id: R2-004 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 46 +++++++++---------- .../cross-refactoring/scripts/refactor.py | 46 +++++++++---------- .../cross-refactoring/scripts/refactor.py | 46 +++++++++---------- .../cross-refactoring/scripts/refactor.py | 46 +++++++++---------- 4 files changed, 88 insertions(+), 96 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 2b7411f1..13d1f695 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -486,20 +486,23 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_single_commit( + commit: dict[str, Any], scope: Iterable[str], ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """1 コミットの存在・トレーラー・スコープ・テスト結果を検証する。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 問題があれば理由を返す。問題なければ None。 + verify_fix_commit と verify_apply_item の per-commit 検証を共通化する。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return ( + f"コミット {commit.get('sha', '?')} が対象の範囲にありません" + "(申告だけで実体がありません)" + ) problem = verify_commit_trailers(commit) if problem: return problem - problem = verify_scope(commit, scope or []) + problem = verify_scope(commit, scope) if problem: return problem if commit.get("test_status") != "pass": @@ -510,6 +513,17 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_single_commit(commit, scope or []) + + def verify_apply_item( item: dict[str, Any], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -524,22 +538,9 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_single_commit(commit, scope or []) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -549,9 +550,6 @@ def verify_apply_item( ) if item.get("test_gap"): - # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 - # 実測では同じ課題で固定テストの追加数が 17 本 / 1 メソッド / 0 本と揃わなかった。 - # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 if not facts[0].get("touches_tests"): return ( "テストが乏しい項目なのに、現状固定テストの追加コミットが先行していません" diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 2b7411f1..13d1f695 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -486,20 +486,23 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_single_commit( + commit: dict[str, Any], scope: Iterable[str], ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """1 コミットの存在・トレーラー・スコープ・テスト結果を検証する。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 問題があれば理由を返す。問題なければ None。 + verify_fix_commit と verify_apply_item の per-commit 検証を共通化する。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return ( + f"コミット {commit.get('sha', '?')} が対象の範囲にありません" + "(申告だけで実体がありません)" + ) problem = verify_commit_trailers(commit) if problem: return problem - problem = verify_scope(commit, scope or []) + problem = verify_scope(commit, scope) if problem: return problem if commit.get("test_status") != "pass": @@ -510,6 +513,17 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_single_commit(commit, scope or []) + + def verify_apply_item( item: dict[str, Any], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -524,22 +538,9 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_single_commit(commit, scope or []) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -549,9 +550,6 @@ def verify_apply_item( ) if item.get("test_gap"): - # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 - # 実測では同じ課題で固定テストの追加数が 17 本 / 1 メソッド / 0 本と揃わなかった。 - # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 if not facts[0].get("touches_tests"): return ( "テストが乏しい項目なのに、現状固定テストの追加コミットが先行していません" diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 2b7411f1..13d1f695 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -486,20 +486,23 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_single_commit( + commit: dict[str, Any], scope: Iterable[str], ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """1 コミットの存在・トレーラー・スコープ・テスト結果を検証する。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 問題があれば理由を返す。問題なければ None。 + verify_fix_commit と verify_apply_item の per-commit 検証を共通化する。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return ( + f"コミット {commit.get('sha', '?')} が対象の範囲にありません" + "(申告だけで実体がありません)" + ) problem = verify_commit_trailers(commit) if problem: return problem - problem = verify_scope(commit, scope or []) + problem = verify_scope(commit, scope) if problem: return problem if commit.get("test_status") != "pass": @@ -510,6 +513,17 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_single_commit(commit, scope or []) + + def verify_apply_item( item: dict[str, Any], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -524,22 +538,9 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_single_commit(commit, scope or []) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -549,9 +550,6 @@ def verify_apply_item( ) if item.get("test_gap"): - # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 - # 実測では同じ課題で固定テストの追加数が 17 本 / 1 メソッド / 0 本と揃わなかった。 - # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 if not facts[0].get("touches_tests"): return ( "テストが乏しい項目なのに、現状固定テストの追加コミットが先行していません" diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2b7411f1..13d1f695 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -486,20 +486,23 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_single_commit( + commit: dict[str, Any], scope: Iterable[str], ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """1 コミットの存在・トレーラー・スコープ・テスト結果を検証する。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 問題があれば理由を返す。問題なければ None。 + verify_fix_commit と verify_apply_item の per-commit 検証を共通化する。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return ( + f"コミット {commit.get('sha', '?')} が対象の範囲にありません" + "(申告だけで実体がありません)" + ) problem = verify_commit_trailers(commit) if problem: return problem - problem = verify_scope(commit, scope or []) + problem = verify_scope(commit, scope) if problem: return problem if commit.get("test_status") != "pass": @@ -510,6 +513,17 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_single_commit(commit, scope or []) + + def verify_apply_item( item: dict[str, Any], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -524,22 +538,9 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_single_commit(commit, scope or []) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -549,9 +550,6 @@ def verify_apply_item( ) if item.get("test_gap"): - # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 - # 実測では同じ課題で固定テストの追加数が 17 本 / 1 メソッド / 0 本と揃わなかった。 - # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 if not facts[0].get("touches_tests"): return ( "テストが乏しい項目なのに、現状固定テストの追加コミットが先行していません" From 9fd45463139f06c01187113b9e3c2095e6be59a9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:23:27 +0000 Subject: [PATCH 44/57] =?UTF-8?q?Refactor:=20introduce=5Fparameter=5Fobjec?= =?UTF-8?q?t=20=E2=80=94=20refactor.py#=5Fverify=5Fand=5Frecord=5Fitems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit work, scope, test_command, head_branch, timeout の 5 パラメータを VerifyContext dataclass にまとめ、_verify_and_record_items, collect_commit_facts, _validate_fix_commits の引数を統合する。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 74 +++++++++++-------- .../tests/test_abandon_items.py | 4 +- .../cross-refactoring/tests/test_git_facts.py | 15 ++-- .../tests/test_merge_apply.py | 10 ++- .../cross-refactoring/scripts/refactor.py | 74 +++++++++++-------- .../tests/test_abandon_items.py | 4 +- .../cross-refactoring/tests/test_git_facts.py | 15 ++-- .../tests/test_merge_apply.py | 10 ++- .../cross-refactoring/scripts/refactor.py | 74 +++++++++++-------- .../tests/test_abandon_items.py | 4 +- .../cross-refactoring/tests/test_git_facts.py | 15 ++-- .../tests/test_merge_apply.py | 10 ++- .../cross-refactoring/scripts/refactor.py | 74 +++++++++++-------- .../tests/test_abandon_items.py | 4 +- .../cross-refactoring/tests/test_git_facts.py | 15 ++-- .../tests/test_merge_apply.py | 10 ++- 16 files changed, 240 insertions(+), 172 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 13d1f695..d6575544 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -36,6 +36,7 @@ import sys import time from typing import Any, Iterable, Optional +from dataclasses import dataclass sys.path.insert( 0, @@ -443,6 +444,20 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: return False +@dataclass +class VerifyContext: + """検証の共通パラメータをまとめる。 + + _verify_and_record_items, _validate_fix_commits, collect_commit_facts が + 同じ 5 パラメータを受け渡すため、1 つのオブジェクトに集約する。 + """ + work: str + scope: list[str] + test_command: str + head_branch: str + timeout: int + + def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" paths = list(scope) @@ -1151,13 +1166,9 @@ def _verify_and_record_items( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - work: str, + ctx: VerifyContext, reported: dict[str, dict[str, Any]], in_range: set[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, dry_run: bool, ) -> tuple[list[str], list[str]]: """適用項目を検証し、項目と進捗に判定を記録する。""" @@ -1172,10 +1183,8 @@ def _verify_and_record_items( problem = "適用結果に項目がありません" facts: list[dict[str, Any]] = [] else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, timeout, - ) - problem = verify_apply_item(item, facts, scope) + facts = collect_commit_facts(ctx, _reported_shas(got), in_range) + problem = verify_apply_item(item, facts, ctx.scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem @@ -1393,13 +1402,17 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] + ctx = VerifyContext( + work=work, scope=scope, test_command=test_command, + head_branch=head_branch, + timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 applied, failed = _verify_and_record_items( - path, state, entry, work, reported, in_range, scope, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + path, state, entry, ctx, reported, in_range, args.dry_run, ) # 状態構築と失敗項目の取り消し @@ -1704,25 +1717,19 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: def _validate_fix_commits( - work: str, + ctx: VerifyContext, ordered_range: list[str], reported_shas: list[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(work, reported_shas) + reported_full = _resolve_reported_to_full(ctx.work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), test_command, head_branch, timeout, - ) + facts = collect_commit_facts(ctx, reported_shas, set(ordered_range)) accepted: list[tuple[str, str]] = [] problems: list[str] = [] for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, scope) + problem = verify_fix_commit(commit, ctx.scope) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1786,10 +1793,14 @@ def _merge_fix_verify_and_reconcile( ) reported_shas = _reported_shas(payload) + ctx = VerifyContext( + work=work, scope=state.get("target_scope") or [], + test_command=baseline.get("command") or "true", + head_branch=state["head_branch"], + timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ctx, ordered_range, reported_shas, ) if unassigned: @@ -2267,8 +2278,7 @@ def run_test_at( def collect_commit_facts( - work: str, shas: list[str], in_range: set[str], test_command: str, - head_branch: str, test_timeout: int = DEFAULT_TEST_TIMEOUT, + ctx: VerifyContext, shas: list[str], in_range: set[str], ) -> list[dict[str, Any]]: """申告されたコミットについて、git と実際のテスト実行から事実を集める。 @@ -2277,19 +2287,19 @@ def collect_commit_facts( """ facts: list[dict[str, Any]] = [] for sha in shas: - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + full = _git_out(ctx.work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) if full is None or full not in in_range: facts.append({"sha": sha, "exists": False}) continue facts.append({ "sha": sha, "exists": True, - "trailers": commit_trailers(work, full), - "diff_lines": commit_diff_lines(work, full), - "files": commit_files(work, full), - "touches_tests": commit_touches_tests(work, full), + "trailers": commit_trailers(ctx.work, full), + "diff_lines": commit_diff_lines(ctx.work, full), + "files": commit_files(ctx.work, full), + "touches_tests": commit_touches_tests(ctx.work, full), "test_status": run_test_at( - work, full, test_command, head_branch, test_timeout + ctx.work, full, ctx.test_command, ctx.head_branch, ctx.timeout, ), }) return facts diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py index 63b2bb64..2114bfb5 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py @@ -242,7 +242,7 @@ def _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, claimed, # 検証の材料は git から取る。テストでは git 由来の事実だけを差し替える。 monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: resolved_facts, + lambda ctx, shas, rng: resolved_facts, ) write_result(state_path, "codex-fix-r1", { "resolved_thread_ids": claimed, @@ -405,7 +405,7 @@ def test_broken_fix_result_does_not_crash( monkeypatch.setattr(refactor, "_git_out", lambda work, args: "HEAD") monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [], + lambda ctx, shas, rng: [], ) refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) state = read_state(state_path) diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_git_facts.py index 034ceee7..1c13f6c9 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_git_facts.py @@ -60,7 +60,8 @@ def test_facts_come_from_a_real_repository(refactor, work): assert ordered == [second, first], "新しい順で返っていない" facts = refactor.collect_commit_facts( - str(work), [first, second], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [first, second], set(ordered), ) assert [f["sha"] for f in facts] == [first, second] assert all(f["exists"] for f in facts) @@ -81,7 +82,8 @@ def test_missing_trailers_are_seen_as_missing(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: トレーラーなし", {"src/foo.py": "def f():\n return 2\n"}) facts = refactor.collect_commit_facts( - str(work), [sha], {sha}, "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [sha], {sha}, ) problem = refactor.verify_commit_trailers(facts[0]) assert problem is not None and "Item-Id" in problem @@ -97,7 +99,8 @@ def test_commit_outside_the_range_is_rejected(refactor, work): ordered = refactor.commits_in_range(str(work), base, "HEAD") assert ordered == [new] facts = refactor.collect_commit_facts( - str(work), [old], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [old], set(ordered), ) assert facts[0]["exists"] is False @@ -107,7 +110,8 @@ def test_failing_test_is_detected_by_running_it(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: 壊した" + TRAILERS, {"src/foo.py": "def f():\n return 9\n"}) facts = refactor.collect_commit_facts( - str(work), [sha], {sha}, "false", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="false", head_branch="main", timeout=300), + [sha], {sha}, ) assert facts[0]["test_status"] == "fail" assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" @@ -124,7 +128,8 @@ def test_fix_commits_pass_verification_through_real_git(refactor, work): {"src/foo.py": "def f():\n return 1 # 直した\n"}) ordered = refactor.commits_in_range(str(work), base, "HEAD") facts = refactor.collect_commit_facts( - str(work), [sha], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [sha], set(ordered), ) assert refactor.verify_fix_commit(facts[0]) is None diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py index 6b772a0c..af908227 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py @@ -248,13 +248,15 @@ def fake_run(cmd, **kw): def test_collect_facts_marks_unknown_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: None) - facts = refactor.collect_commit_facts("/w", ["ghost"], {"aaa"}, "true", "main") + ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) + facts = refactor.collect_commit_facts(ctx, ["ghost"], {"aaa"}) assert facts == [{"sha": "ghost", "exists": False}] def test_collect_facts_marks_out_of_range_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: "zzz") - facts = refactor.collect_commit_facts("/w", ["zzz"], {"aaa"}, "true", "main") + ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) + facts = refactor.collect_commit_facts(ctx, ["zzz"], {"aaa"}) assert facts[0]["exists"] is False @@ -293,7 +295,7 @@ def _set(mapping, in_range=None): ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [ + lambda ctx, shas, rng: [ mapping.get(s, {"sha": s, "exists": False}) for s in shas ], ) @@ -993,7 +995,7 @@ def test_short_and_full_sha_are_seen_as_the_same_commit( ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [fact(sha=s) for s in shas], + lambda ctx, shas, rng: [fact(sha=s) for s in shas], ) write_result(state_path, "codex-apply-r1", { "items": [ diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 13d1f695..d6575544 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -36,6 +36,7 @@ import sys import time from typing import Any, Iterable, Optional +from dataclasses import dataclass sys.path.insert( 0, @@ -443,6 +444,20 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: return False +@dataclass +class VerifyContext: + """検証の共通パラメータをまとめる。 + + _verify_and_record_items, _validate_fix_commits, collect_commit_facts が + 同じ 5 パラメータを受け渡すため、1 つのオブジェクトに集約する。 + """ + work: str + scope: list[str] + test_command: str + head_branch: str + timeout: int + + def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" paths = list(scope) @@ -1151,13 +1166,9 @@ def _verify_and_record_items( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - work: str, + ctx: VerifyContext, reported: dict[str, dict[str, Any]], in_range: set[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, dry_run: bool, ) -> tuple[list[str], list[str]]: """適用項目を検証し、項目と進捗に判定を記録する。""" @@ -1172,10 +1183,8 @@ def _verify_and_record_items( problem = "適用結果に項目がありません" facts: list[dict[str, Any]] = [] else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, timeout, - ) - problem = verify_apply_item(item, facts, scope) + facts = collect_commit_facts(ctx, _reported_shas(got), in_range) + problem = verify_apply_item(item, facts, ctx.scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem @@ -1393,13 +1402,17 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] + ctx = VerifyContext( + work=work, scope=scope, test_command=test_command, + head_branch=head_branch, + timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 applied, failed = _verify_and_record_items( - path, state, entry, work, reported, in_range, scope, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + path, state, entry, ctx, reported, in_range, args.dry_run, ) # 状態構築と失敗項目の取り消し @@ -1704,25 +1717,19 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: def _validate_fix_commits( - work: str, + ctx: VerifyContext, ordered_range: list[str], reported_shas: list[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(work, reported_shas) + reported_full = _resolve_reported_to_full(ctx.work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), test_command, head_branch, timeout, - ) + facts = collect_commit_facts(ctx, reported_shas, set(ordered_range)) accepted: list[tuple[str, str]] = [] problems: list[str] = [] for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, scope) + problem = verify_fix_commit(commit, ctx.scope) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1786,10 +1793,14 @@ def _merge_fix_verify_and_reconcile( ) reported_shas = _reported_shas(payload) + ctx = VerifyContext( + work=work, scope=state.get("target_scope") or [], + test_command=baseline.get("command") or "true", + head_branch=state["head_branch"], + timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ctx, ordered_range, reported_shas, ) if unassigned: @@ -2267,8 +2278,7 @@ def run_test_at( def collect_commit_facts( - work: str, shas: list[str], in_range: set[str], test_command: str, - head_branch: str, test_timeout: int = DEFAULT_TEST_TIMEOUT, + ctx: VerifyContext, shas: list[str], in_range: set[str], ) -> list[dict[str, Any]]: """申告されたコミットについて、git と実際のテスト実行から事実を集める。 @@ -2277,19 +2287,19 @@ def collect_commit_facts( """ facts: list[dict[str, Any]] = [] for sha in shas: - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + full = _git_out(ctx.work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) if full is None or full not in in_range: facts.append({"sha": sha, "exists": False}) continue facts.append({ "sha": sha, "exists": True, - "trailers": commit_trailers(work, full), - "diff_lines": commit_diff_lines(work, full), - "files": commit_files(work, full), - "touches_tests": commit_touches_tests(work, full), + "trailers": commit_trailers(ctx.work, full), + "diff_lines": commit_diff_lines(ctx.work, full), + "files": commit_files(ctx.work, full), + "touches_tests": commit_touches_tests(ctx.work, full), "test_status": run_test_at( - work, full, test_command, head_branch, test_timeout + ctx.work, full, ctx.test_command, ctx.head_branch, ctx.timeout, ), }) return facts diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py index 63b2bb64..2114bfb5 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py @@ -242,7 +242,7 @@ def _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, claimed, # 検証の材料は git から取る。テストでは git 由来の事実だけを差し替える。 monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: resolved_facts, + lambda ctx, shas, rng: resolved_facts, ) write_result(state_path, "codex-fix-r1", { "resolved_thread_ids": claimed, @@ -405,7 +405,7 @@ def test_broken_fix_result_does_not_crash( monkeypatch.setattr(refactor, "_git_out", lambda work, args: "HEAD") monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [], + lambda ctx, shas, rng: [], ) refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) state = read_state(state_path) diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_git_facts.py index 034ceee7..1c13f6c9 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_git_facts.py @@ -60,7 +60,8 @@ def test_facts_come_from_a_real_repository(refactor, work): assert ordered == [second, first], "新しい順で返っていない" facts = refactor.collect_commit_facts( - str(work), [first, second], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [first, second], set(ordered), ) assert [f["sha"] for f in facts] == [first, second] assert all(f["exists"] for f in facts) @@ -81,7 +82,8 @@ def test_missing_trailers_are_seen_as_missing(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: トレーラーなし", {"src/foo.py": "def f():\n return 2\n"}) facts = refactor.collect_commit_facts( - str(work), [sha], {sha}, "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [sha], {sha}, ) problem = refactor.verify_commit_trailers(facts[0]) assert problem is not None and "Item-Id" in problem @@ -97,7 +99,8 @@ def test_commit_outside_the_range_is_rejected(refactor, work): ordered = refactor.commits_in_range(str(work), base, "HEAD") assert ordered == [new] facts = refactor.collect_commit_facts( - str(work), [old], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [old], set(ordered), ) assert facts[0]["exists"] is False @@ -107,7 +110,8 @@ def test_failing_test_is_detected_by_running_it(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: 壊した" + TRAILERS, {"src/foo.py": "def f():\n return 9\n"}) facts = refactor.collect_commit_facts( - str(work), [sha], {sha}, "false", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="false", head_branch="main", timeout=300), + [sha], {sha}, ) assert facts[0]["test_status"] == "fail" assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" @@ -124,7 +128,8 @@ def test_fix_commits_pass_verification_through_real_git(refactor, work): {"src/foo.py": "def f():\n return 1 # 直した\n"}) ordered = refactor.commits_in_range(str(work), base, "HEAD") facts = refactor.collect_commit_facts( - str(work), [sha], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [sha], set(ordered), ) assert refactor.verify_fix_commit(facts[0]) is None diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py index 6b772a0c..af908227 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py @@ -248,13 +248,15 @@ def fake_run(cmd, **kw): def test_collect_facts_marks_unknown_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: None) - facts = refactor.collect_commit_facts("/w", ["ghost"], {"aaa"}, "true", "main") + ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) + facts = refactor.collect_commit_facts(ctx, ["ghost"], {"aaa"}) assert facts == [{"sha": "ghost", "exists": False}] def test_collect_facts_marks_out_of_range_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: "zzz") - facts = refactor.collect_commit_facts("/w", ["zzz"], {"aaa"}, "true", "main") + ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) + facts = refactor.collect_commit_facts(ctx, ["zzz"], {"aaa"}) assert facts[0]["exists"] is False @@ -293,7 +295,7 @@ def _set(mapping, in_range=None): ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [ + lambda ctx, shas, rng: [ mapping.get(s, {"sha": s, "exists": False}) for s in shas ], ) @@ -993,7 +995,7 @@ def test_short_and_full_sha_are_seen_as_the_same_commit( ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [fact(sha=s) for s in shas], + lambda ctx, shas, rng: [fact(sha=s) for s in shas], ) write_result(state_path, "codex-apply-r1", { "items": [ diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 13d1f695..d6575544 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -36,6 +36,7 @@ import sys import time from typing import Any, Iterable, Optional +from dataclasses import dataclass sys.path.insert( 0, @@ -443,6 +444,20 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: return False +@dataclass +class VerifyContext: + """検証の共通パラメータをまとめる。 + + _verify_and_record_items, _validate_fix_commits, collect_commit_facts が + 同じ 5 パラメータを受け渡すため、1 つのオブジェクトに集約する。 + """ + work: str + scope: list[str] + test_command: str + head_branch: str + timeout: int + + def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" paths = list(scope) @@ -1151,13 +1166,9 @@ def _verify_and_record_items( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - work: str, + ctx: VerifyContext, reported: dict[str, dict[str, Any]], in_range: set[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, dry_run: bool, ) -> tuple[list[str], list[str]]: """適用項目を検証し、項目と進捗に判定を記録する。""" @@ -1172,10 +1183,8 @@ def _verify_and_record_items( problem = "適用結果に項目がありません" facts: list[dict[str, Any]] = [] else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, timeout, - ) - problem = verify_apply_item(item, facts, scope) + facts = collect_commit_facts(ctx, _reported_shas(got), in_range) + problem = verify_apply_item(item, facts, ctx.scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem @@ -1393,13 +1402,17 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] + ctx = VerifyContext( + work=work, scope=scope, test_command=test_command, + head_branch=head_branch, + timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 applied, failed = _verify_and_record_items( - path, state, entry, work, reported, in_range, scope, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + path, state, entry, ctx, reported, in_range, args.dry_run, ) # 状態構築と失敗項目の取り消し @@ -1704,25 +1717,19 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: def _validate_fix_commits( - work: str, + ctx: VerifyContext, ordered_range: list[str], reported_shas: list[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(work, reported_shas) + reported_full = _resolve_reported_to_full(ctx.work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), test_command, head_branch, timeout, - ) + facts = collect_commit_facts(ctx, reported_shas, set(ordered_range)) accepted: list[tuple[str, str]] = [] problems: list[str] = [] for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, scope) + problem = verify_fix_commit(commit, ctx.scope) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1786,10 +1793,14 @@ def _merge_fix_verify_and_reconcile( ) reported_shas = _reported_shas(payload) + ctx = VerifyContext( + work=work, scope=state.get("target_scope") or [], + test_command=baseline.get("command") or "true", + head_branch=state["head_branch"], + timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ctx, ordered_range, reported_shas, ) if unassigned: @@ -2267,8 +2278,7 @@ def run_test_at( def collect_commit_facts( - work: str, shas: list[str], in_range: set[str], test_command: str, - head_branch: str, test_timeout: int = DEFAULT_TEST_TIMEOUT, + ctx: VerifyContext, shas: list[str], in_range: set[str], ) -> list[dict[str, Any]]: """申告されたコミットについて、git と実際のテスト実行から事実を集める。 @@ -2277,19 +2287,19 @@ def collect_commit_facts( """ facts: list[dict[str, Any]] = [] for sha in shas: - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + full = _git_out(ctx.work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) if full is None or full not in in_range: facts.append({"sha": sha, "exists": False}) continue facts.append({ "sha": sha, "exists": True, - "trailers": commit_trailers(work, full), - "diff_lines": commit_diff_lines(work, full), - "files": commit_files(work, full), - "touches_tests": commit_touches_tests(work, full), + "trailers": commit_trailers(ctx.work, full), + "diff_lines": commit_diff_lines(ctx.work, full), + "files": commit_files(ctx.work, full), + "touches_tests": commit_touches_tests(ctx.work, full), "test_status": run_test_at( - work, full, test_command, head_branch, test_timeout + ctx.work, full, ctx.test_command, ctx.head_branch, ctx.timeout, ), }) return facts diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py index 63b2bb64..2114bfb5 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py @@ -242,7 +242,7 @@ def _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, claimed, # 検証の材料は git から取る。テストでは git 由来の事実だけを差し替える。 monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: resolved_facts, + lambda ctx, shas, rng: resolved_facts, ) write_result(state_path, "codex-fix-r1", { "resolved_thread_ids": claimed, @@ -405,7 +405,7 @@ def test_broken_fix_result_does_not_crash( monkeypatch.setattr(refactor, "_git_out", lambda work, args: "HEAD") monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [], + lambda ctx, shas, rng: [], ) refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) state = read_state(state_path) diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_git_facts.py index 034ceee7..1c13f6c9 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_git_facts.py @@ -60,7 +60,8 @@ def test_facts_come_from_a_real_repository(refactor, work): assert ordered == [second, first], "新しい順で返っていない" facts = refactor.collect_commit_facts( - str(work), [first, second], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [first, second], set(ordered), ) assert [f["sha"] for f in facts] == [first, second] assert all(f["exists"] for f in facts) @@ -81,7 +82,8 @@ def test_missing_trailers_are_seen_as_missing(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: トレーラーなし", {"src/foo.py": "def f():\n return 2\n"}) facts = refactor.collect_commit_facts( - str(work), [sha], {sha}, "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [sha], {sha}, ) problem = refactor.verify_commit_trailers(facts[0]) assert problem is not None and "Item-Id" in problem @@ -97,7 +99,8 @@ def test_commit_outside_the_range_is_rejected(refactor, work): ordered = refactor.commits_in_range(str(work), base, "HEAD") assert ordered == [new] facts = refactor.collect_commit_facts( - str(work), [old], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [old], set(ordered), ) assert facts[0]["exists"] is False @@ -107,7 +110,8 @@ def test_failing_test_is_detected_by_running_it(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: 壊した" + TRAILERS, {"src/foo.py": "def f():\n return 9\n"}) facts = refactor.collect_commit_facts( - str(work), [sha], {sha}, "false", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="false", head_branch="main", timeout=300), + [sha], {sha}, ) assert facts[0]["test_status"] == "fail" assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" @@ -124,7 +128,8 @@ def test_fix_commits_pass_verification_through_real_git(refactor, work): {"src/foo.py": "def f():\n return 1 # 直した\n"}) ordered = refactor.commits_in_range(str(work), base, "HEAD") facts = refactor.collect_commit_facts( - str(work), [sha], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [sha], set(ordered), ) assert refactor.verify_fix_commit(facts[0]) is None diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py index 6b772a0c..af908227 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py @@ -248,13 +248,15 @@ def fake_run(cmd, **kw): def test_collect_facts_marks_unknown_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: None) - facts = refactor.collect_commit_facts("/w", ["ghost"], {"aaa"}, "true", "main") + ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) + facts = refactor.collect_commit_facts(ctx, ["ghost"], {"aaa"}) assert facts == [{"sha": "ghost", "exists": False}] def test_collect_facts_marks_out_of_range_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: "zzz") - facts = refactor.collect_commit_facts("/w", ["zzz"], {"aaa"}, "true", "main") + ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) + facts = refactor.collect_commit_facts(ctx, ["zzz"], {"aaa"}) assert facts[0]["exists"] is False @@ -293,7 +295,7 @@ def _set(mapping, in_range=None): ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [ + lambda ctx, shas, rng: [ mapping.get(s, {"sha": s, "exists": False}) for s in shas ], ) @@ -993,7 +995,7 @@ def test_short_and_full_sha_are_seen_as_the_same_commit( ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [fact(sha=s) for s in shas], + lambda ctx, shas, rng: [fact(sha=s) for s in shas], ) write_result(state_path, "codex-apply-r1", { "items": [ diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 13d1f695..d6575544 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -36,6 +36,7 @@ import sys import time from typing import Any, Iterable, Optional +from dataclasses import dataclass sys.path.insert( 0, @@ -443,6 +444,20 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: return False +@dataclass +class VerifyContext: + """検証の共通パラメータをまとめる。 + + _verify_and_record_items, _validate_fix_commits, collect_commit_facts が + 同じ 5 パラメータを受け渡すため、1 つのオブジェクトに集約する。 + """ + work: str + scope: list[str] + test_command: str + head_branch: str + timeout: int + + def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" paths = list(scope) @@ -1151,13 +1166,9 @@ def _verify_and_record_items( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - work: str, + ctx: VerifyContext, reported: dict[str, dict[str, Any]], in_range: set[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, dry_run: bool, ) -> tuple[list[str], list[str]]: """適用項目を検証し、項目と進捗に判定を記録する。""" @@ -1172,10 +1183,8 @@ def _verify_and_record_items( problem = "適用結果に項目がありません" facts: list[dict[str, Any]] = [] else: - facts = collect_commit_facts( - work, _reported_shas(got), in_range, test_command, head_branch, timeout, - ) - problem = verify_apply_item(item, facts, scope) + facts = collect_commit_facts(ctx, _reported_shas(got), in_range) + problem = verify_apply_item(item, facts, ctx.scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem @@ -1393,13 +1402,17 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] + ctx = VerifyContext( + work=work, scope=scope, test_command=test_command, + head_branch=head_branch, + timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 applied, failed = _verify_and_record_items( - path, state, entry, work, reported, in_range, scope, test_command, head_branch, - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, + path, state, entry, ctx, reported, in_range, args.dry_run, ) # 状態構築と失敗項目の取り消し @@ -1704,25 +1717,19 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: def _validate_fix_commits( - work: str, + ctx: VerifyContext, ordered_range: list[str], reported_shas: list[str], - scope: list[str], - test_command: str, - head_branch: str, - timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(work, reported_shas) + reported_full = _resolve_reported_to_full(ctx.work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts( - work, reported_shas, set(ordered_range), test_command, head_branch, timeout, - ) + facts = collect_commit_facts(ctx, reported_shas, set(ordered_range)) accepted: list[tuple[str, str]] = [] problems: list[str] = [] for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, scope) + problem = verify_fix_commit(commit, ctx.scope) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1786,10 +1793,14 @@ def _merge_fix_verify_and_reconcile( ) reported_shas = _reported_shas(payload) + ctx = VerifyContext( + work=work, scope=state.get("target_scope") or [], + test_command=baseline.get("command") or "true", + head_branch=state["head_branch"], + timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ) accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), + ctx, ordered_range, reported_shas, ) if unassigned: @@ -2267,8 +2278,7 @@ def run_test_at( def collect_commit_facts( - work: str, shas: list[str], in_range: set[str], test_command: str, - head_branch: str, test_timeout: int = DEFAULT_TEST_TIMEOUT, + ctx: VerifyContext, shas: list[str], in_range: set[str], ) -> list[dict[str, Any]]: """申告されたコミットについて、git と実際のテスト実行から事実を集める。 @@ -2277,19 +2287,19 @@ def collect_commit_facts( """ facts: list[dict[str, Any]] = [] for sha in shas: - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + full = _git_out(ctx.work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) if full is None or full not in in_range: facts.append({"sha": sha, "exists": False}) continue facts.append({ "sha": sha, "exists": True, - "trailers": commit_trailers(work, full), - "diff_lines": commit_diff_lines(work, full), - "files": commit_files(work, full), - "touches_tests": commit_touches_tests(work, full), + "trailers": commit_trailers(ctx.work, full), + "diff_lines": commit_diff_lines(ctx.work, full), + "files": commit_files(ctx.work, full), + "touches_tests": commit_touches_tests(ctx.work, full), "test_status": run_test_at( - work, full, test_command, head_branch, test_timeout + ctx.work, full, ctx.test_command, ctx.head_branch, ctx.timeout, ), }) return facts diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py index 63b2bb64..2114bfb5 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py @@ -242,7 +242,7 @@ def _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, claimed, # 検証の材料は git から取る。テストでは git 由来の事実だけを差し替える。 monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: resolved_facts, + lambda ctx, shas, rng: resolved_facts, ) write_result(state_path, "codex-fix-r1", { "resolved_thread_ids": claimed, @@ -405,7 +405,7 @@ def test_broken_fix_result_does_not_crash( monkeypatch.setattr(refactor, "_git_out", lambda work, args: "HEAD") monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [], + lambda ctx, shas, rng: [], ) refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) state = read_state(state_path) diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_git_facts.py index 034ceee7..1c13f6c9 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_git_facts.py @@ -60,7 +60,8 @@ def test_facts_come_from_a_real_repository(refactor, work): assert ordered == [second, first], "新しい順で返っていない" facts = refactor.collect_commit_facts( - str(work), [first, second], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [first, second], set(ordered), ) assert [f["sha"] for f in facts] == [first, second] assert all(f["exists"] for f in facts) @@ -81,7 +82,8 @@ def test_missing_trailers_are_seen_as_missing(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: トレーラーなし", {"src/foo.py": "def f():\n return 2\n"}) facts = refactor.collect_commit_facts( - str(work), [sha], {sha}, "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [sha], {sha}, ) problem = refactor.verify_commit_trailers(facts[0]) assert problem is not None and "Item-Id" in problem @@ -97,7 +99,8 @@ def test_commit_outside_the_range_is_rejected(refactor, work): ordered = refactor.commits_in_range(str(work), base, "HEAD") assert ordered == [new] facts = refactor.collect_commit_facts( - str(work), [old], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [old], set(ordered), ) assert facts[0]["exists"] is False @@ -107,7 +110,8 @@ def test_failing_test_is_detected_by_running_it(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: 壊した" + TRAILERS, {"src/foo.py": "def f():\n return 9\n"}) facts = refactor.collect_commit_facts( - str(work), [sha], {sha}, "false", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="false", head_branch="main", timeout=300), + [sha], {sha}, ) assert facts[0]["test_status"] == "fail" assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" @@ -124,7 +128,8 @@ def test_fix_commits_pass_verification_through_real_git(refactor, work): {"src/foo.py": "def f():\n return 1 # 直した\n"}) ordered = refactor.commits_in_range(str(work), base, "HEAD") facts = refactor.collect_commit_facts( - str(work), [sha], set(ordered), "true", "main" + refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), + [sha], set(ordered), ) assert refactor.verify_fix_commit(facts[0]) is None diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py index 6b772a0c..af908227 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py @@ -248,13 +248,15 @@ def fake_run(cmd, **kw): def test_collect_facts_marks_unknown_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: None) - facts = refactor.collect_commit_facts("/w", ["ghost"], {"aaa"}, "true", "main") + ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) + facts = refactor.collect_commit_facts(ctx, ["ghost"], {"aaa"}) assert facts == [{"sha": "ghost", "exists": False}] def test_collect_facts_marks_out_of_range_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: "zzz") - facts = refactor.collect_commit_facts("/w", ["zzz"], {"aaa"}, "true", "main") + ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) + facts = refactor.collect_commit_facts(ctx, ["zzz"], {"aaa"}) assert facts[0]["exists"] is False @@ -293,7 +295,7 @@ def _set(mapping, in_range=None): ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [ + lambda ctx, shas, rng: [ mapping.get(s, {"sha": s, "exists": False}) for s in shas ], ) @@ -993,7 +995,7 @@ def test_short_and_full_sha_are_seen_as_the_same_commit( ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda work, shas, rng, cmd, branch, timeout=None: [fact(sha=s) for s in shas], + lambda ctx, shas, rng: [fact(sha=s) for s in shas], ) write_result(state_path, "codex-apply-r1", { "items": [ From 2c7242681a731a1319d315f6f725de8326a780d4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:27 +0000 Subject: [PATCH 45/57] =?UTF-8?q?Revert=20"Refactor:=20introduce=5Fparamet?= =?UTF-8?q?er=5Fobject=20=E2=80=94=20refactor.py#=5Fverify=5Fand=5Frecord?= =?UTF-8?q?=5Fitems"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9fd45463139f06c01187113b9e3c2095e6be59a9. --- .../cross-refactoring/scripts/refactor.py | 74 ++++++++----------- .../tests/test_abandon_items.py | 4 +- .../cross-refactoring/tests/test_git_facts.py | 15 ++-- .../tests/test_merge_apply.py | 10 +-- .../cross-refactoring/scripts/refactor.py | 74 ++++++++----------- .../tests/test_abandon_items.py | 4 +- .../cross-refactoring/tests/test_git_facts.py | 15 ++-- .../tests/test_merge_apply.py | 10 +-- .../cross-refactoring/scripts/refactor.py | 74 ++++++++----------- .../tests/test_abandon_items.py | 4 +- .../cross-refactoring/tests/test_git_facts.py | 15 ++-- .../tests/test_merge_apply.py | 10 +-- .../cross-refactoring/scripts/refactor.py | 74 ++++++++----------- .../tests/test_abandon_items.py | 4 +- .../cross-refactoring/tests/test_git_facts.py | 15 ++-- .../tests/test_merge_apply.py | 10 +-- 16 files changed, 172 insertions(+), 240 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index d6575544..13d1f695 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -36,7 +36,6 @@ import sys import time from typing import Any, Iterable, Optional -from dataclasses import dataclass sys.path.insert( 0, @@ -444,20 +443,6 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: return False -@dataclass -class VerifyContext: - """検証の共通パラメータをまとめる。 - - _verify_and_record_items, _validate_fix_commits, collect_commit_facts が - 同じ 5 パラメータを受け渡すため、1 つのオブジェクトに集約する。 - """ - work: str - scope: list[str] - test_command: str - head_branch: str - timeout: int - - def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" paths = list(scope) @@ -1166,9 +1151,13 @@ def _verify_and_record_items( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - ctx: VerifyContext, + work: str, reported: dict[str, dict[str, Any]], in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, dry_run: bool, ) -> tuple[list[str], list[str]]: """適用項目を検証し、項目と進捗に判定を記録する。""" @@ -1183,8 +1172,10 @@ def _verify_and_record_items( problem = "適用結果に項目がありません" facts: list[dict[str, Any]] = [] else: - facts = collect_commit_facts(ctx, _reported_shas(got), in_range) - problem = verify_apply_item(item, facts, ctx.scope) + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem @@ -1402,17 +1393,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] - ctx = VerifyContext( - work=work, scope=scope, test_command=test_command, - head_branch=head_branch, - timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 applied, failed = _verify_and_record_items( - path, state, entry, ctx, reported, in_range, args.dry_run, + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) # 状態構築と失敗項目の取り消し @@ -1717,19 +1704,25 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: def _validate_fix_commits( - ctx: VerifyContext, + work: str, ordered_range: list[str], reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(ctx.work, reported_shas) + reported_full = _resolve_reported_to_full(work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts(ctx, reported_shas, set(ordered_range)) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) accepted: list[tuple[str, str]] = [] problems: list[str] = [] for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, ctx.scope) + problem = verify_fix_commit(commit, scope) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1793,14 +1786,10 @@ def _merge_fix_verify_and_reconcile( ) reported_shas = _reported_shas(payload) - ctx = VerifyContext( - work=work, scope=state.get("target_scope") or [], - test_command=baseline.get("command") or "true", - head_branch=state["head_branch"], - timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) accepted, unassigned, problems = _validate_fix_commits( - ctx, ordered_range, reported_shas, + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) if unassigned: @@ -2278,7 +2267,8 @@ def run_test_at( def collect_commit_facts( - ctx: VerifyContext, shas: list[str], in_range: set[str], + work: str, shas: list[str], in_range: set[str], test_command: str, + head_branch: str, test_timeout: int = DEFAULT_TEST_TIMEOUT, ) -> list[dict[str, Any]]: """申告されたコミットについて、git と実際のテスト実行から事実を集める。 @@ -2287,19 +2277,19 @@ def collect_commit_facts( """ facts: list[dict[str, Any]] = [] for sha in shas: - full = _git_out(ctx.work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) if full is None or full not in in_range: facts.append({"sha": sha, "exists": False}) continue facts.append({ "sha": sha, "exists": True, - "trailers": commit_trailers(ctx.work, full), - "diff_lines": commit_diff_lines(ctx.work, full), - "files": commit_files(ctx.work, full), - "touches_tests": commit_touches_tests(ctx.work, full), + "trailers": commit_trailers(work, full), + "diff_lines": commit_diff_lines(work, full), + "files": commit_files(work, full), + "touches_tests": commit_touches_tests(work, full), "test_status": run_test_at( - ctx.work, full, ctx.test_command, ctx.head_branch, ctx.timeout, + work, full, test_command, head_branch, test_timeout ), }) return facts diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py index 2114bfb5..63b2bb64 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py @@ -242,7 +242,7 @@ def _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, claimed, # 検証の材料は git から取る。テストでは git 由来の事実だけを差し替える。 monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: resolved_facts, + lambda work, shas, rng, cmd, branch, timeout=None: resolved_facts, ) write_result(state_path, "codex-fix-r1", { "resolved_thread_ids": claimed, @@ -405,7 +405,7 @@ def test_broken_fix_result_does_not_crash( monkeypatch.setattr(refactor, "_git_out", lambda work, args: "HEAD") monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [], + lambda work, shas, rng, cmd, branch, timeout=None: [], ) refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) state = read_state(state_path) diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_git_facts.py index 1c13f6c9..034ceee7 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_git_facts.py @@ -60,8 +60,7 @@ def test_facts_come_from_a_real_repository(refactor, work): assert ordered == [second, first], "新しい順で返っていない" facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [first, second], set(ordered), + str(work), [first, second], set(ordered), "true", "main" ) assert [f["sha"] for f in facts] == [first, second] assert all(f["exists"] for f in facts) @@ -82,8 +81,7 @@ def test_missing_trailers_are_seen_as_missing(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: トレーラーなし", {"src/foo.py": "def f():\n return 2\n"}) facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [sha], {sha}, + str(work), [sha], {sha}, "true", "main" ) problem = refactor.verify_commit_trailers(facts[0]) assert problem is not None and "Item-Id" in problem @@ -99,8 +97,7 @@ def test_commit_outside_the_range_is_rejected(refactor, work): ordered = refactor.commits_in_range(str(work), base, "HEAD") assert ordered == [new] facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [old], set(ordered), + str(work), [old], set(ordered), "true", "main" ) assert facts[0]["exists"] is False @@ -110,8 +107,7 @@ def test_failing_test_is_detected_by_running_it(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: 壊した" + TRAILERS, {"src/foo.py": "def f():\n return 9\n"}) facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="false", head_branch="main", timeout=300), - [sha], {sha}, + str(work), [sha], {sha}, "false", "main" ) assert facts[0]["test_status"] == "fail" assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" @@ -128,8 +124,7 @@ def test_fix_commits_pass_verification_through_real_git(refactor, work): {"src/foo.py": "def f():\n return 1 # 直した\n"}) ordered = refactor.commits_in_range(str(work), base, "HEAD") facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [sha], set(ordered), + str(work), [sha], set(ordered), "true", "main" ) assert refactor.verify_fix_commit(facts[0]) is None diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py index af908227..6b772a0c 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py @@ -248,15 +248,13 @@ def fake_run(cmd, **kw): def test_collect_facts_marks_unknown_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: None) - ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) - facts = refactor.collect_commit_facts(ctx, ["ghost"], {"aaa"}) + facts = refactor.collect_commit_facts("/w", ["ghost"], {"aaa"}, "true", "main") assert facts == [{"sha": "ghost", "exists": False}] def test_collect_facts_marks_out_of_range_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: "zzz") - ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) - facts = refactor.collect_commit_facts(ctx, ["zzz"], {"aaa"}) + facts = refactor.collect_commit_facts("/w", ["zzz"], {"aaa"}, "true", "main") assert facts[0]["exists"] is False @@ -295,7 +293,7 @@ def _set(mapping, in_range=None): ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [ + lambda work, shas, rng, cmd, branch, timeout=None: [ mapping.get(s, {"sha": s, "exists": False}) for s in shas ], ) @@ -995,7 +993,7 @@ def test_short_and_full_sha_are_seen_as_the_same_commit( ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [fact(sha=s) for s in shas], + lambda work, shas, rng, cmd, branch, timeout=None: [fact(sha=s) for s in shas], ) write_result(state_path, "codex-apply-r1", { "items": [ diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index d6575544..13d1f695 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -36,7 +36,6 @@ import sys import time from typing import Any, Iterable, Optional -from dataclasses import dataclass sys.path.insert( 0, @@ -444,20 +443,6 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: return False -@dataclass -class VerifyContext: - """検証の共通パラメータをまとめる。 - - _verify_and_record_items, _validate_fix_commits, collect_commit_facts が - 同じ 5 パラメータを受け渡すため、1 つのオブジェクトに集約する。 - """ - work: str - scope: list[str] - test_command: str - head_branch: str - timeout: int - - def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" paths = list(scope) @@ -1166,9 +1151,13 @@ def _verify_and_record_items( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - ctx: VerifyContext, + work: str, reported: dict[str, dict[str, Any]], in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, dry_run: bool, ) -> tuple[list[str], list[str]]: """適用項目を検証し、項目と進捗に判定を記録する。""" @@ -1183,8 +1172,10 @@ def _verify_and_record_items( problem = "適用結果に項目がありません" facts: list[dict[str, Any]] = [] else: - facts = collect_commit_facts(ctx, _reported_shas(got), in_range) - problem = verify_apply_item(item, facts, ctx.scope) + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem @@ -1402,17 +1393,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] - ctx = VerifyContext( - work=work, scope=scope, test_command=test_command, - head_branch=head_branch, - timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 applied, failed = _verify_and_record_items( - path, state, entry, ctx, reported, in_range, args.dry_run, + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) # 状態構築と失敗項目の取り消し @@ -1717,19 +1704,25 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: def _validate_fix_commits( - ctx: VerifyContext, + work: str, ordered_range: list[str], reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(ctx.work, reported_shas) + reported_full = _resolve_reported_to_full(work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts(ctx, reported_shas, set(ordered_range)) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) accepted: list[tuple[str, str]] = [] problems: list[str] = [] for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, ctx.scope) + problem = verify_fix_commit(commit, scope) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1793,14 +1786,10 @@ def _merge_fix_verify_and_reconcile( ) reported_shas = _reported_shas(payload) - ctx = VerifyContext( - work=work, scope=state.get("target_scope") or [], - test_command=baseline.get("command") or "true", - head_branch=state["head_branch"], - timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) accepted, unassigned, problems = _validate_fix_commits( - ctx, ordered_range, reported_shas, + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) if unassigned: @@ -2278,7 +2267,8 @@ def run_test_at( def collect_commit_facts( - ctx: VerifyContext, shas: list[str], in_range: set[str], + work: str, shas: list[str], in_range: set[str], test_command: str, + head_branch: str, test_timeout: int = DEFAULT_TEST_TIMEOUT, ) -> list[dict[str, Any]]: """申告されたコミットについて、git と実際のテスト実行から事実を集める。 @@ -2287,19 +2277,19 @@ def collect_commit_facts( """ facts: list[dict[str, Any]] = [] for sha in shas: - full = _git_out(ctx.work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) if full is None or full not in in_range: facts.append({"sha": sha, "exists": False}) continue facts.append({ "sha": sha, "exists": True, - "trailers": commit_trailers(ctx.work, full), - "diff_lines": commit_diff_lines(ctx.work, full), - "files": commit_files(ctx.work, full), - "touches_tests": commit_touches_tests(ctx.work, full), + "trailers": commit_trailers(work, full), + "diff_lines": commit_diff_lines(work, full), + "files": commit_files(work, full), + "touches_tests": commit_touches_tests(work, full), "test_status": run_test_at( - ctx.work, full, ctx.test_command, ctx.head_branch, ctx.timeout, + work, full, test_command, head_branch, test_timeout ), }) return facts diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py index 2114bfb5..63b2bb64 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py @@ -242,7 +242,7 @@ def _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, claimed, # 検証の材料は git から取る。テストでは git 由来の事実だけを差し替える。 monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: resolved_facts, + lambda work, shas, rng, cmd, branch, timeout=None: resolved_facts, ) write_result(state_path, "codex-fix-r1", { "resolved_thread_ids": claimed, @@ -405,7 +405,7 @@ def test_broken_fix_result_does_not_crash( monkeypatch.setattr(refactor, "_git_out", lambda work, args: "HEAD") monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [], + lambda work, shas, rng, cmd, branch, timeout=None: [], ) refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) state = read_state(state_path) diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_git_facts.py index 1c13f6c9..034ceee7 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_git_facts.py @@ -60,8 +60,7 @@ def test_facts_come_from_a_real_repository(refactor, work): assert ordered == [second, first], "新しい順で返っていない" facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [first, second], set(ordered), + str(work), [first, second], set(ordered), "true", "main" ) assert [f["sha"] for f in facts] == [first, second] assert all(f["exists"] for f in facts) @@ -82,8 +81,7 @@ def test_missing_trailers_are_seen_as_missing(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: トレーラーなし", {"src/foo.py": "def f():\n return 2\n"}) facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [sha], {sha}, + str(work), [sha], {sha}, "true", "main" ) problem = refactor.verify_commit_trailers(facts[0]) assert problem is not None and "Item-Id" in problem @@ -99,8 +97,7 @@ def test_commit_outside_the_range_is_rejected(refactor, work): ordered = refactor.commits_in_range(str(work), base, "HEAD") assert ordered == [new] facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [old], set(ordered), + str(work), [old], set(ordered), "true", "main" ) assert facts[0]["exists"] is False @@ -110,8 +107,7 @@ def test_failing_test_is_detected_by_running_it(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: 壊した" + TRAILERS, {"src/foo.py": "def f():\n return 9\n"}) facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="false", head_branch="main", timeout=300), - [sha], {sha}, + str(work), [sha], {sha}, "false", "main" ) assert facts[0]["test_status"] == "fail" assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" @@ -128,8 +124,7 @@ def test_fix_commits_pass_verification_through_real_git(refactor, work): {"src/foo.py": "def f():\n return 1 # 直した\n"}) ordered = refactor.commits_in_range(str(work), base, "HEAD") facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [sha], set(ordered), + str(work), [sha], set(ordered), "true", "main" ) assert refactor.verify_fix_commit(facts[0]) is None diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py index af908227..6b772a0c 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py @@ -248,15 +248,13 @@ def fake_run(cmd, **kw): def test_collect_facts_marks_unknown_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: None) - ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) - facts = refactor.collect_commit_facts(ctx, ["ghost"], {"aaa"}) + facts = refactor.collect_commit_facts("/w", ["ghost"], {"aaa"}, "true", "main") assert facts == [{"sha": "ghost", "exists": False}] def test_collect_facts_marks_out_of_range_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: "zzz") - ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) - facts = refactor.collect_commit_facts(ctx, ["zzz"], {"aaa"}) + facts = refactor.collect_commit_facts("/w", ["zzz"], {"aaa"}, "true", "main") assert facts[0]["exists"] is False @@ -295,7 +293,7 @@ def _set(mapping, in_range=None): ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [ + lambda work, shas, rng, cmd, branch, timeout=None: [ mapping.get(s, {"sha": s, "exists": False}) for s in shas ], ) @@ -995,7 +993,7 @@ def test_short_and_full_sha_are_seen_as_the_same_commit( ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [fact(sha=s) for s in shas], + lambda work, shas, rng, cmd, branch, timeout=None: [fact(sha=s) for s in shas], ) write_result(state_path, "codex-apply-r1", { "items": [ diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index d6575544..13d1f695 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -36,7 +36,6 @@ import sys import time from typing import Any, Iterable, Optional -from dataclasses import dataclass sys.path.insert( 0, @@ -444,20 +443,6 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: return False -@dataclass -class VerifyContext: - """検証の共通パラメータをまとめる。 - - _verify_and_record_items, _validate_fix_commits, collect_commit_facts が - 同じ 5 パラメータを受け渡すため、1 つのオブジェクトに集約する。 - """ - work: str - scope: list[str] - test_command: str - head_branch: str - timeout: int - - def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" paths = list(scope) @@ -1166,9 +1151,13 @@ def _verify_and_record_items( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - ctx: VerifyContext, + work: str, reported: dict[str, dict[str, Any]], in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, dry_run: bool, ) -> tuple[list[str], list[str]]: """適用項目を検証し、項目と進捗に判定を記録する。""" @@ -1183,8 +1172,10 @@ def _verify_and_record_items( problem = "適用結果に項目がありません" facts: list[dict[str, Any]] = [] else: - facts = collect_commit_facts(ctx, _reported_shas(got), in_range) - problem = verify_apply_item(item, facts, ctx.scope) + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem @@ -1402,17 +1393,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] - ctx = VerifyContext( - work=work, scope=scope, test_command=test_command, - head_branch=head_branch, - timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 applied, failed = _verify_and_record_items( - path, state, entry, ctx, reported, in_range, args.dry_run, + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) # 状態構築と失敗項目の取り消し @@ -1717,19 +1704,25 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: def _validate_fix_commits( - ctx: VerifyContext, + work: str, ordered_range: list[str], reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(ctx.work, reported_shas) + reported_full = _resolve_reported_to_full(work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts(ctx, reported_shas, set(ordered_range)) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) accepted: list[tuple[str, str]] = [] problems: list[str] = [] for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, ctx.scope) + problem = verify_fix_commit(commit, scope) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1793,14 +1786,10 @@ def _merge_fix_verify_and_reconcile( ) reported_shas = _reported_shas(payload) - ctx = VerifyContext( - work=work, scope=state.get("target_scope") or [], - test_command=baseline.get("command") or "true", - head_branch=state["head_branch"], - timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) accepted, unassigned, problems = _validate_fix_commits( - ctx, ordered_range, reported_shas, + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) if unassigned: @@ -2278,7 +2267,8 @@ def run_test_at( def collect_commit_facts( - ctx: VerifyContext, shas: list[str], in_range: set[str], + work: str, shas: list[str], in_range: set[str], test_command: str, + head_branch: str, test_timeout: int = DEFAULT_TEST_TIMEOUT, ) -> list[dict[str, Any]]: """申告されたコミットについて、git と実際のテスト実行から事実を集める。 @@ -2287,19 +2277,19 @@ def collect_commit_facts( """ facts: list[dict[str, Any]] = [] for sha in shas: - full = _git_out(ctx.work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) if full is None or full not in in_range: facts.append({"sha": sha, "exists": False}) continue facts.append({ "sha": sha, "exists": True, - "trailers": commit_trailers(ctx.work, full), - "diff_lines": commit_diff_lines(ctx.work, full), - "files": commit_files(ctx.work, full), - "touches_tests": commit_touches_tests(ctx.work, full), + "trailers": commit_trailers(work, full), + "diff_lines": commit_diff_lines(work, full), + "files": commit_files(work, full), + "touches_tests": commit_touches_tests(work, full), "test_status": run_test_at( - ctx.work, full, ctx.test_command, ctx.head_branch, ctx.timeout, + work, full, test_command, head_branch, test_timeout ), }) return facts diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py index 2114bfb5..63b2bb64 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py @@ -242,7 +242,7 @@ def _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, claimed, # 検証の材料は git から取る。テストでは git 由来の事実だけを差し替える。 monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: resolved_facts, + lambda work, shas, rng, cmd, branch, timeout=None: resolved_facts, ) write_result(state_path, "codex-fix-r1", { "resolved_thread_ids": claimed, @@ -405,7 +405,7 @@ def test_broken_fix_result_does_not_crash( monkeypatch.setattr(refactor, "_git_out", lambda work, args: "HEAD") monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [], + lambda work, shas, rng, cmd, branch, timeout=None: [], ) refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) state = read_state(state_path) diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_git_facts.py index 1c13f6c9..034ceee7 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_git_facts.py @@ -60,8 +60,7 @@ def test_facts_come_from_a_real_repository(refactor, work): assert ordered == [second, first], "新しい順で返っていない" facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [first, second], set(ordered), + str(work), [first, second], set(ordered), "true", "main" ) assert [f["sha"] for f in facts] == [first, second] assert all(f["exists"] for f in facts) @@ -82,8 +81,7 @@ def test_missing_trailers_are_seen_as_missing(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: トレーラーなし", {"src/foo.py": "def f():\n return 2\n"}) facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [sha], {sha}, + str(work), [sha], {sha}, "true", "main" ) problem = refactor.verify_commit_trailers(facts[0]) assert problem is not None and "Item-Id" in problem @@ -99,8 +97,7 @@ def test_commit_outside_the_range_is_rejected(refactor, work): ordered = refactor.commits_in_range(str(work), base, "HEAD") assert ordered == [new] facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [old], set(ordered), + str(work), [old], set(ordered), "true", "main" ) assert facts[0]["exists"] is False @@ -110,8 +107,7 @@ def test_failing_test_is_detected_by_running_it(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: 壊した" + TRAILERS, {"src/foo.py": "def f():\n return 9\n"}) facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="false", head_branch="main", timeout=300), - [sha], {sha}, + str(work), [sha], {sha}, "false", "main" ) assert facts[0]["test_status"] == "fail" assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" @@ -128,8 +124,7 @@ def test_fix_commits_pass_verification_through_real_git(refactor, work): {"src/foo.py": "def f():\n return 1 # 直した\n"}) ordered = refactor.commits_in_range(str(work), base, "HEAD") facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [sha], set(ordered), + str(work), [sha], set(ordered), "true", "main" ) assert refactor.verify_fix_commit(facts[0]) is None diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py index af908227..6b772a0c 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py @@ -248,15 +248,13 @@ def fake_run(cmd, **kw): def test_collect_facts_marks_unknown_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: None) - ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) - facts = refactor.collect_commit_facts(ctx, ["ghost"], {"aaa"}) + facts = refactor.collect_commit_facts("/w", ["ghost"], {"aaa"}, "true", "main") assert facts == [{"sha": "ghost", "exists": False}] def test_collect_facts_marks_out_of_range_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: "zzz") - ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) - facts = refactor.collect_commit_facts(ctx, ["zzz"], {"aaa"}) + facts = refactor.collect_commit_facts("/w", ["zzz"], {"aaa"}, "true", "main") assert facts[0]["exists"] is False @@ -295,7 +293,7 @@ def _set(mapping, in_range=None): ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [ + lambda work, shas, rng, cmd, branch, timeout=None: [ mapping.get(s, {"sha": s, "exists": False}) for s in shas ], ) @@ -995,7 +993,7 @@ def test_short_and_full_sha_are_seen_as_the_same_commit( ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [fact(sha=s) for s in shas], + lambda work, shas, rng, cmd, branch, timeout=None: [fact(sha=s) for s in shas], ) write_result(state_path, "codex-apply-r1", { "items": [ diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index d6575544..13d1f695 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -36,7 +36,6 @@ import sys import time from typing import Any, Iterable, Optional -from dataclasses import dataclass sys.path.insert( 0, @@ -444,20 +443,6 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: return False -@dataclass -class VerifyContext: - """検証の共通パラメータをまとめる。 - - _verify_and_record_items, _validate_fix_commits, collect_commit_facts が - 同じ 5 パラメータを受け渡すため、1 つのオブジェクトに集約する。 - """ - work: str - scope: list[str] - test_command: str - head_branch: str - timeout: int - - def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" paths = list(scope) @@ -1166,9 +1151,13 @@ def _verify_and_record_items( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - ctx: VerifyContext, + work: str, reported: dict[str, dict[str, Any]], in_range: set[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, dry_run: bool, ) -> tuple[list[str], list[str]]: """適用項目を検証し、項目と進捗に判定を記録する。""" @@ -1183,8 +1172,10 @@ def _verify_and_record_items( problem = "適用結果に項目がありません" facts: list[dict[str, Any]] = [] else: - facts = collect_commit_facts(ctx, _reported_shas(got), in_range) - problem = verify_apply_item(item, facts, ctx.scope) + facts = collect_commit_facts( + work, _reported_shas(got), in_range, test_command, head_branch, timeout, + ) + problem = verify_apply_item(item, facts, scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem @@ -1402,17 +1393,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, owner_of, in_range = ownership_result scope = state.get("target_scope") or [] - ctx = VerifyContext( - work=work, scope=scope, test_command=test_command, - head_branch=head_branch, - timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると # 中断からの復帰手段が無くなる。 applied, failed = _verify_and_record_items( - path, state, entry, ctx, reported, in_range, args.dry_run, + path, state, entry, work, reported, in_range, scope, test_command, head_branch, + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) # 状態構築と失敗項目の取り消し @@ -1717,19 +1704,25 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: def _validate_fix_commits( - ctx: VerifyContext, + work: str, ordered_range: list[str], reported_shas: list[str], + scope: list[str], + test_command: str, + head_branch: str, + timeout: int, ) -> tuple[list[tuple[str, str]], list[str], list[str]]: """修正コミットの申告漏れと手順適合を検証する。""" - reported_full = _resolve_reported_to_full(ctx.work, reported_shas) + reported_full = _resolve_reported_to_full(work, reported_shas) unassigned = sorted(set(ordered_range) - reported_full) - facts = collect_commit_facts(ctx, reported_shas, set(ordered_range)) + facts = collect_commit_facts( + work, reported_shas, set(ordered_range), test_command, head_branch, timeout, + ) accepted: list[tuple[str, str]] = [] problems: list[str] = [] for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, ctx.scope) + problem = verify_fix_commit(commit, scope) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1793,14 +1786,10 @@ def _merge_fix_verify_and_reconcile( ) reported_shas = _reported_shas(payload) - ctx = VerifyContext( - work=work, scope=state.get("target_scope") or [], - test_command=baseline.get("command") or "true", - head_branch=state["head_branch"], - timeout=_safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) accepted, unassigned, problems = _validate_fix_commits( - ctx, ordered_range, reported_shas, + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) if unassigned: @@ -2278,7 +2267,8 @@ def run_test_at( def collect_commit_facts( - ctx: VerifyContext, shas: list[str], in_range: set[str], + work: str, shas: list[str], in_range: set[str], test_command: str, + head_branch: str, test_timeout: int = DEFAULT_TEST_TIMEOUT, ) -> list[dict[str, Any]]: """申告されたコミットについて、git と実際のテスト実行から事実を集める。 @@ -2287,19 +2277,19 @@ def collect_commit_facts( """ facts: list[dict[str, Any]] = [] for sha in shas: - full = _git_out(ctx.work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) if full is None or full not in in_range: facts.append({"sha": sha, "exists": False}) continue facts.append({ "sha": sha, "exists": True, - "trailers": commit_trailers(ctx.work, full), - "diff_lines": commit_diff_lines(ctx.work, full), - "files": commit_files(ctx.work, full), - "touches_tests": commit_touches_tests(ctx.work, full), + "trailers": commit_trailers(work, full), + "diff_lines": commit_diff_lines(work, full), + "files": commit_files(work, full), + "touches_tests": commit_touches_tests(work, full), "test_status": run_test_at( - ctx.work, full, ctx.test_command, ctx.head_branch, ctx.timeout, + work, full, test_command, head_branch, test_timeout ), }) return facts diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py index 2114bfb5..63b2bb64 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py @@ -242,7 +242,7 @@ def _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, claimed, # 検証の材料は git から取る。テストでは git 由来の事実だけを差し替える。 monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: resolved_facts, + lambda work, shas, rng, cmd, branch, timeout=None: resolved_facts, ) write_result(state_path, "codex-fix-r1", { "resolved_thread_ids": claimed, @@ -405,7 +405,7 @@ def test_broken_fix_result_does_not_crash( monkeypatch.setattr(refactor, "_git_out", lambda work, args: "HEAD") monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [], + lambda work, shas, rng, cmd, branch, timeout=None: [], ) refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) state = read_state(state_path) diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_git_facts.py index 1c13f6c9..034ceee7 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_git_facts.py @@ -60,8 +60,7 @@ def test_facts_come_from_a_real_repository(refactor, work): assert ordered == [second, first], "新しい順で返っていない" facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [first, second], set(ordered), + str(work), [first, second], set(ordered), "true", "main" ) assert [f["sha"] for f in facts] == [first, second] assert all(f["exists"] for f in facts) @@ -82,8 +81,7 @@ def test_missing_trailers_are_seen_as_missing(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: トレーラーなし", {"src/foo.py": "def f():\n return 2\n"}) facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [sha], {sha}, + str(work), [sha], {sha}, "true", "main" ) problem = refactor.verify_commit_trailers(facts[0]) assert problem is not None and "Item-Id" in problem @@ -99,8 +97,7 @@ def test_commit_outside_the_range_is_rejected(refactor, work): ordered = refactor.commits_in_range(str(work), base, "HEAD") assert ordered == [new] facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [old], set(ordered), + str(work), [old], set(ordered), "true", "main" ) assert facts[0]["exists"] is False @@ -110,8 +107,7 @@ def test_failing_test_is_detected_by_running_it(refactor, work): base = _git("rev-parse", "HEAD", cwd=work).stdout.strip() sha = _commit(work, "Refactor: 壊した" + TRAILERS, {"src/foo.py": "def f():\n return 9\n"}) facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="false", head_branch="main", timeout=300), - [sha], {sha}, + str(work), [sha], {sha}, "false", "main" ) assert facts[0]["test_status"] == "fail" assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" @@ -128,8 +124,7 @@ def test_fix_commits_pass_verification_through_real_git(refactor, work): {"src/foo.py": "def f():\n return 1 # 直した\n"}) ordered = refactor.commits_in_range(str(work), base, "HEAD") facts = refactor.collect_commit_facts( - refactor.VerifyContext(work=str(work), scope=[], test_command="true", head_branch="main", timeout=300), - [sha], set(ordered), + str(work), [sha], set(ordered), "true", "main" ) assert refactor.verify_fix_commit(facts[0]) is None diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py index af908227..6b772a0c 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py @@ -248,15 +248,13 @@ def fake_run(cmd, **kw): def test_collect_facts_marks_unknown_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: None) - ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) - facts = refactor.collect_commit_facts(ctx, ["ghost"], {"aaa"}) + facts = refactor.collect_commit_facts("/w", ["ghost"], {"aaa"}, "true", "main") assert facts == [{"sha": "ghost", "exists": False}] def test_collect_facts_marks_out_of_range_sha_as_missing(refactor, monkeypatch): monkeypatch.setattr(refactor, "_git_out", lambda work, args: "zzz") - ctx = refactor.VerifyContext(work="/w", scope=[], test_command="true", head_branch="main", timeout=300) - facts = refactor.collect_commit_facts(ctx, ["zzz"], {"aaa"}) + facts = refactor.collect_commit_facts("/w", ["zzz"], {"aaa"}, "true", "main") assert facts[0]["exists"] is False @@ -295,7 +293,7 @@ def _set(mapping, in_range=None): ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [ + lambda work, shas, rng, cmd, branch, timeout=None: [ mapping.get(s, {"sha": s, "exists": False}) for s in shas ], ) @@ -995,7 +993,7 @@ def test_short_and_full_sha_are_seen_as_the_same_commit( ) monkeypatch.setattr( refactor, "collect_commit_facts", - lambda ctx, shas, rng: [fact(sha=s) for s in shas], + lambda work, shas, rng, cmd, branch, timeout=None: [fact(sha=s) for s in shas], ) write_result(state_path, "codex-apply-r1", { "items": [ From c8377fe67b6ec2bc0746a382bbc0860dec3dfad2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:27 +0000 Subject: [PATCH 46/57] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20refactor.py#verify=5Fapply=5Fitem"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f9247dac85f6305c73ea4feb7620e81e478914e5. --- .../cross-refactoring/scripts/refactor.py | 46 ++++++++++--------- .../cross-refactoring/scripts/refactor.py | 46 ++++++++++--------- .../cross-refactoring/scripts/refactor.py | 46 ++++++++++--------- .../cross-refactoring/scripts/refactor.py | 46 ++++++++++--------- 4 files changed, 96 insertions(+), 88 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 13d1f695..2b7411f1 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -486,23 +486,20 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def _verify_single_commit( - commit: dict[str, Any], scope: Iterable[str], +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None ) -> Optional[str]: - """1 コミットの存在・トレーラー・スコープ・テスト結果を検証する。 + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - 問題があれば理由を返す。問題なければ None。 - verify_fix_commit と verify_apply_item の per-commit 検証を共通化する。 + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 """ if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が対象の範囲にありません" - "(申告だけで実体がありません)" - ) + return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) if problem: return problem - problem = verify_scope(commit, scope) + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -513,17 +510,6 @@ def _verify_single_commit( return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None -) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 - """ - return _verify_single_commit(commit, scope or []) - - def verify_apply_item( item: dict[str, Any], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -538,9 +524,22 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - problem = _verify_single_commit(commit, scope or []) + if not commit.get("exists", True): + return ( + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)" + ) + problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem + if commit.get("test_status") != "pass": + return ( + f"コミット {commit.get('sha', '?')} でテストが成功していません " + f"({commit.get('test_status')})" + ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -550,6 +549,9 @@ def verify_apply_item( ) if item.get("test_gap"): + # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 + # 実測では同じ課題で固定テストの追加数が 17 本 / 1 メソッド / 0 本と揃わなかった。 + # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 if not facts[0].get("touches_tests"): return ( "テストが乏しい項目なのに、現状固定テストの追加コミットが先行していません" diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 13d1f695..2b7411f1 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -486,23 +486,20 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def _verify_single_commit( - commit: dict[str, Any], scope: Iterable[str], +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None ) -> Optional[str]: - """1 コミットの存在・トレーラー・スコープ・テスト結果を検証する。 + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - 問題があれば理由を返す。問題なければ None。 - verify_fix_commit と verify_apply_item の per-commit 検証を共通化する。 + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 """ if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が対象の範囲にありません" - "(申告だけで実体がありません)" - ) + return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) if problem: return problem - problem = verify_scope(commit, scope) + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -513,17 +510,6 @@ def _verify_single_commit( return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None -) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 - """ - return _verify_single_commit(commit, scope or []) - - def verify_apply_item( item: dict[str, Any], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -538,9 +524,22 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - problem = _verify_single_commit(commit, scope or []) + if not commit.get("exists", True): + return ( + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)" + ) + problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem + if commit.get("test_status") != "pass": + return ( + f"コミット {commit.get('sha', '?')} でテストが成功していません " + f"({commit.get('test_status')})" + ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -550,6 +549,9 @@ def verify_apply_item( ) if item.get("test_gap"): + # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 + # 実測では同じ課題で固定テストの追加数が 17 本 / 1 メソッド / 0 本と揃わなかった。 + # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 if not facts[0].get("touches_tests"): return ( "テストが乏しい項目なのに、現状固定テストの追加コミットが先行していません" diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 13d1f695..2b7411f1 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -486,23 +486,20 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def _verify_single_commit( - commit: dict[str, Any], scope: Iterable[str], +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None ) -> Optional[str]: - """1 コミットの存在・トレーラー・スコープ・テスト結果を検証する。 + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - 問題があれば理由を返す。問題なければ None。 - verify_fix_commit と verify_apply_item の per-commit 検証を共通化する。 + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 """ if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が対象の範囲にありません" - "(申告だけで実体がありません)" - ) + return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) if problem: return problem - problem = verify_scope(commit, scope) + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -513,17 +510,6 @@ def _verify_single_commit( return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None -) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 - """ - return _verify_single_commit(commit, scope or []) - - def verify_apply_item( item: dict[str, Any], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -538,9 +524,22 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - problem = _verify_single_commit(commit, scope or []) + if not commit.get("exists", True): + return ( + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)" + ) + problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem + if commit.get("test_status") != "pass": + return ( + f"コミット {commit.get('sha', '?')} でテストが成功していません " + f"({commit.get('test_status')})" + ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -550,6 +549,9 @@ def verify_apply_item( ) if item.get("test_gap"): + # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 + # 実測では同じ課題で固定テストの追加数が 17 本 / 1 メソッド / 0 本と揃わなかった。 + # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 if not facts[0].get("touches_tests"): return ( "テストが乏しい項目なのに、現状固定テストの追加コミットが先行していません" diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 13d1f695..2b7411f1 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -486,23 +486,20 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def _verify_single_commit( - commit: dict[str, Any], scope: Iterable[str], +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None ) -> Optional[str]: - """1 コミットの存在・トレーラー・スコープ・テスト結果を検証する。 + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - 問題があれば理由を返す。問題なければ None。 - verify_fix_commit と verify_apply_item の per-commit 検証を共通化する。 + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 """ if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が対象の範囲にありません" - "(申告だけで実体がありません)" - ) + return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) if problem: return problem - problem = verify_scope(commit, scope) + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -513,17 +510,6 @@ def _verify_single_commit( return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None -) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 - """ - return _verify_single_commit(commit, scope or []) - - def verify_apply_item( item: dict[str, Any], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -538,9 +524,22 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - problem = _verify_single_commit(commit, scope or []) + if not commit.get("exists", True): + return ( + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)" + ) + problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem + if commit.get("test_status") != "pass": + return ( + f"コミット {commit.get('sha', '?')} でテストが成功していません " + f"({commit.get('test_status')})" + ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -550,6 +549,9 @@ def verify_apply_item( ) if item.get("test_gap"): + # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 + # 実測では同じ課題で固定テストの追加数が 17 本 / 1 メソッド / 0 本と揃わなかった。 + # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 if not facts[0].get("touches_tests"): return ( "テストが乏しい項目なのに、現状固定テストの追加コミットが先行していません" From 73d3276f975805238901883a46c5c1997965046f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:27 +0000 Subject: [PATCH 47/57] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ef60c8737457601b57b96f49a50c7328b8522091. --- .../cross-refactoring/scripts/refactor.py | 77 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 77 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 77 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 77 ++++++++----------- 4 files changed, 128 insertions(+), 180 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 2b7411f1..66b4a191 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1203,48 +1203,6 @@ def _verify_and_record_items( return applied, failed -def _merge_apply_build_state_and_finalize( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - applied: list[str], - failed: list[str], - head_sha: str, - payload: dict[str, Any], - dry_run: bool, -) -> None: - """apply 状態の構築と、失敗項目の取り消し・保存。 - - dry-run / 部分失敗 / 全成功で分岐し、適切に終了する。 - """ - entry["apply"] = { - "applied": applied, - "failed": failed, - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") - ) - state["phase"] = "review" if applied else "propose" - - if dry_run: - if failed: - _drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(entry["apply"]["applied"]) - elif failed: - applied = _apply_drop(path, state, entry, failed) - else: - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) - - def _merge_apply_validate_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1404,10 +1362,39 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) - # 状態構築と失敗項目の取り消し - _merge_apply_build_state_and_finalize( - path, state, entry, applied, failed, head_sha, payload, args.dry_run, + entry["apply"] = { + "applied": applied, + "failed": failed, + # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") ) + state["phase"] = "review" if applied else "propose" + + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if args.dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(entry["apply"]["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(path, state, entry, failed) + else: + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) def _run_drop( diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 2b7411f1..66b4a191 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1203,48 +1203,6 @@ def _verify_and_record_items( return applied, failed -def _merge_apply_build_state_and_finalize( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - applied: list[str], - failed: list[str], - head_sha: str, - payload: dict[str, Any], - dry_run: bool, -) -> None: - """apply 状態の構築と、失敗項目の取り消し・保存。 - - dry-run / 部分失敗 / 全成功で分岐し、適切に終了する。 - """ - entry["apply"] = { - "applied": applied, - "failed": failed, - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") - ) - state["phase"] = "review" if applied else "propose" - - if dry_run: - if failed: - _drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(entry["apply"]["applied"]) - elif failed: - applied = _apply_drop(path, state, entry, failed) - else: - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) - - def _merge_apply_validate_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1404,10 +1362,39 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) - # 状態構築と失敗項目の取り消し - _merge_apply_build_state_and_finalize( - path, state, entry, applied, failed, head_sha, payload, args.dry_run, + entry["apply"] = { + "applied": applied, + "failed": failed, + # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") ) + state["phase"] = "review" if applied else "propose" + + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if args.dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(entry["apply"]["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(path, state, entry, failed) + else: + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) def _run_drop( diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 2b7411f1..66b4a191 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1203,48 +1203,6 @@ def _verify_and_record_items( return applied, failed -def _merge_apply_build_state_and_finalize( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - applied: list[str], - failed: list[str], - head_sha: str, - payload: dict[str, Any], - dry_run: bool, -) -> None: - """apply 状態の構築と、失敗項目の取り消し・保存。 - - dry-run / 部分失敗 / 全成功で分岐し、適切に終了する。 - """ - entry["apply"] = { - "applied": applied, - "failed": failed, - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") - ) - state["phase"] = "review" if applied else "propose" - - if dry_run: - if failed: - _drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(entry["apply"]["applied"]) - elif failed: - applied = _apply_drop(path, state, entry, failed) - else: - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) - - def _merge_apply_validate_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1404,10 +1362,39 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) - # 状態構築と失敗項目の取り消し - _merge_apply_build_state_and_finalize( - path, state, entry, applied, failed, head_sha, payload, args.dry_run, + entry["apply"] = { + "applied": applied, + "failed": failed, + # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") ) + state["phase"] = "review" if applied else "propose" + + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if args.dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(entry["apply"]["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(path, state, entry, failed) + else: + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) def _run_drop( diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2b7411f1..66b4a191 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1203,48 +1203,6 @@ def _verify_and_record_items( return applied, failed -def _merge_apply_build_state_and_finalize( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - applied: list[str], - failed: list[str], - head_sha: str, - payload: dict[str, Any], - dry_run: bool, -) -> None: - """apply 状態の構築と、失敗項目の取り消し・保存。 - - dry-run / 部分失敗 / 全成功で分岐し、適切に終了する。 - """ - entry["apply"] = { - "applied": applied, - "failed": failed, - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") - ) - state["phase"] = "review" if applied else "propose" - - if dry_run: - if failed: - _drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(entry["apply"]["applied"]) - elif failed: - applied = _apply_drop(path, state, entry, failed) - else: - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) - - def _merge_apply_validate_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1404,10 +1362,39 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), args.dry_run, ) - # 状態構築と失敗項目の取り消し - _merge_apply_build_state_and_finalize( - path, state, entry, applied, failed, head_sha, payload, args.dry_run, + entry["apply"] = { + "applied": applied, + "failed": failed, + # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") ) + state["phase"] = "review" if applied else "propose" + + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if args.dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(entry["apply"]["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(path, state, entry, failed) + else: + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) def _run_drop( From e476f591d862a70803e224d3c3f07bb8dd509412 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:27 +0000 Subject: [PATCH 48/57] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20refactor.py#cmd=5Fmerge=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6ffb03849b68ceb96cf43ad0bf9705262ae3e229. --- .../cross-refactoring/scripts/refactor.py | 142 ++++++++---------- .../cross-refactoring/scripts/refactor.py | 142 ++++++++---------- .../cross-refactoring/scripts/refactor.py | 142 ++++++++---------- .../cross-refactoring/scripts/refactor.py | 142 ++++++++---------- 4 files changed, 260 insertions(+), 308 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 66b4a191..321d0158 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1203,77 +1203,6 @@ def _verify_and_record_items( return applied, failed -def _merge_apply_validate_ownership( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - ordered_range: list[str], - payload: dict[str, Any], - head_sha: str, - dry_run: bool, -) -> Optional[tuple[dict[str, list[str]], dict[str, str], set[str]]]: - """所有権検証。不正時はラウンドを取り消して sys.exit(2) する。 - - Returns: - 正常時は (reported, owner_of, in_range) のタプル。 - 不正時はこの関数内で sys.exit(2) し、返らない。 - """ - round_items = set(entry["items"]) - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) - ) - if not (unassigned or unknown_ids or duplicated): - return reported, owner_of, set(ordered_range) - - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", dry_run, - ) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1344,13 +1273,72 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: code=2, ) - # 申告は**このラウンドの改善項目のものだけ**を採る。 - ownership_result = _merge_apply_validate_ownership( - path, state, entry, work, ordered_range, payload, head_sha, args.dry_run, + # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた + # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 + # そのまま Pull Request に残せてしまう。 + round_items = set(entry["items"]) + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + # 実際の差分が食い違い、どちらが正しいか決められなくなる。 + # + # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) ) - # 不正時は _merge_apply_validate_ownership 内で sys.exit(2) するので、 - # ここに到達した時点で正常。 - reported, owner_of, in_range = ownership_result + if unassigned or unknown_ids or duplicated: + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 66b4a191..321d0158 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1203,77 +1203,6 @@ def _verify_and_record_items( return applied, failed -def _merge_apply_validate_ownership( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - ordered_range: list[str], - payload: dict[str, Any], - head_sha: str, - dry_run: bool, -) -> Optional[tuple[dict[str, list[str]], dict[str, str], set[str]]]: - """所有権検証。不正時はラウンドを取り消して sys.exit(2) する。 - - Returns: - 正常時は (reported, owner_of, in_range) のタプル。 - 不正時はこの関数内で sys.exit(2) し、返らない。 - """ - round_items = set(entry["items"]) - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) - ) - if not (unassigned or unknown_ids or duplicated): - return reported, owner_of, set(ordered_range) - - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", dry_run, - ) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1344,13 +1273,72 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: code=2, ) - # 申告は**このラウンドの改善項目のものだけ**を採る。 - ownership_result = _merge_apply_validate_ownership( - path, state, entry, work, ordered_range, payload, head_sha, args.dry_run, + # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた + # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 + # そのまま Pull Request に残せてしまう。 + round_items = set(entry["items"]) + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + # 実際の差分が食い違い、どちらが正しいか決められなくなる。 + # + # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) ) - # 不正時は _merge_apply_validate_ownership 内で sys.exit(2) するので、 - # ここに到達した時点で正常。 - reported, owner_of, in_range = ownership_result + if unassigned or unknown_ids or duplicated: + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 66b4a191..321d0158 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1203,77 +1203,6 @@ def _verify_and_record_items( return applied, failed -def _merge_apply_validate_ownership( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - ordered_range: list[str], - payload: dict[str, Any], - head_sha: str, - dry_run: bool, -) -> Optional[tuple[dict[str, list[str]], dict[str, str], set[str]]]: - """所有権検証。不正時はラウンドを取り消して sys.exit(2) する。 - - Returns: - 正常時は (reported, owner_of, in_range) のタプル。 - 不正時はこの関数内で sys.exit(2) し、返らない。 - """ - round_items = set(entry["items"]) - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) - ) - if not (unassigned or unknown_ids or duplicated): - return reported, owner_of, set(ordered_range) - - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", dry_run, - ) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1344,13 +1273,72 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: code=2, ) - # 申告は**このラウンドの改善項目のものだけ**を採る。 - ownership_result = _merge_apply_validate_ownership( - path, state, entry, work, ordered_range, payload, head_sha, args.dry_run, + # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた + # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 + # そのまま Pull Request に残せてしまう。 + round_items = set(entry["items"]) + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + # 実際の差分が食い違い、どちらが正しいか決められなくなる。 + # + # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) ) - # 不正時は _merge_apply_validate_ownership 内で sys.exit(2) するので、 - # ここに到達した時点で正常。 - reported, owner_of, in_range = ownership_result + if unassigned or unknown_ids or duplicated: + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 66b4a191..321d0158 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1203,77 +1203,6 @@ def _verify_and_record_items( return applied, failed -def _merge_apply_validate_ownership( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - ordered_range: list[str], - payload: dict[str, Any], - head_sha: str, - dry_run: bool, -) -> Optional[tuple[dict[str, list[str]], dict[str, str], set[str]]]: - """所有権検証。不正時はラウンドを取り消して sys.exit(2) する。 - - Returns: - 正常時は (reported, owner_of, in_range) のタプル。 - 不正時はこの関数内で sys.exit(2) し、返らない。 - """ - round_items = set(entry["items"]) - reported, owner_of, unassigned, unknown_ids, duplicated = ( - _validate_commit_ownership(work, ordered_range, payload, round_items) - ) - if not (unassigned or unknown_ids or duplicated): - return reported, owner_of, set(ordered_range) - - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "apply_base_sha", dry_run, - ) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) - - def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用結果を検証して取り込む。 @@ -1344,13 +1273,72 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: code=2, ) - # 申告は**このラウンドの改善項目のものだけ**を採る。 - ownership_result = _merge_apply_validate_ownership( - path, state, entry, work, ordered_range, payload, head_sha, args.dry_run, + # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた + # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 + # そのまま Pull Request に残せてしまう。 + round_items = set(entry["items"]) + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + # 実際の差分が食い違い、どちらが正しいか決められなくなる。 + # + # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + reported, owner_of, unassigned, unknown_ids, duplicated = ( + _validate_commit_ownership(work, ordered_range, payload, round_items) ) - # 不正時は _merge_apply_validate_ownership 内で sys.exit(2) するので、 - # ここに到達した時点で正常。 - reported, owner_of, in_range = ownership_result + if unassigned or unknown_ids or duplicated: + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "apply_base_sha", args.dry_run, + ) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) scope = state.get("target_scope") or [] # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した From bf9d2d4f574dae9e6bfcf9de66b15b0a5e989416 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:27 +0000 Subject: [PATCH 49/57] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ec9caf548fde5b701d667d8073ba113d5277f2ff. --- .../cross-refactoring/scripts/refactor.py | 50 +++++++------------ .../cross-refactoring/scripts/refactor.py | 50 +++++++------------ .../cross-refactoring/scripts/refactor.py | 50 +++++++------------ .../cross-refactoring/scripts/refactor.py | 50 +++++++------------ 4 files changed, 72 insertions(+), 128 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 321d0158..d1287cbd 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1708,36 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_finalize( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - resolved: set[str], - merge_key: str, - payload: dict[str, Any], - needs_push: bool, -) -> None: - """レビュー解決状態の反映・ラウンド計数・保存・push。""" - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - merged_keys = entry.setdefault("fix_merged_keys", []) - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 - - entry.setdefault("durations", {})["fix"] = ( - entry.get("durations", {}).get("fix", 0) - + _safe_int(payload.get("elapsed_seconds")) - ) - statefile.save(path, state) - if needs_push: - _push_with_retry_marker(path, state, entry) - info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") - - def _merge_fix_verify_and_reconcile( path: pathlib.Path, state: dict[str, Any], @@ -1868,6 +1838,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() ) + merged_keys = entry.setdefault("fix_merged_keys", []) # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) @@ -1881,8 +1852,23 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - # 状態保存と push - _merge_fix_finalize(path, state, entry, resolved, merge_key, payload, needs_push) + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + merged_keys.append(merge_key) + entry["fix_rounds"] += 1 + + entry.setdefault("durations", {})["fix"] = ( + entry.get("durations", {}).get("fix", 0) + + _safe_int(payload.get("elapsed_seconds")) + ) + statefile.save(path, state) + if needs_push: + _push_with_retry_marker(path, state, entry) + info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") def cmd_advance(args: argparse.Namespace) -> None: diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 321d0158..d1287cbd 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1708,36 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_finalize( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - resolved: set[str], - merge_key: str, - payload: dict[str, Any], - needs_push: bool, -) -> None: - """レビュー解決状態の反映・ラウンド計数・保存・push。""" - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - merged_keys = entry.setdefault("fix_merged_keys", []) - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 - - entry.setdefault("durations", {})["fix"] = ( - entry.get("durations", {}).get("fix", 0) - + _safe_int(payload.get("elapsed_seconds")) - ) - statefile.save(path, state) - if needs_push: - _push_with_retry_marker(path, state, entry) - info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") - - def _merge_fix_verify_and_reconcile( path: pathlib.Path, state: dict[str, Any], @@ -1868,6 +1838,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() ) + merged_keys = entry.setdefault("fix_merged_keys", []) # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) @@ -1881,8 +1852,23 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - # 状態保存と push - _merge_fix_finalize(path, state, entry, resolved, merge_key, payload, needs_push) + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + merged_keys.append(merge_key) + entry["fix_rounds"] += 1 + + entry.setdefault("durations", {})["fix"] = ( + entry.get("durations", {}).get("fix", 0) + + _safe_int(payload.get("elapsed_seconds")) + ) + statefile.save(path, state) + if needs_push: + _push_with_retry_marker(path, state, entry) + info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") def cmd_advance(args: argparse.Namespace) -> None: diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 321d0158..d1287cbd 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1708,36 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_finalize( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - resolved: set[str], - merge_key: str, - payload: dict[str, Any], - needs_push: bool, -) -> None: - """レビュー解決状態の反映・ラウンド計数・保存・push。""" - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - merged_keys = entry.setdefault("fix_merged_keys", []) - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 - - entry.setdefault("durations", {})["fix"] = ( - entry.get("durations", {}).get("fix", 0) - + _safe_int(payload.get("elapsed_seconds")) - ) - statefile.save(path, state) - if needs_push: - _push_with_retry_marker(path, state, entry) - info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") - - def _merge_fix_verify_and_reconcile( path: pathlib.Path, state: dict[str, Any], @@ -1868,6 +1838,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() ) + merged_keys = entry.setdefault("fix_merged_keys", []) # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) @@ -1881,8 +1852,23 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - # 状態保存と push - _merge_fix_finalize(path, state, entry, resolved, merge_key, payload, needs_push) + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + merged_keys.append(merge_key) + entry["fix_rounds"] += 1 + + entry.setdefault("durations", {})["fix"] = ( + entry.get("durations", {}).get("fix", 0) + + _safe_int(payload.get("elapsed_seconds")) + ) + statefile.save(path, state) + if needs_push: + _push_with_retry_marker(path, state, entry) + info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") def cmd_advance(args: argparse.Namespace) -> None: diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 321d0158..d1287cbd 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1708,36 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_finalize( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - resolved: set[str], - merge_key: str, - payload: dict[str, Any], - needs_push: bool, -) -> None: - """レビュー解決状態の反映・ラウンド計数・保存・push。""" - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - merged_keys = entry.setdefault("fix_merged_keys", []) - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 - - entry.setdefault("durations", {})["fix"] = ( - entry.get("durations", {}).get("fix", 0) - + _safe_int(payload.get("elapsed_seconds")) - ) - statefile.save(path, state) - if needs_push: - _push_with_retry_marker(path, state, entry) - info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") - - def _merge_fix_verify_and_reconcile( path: pathlib.Path, state: dict[str, Any], @@ -1868,6 +1838,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() ) + merged_keys = entry.setdefault("fix_merged_keys", []) # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) @@ -1881,8 +1852,23 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() - # 状態保存と push - _merge_fix_finalize(path, state, entry, resolved, merge_key, payload, needs_push) + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + merged_keys.append(merge_key) + entry["fix_rounds"] += 1 + + entry.setdefault("durations", {})["fix"] = ( + entry.get("durations", {}).get("fix", 0) + + _safe_int(payload.get("elapsed_seconds")) + ) + statefile.save(path, state) + if needs_push: + _push_with_retry_marker(path, state, entry) + info(f"修正を取り込みました(解決 {len(resolved)} スレッド / 修正ラウンド {entry['fix_rounds']})") def cmd_advance(args: argparse.Namespace) -> None: From 3c181542c2306e02235c7a905bbe93f36c717c45 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:27 +0000 Subject: [PATCH 50/57] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7f531cc006a76f04c48a4c22a4daa4eee7a03733. --- .../cross-refactoring/scripts/refactor.py | 106 +++++++++--------- .../cross-refactoring/scripts/refactor.py | 106 +++++++++--------- .../cross-refactoring/scripts/refactor.py | 106 +++++++++--------- .../cross-refactoring/scripts/refactor.py | 106 +++++++++--------- 4 files changed, 200 insertions(+), 224 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index d1287cbd..cb321cfa 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1708,57 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_verify_and_reconcile( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - head_now: str, - payload: dict[str, Any], -) -> tuple[bool, set[str]]: - """修正コミット範囲の確定・検証・不正時の取り消し。 - - Returns: - (needs_push, resolved): needs_push が True なら呼び出し側が push する。 - resolved が渡された resolved_threads を返す(失敗時は空集合にリセット)。 - """ - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) - return True, set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - return False, set() # sentinel — resolved is not reset here - - def _merge_fix_resolve_threads( payload: dict[str, Any], repo: str, @@ -1843,14 +1792,59 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) - # 修正コミットの検証と取り消し - needs_push, reset_resolved = _merge_fix_verify_and_reconcile( - path, state, entry, work, head_now, payload, + # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 + # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} + # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は + # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** + # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける + # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 + # 適用フェーズの未割当コミットと同じ扱いにする。 + needs_push = False + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - if reset_resolved is not None and needs_push: - # 検証失敗: 解決の申告は採用しない + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + needs_push = True info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index d1287cbd..cb321cfa 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1708,57 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_verify_and_reconcile( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - head_now: str, - payload: dict[str, Any], -) -> tuple[bool, set[str]]: - """修正コミット範囲の確定・検証・不正時の取り消し。 - - Returns: - (needs_push, resolved): needs_push が True なら呼び出し側が push する。 - resolved が渡された resolved_threads を返す(失敗時は空集合にリセット)。 - """ - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) - return True, set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - return False, set() # sentinel — resolved is not reset here - - def _merge_fix_resolve_threads( payload: dict[str, Any], repo: str, @@ -1843,14 +1792,59 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) - # 修正コミットの検証と取り消し - needs_push, reset_resolved = _merge_fix_verify_and_reconcile( - path, state, entry, work, head_now, payload, + # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 + # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} + # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は + # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** + # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける + # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 + # 適用フェーズの未割当コミットと同じ扱いにする。 + needs_push = False + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - if reset_resolved is not None and needs_push: - # 検証失敗: 解決の申告は採用しない + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + needs_push = True info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index d1287cbd..cb321cfa 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1708,57 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_verify_and_reconcile( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - head_now: str, - payload: dict[str, Any], -) -> tuple[bool, set[str]]: - """修正コミット範囲の確定・検証・不正時の取り消し。 - - Returns: - (needs_push, resolved): needs_push が True なら呼び出し側が push する。 - resolved が渡された resolved_threads を返す(失敗時は空集合にリセット)。 - """ - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) - return True, set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - return False, set() # sentinel — resolved is not reset here - - def _merge_fix_resolve_threads( payload: dict[str, Any], repo: str, @@ -1843,14 +1792,59 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) - # 修正コミットの検証と取り消し - needs_push, reset_resolved = _merge_fix_verify_and_reconcile( - path, state, entry, work, head_now, payload, + # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 + # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} + # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は + # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** + # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける + # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 + # 適用フェーズの未割当コミットと同じ扱いにする。 + needs_push = False + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - if reset_resolved is not None and needs_push: - # 検証失敗: 解決の申告は採用しない + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + needs_push = True info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index d1287cbd..cb321cfa 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1708,57 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_verify_and_reconcile( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - head_now: str, - payload: dict[str, Any], -) -> tuple[bool, set[str]]: - """修正コミット範囲の確定・検証・不正時の取り消し。 - - Returns: - (needs_push, resolved): needs_push が True なら呼び出し側が push する。 - resolved が渡された resolved_threads を返す(失敗時は空集合にリセット)。 - """ - baseline = state.get("baseline_test") or {} - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) - if ordered_range is None: - die( - "修正の範囲を確定できませんでした" - f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" - "検証できない修正は採りません", - code=2, - ) - reported_shas = _reported_shas(payload) - - accepted, unassigned, problems = _validate_fix_commits( - work, ordered_range, reported_shas, state.get("target_scope") or [], - baseline.get("command") or "true", state["head_branch"], - _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), - ) - - if unassigned: - info( - f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - - if unassigned or problems: - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - _revert_range_with_recovery( - path, state, entry, work, ordered_range, "fix_base_sha", - ) - return True, set() - else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - return False, set() # sentinel — resolved is not reset here - - def _merge_fix_resolve_threads( payload: dict[str, Any], repo: str, @@ -1843,14 +1792,59 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # スレッド解決の照合 resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) - # 修正コミットの検証と取り消し - needs_push, reset_resolved = _merge_fix_verify_and_reconcile( - path, state, entry, work, head_now, payload, + # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 + # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 + baseline = state.get("baseline_test") or {} + # 修正の範囲も**オーケストレータが記録した起点**から取る。起点は + # `judge-review` が変更要求を返したときの HEAD である。 + ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + if ordered_range is None: + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" + "検証できない修正は採りません", + code=2, + ) + reported_shas = _reported_shas(payload) + + # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** + # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける + # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 + # 適用フェーズの未割当コミットと同じ扱いにする。 + needs_push = False + accepted, unassigned, problems = _validate_fix_commits( + work, ordered_range, reported_shas, state.get("target_scope") or [], + baseline.get("command") or "true", state["head_branch"], + _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - if reset_resolved is not None and needs_push: - # 検証失敗: 解決の申告は採用しない + + if unassigned: + info( + f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + + if unassigned or problems: + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + _revert_range_with_recovery( + path, state, entry, work, ordered_range, "fix_base_sha", + ) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + needs_push = True info("⚠ 修正を取り消したため、解決の申告は採用しません") resolved = set() + else: + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: From b44d1c9f0710a94f76a981ad73add854128a06d1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:27 +0000 Subject: [PATCH 51/57] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c93228233d50beabdc01229cbdd9e9d5688c6770. --- .../cross-refactoring/scripts/refactor.py | 56 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 56 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 56 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 56 ++++++++----------- 4 files changed, 88 insertions(+), 136 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index cb321cfa..c0016e43 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1708,38 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_resolve_threads( - payload: dict[str, Any], - repo: str, - pr: int, -) -> set[str]: - """resolved_thread_ids の正規化と GitHub 実状態との照合。 - - 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を返す。 - """ - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(repo, pr) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return resolved - - def _merge_fix_load_and_check_idempotent( path: pathlib.Path, state: dict[str, Any], @@ -1789,8 +1757,28 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) merged_keys = entry.setdefault("fix_merged_keys", []) - # スレッド解決の照合 - resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) + # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + resolved: set[str] = set() + else: + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index cb321cfa..c0016e43 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1708,38 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_resolve_threads( - payload: dict[str, Any], - repo: str, - pr: int, -) -> set[str]: - """resolved_thread_ids の正規化と GitHub 実状態との照合。 - - 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を返す。 - """ - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(repo, pr) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return resolved - - def _merge_fix_load_and_check_idempotent( path: pathlib.Path, state: dict[str, Any], @@ -1789,8 +1757,28 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) merged_keys = entry.setdefault("fix_merged_keys", []) - # スレッド解決の照合 - resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) + # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + resolved: set[str] = set() + else: + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index cb321cfa..c0016e43 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1708,38 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_resolve_threads( - payload: dict[str, Any], - repo: str, - pr: int, -) -> set[str]: - """resolved_thread_ids の正規化と GitHub 実状態との照合。 - - 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を返す。 - """ - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(repo, pr) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return resolved - - def _merge_fix_load_and_check_idempotent( path: pathlib.Path, state: dict[str, Any], @@ -1789,8 +1757,28 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) merged_keys = entry.setdefault("fix_merged_keys", []) - # スレッド解決の照合 - resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) + # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + resolved: set[str] = set() + else: + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index cb321cfa..c0016e43 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1708,38 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_resolve_threads( - payload: dict[str, Any], - repo: str, - pr: int, -) -> set[str]: - """resolved_thread_ids の正規化と GitHub 実状態との照合。 - - 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を返す。 - """ - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(repo, pr) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - return set() - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") - return resolved - - def _merge_fix_load_and_check_idempotent( path: pathlib.Path, state: dict[str, Any], @@ -1789,8 +1757,28 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) merged_keys = entry.setdefault("fix_merged_keys", []) - # スレッド解決の照合 - resolved = _merge_fix_resolve_threads(payload, state["repo"], state["current_pr"]) + # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + resolved: set[str] = set() + else: + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From d468f4d1a4ffef4b8304a49875ca769c6f30796a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:28 +0000 Subject: [PATCH 52/57] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9aaab34b1955307837a6ad49b4f27386d0bb0764. --- .../cross-refactoring/scripts/refactor.py | 52 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 52 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 52 ++++++++----------- .../cross-refactoring/scripts/refactor.py | 52 ++++++++----------- 4 files changed, 84 insertions(+), 124 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index c0016e43..f4093f23 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1708,33 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_load_and_check_idempotent( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - result: pathlib.Path, - payload: dict[str, Any], -) -> Optional[str]: - """修正結果の読込と merge_key による再実行判定。 - - 取り込み済みなら merge_key を返す(呼び出し側は return する)。 - 新規なら None を返す。 - """ - attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) - merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) - return merge_key - return None - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1744,10 +1717,21 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # 冪等性チェック - if _merge_fix_load_and_check_idempotent(path, state, entry, result, payload) is not None: - return - + # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 + # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 + # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 + # 鍵は**試行番号と結果ファイルの内容**から作る。 + # + # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず + # 同じ申告を再処理してしまう。 + # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ + # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま + # 同じ修正を起動し続ける。 + # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に + # なりうる。 + # + # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が + # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) @@ -1756,6 +1740,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: + hashlib.sha256(result.read_bytes()).hexdigest() ) merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index c0016e43..f4093f23 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1708,33 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_load_and_check_idempotent( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - result: pathlib.Path, - payload: dict[str, Any], -) -> Optional[str]: - """修正結果の読込と merge_key による再実行判定。 - - 取り込み済みなら merge_key を返す(呼び出し側は return する)。 - 新規なら None を返す。 - """ - attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) - merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) - return merge_key - return None - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1744,10 +1717,21 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # 冪等性チェック - if _merge_fix_load_and_check_idempotent(path, state, entry, result, payload) is not None: - return - + # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 + # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 + # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 + # 鍵は**試行番号と結果ファイルの内容**から作る。 + # + # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず + # 同じ申告を再処理してしまう。 + # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ + # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま + # 同じ修正を起動し続ける。 + # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に + # なりうる。 + # + # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が + # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) @@ -1756,6 +1740,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: + hashlib.sha256(result.read_bytes()).hexdigest() ) merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index c0016e43..f4093f23 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1708,33 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_load_and_check_idempotent( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - result: pathlib.Path, - payload: dict[str, Any], -) -> Optional[str]: - """修正結果の読込と merge_key による再実行判定。 - - 取り込み済みなら merge_key を返す(呼び出し側は return する)。 - 新規なら None を返す。 - """ - attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) - merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) - return merge_key - return None - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1744,10 +1717,21 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # 冪等性チェック - if _merge_fix_load_and_check_idempotent(path, state, entry, result, payload) is not None: - return - + # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 + # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 + # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 + # 鍵は**試行番号と結果ファイルの内容**から作る。 + # + # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず + # 同じ申告を再処理してしまう。 + # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ + # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま + # 同じ修正を起動し続ける。 + # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に + # なりうる。 + # + # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が + # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) @@ -1756,6 +1740,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: + hashlib.sha256(result.read_bytes()).hexdigest() ) merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index c0016e43..f4093f23 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1708,33 +1708,6 @@ def _validate_fix_commits( return accepted, unassigned, problems -def _merge_fix_load_and_check_idempotent( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - result: pathlib.Path, - payload: dict[str, Any], -) -> Optional[str]: - """修正結果の読込と merge_key による再実行判定。 - - 取り込み済みなら merge_key を返す(呼び出し側は return する)。 - 新規なら None を返す。 - """ - attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) - merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) - return merge_key - return None - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1744,10 +1717,21 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # 冪等性チェック - if _merge_fix_load_and_check_idempotent(path, state, entry, result, payload) is not None: - return - + # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 + # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 + # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 + # 鍵は**試行番号と結果ファイルの内容**から作る。 + # + # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず + # 同じ申告を再処理してしまう。 + # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ + # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま + # 同じ修正を起動し続ける。 + # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に + # なりうる。 + # + # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が + # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" attempt = entry.get("fix_attempts", 0) @@ -1756,6 +1740,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: + hashlib.sha256(result.read_bytes()).hexdigest() ) merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の From aca1783f5d0045ede9d69c9b02d150655e8f8f18 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:28 +0000 Subject: [PATCH 53/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 674086e0df3e67d8852899bfdbcd145c5484c439. --- .../cross-review/scripts/lib/monitor.py | 55 +++++++------------ .../cross-review/scripts/lib/monitor.py | 55 +++++++------------ .../cross-review/scripts/lib/monitor.py | 55 +++++++------------ .../cross-review/scripts/lib/monitor.py | 55 +++++++------------ 4 files changed, 84 insertions(+), 136 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index 8a11a13e..523addce 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -613,36 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_early_error( - pid: int, - paths: AgentPaths, - status: AgentStatus, - agent: str, - alive: bool, -) -> Optional[AgentStatus]: - """err.log (claude は stdout.log も) に致命パターンがあれば EARLY_ERROR を返す。 - - 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - この関数では扱わない(呼び出し側で WARN として警告ログのみ出す)。 - """ - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if not fatal_err: - return None - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - return status - - def _check_result_age_fallback( pid: int, paths: AgentPaths, @@ -810,11 +780,28 @@ def monitor_agent( return status # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - result = _check_early_error(pid, paths, status, agent, alive) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status if not warned_early_error: warn_err = _scan_early_warn(paths.err_log) diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 8a11a13e..523addce 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -613,36 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_early_error( - pid: int, - paths: AgentPaths, - status: AgentStatus, - agent: str, - alive: bool, -) -> Optional[AgentStatus]: - """err.log (claude は stdout.log も) に致命パターンがあれば EARLY_ERROR を返す。 - - 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - この関数では扱わない(呼び出し側で WARN として警告ログのみ出す)。 - """ - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if not fatal_err: - return None - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - return status - - def _check_result_age_fallback( pid: int, paths: AgentPaths, @@ -810,11 +780,28 @@ def monitor_agent( return status # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - result = _check_early_error(pid, paths, status, agent, alive) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status if not warned_early_error: warn_err = _scan_early_warn(paths.err_log) diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 8a11a13e..523addce 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -613,36 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_early_error( - pid: int, - paths: AgentPaths, - status: AgentStatus, - agent: str, - alive: bool, -) -> Optional[AgentStatus]: - """err.log (claude は stdout.log も) に致命パターンがあれば EARLY_ERROR を返す。 - - 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - この関数では扱わない(呼び出し側で WARN として警告ログのみ出す)。 - """ - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if not fatal_err: - return None - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - return status - - def _check_result_age_fallback( pid: int, paths: AgentPaths, @@ -810,11 +780,28 @@ def monitor_agent( return status # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - result = _check_early_error(pid, paths, status, agent, alive) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status if not warned_early_error: warn_err = _scan_early_warn(paths.err_log) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 8a11a13e..523addce 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,36 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_early_error( - pid: int, - paths: AgentPaths, - status: AgentStatus, - agent: str, - alive: bool, -) -> Optional[AgentStatus]: - """err.log (claude は stdout.log も) に致命パターンがあれば EARLY_ERROR を返す。 - - 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - この関数では扱わない(呼び出し側で WARN として警告ログのみ出す)。 - """ - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if not fatal_err: - return None - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - return status - - def _check_result_age_fallback( pid: int, paths: AgentPaths, @@ -810,11 +780,28 @@ def monitor_agent( return status # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 if not no_early_error: - result = _check_early_error(pid, paths, status, agent, alive) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status if not warned_early_error: warn_err = _scan_early_warn(paths.err_log) From 45064e18142c2de7fbc5fe04b77f28776fd9e14c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:28 +0000 Subject: [PATCH 54/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d390be783df9f18e1822c13026066718b288a17e. --- .../cross-review/scripts/lib/monitor.py | 75 +++++++------------ .../cross-review/scripts/lib/monitor.py | 75 +++++++------------ .../cross-review/scripts/lib/monitor.py | 75 +++++++------------ .../cross-review/scripts/lib/monitor.py | 75 +++++++------------ 4 files changed, 112 insertions(+), 188 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index 523addce..e3278662 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -613,47 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_result_age_fallback( - pid: int, - paths: AgentPaths, - status: AgentStatus, - started_wall: float, - cmdline_validated: bool, -) -> Optional[AgentStatus]: - """result.json の age fallback で完了判定する。 - - sentinel 機構を持たない agent (gemini) 向け。result.json が書かれた後も - プロセスがハングするケース (MCP サーバー切断待ち等で exit しない) の - fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - 完了とみなし、プロセスを kill → OK。 - - 安全条件: - - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - - mtime >= started_wall: 前 round の stale result.json を拾わない - """ - if status.sentinel_seen: - return None - if not cmdline_validated: - return None - if not (paths.result.exists() and paths.result.stat().st_size > 0): - return None - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return None - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return None - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - return status - - def _check_codex_sentinel_complete( pid: int, paths: AgentPaths, @@ -748,12 +707,34 @@ def monitor_agent( _emit_log(log_prefix, agent, result) return result - result = _check_result_age_fallback( - pid, paths, status, started_wall, cmdline_validated, - ) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index 523addce..e3278662 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -613,47 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_result_age_fallback( - pid: int, - paths: AgentPaths, - status: AgentStatus, - started_wall: float, - cmdline_validated: bool, -) -> Optional[AgentStatus]: - """result.json の age fallback で完了判定する。 - - sentinel 機構を持たない agent (gemini) 向け。result.json が書かれた後も - プロセスがハングするケース (MCP サーバー切断待ち等で exit しない) の - fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - 完了とみなし、プロセスを kill → OK。 - - 安全条件: - - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - - mtime >= started_wall: 前 round の stale result.json を拾わない - """ - if status.sentinel_seen: - return None - if not cmdline_validated: - return None - if not (paths.result.exists() and paths.result.stat().st_size > 0): - return None - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return None - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return None - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - return status - - def _check_codex_sentinel_complete( pid: int, paths: AgentPaths, @@ -748,12 +707,34 @@ def monitor_agent( _emit_log(log_prefix, agent, result) return result - result = _check_result_age_fallback( - pid, paths, status, started_wall, cmdline_validated, - ) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index 523addce..e3278662 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -613,47 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_result_age_fallback( - pid: int, - paths: AgentPaths, - status: AgentStatus, - started_wall: float, - cmdline_validated: bool, -) -> Optional[AgentStatus]: - """result.json の age fallback で完了判定する。 - - sentinel 機構を持たない agent (gemini) 向け。result.json が書かれた後も - プロセスがハングするケース (MCP サーバー切断待ち等で exit しない) の - fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - 完了とみなし、プロセスを kill → OK。 - - 安全条件: - - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - - mtime >= started_wall: 前 round の stale result.json を拾わない - """ - if status.sentinel_seen: - return None - if not cmdline_validated: - return None - if not (paths.result.exists() and paths.result.stat().st_size > 0): - return None - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return None - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return None - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - return status - - def _check_codex_sentinel_complete( pid: int, paths: AgentPaths, @@ -748,12 +707,34 @@ def monitor_agent( _emit_log(log_prefix, agent, result) return result - result = _check_result_age_fallback( - pid, paths, status, started_wall, cmdline_validated, - ) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 523addce..e3278662 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,47 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_result_age_fallback( - pid: int, - paths: AgentPaths, - status: AgentStatus, - started_wall: float, - cmdline_validated: bool, -) -> Optional[AgentStatus]: - """result.json の age fallback で完了判定する。 - - sentinel 機構を持たない agent (gemini) 向け。result.json が書かれた後も - プロセスがハングするケース (MCP サーバー切断待ち等で exit しない) の - fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - 完了とみなし、プロセスを kill → OK。 - - 安全条件: - - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - - mtime >= started_wall: 前 round の stale result.json を拾わない - """ - if status.sentinel_seen: - return None - if not cmdline_validated: - return None - if not (paths.result.exists() and paths.result.stat().st_size > 0): - return None - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return None - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return None - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - return status - - def _check_codex_sentinel_complete( pid: int, paths: AgentPaths, @@ -748,12 +707,34 @@ def monitor_agent( _emit_log(log_prefix, agent, result) return result - result = _check_result_age_fallback( - pid, paths, status, started_wall, cmdline_validated, - ) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと From f858949531223dfec5981528919bcd36bc77d115 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:28 +0000 Subject: [PATCH 55/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d5cae7c68d2bcfc4c47639d61a1ff97cf01eabee. --- .../cross-review/scripts/lib/monitor.py | 54 +++++++------------ .../cross-review/scripts/lib/monitor.py | 54 +++++++------------ .../cross-review/scripts/lib/monitor.py | 54 +++++++------------ .../cross-review/scripts/lib/monitor.py | 54 +++++++------------ 4 files changed, 80 insertions(+), 136 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py index e3278662..401a760f 100755 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/monitor.py @@ -613,35 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_codex_sentinel_complete( - pid: int, - paths: AgentPaths, - status: AgentStatus, -) -> Optional[AgentStatus]: - """codex sentinel + result.json が揃っていれば OK を返す。 - - codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - """ - if not ( - paths.agent == "codex" - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return None - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - return status - - def monitor_agent( agent: str, pr: int, @@ -701,11 +672,26 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - if alive: - result = _check_codex_sentinel_complete(pid, paths, status) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py index e3278662..401a760f 100755 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/monitor.py @@ -613,35 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_codex_sentinel_complete( - pid: int, - paths: AgentPaths, - status: AgentStatus, -) -> Optional[AgentStatus]: - """codex sentinel + result.json が揃っていれば OK を返す。 - - codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - """ - if not ( - paths.agent == "codex" - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return None - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - return status - - def monitor_agent( agent: str, pr: int, @@ -701,11 +672,26 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - if alive: - result = _check_codex_sentinel_complete(pid, paths, status) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py index e3278662..401a760f 100755 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/monitor.py @@ -613,35 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_codex_sentinel_complete( - pid: int, - paths: AgentPaths, - status: AgentStatus, -) -> Optional[AgentStatus]: - """codex sentinel + result.json が揃っていれば OK を返す。 - - codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - """ - if not ( - paths.agent == "codex" - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return None - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - return status - - def monitor_agent( agent: str, pr: int, @@ -701,11 +672,26 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - if alive: - result = _check_codex_sentinel_complete(pid, paths, status) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index e3278662..401a760f 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,35 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_codex_sentinel_complete( - pid: int, - paths: AgentPaths, - status: AgentStatus, -) -> Optional[AgentStatus]: - """codex sentinel + result.json が揃っていれば OK を返す。 - - codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - """ - if not ( - paths.agent == "codex" - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return None - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - return status - - def monitor_agent( agent: str, pr: int, @@ -701,11 +672,26 @@ def monitor_agent( if agent == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - if alive: - result = _check_codex_sentinel_complete(pid, paths, status) - if result is not None: - _emit_log(log_prefix, agent, result) - return result + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け From d69c5e89490ca9747a66e079045af2928006bebd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 05:36:28 +0000 Subject: [PATCH 56/57] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20monitor.py#monitor=5Fagent=20(characterization=20te?= =?UTF-8?q?sts)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 48f2ac954b689f29837bd14c07e185b9506b06a4. --- .../tests/test_monitor_termination.py | 463 ------------------ .../tests/test_monitor_termination.py | 463 ------------------ .../tests/test_monitor_termination.py | 463 ------------------ .../tests/test_monitor_termination.py | 463 ------------------ 4 files changed, 1852 deletions(-) delete mode 100644 plugins/ndf-claude/skills/cross-review/tests/test_monitor_termination.py delete mode 100644 plugins/ndf-codex/skills/cross-review/tests/test_monitor_termination.py delete mode 100644 plugins/ndf-kiro/skills/cross-review/tests/test_monitor_termination.py delete mode 100644 plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py diff --git a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-claude/skills/cross-review/tests/test_monitor_termination.py deleted file mode 100644 index a0032dc5..00000000 --- a/plugins/ndf-claude/skills/cross-review/tests/test_monitor_termination.py +++ /dev/null @@ -1,463 +0,0 @@ -"""monitor_agent の 5 つの終了経路を固定する現状固定テスト。 - -リファクタリング R2-001 で monitor_agent を分割する前に、 -各終了経路の入出力と副作用を振る舞いとして記録する。 - -終了経路: - 1. codex sentinel + result.json → OK (kill + detail に "codex sentinel") - 2. result.json age fallback → OK (kill + detail に "result.json exists for") - 3. hard timeout → TIMEOUT (kill + exit_code=2) - 4. early error (FATAL) → EARLY_ERROR (kill + exit_code=4) - 5. stall detection → STALLED (kill + exit_code=5) - -追加で固定する経路: - 6. プロセス正常終了 + result.json あり → OK (exit_code=0) - 7. プロセス正常終了 + result.json なし → NO_RESULT (exit_code=3) - 8. pidfile 不在 → PIDFILE_BAD (exit_code=6) - 9. cmdline 不一致 → PIDFILE_BAD (kill + exit_code=6) -""" -from __future__ import annotations - -import json -import os -import pathlib -import time -from unittest import mock - - -# ---------- 1. codex sentinel + result.json ---------- - -def test_codex_sentinel_with_result_returns_ok_and_kills(monitor_mod, tmp_path): - """codex sentinel + result.json が揃ったら即 OK で pid を kill する。""" - pr = 800 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("11111") - (pathlib.Path(f"{base}-err.log")).write_text("processing...\ntokens used\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert st.sentinel_seen is True - assert "codex sentinel" in st.detail - assert 11111 in killed - - -# ---------- 2. result.json age fallback ---------- - -def test_result_age_fallback_returns_ok_and_kills(monitor_mod, tmp_path): - """result.json の age >= RESULT_AGE_GRACE で OK、pid を kill する。""" - pr = 801 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("22222") - (pathlib.Path(f"{base}-err.log")).write_text("working...\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - result = pathlib.Path(f"{base}-result.json") - result.write_text(json.dumps({"event": "APPROVE"})) - - base_time = time.time() - result_mtime = base_time + 5 - os.utime(result, (result_mtime, result_mtime)) - - time_calls = [0] - def fake_time(): - time_calls[0] += 1 - if time_calls[0] == 1: - return base_time - return base_time + 40 # age = 40 - 5 = 35s >= 30s - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), - mock.patch("time.sleep"), - mock.patch("time.time", side_effect=fake_time), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert "result.json exists for" in st.detail - assert 22222 in killed - - -# ---------- 3. hard timeout ---------- - -def test_timeout_returns_timeout_and_kills(monitor_mod, tmp_path): - """hard timeout に達したら TIMEOUT で pid を kill する。""" - pr = 802 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("33333") - (pathlib.Path(f"{base}-err.log")).write_text("working...\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - poll_count = [0] - # monotonic を進めて timeout に到達させる - mono_base = time.monotonic() - - def fake_pid_alive(pid): - return True - - def fake_sleep(s): - poll_count[0] += 1 - if poll_count[0] > 50: - raise RuntimeError("too many polls") - - def fake_monotonic(): - # 1 回目: start, 以降: timeout 超え - if poll_count[0] >= 1: - return mono_base + 500 # > timeout=420 - return mono_base - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - mock.patch("time.monotonic", side_effect=fake_monotonic), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "TIMEOUT" - assert st.exit_code == 2 - assert "hard timeout" in st.detail - assert 33333 in killed - - -# ---------- 4. early error (FATAL) ---------- - -def test_early_error_fatal_returns_early_error_and_kills(monitor_mod, tmp_path): - """致命エラーパターン検出で EARLY_ERROR、pid を kill する。""" - pr = 803 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("44444") - (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: token expired\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "EARLY_ERROR" - assert st.exit_code == 4 - assert "early error (fatal)" in st.detail - assert 44444 in killed - - -# ---------- 5. stall detection ---------- - -def test_stall_returns_stalled_and_kills(monitor_mod, tmp_path): - """ログ無進捗が stall_timeout に達したら STALLED、pid を kill する。""" - pr = 804 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("55555") - err_log = pathlib.Path(f"{base}-err.log") - err_log.write_text("initial\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - poll_count = [0] - mono_base = time.monotonic() - - def fake_pid_alive(pid): - return True - - def fake_sleep(s): - poll_count[0] += 1 - if poll_count[0] > 50: - raise RuntimeError("too many polls") - - def fake_monotonic(): - # elapsed < timeout (420), idle >= stall_timeout (60) - # poll_count で進める: idle が stall_timeout を超えるようにする - if poll_count[0] >= 2: - return mono_base + 100 # idle_seconds will exceed stall_timeout=60 - return mono_base - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - mock.patch("time.monotonic", side_effect=fake_monotonic), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=60, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "STALLED" - assert st.exit_code == 5 - assert "no log progress" in st.detail - assert 55555 in killed - - -# ---------- 6. プロセス正常終了 + result.json あり ---------- - -def test_process_exit_with_result_returns_ok(monitor_mod, tmp_path): - """プロセスが正常に終了し result.json があれば OK。""" - pr = 805 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("66666") - (pathlib.Path(f"{base}-err.log")).write_text("done\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=False), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert "process exited" in st.detail - - -# ---------- 7. プロセス正常終了 + result.json なし ---------- - -def test_process_exit_without_result_returns_no_result(monitor_mod, tmp_path): - """プロセスが終了したが result.json がなければ NO_RESULT。""" - pr = 806 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("77777") - (pathlib.Path(f"{base}-err.log")).write_text("crashed\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=False), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "NO_RESULT" - assert st.exit_code == 3 - assert "result.json missing" in st.detail - - -# ---------- 8. pidfile 不在 ---------- - -def test_no_pidfile_returns_pidfile_bad(monitor_mod, tmp_path): - """pidfile が見つからなければ PIDFILE_BAD。""" - pr = 807 - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "PIDFILE_BAD" - assert st.exit_code == 6 - assert "pidfile not found" in st.detail - - -# ---------- 9. cmdline 不一致 ---------- - -def test_cmdline_mismatch_returns_pidfile_bad_and_kills(monitor_mod, tmp_path): - """cmdline に agent 名がなければ PIDFILE_BAD で kill する。""" - pr = 808 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("88888") - (pathlib.Path(f"{base}-err.log")).write_text("") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=False), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "PIDFILE_BAD" - assert st.exit_code == 6 - assert "cmdline does not contain" in st.detail - assert 88888 in killed - - -# ---------- 10. early error WARN は kill しない ---------- - -def test_early_error_warn_does_not_kill(monitor_mod, tmp_path): - """WARN パターンは kill せず、プロセス終了後に OK で返る。""" - pr = 809 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("99999") - (pathlib.Path(f"{base}-err.log")).write_text("Error: something non-fatal\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - poll_count = [0] - - def fake_pid_alive(pid): - if poll_count[0] >= 1: - return False - return True - - def fake_sleep(s): - poll_count[0] += 1 - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert killed == [] # WARN does not kill - - -# ---------- 11. no_early_error フラグが FATAL を抑制する ---------- - -def test_no_early_error_suppresses_fatal_detection(monitor_mod, tmp_path): - """no_early_error=True のとき、FATAL パターンがあっても検知しない。""" - pr = 810 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("10101") - (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: bad token\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - poll_count = [0] - - def fake_pid_alive(pid): - if poll_count[0] >= 1: - return False - return True - - def fake_sleep(s): - poll_count[0] += 1 - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid"), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - - -# ---------- 12. claude stdout fatal ---------- - -def test_claude_stdout_fatal_returns_early_error(monitor_mod, tmp_path): - """claude の stdout.log に致命パターンがあれば EARLY_ERROR。""" - pr = 811 - base = tmp_path / f"claude-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("12121") - (pathlib.Path(f"{base}-err.log")).write_text("") - stdout = pathlib.Path(f"{base}-stdout.log") - stdout.write_text(json.dumps({ - "type": "result", "is_error": True, "permission_denials": [], - })) - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="claude", pr=pr, timeout=420, stall_timeout=900, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "EARLY_ERROR" - assert st.exit_code == 4 - assert "stdout.log" in st.detail - assert 12121 in killed diff --git a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-codex/skills/cross-review/tests/test_monitor_termination.py deleted file mode 100644 index a0032dc5..00000000 --- a/plugins/ndf-codex/skills/cross-review/tests/test_monitor_termination.py +++ /dev/null @@ -1,463 +0,0 @@ -"""monitor_agent の 5 つの終了経路を固定する現状固定テスト。 - -リファクタリング R2-001 で monitor_agent を分割する前に、 -各終了経路の入出力と副作用を振る舞いとして記録する。 - -終了経路: - 1. codex sentinel + result.json → OK (kill + detail に "codex sentinel") - 2. result.json age fallback → OK (kill + detail に "result.json exists for") - 3. hard timeout → TIMEOUT (kill + exit_code=2) - 4. early error (FATAL) → EARLY_ERROR (kill + exit_code=4) - 5. stall detection → STALLED (kill + exit_code=5) - -追加で固定する経路: - 6. プロセス正常終了 + result.json あり → OK (exit_code=0) - 7. プロセス正常終了 + result.json なし → NO_RESULT (exit_code=3) - 8. pidfile 不在 → PIDFILE_BAD (exit_code=6) - 9. cmdline 不一致 → PIDFILE_BAD (kill + exit_code=6) -""" -from __future__ import annotations - -import json -import os -import pathlib -import time -from unittest import mock - - -# ---------- 1. codex sentinel + result.json ---------- - -def test_codex_sentinel_with_result_returns_ok_and_kills(monitor_mod, tmp_path): - """codex sentinel + result.json が揃ったら即 OK で pid を kill する。""" - pr = 800 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("11111") - (pathlib.Path(f"{base}-err.log")).write_text("processing...\ntokens used\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert st.sentinel_seen is True - assert "codex sentinel" in st.detail - assert 11111 in killed - - -# ---------- 2. result.json age fallback ---------- - -def test_result_age_fallback_returns_ok_and_kills(monitor_mod, tmp_path): - """result.json の age >= RESULT_AGE_GRACE で OK、pid を kill する。""" - pr = 801 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("22222") - (pathlib.Path(f"{base}-err.log")).write_text("working...\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - result = pathlib.Path(f"{base}-result.json") - result.write_text(json.dumps({"event": "APPROVE"})) - - base_time = time.time() - result_mtime = base_time + 5 - os.utime(result, (result_mtime, result_mtime)) - - time_calls = [0] - def fake_time(): - time_calls[0] += 1 - if time_calls[0] == 1: - return base_time - return base_time + 40 # age = 40 - 5 = 35s >= 30s - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), - mock.patch("time.sleep"), - mock.patch("time.time", side_effect=fake_time), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert "result.json exists for" in st.detail - assert 22222 in killed - - -# ---------- 3. hard timeout ---------- - -def test_timeout_returns_timeout_and_kills(monitor_mod, tmp_path): - """hard timeout に達したら TIMEOUT で pid を kill する。""" - pr = 802 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("33333") - (pathlib.Path(f"{base}-err.log")).write_text("working...\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - poll_count = [0] - # monotonic を進めて timeout に到達させる - mono_base = time.monotonic() - - def fake_pid_alive(pid): - return True - - def fake_sleep(s): - poll_count[0] += 1 - if poll_count[0] > 50: - raise RuntimeError("too many polls") - - def fake_monotonic(): - # 1 回目: start, 以降: timeout 超え - if poll_count[0] >= 1: - return mono_base + 500 # > timeout=420 - return mono_base - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - mock.patch("time.monotonic", side_effect=fake_monotonic), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "TIMEOUT" - assert st.exit_code == 2 - assert "hard timeout" in st.detail - assert 33333 in killed - - -# ---------- 4. early error (FATAL) ---------- - -def test_early_error_fatal_returns_early_error_and_kills(monitor_mod, tmp_path): - """致命エラーパターン検出で EARLY_ERROR、pid を kill する。""" - pr = 803 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("44444") - (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: token expired\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "EARLY_ERROR" - assert st.exit_code == 4 - assert "early error (fatal)" in st.detail - assert 44444 in killed - - -# ---------- 5. stall detection ---------- - -def test_stall_returns_stalled_and_kills(monitor_mod, tmp_path): - """ログ無進捗が stall_timeout に達したら STALLED、pid を kill する。""" - pr = 804 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("55555") - err_log = pathlib.Path(f"{base}-err.log") - err_log.write_text("initial\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - poll_count = [0] - mono_base = time.monotonic() - - def fake_pid_alive(pid): - return True - - def fake_sleep(s): - poll_count[0] += 1 - if poll_count[0] > 50: - raise RuntimeError("too many polls") - - def fake_monotonic(): - # elapsed < timeout (420), idle >= stall_timeout (60) - # poll_count で進める: idle が stall_timeout を超えるようにする - if poll_count[0] >= 2: - return mono_base + 100 # idle_seconds will exceed stall_timeout=60 - return mono_base - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - mock.patch("time.monotonic", side_effect=fake_monotonic), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=60, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "STALLED" - assert st.exit_code == 5 - assert "no log progress" in st.detail - assert 55555 in killed - - -# ---------- 6. プロセス正常終了 + result.json あり ---------- - -def test_process_exit_with_result_returns_ok(monitor_mod, tmp_path): - """プロセスが正常に終了し result.json があれば OK。""" - pr = 805 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("66666") - (pathlib.Path(f"{base}-err.log")).write_text("done\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=False), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert "process exited" in st.detail - - -# ---------- 7. プロセス正常終了 + result.json なし ---------- - -def test_process_exit_without_result_returns_no_result(monitor_mod, tmp_path): - """プロセスが終了したが result.json がなければ NO_RESULT。""" - pr = 806 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("77777") - (pathlib.Path(f"{base}-err.log")).write_text("crashed\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=False), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "NO_RESULT" - assert st.exit_code == 3 - assert "result.json missing" in st.detail - - -# ---------- 8. pidfile 不在 ---------- - -def test_no_pidfile_returns_pidfile_bad(monitor_mod, tmp_path): - """pidfile が見つからなければ PIDFILE_BAD。""" - pr = 807 - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "PIDFILE_BAD" - assert st.exit_code == 6 - assert "pidfile not found" in st.detail - - -# ---------- 9. cmdline 不一致 ---------- - -def test_cmdline_mismatch_returns_pidfile_bad_and_kills(monitor_mod, tmp_path): - """cmdline に agent 名がなければ PIDFILE_BAD で kill する。""" - pr = 808 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("88888") - (pathlib.Path(f"{base}-err.log")).write_text("") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=False), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "PIDFILE_BAD" - assert st.exit_code == 6 - assert "cmdline does not contain" in st.detail - assert 88888 in killed - - -# ---------- 10. early error WARN は kill しない ---------- - -def test_early_error_warn_does_not_kill(monitor_mod, tmp_path): - """WARN パターンは kill せず、プロセス終了後に OK で返る。""" - pr = 809 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("99999") - (pathlib.Path(f"{base}-err.log")).write_text("Error: something non-fatal\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - poll_count = [0] - - def fake_pid_alive(pid): - if poll_count[0] >= 1: - return False - return True - - def fake_sleep(s): - poll_count[0] += 1 - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert killed == [] # WARN does not kill - - -# ---------- 11. no_early_error フラグが FATAL を抑制する ---------- - -def test_no_early_error_suppresses_fatal_detection(monitor_mod, tmp_path): - """no_early_error=True のとき、FATAL パターンがあっても検知しない。""" - pr = 810 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("10101") - (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: bad token\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - poll_count = [0] - - def fake_pid_alive(pid): - if poll_count[0] >= 1: - return False - return True - - def fake_sleep(s): - poll_count[0] += 1 - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid"), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - - -# ---------- 12. claude stdout fatal ---------- - -def test_claude_stdout_fatal_returns_early_error(monitor_mod, tmp_path): - """claude の stdout.log に致命パターンがあれば EARLY_ERROR。""" - pr = 811 - base = tmp_path / f"claude-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("12121") - (pathlib.Path(f"{base}-err.log")).write_text("") - stdout = pathlib.Path(f"{base}-stdout.log") - stdout.write_text(json.dumps({ - "type": "result", "is_error": True, "permission_denials": [], - })) - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="claude", pr=pr, timeout=420, stall_timeout=900, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "EARLY_ERROR" - assert st.exit_code == 4 - assert "stdout.log" in st.detail - assert 12121 in killed diff --git a/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_termination.py deleted file mode 100644 index a0032dc5..00000000 --- a/plugins/ndf-kiro/skills/cross-review/tests/test_monitor_termination.py +++ /dev/null @@ -1,463 +0,0 @@ -"""monitor_agent の 5 つの終了経路を固定する現状固定テスト。 - -リファクタリング R2-001 で monitor_agent を分割する前に、 -各終了経路の入出力と副作用を振る舞いとして記録する。 - -終了経路: - 1. codex sentinel + result.json → OK (kill + detail に "codex sentinel") - 2. result.json age fallback → OK (kill + detail に "result.json exists for") - 3. hard timeout → TIMEOUT (kill + exit_code=2) - 4. early error (FATAL) → EARLY_ERROR (kill + exit_code=4) - 5. stall detection → STALLED (kill + exit_code=5) - -追加で固定する経路: - 6. プロセス正常終了 + result.json あり → OK (exit_code=0) - 7. プロセス正常終了 + result.json なし → NO_RESULT (exit_code=3) - 8. pidfile 不在 → PIDFILE_BAD (exit_code=6) - 9. cmdline 不一致 → PIDFILE_BAD (kill + exit_code=6) -""" -from __future__ import annotations - -import json -import os -import pathlib -import time -from unittest import mock - - -# ---------- 1. codex sentinel + result.json ---------- - -def test_codex_sentinel_with_result_returns_ok_and_kills(monitor_mod, tmp_path): - """codex sentinel + result.json が揃ったら即 OK で pid を kill する。""" - pr = 800 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("11111") - (pathlib.Path(f"{base}-err.log")).write_text("processing...\ntokens used\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert st.sentinel_seen is True - assert "codex sentinel" in st.detail - assert 11111 in killed - - -# ---------- 2. result.json age fallback ---------- - -def test_result_age_fallback_returns_ok_and_kills(monitor_mod, tmp_path): - """result.json の age >= RESULT_AGE_GRACE で OK、pid を kill する。""" - pr = 801 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("22222") - (pathlib.Path(f"{base}-err.log")).write_text("working...\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - result = pathlib.Path(f"{base}-result.json") - result.write_text(json.dumps({"event": "APPROVE"})) - - base_time = time.time() - result_mtime = base_time + 5 - os.utime(result, (result_mtime, result_mtime)) - - time_calls = [0] - def fake_time(): - time_calls[0] += 1 - if time_calls[0] == 1: - return base_time - return base_time + 40 # age = 40 - 5 = 35s >= 30s - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), - mock.patch("time.sleep"), - mock.patch("time.time", side_effect=fake_time), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert "result.json exists for" in st.detail - assert 22222 in killed - - -# ---------- 3. hard timeout ---------- - -def test_timeout_returns_timeout_and_kills(monitor_mod, tmp_path): - """hard timeout に達したら TIMEOUT で pid を kill する。""" - pr = 802 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("33333") - (pathlib.Path(f"{base}-err.log")).write_text("working...\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - poll_count = [0] - # monotonic を進めて timeout に到達させる - mono_base = time.monotonic() - - def fake_pid_alive(pid): - return True - - def fake_sleep(s): - poll_count[0] += 1 - if poll_count[0] > 50: - raise RuntimeError("too many polls") - - def fake_monotonic(): - # 1 回目: start, 以降: timeout 超え - if poll_count[0] >= 1: - return mono_base + 500 # > timeout=420 - return mono_base - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - mock.patch("time.monotonic", side_effect=fake_monotonic), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "TIMEOUT" - assert st.exit_code == 2 - assert "hard timeout" in st.detail - assert 33333 in killed - - -# ---------- 4. early error (FATAL) ---------- - -def test_early_error_fatal_returns_early_error_and_kills(monitor_mod, tmp_path): - """致命エラーパターン検出で EARLY_ERROR、pid を kill する。""" - pr = 803 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("44444") - (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: token expired\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "EARLY_ERROR" - assert st.exit_code == 4 - assert "early error (fatal)" in st.detail - assert 44444 in killed - - -# ---------- 5. stall detection ---------- - -def test_stall_returns_stalled_and_kills(monitor_mod, tmp_path): - """ログ無進捗が stall_timeout に達したら STALLED、pid を kill する。""" - pr = 804 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("55555") - err_log = pathlib.Path(f"{base}-err.log") - err_log.write_text("initial\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - poll_count = [0] - mono_base = time.monotonic() - - def fake_pid_alive(pid): - return True - - def fake_sleep(s): - poll_count[0] += 1 - if poll_count[0] > 50: - raise RuntimeError("too many polls") - - def fake_monotonic(): - # elapsed < timeout (420), idle >= stall_timeout (60) - # poll_count で進める: idle が stall_timeout を超えるようにする - if poll_count[0] >= 2: - return mono_base + 100 # idle_seconds will exceed stall_timeout=60 - return mono_base - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - mock.patch("time.monotonic", side_effect=fake_monotonic), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=60, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "STALLED" - assert st.exit_code == 5 - assert "no log progress" in st.detail - assert 55555 in killed - - -# ---------- 6. プロセス正常終了 + result.json あり ---------- - -def test_process_exit_with_result_returns_ok(monitor_mod, tmp_path): - """プロセスが正常に終了し result.json があれば OK。""" - pr = 805 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("66666") - (pathlib.Path(f"{base}-err.log")).write_text("done\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=False), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert "process exited" in st.detail - - -# ---------- 7. プロセス正常終了 + result.json なし ---------- - -def test_process_exit_without_result_returns_no_result(monitor_mod, tmp_path): - """プロセスが終了したが result.json がなければ NO_RESULT。""" - pr = 806 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("77777") - (pathlib.Path(f"{base}-err.log")).write_text("crashed\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=False), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "NO_RESULT" - assert st.exit_code == 3 - assert "result.json missing" in st.detail - - -# ---------- 8. pidfile 不在 ---------- - -def test_no_pidfile_returns_pidfile_bad(monitor_mod, tmp_path): - """pidfile が見つからなければ PIDFILE_BAD。""" - pr = 807 - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "PIDFILE_BAD" - assert st.exit_code == 6 - assert "pidfile not found" in st.detail - - -# ---------- 9. cmdline 不一致 ---------- - -def test_cmdline_mismatch_returns_pidfile_bad_and_kills(monitor_mod, tmp_path): - """cmdline に agent 名がなければ PIDFILE_BAD で kill する。""" - pr = 808 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("88888") - (pathlib.Path(f"{base}-err.log")).write_text("") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=False), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "PIDFILE_BAD" - assert st.exit_code == 6 - assert "cmdline does not contain" in st.detail - assert 88888 in killed - - -# ---------- 10. early error WARN は kill しない ---------- - -def test_early_error_warn_does_not_kill(monitor_mod, tmp_path): - """WARN パターンは kill せず、プロセス終了後に OK で返る。""" - pr = 809 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("99999") - (pathlib.Path(f"{base}-err.log")).write_text("Error: something non-fatal\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - poll_count = [0] - - def fake_pid_alive(pid): - if poll_count[0] >= 1: - return False - return True - - def fake_sleep(s): - poll_count[0] += 1 - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert killed == [] # WARN does not kill - - -# ---------- 11. no_early_error フラグが FATAL を抑制する ---------- - -def test_no_early_error_suppresses_fatal_detection(monitor_mod, tmp_path): - """no_early_error=True のとき、FATAL パターンがあっても検知しない。""" - pr = 810 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("10101") - (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: bad token\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - poll_count = [0] - - def fake_pid_alive(pid): - if poll_count[0] >= 1: - return False - return True - - def fake_sleep(s): - poll_count[0] += 1 - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid"), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - - -# ---------- 12. claude stdout fatal ---------- - -def test_claude_stdout_fatal_returns_early_error(monitor_mod, tmp_path): - """claude の stdout.log に致命パターンがあれば EARLY_ERROR。""" - pr = 811 - base = tmp_path / f"claude-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("12121") - (pathlib.Path(f"{base}-err.log")).write_text("") - stdout = pathlib.Path(f"{base}-stdout.log") - stdout.write_text(json.dumps({ - "type": "result", "is_error": True, "permission_denials": [], - })) - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="claude", pr=pr, timeout=420, stall_timeout=900, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "EARLY_ERROR" - assert st.exit_code == 4 - assert "stdout.log" in st.detail - assert 12121 in killed diff --git a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py b/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py deleted file mode 100644 index a0032dc5..00000000 --- a/plugins/ndf-shared/skills/cross-review/tests/test_monitor_termination.py +++ /dev/null @@ -1,463 +0,0 @@ -"""monitor_agent の 5 つの終了経路を固定する現状固定テスト。 - -リファクタリング R2-001 で monitor_agent を分割する前に、 -各終了経路の入出力と副作用を振る舞いとして記録する。 - -終了経路: - 1. codex sentinel + result.json → OK (kill + detail に "codex sentinel") - 2. result.json age fallback → OK (kill + detail に "result.json exists for") - 3. hard timeout → TIMEOUT (kill + exit_code=2) - 4. early error (FATAL) → EARLY_ERROR (kill + exit_code=4) - 5. stall detection → STALLED (kill + exit_code=5) - -追加で固定する経路: - 6. プロセス正常終了 + result.json あり → OK (exit_code=0) - 7. プロセス正常終了 + result.json なし → NO_RESULT (exit_code=3) - 8. pidfile 不在 → PIDFILE_BAD (exit_code=6) - 9. cmdline 不一致 → PIDFILE_BAD (kill + exit_code=6) -""" -from __future__ import annotations - -import json -import os -import pathlib -import time -from unittest import mock - - -# ---------- 1. codex sentinel + result.json ---------- - -def test_codex_sentinel_with_result_returns_ok_and_kills(monitor_mod, tmp_path): - """codex sentinel + result.json が揃ったら即 OK で pid を kill する。""" - pr = 800 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("11111") - (pathlib.Path(f"{base}-err.log")).write_text("processing...\ntokens used\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert st.sentinel_seen is True - assert "codex sentinel" in st.detail - assert 11111 in killed - - -# ---------- 2. result.json age fallback ---------- - -def test_result_age_fallback_returns_ok_and_kills(monitor_mod, tmp_path): - """result.json の age >= RESULT_AGE_GRACE で OK、pid を kill する。""" - pr = 801 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("22222") - (pathlib.Path(f"{base}-err.log")).write_text("working...\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - result = pathlib.Path(f"{base}-result.json") - result.write_text(json.dumps({"event": "APPROVE"})) - - base_time = time.time() - result_mtime = base_time + 5 - os.utime(result, (result_mtime, result_mtime)) - - time_calls = [0] - def fake_time(): - time_calls[0] += 1 - if time_calls[0] == 1: - return base_time - return base_time + 40 # age = 40 - 5 = 35s >= 30s - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=True), - mock.patch("time.sleep"), - mock.patch("time.time", side_effect=fake_time), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert "result.json exists for" in st.detail - assert 22222 in killed - - -# ---------- 3. hard timeout ---------- - -def test_timeout_returns_timeout_and_kills(monitor_mod, tmp_path): - """hard timeout に達したら TIMEOUT で pid を kill する。""" - pr = 802 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("33333") - (pathlib.Path(f"{base}-err.log")).write_text("working...\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - poll_count = [0] - # monotonic を進めて timeout に到達させる - mono_base = time.monotonic() - - def fake_pid_alive(pid): - return True - - def fake_sleep(s): - poll_count[0] += 1 - if poll_count[0] > 50: - raise RuntimeError("too many polls") - - def fake_monotonic(): - # 1 回目: start, 以降: timeout 超え - if poll_count[0] >= 1: - return mono_base + 500 # > timeout=420 - return mono_base - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - mock.patch("time.monotonic", side_effect=fake_monotonic), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "TIMEOUT" - assert st.exit_code == 2 - assert "hard timeout" in st.detail - assert 33333 in killed - - -# ---------- 4. early error (FATAL) ---------- - -def test_early_error_fatal_returns_early_error_and_kills(monitor_mod, tmp_path): - """致命エラーパターン検出で EARLY_ERROR、pid を kill する。""" - pr = 803 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("44444") - (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: token expired\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "EARLY_ERROR" - assert st.exit_code == 4 - assert "early error (fatal)" in st.detail - assert 44444 in killed - - -# ---------- 5. stall detection ---------- - -def test_stall_returns_stalled_and_kills(monitor_mod, tmp_path): - """ログ無進捗が stall_timeout に達したら STALLED、pid を kill する。""" - pr = 804 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("55555") - err_log = pathlib.Path(f"{base}-err.log") - err_log.write_text("initial\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - poll_count = [0] - mono_base = time.monotonic() - - def fake_pid_alive(pid): - return True - - def fake_sleep(s): - poll_count[0] += 1 - if poll_count[0] > 50: - raise RuntimeError("too many polls") - - def fake_monotonic(): - # elapsed < timeout (420), idle >= stall_timeout (60) - # poll_count で進める: idle が stall_timeout を超えるようにする - if poll_count[0] >= 2: - return mono_base + 100 # idle_seconds will exceed stall_timeout=60 - return mono_base - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - mock.patch("time.monotonic", side_effect=fake_monotonic), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=60, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "STALLED" - assert st.exit_code == 5 - assert "no log progress" in st.detail - assert 55555 in killed - - -# ---------- 6. プロセス正常終了 + result.json あり ---------- - -def test_process_exit_with_result_returns_ok(monitor_mod, tmp_path): - """プロセスが正常に終了し result.json があれば OK。""" - pr = 805 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("66666") - (pathlib.Path(f"{base}-err.log")).write_text("done\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=False), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert st.result_exists is True - assert "process exited" in st.detail - - -# ---------- 7. プロセス正常終了 + result.json なし ---------- - -def test_process_exit_without_result_returns_no_result(monitor_mod, tmp_path): - """プロセスが終了したが result.json がなければ NO_RESULT。""" - pr = 806 - base = tmp_path / f"gemini-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("77777") - (pathlib.Path(f"{base}-err.log")).write_text("crashed\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=False), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="gemini", pr=pr, timeout=420, stall_timeout=480, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "NO_RESULT" - assert st.exit_code == 3 - assert "result.json missing" in st.detail - - -# ---------- 8. pidfile 不在 ---------- - -def test_no_pidfile_returns_pidfile_bad(monitor_mod, tmp_path): - """pidfile が見つからなければ PIDFILE_BAD。""" - pr = 807 - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "PIDFILE_BAD" - assert st.exit_code == 6 - assert "pidfile not found" in st.detail - - -# ---------- 9. cmdline 不一致 ---------- - -def test_cmdline_mismatch_returns_pidfile_bad_and_kills(monitor_mod, tmp_path): - """cmdline に agent 名がなければ PIDFILE_BAD で kill する。""" - pr = 808 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("88888") - (pathlib.Path(f"{base}-err.log")).write_text("") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=False), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "PIDFILE_BAD" - assert st.exit_code == 6 - assert "cmdline does not contain" in st.detail - assert 88888 in killed - - -# ---------- 10. early error WARN は kill しない ---------- - -def test_early_error_warn_does_not_kill(monitor_mod, tmp_path): - """WARN パターンは kill せず、プロセス終了後に OK で返る。""" - pr = 809 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("99999") - (pathlib.Path(f"{base}-err.log")).write_text("Error: something non-fatal\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - poll_count = [0] - - def fake_pid_alive(pid): - if poll_count[0] >= 1: - return False - return True - - def fake_sleep(s): - poll_count[0] += 1 - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - assert killed == [] # WARN does not kill - - -# ---------- 11. no_early_error フラグが FATAL を抑制する ---------- - -def test_no_early_error_suppresses_fatal_detection(monitor_mod, tmp_path): - """no_early_error=True のとき、FATAL パターンがあっても検知しない。""" - pr = 810 - base = tmp_path / f"codex-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("10101") - (pathlib.Path(f"{base}-err.log")).write_text("Authentication failed: bad token\n") - (pathlib.Path(f"{base}-stdout.log")).write_text("") - (pathlib.Path(f"{base}-result.json")).write_text( - json.dumps({"event": "APPROVE"}) - ) - - poll_count = [0] - - def fake_pid_alive(pid): - if poll_count[0] >= 1: - return False - return True - - def fake_sleep(s): - poll_count[0] += 1 - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", side_effect=fake_pid_alive), - mock.patch.object(monitor_mod, "_kill_pid"), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep", side_effect=fake_sleep), - ): - st = monitor_mod.monitor_agent( - agent="codex", pr=pr, timeout=420, stall_timeout=180, poll=15, - require_result=True, no_early_error=True, - ) - - assert st.status == "OK" - assert st.exit_code == 0 - - -# ---------- 12. claude stdout fatal ---------- - -def test_claude_stdout_fatal_returns_early_error(monitor_mod, tmp_path): - """claude の stdout.log に致命パターンがあれば EARLY_ERROR。""" - pr = 811 - base = tmp_path / f"claude-review-pr{pr}" - - (pathlib.Path(f"{base}.pid")).write_text("12121") - (pathlib.Path(f"{base}-err.log")).write_text("") - stdout = pathlib.Path(f"{base}-stdout.log") - stdout.write_text(json.dumps({ - "type": "result", "is_error": True, "permission_denials": [], - })) - - killed = [] - - with ( - mock.patch.object(monitor_mod, "_tmp_dir", return_value=tmp_path), - mock.patch.object(monitor_mod, "_pid_alive", return_value=True), - mock.patch.object(monitor_mod, "_kill_pid", side_effect=lambda p, **kw: killed.append(p)), - mock.patch.object(monitor_mod, "_pid_cmdline_matches", return_value=None), - mock.patch("time.sleep"), - ): - st = monitor_mod.monitor_agent( - agent="claude", pr=pr, timeout=420, stall_timeout=900, poll=15, - require_result=True, no_early_error=False, - ) - - assert st.status == "EARLY_ERROR" - assert st.exit_code == 4 - assert "stdout.log" in st.detail - assert 12121 in killed From 7006f5943268c5dee71c150a5d12faeaab5e894b Mon Sep 17 00:00:00 2001 From: cross-refactoring orchestrator Date: Sun, 16 Aug 2026 05:38:49 +0000 Subject: [PATCH 57/57] =?UTF-8?q?Docs:=20=E4=BF=AE=E6=AD=A3=E5=BE=8C?= =?UTF-8?q?=E3=81=AE=E5=86=8D=E6=A4=9C=E8=A8=BC=E3=81=AE=E7=B5=90=E6=9E=9C?= =?UTF-8?q?=E3=82=92=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正した 9 件はすべて実機で成立し、レビュー・判定・輪番・集計まで到達した。 新しい不具合を 2 件見つけた(pre-push の同期検査との衝突、適用失敗項目が 対象外リストに入らない)。前者は生成物を持つリポジトリで収束ループを不能にする。 --- issues/issue-113-cross-refactoring-retrial.md | 127 +++++++++++++++++- 1 file changed, 126 insertions(+), 1 deletion(-) diff --git a/issues/issue-113-cross-refactoring-retrial.md b/issues/issue-113-cross-refactoring-retrial.md index 8ed34f87..e1afe2fa 100644 --- a/issues/issue-113-cross-refactoring-retrial.md +++ b/issues/issue-113-cross-refactoring-retrial.md @@ -59,4 +59,129 @@ PLUGIN_ROOT=/work/ai-plugins/plugins/ndf-claude ## 結果 -(実行後に追記する) +**修正した 9 件はすべて実機で成立した。** 前回は適用結果の検証で失敗した項目を取り消す +経路が破綻して停止したが、今回はレビュー・判定・輪番・集計まで到達した。 +一方で**新しい不具合を 2 件見つけ**、うち 1 件は収束ループを実質的に不能にする。 + +## 到達点 + +```mermaid +flowchart TD + Init([初期化・作業ディレクトリ・手順書配置]):::ok --> P1 + P1["R1 提案(3 CLI 並列)11 件"]:::ok --> M1 + M1["統合 7 件 → 採用 5 件"]:::ok --> A1 + A1["R1 適用(codex)11 コミット"]:::ok --> V1 + V1["検証: 採用 4 / 失敗 1"]:::ok --> D1 + D1["取り消し 11 → 積み直し 8
(項目単位)"]:::ok --> RV1 + RV1["R1 レビュー(gemini + kiro)
両者 APPROVE"]:::ok --> P2 + P2["R2 提案 → 採用 5 件"]:::ok --> A2 + A2["R2 適用(kiro)12 コミット"]:::ok --> V2 + V2["検証: 全 5 件が範囲外で失敗"]:::ng --> D2 + D2["取り消し 12 → 積み直し 0
ラウンドごと失敗"]:::ok --> Stop + Stop["不具合 10 のため中止"]:::ng + + classDef ok fill:#dfd,stroke:#383 + classDef ng fill:#fdd,stroke:#933 +``` + +前回の到達点は `V1` の直前までだった。 + +## 修正 9 件の実機確認 + +| # | 直したこと | 実機での観測 | +| --- | --- | --- | +| 1 | 取り消しの巻き戻しと積み直し | `取り消し 11 コミット / 積み直し 8 コミット(項目単位)`。失敗した 1 項目だけを落とし、**同じ `refactor.py` を触る 3 項目 6 コミットを積み直した** | +| 2 | 中断と全件失敗の区別 | push 失敗で終了コード 4。次の提案ラウンドへ進まず停止した | +| 3 | 判定の逐次記録 | `rounds[].apply_progress` に全項目の判定が残った | +| 4 | 再送信の印 | 中断を跨いで `pending_push` が残り、次の実行が**処理済み判定より先に** push を再送した | +| 5 | 範囲の検査 | 範囲外コミットを検出して項目を失敗にした(R1-004 / R2 全件) | +| 6 | 提案結果のラウンド別保存 | `*-propose-rf120-r1-result.json` と `-r2-` が**両方残った** | +| 7 | gemini の手順書読み取り | `.gemini/settings.json` が配置され、差分にも出なかった | +| 8 | 語彙の列挙 | 3 ランタイムとも `smell` / `technique` を英字の識別子で返した | +| 9 | 認証の確認 | 初期化時に 4 CLI の認証状態を出力した | + +不具合 1 は、**前回破綻したのと同じ条件**(採用項目の多くが同一ファイルを触る)で成立した。 + +## 前回まったく実行できていなかった範囲 + +| 対象 | 結果 | +| --- | --- | +| レビュー担当 2 者の並列実行と承認判定 | ✅ gemini / kiro を並列起動し、両者 `APPROVE` で `judge-review` が終了コード 0 | +| 実装担当の輪番 | ✅ R1 codex → R2 kiro。レビュー担当も gemini+kiro → codex+gemini へ入れ替わった | +| 提案ラウンドの収束判定 | ✅ `advance` が継続を判定 | +| 集計値の出力 | ✅ `report --metrics` が実装担当・レビュー担当の表と、`auto` モデルの分離注記まで出した | +| 指摘の修正と再レビューの繰り返し | ❌ **未到達**。R1 は指摘 0 件、R2 は適用が全件失敗したため | +| 上限到達時の項目単位の見送り | ❌ **未到達**(同上) | + +## 新しく見つかった不具合 + +### 10. pre-push の同期検査と範囲ルールが両立しない + +このリポジトリは `.githooks/pre-push` で生成物の同期を検査する。一方この Skill は +「実装担当は編集元だけを触る / 生成物の同期は**進行側が収束後に**まとめて行う」と決めた。 +この 2 つは両立しない。 + +```console +$ git push origin refactor/cross-refactoring-retrial +Generated directory is out of date: plugins/ndf-claude/skills +exited 1 +``` + +**実装担当の push も、取り消しを反映する進行側の push も落ちる。** 結果、検証を通って +いない R1-004 の変更が Pull Request に残った(取り消しはローカルのみ)。 +**不具合 4 が防ごうとした状態そのもの**である。 + +さらに悪いことに、**この検査は実装担当を範囲ルール違反へ誘導する。** ラウンド 2 で +kiro は push を通すために配布物 3 系統を同期し、その結果**採用 5 件が全件範囲外で失敗** +して 1 ラウンドを丸ごと失った。 + +| ラウンド | 実装担当 | 配布物を同期したか | 適用成功 | +| --- | --- | --- | ---: | +| 1 | codex | しなかった(push は失敗のまま放置) | 4 / 5 | +| 2 | kiro | した(4 コミットすべてで) | 0 / 5 | + +「収束後にまとめて同期する」という決定が、**ループの途中で push が起きること**を +見落としていた。 + +**修正の方向**: 実装担当に push させず、**検証を通った後で進行側だけが push する**。 +同期はその直前に行う(`--sync-command` を新設し、状態ファイルへ保持する)。 +これで「未検証の変更が公開される」経路自体が無くなり、不具合 4 は緩和ではなく根絶できる。 + +本再検証では、進行側が生成物を同期するコミットを積んで回避した。 + +### 11. 適用で失敗した項目が「対象外」に入らない + +手順書は「見送った項目は理由付きで記録し、次ラウンドの提案時に**対象外として渡す**。 +そうしないと同じ提案が毎ラウンド出続けて収束しない」と定めている。しかし +`merge-apply` の失敗経路だけがこの記録を行わない(`deferred_items` に入らない)。 + +実測では、ラウンド 1 で失敗した `monitor.py#monitor_agent`(R1-004)が +**3 ランタイム全員から再提案され、合意 3 で最優先に採用された**(R2-001)。 +同じ理由で必ず失敗するため、ラウンドを 1 つ丸ごと消費する。 + +**修正の方向**: `merge-apply` が失敗させた項目も `deferred_items` へ理由付きで記録する。 + +## 運用上の注意 + +`--scope` には、範囲内の各ソースに対応する**テストの置き場所をすべて**含める。 +今回は `cross-review/scripts/lib` を範囲に入れながら `cross-review/tests` を入れ忘れた +ため、`monitor.py` の現状固定テストが範囲外となり R1-004 が失敗した。 +**検査は正しく働いている**(指定の誤り)。 + +## この Pull Request に残ったもの + +ラウンド 1 で採用・検証・レビュー承認まで通った 4 項目が残る。 + +| ID | 対象 | 手法 | +| --- | --- | --- | +| R1-001 | `refactor.py#cmd_merge_apply` | `extract_method` | +| R1-002 | `metrics.py#aggregate` | `extract_method` | +| R1-003 | `refactor.py#cmd_merge_fix` | `extract_method` | +| R1-005 | `refactor.py#cmd_merge_apply` | `consolidate_duplication` | + +ラウンド 2 の 12 コミットは全件取り消し済み。テストは 430 件成功し、生成物も同期済みである。 + +## 次にすること + +不具合 10 と 11 を直してから、**指摘の修正と再レビューの繰り返し**を通す再々検証を行う。 +不具合 10 を直さない限り、生成物を持つリポジトリでは収束ループが成立しない。