diff --git a/orchestrator.py b/orchestrator.py index 60d0497..e0c6d0e 100644 --- a/orchestrator.py +++ b/orchestrator.py @@ -1009,6 +1009,9 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None) # 검증 실패 보고서 (rewind 시 이전 실패 맥락 전달). # 2026-05-16 — issue state 의 failure_report_path 를 source-of-truth 로. # 모든 stage NO (test-verify/final-close 뿐 아니라 code-edit 등) 의 from_stage 캐치. + # P7 (2026-05-26) — banned approaches injection (Codex CLI helper consensus). + # failure_report 본문에서 known anti-pattern keyword 추출 → BANNED_APPROACHES block 생성 + # → 다음 round prompt 에 strong-marker 로 inject. 동일 방식 재제안 방지 (#84 round loop). failure_ctx = "" ist_fc = get_issue_state(n) fr_path_str = ist_fc.get("failure_report_path") @@ -1016,9 +1019,42 @@ def build_context_pack(n, title, body, sid, agent, rnd, start_cnt, compact=None) fail_path = Path(fr_path_str) if fail_path.exists(): from_sid = ist_fc.get("failure_from_stage", "?") + fail_body = fail_path.read_text(encoding='utf-8') + # P7 — extract banned approach signals (deterministic keyword scan). + # 각 entry: (regex, label, why). escape_hatch 는 future patch 의 JSON 구조 에서 형식화. + # 현재 단계 = prompt-injection 만 (Codex 단계화 안의 "즉시 patch" layer). + banned_signals = [ + (r"tests:\s*\[\s*\]", + "tests: [] empty test list per implementation unit", + "Orchestrator strict rule — 1 unit = impl + test inseparable. NOT allowed to defer tests to later units."), + (r"@testing-library|jsdom|render\s*\(|screen\.", + "DOM mount-based vitest (render() / screen / @testing-library)", + "Front/package.json devDependencies has no jsdom / @testing-library/react. Mount-based tests cannot run."), + (r"toast\.error\s*\(\s*formatAiRepairHumanReviewMessage", + "Home.tsx formatAiRepairHumanReviewMessage toast.error removal", + "Post-#92 commit 896f273 rewrote the formatter to operational-only channel. Removing toast call = operational alert regression."), + (r"git\s+add\s+(-A|--all|\.)\b", + "git add -A / git add . / git add --all", + "Untracked artifact pollution risk. Stage 5 must add only files in unit's declared `files:` list explicitly."), + ] + hits = [] + for pat, label, why in banned_signals: + if re.search(pat, fail_body, re.IGNORECASE): + hits.append((label, why)) + banned_block = "" + if hits: + banned_block = "\n=== BANNED APPROACHES (previously rejected — DO NOT REUSE) ===\n" + for i, (label, why) in enumerate(hits, 1): + banned_block += f"{i}. {label}\n reason: {why}\n" + banned_block += ( + "BINDING: re-proposing any banned approach above = automatic FINAL_CONSENSUS: NO. " + "If environment/preconditions changed (e.g., new package install), state the EVIDENCE " + "of the change BEFORE re-proposal.\n" + ) failure_ctx = ( f"\n\n=== REWIND: FAILURE REPORT (from {from_sid}) ===\n" - f"{fail_path.read_text(encoding='utf-8')[:1500]}\n" + f"{fail_body[:1500]}\n" + f"{banned_block}" f"Fix the issues above before re-attempting.\n" ) @@ -1447,10 +1483,22 @@ def run_stage(n, title, body, sid): return (False, "unit with `tests: []` (forbidden — implementation + tests = same unit)") return (True, "") ok, reason = _iu_valid(last) - if not ok: - # current stage 의 comments 만 검색 (start_cnt 이후) + # P7 (2026-05-26) — fallback skip when last YES body itself is invalid. + # 이전: last invalid → comments[start_cnt:] 에서 valid block 찾아 구제 → + # orchestrator 자기 supplement comment 의 Example block 이 valid 로 통과 (#84 round 5 슬립). + # 변경: last 가 진짜 invalid 면 fallback 자체 skip. 단 last 의 _iu_valid 실패가 + # "block missing" 인 경우만 (Codex 가 YAML block 을 안 echo 한 경우) 이전 round 의 + # Claude plan 으로 fallback — 단 orchestrator-authored supplement 는 제외. + if not ok and reason == "block missing": for c in comments[start_cnt:]: - ok2, _ = _iu_valid(c.get("body", "")) + body = c.get("body", "") or "" + # exclude orchestrator-authored supplement comments (own example block trap) + ls = body.lstrip() + if ls.startswith("⚠️ **[Orchestrator]**") or \ + ls.startswith("📌 **[오케스트레이터]**") or \ + ls.startswith("ℹ️ **[Orchestrator]**"): + continue + ok2, _ = _iu_valid(body) if ok2: ok = True; break if not ok: @@ -1548,6 +1596,45 @@ def run_stage(n, title, body, sid): except: pass # Never `continue` — checker is informational only (Stage 1 guardrail). + # P7 (2026-05-26) — final-close YES casual self-contradiction inline guard. + # parse_consensus 는 건드리지 않음 (다른 caller 영향 차단). YES 처리 block 안에서 + # sid == "final-close" 인 경우만 casual contradiction 검사. + # + # 설계 의도 분기 (Patch B 와 분담) : + # - explicit `disposition: KEEP_OPEN_*` line 이 있으면 = 의도된 keep-open + # → 이 guard 통과 → Patch B (close PATCH skip) 가 처리. + # - explicit disposition line 없이 "NO close signal" 또는 "DO NOT CLOSE" + # casual 표현 만 있으면 = self-contradiction → supplement + continue. + # + # cf. #83 IMP-83 case = YES + explicit `disposition: KEEP_OPEN_AS_UMBRELLA_ANCHOR` + # → 통과 (Patch B 가 close skip). + if sid == "final-close": + has_explicit_disposition = bool(re.search( + r"^\s*disposition\s*:\s*KEEP_OPEN", + last, re.IGNORECASE | re.MULTILINE)) + if not has_explicit_disposition: + casual_contradiction_patterns = [ + (r"NO\s+close\s+signal", "NO close signal"), + (r"DO\s*NOT\s*CLOSE", "DO NOT CLOSE"), + ] + hit = None + for p, label in casual_contradiction_patterns: + if re.search(p, last, re.IGNORECASE): + hit = label; break + if hit: + log(f"⚠️ Stage 6 YES casual self-contradiction ({hit}) — supplement requested") + try: gitea(f"issues/{n}/comments", "POST", {"body": + f"⚠️ **[Orchestrator]** Stage 6 FINAL_CONSENSUS: YES rejected — casual self-contradiction.\n\n" + f"YES marker 와 동시에 본문에 `{hit}` 등장 — 명시적 `disposition:` line 없음.\n\n" + "Resolution:\n" + " (a) If close intended → remove `{hit}` and re-state YES with close evidence.\n" + " (b) If keep-open intended → add explicit line:\n" + " `disposition: KEEP_OPEN_AS_UMBRELLA_ANCHOR` (or similar)\n" + " then orchestrator will honor keep-open at close PATCH (Patch B).\n" + " (c) Or switch to `FINAL_CONSENSUS: NO` with appropriate rewind_target."}) + except: pass + continue + log(f"✅ {si['label']} — YES (evidence verified)") # stage 완료 = unit counter + remaining tracker 모두 reset update_issue_state(n, continue_same_count=0, last_remaining_units=None) @@ -1765,8 +1852,44 @@ def run_issue(n, until=None): continue_same_count=0, last_remaining_units=None) if s["id"] == "final-close": - try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed") - except: pass + # P7 (2026-05-26) — KEEP_OPEN guard. Stage 6 exit body / last YES body 가 명시적 + # keep-open / no-close 신호 내면 close PATCH skip. body-level lock 이 있는 umbrella + # anchor (#83 IMP-83 등) 보호 — Stage 6 성공 = "올바른 disposition 확정" 이며, + # 그 disposition 이 KEEP_OPEN 일 수 있음. + keep_open_patterns = [ + r"KEEP_OPEN_AS_UMBRELLA_ANCHOR", + r"DO\s*NOT\s*CLOSE", + r"disposition\s*:\s*KEEP_OPEN", + r"^\s*action\s*:\s*NONE", + r"^\s*state_after\s*:\s*open", + r"NO\s+close\s+signal", + ] + keep_open = False + last_body = comments[-1].get("body", "") if comments else "" + for p in keep_open_patterns: + if re.search(p, last_body, re.IGNORECASE | re.MULTILINE): + keep_open = True; break + if not keep_open: + exit_path = _erp(n, "final-close") + if exit_path.exists(): + try: + exit_body = exit_path.read_text(encoding="utf-8", errors="ignore") + for p in keep_open_patterns: + if re.search(p, exit_body, re.IGNORECASE | re.MULTILINE): + keep_open = True; break + except: pass + if keep_open: + log(f"Stage 6 KEEP_OPEN signal — issue #{n} NOT closed (umbrella/governance anchor honored)") + try: gitea(f"issues/{n}/comments", "POST", {"body": + "ℹ️ **[Orchestrator]** Stage 6 KEEP_OPEN signal honored — issue not closed.\n\n" + "Detected one of: `KEEP_OPEN_AS_UMBRELLA_ANCHOR`, `DO NOT CLOSE`, " + "`disposition: KEEP_OPEN`, `action: NONE`, `state_after: open`, `NO close signal`.\n\n" + "Orchestrator abstains from `PATCH state=closed` per user-decision-first lock. " + "Final-close stage marked done; issue state preserved as `open`."}) + except: pass + else: + try: gitea(f"issues/{n}", "PATCH", {"state": "closed"}); log("Closed") + except: pass i += 1