fix(orchestrator): P7 governance guards for false-positive YES
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 21s
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 21s
- Block Stage 2 YES when IMPLEMENTATION_UNITS contains tests: []. - Prevent fallback from accepting orchestrator supplement examples as valid plans. - Honor KEEP_OPEN/DO NOT CLOSE final-close dispositions by skipping close PATCH. - Add final-close casual self-contradiction guard for YES bodies (allows explicit `disposition: KEEP_OPEN_*` to pass through to Patch B). - Inject rejected approaches from failure reports into next-round context with BANNED_APPROACHES block (tests: [] / DOM mount without jsdom / Home.tsx toast removal / git add -A). Refs: #83 (governance break — reopen pending user decision) #84 (Stage 2 round 5 slip — replay required after this fix) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
135
orchestrator.py
135
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user