From 3fdbb626deaaddcc45f87bf6c5b66d34e7fc0a32 Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 24 Sep 2026 14:17:12 +0000 Subject: [PATCH] fix(issue-34): review - a correction round keeps the earlier creator jobs' sub-jobs in the evidence table round_roots was replaced by the correction job, so an implementation job's open or failed sub-job vanished from the next review and the loop could pass (planner-reviewer-codex-01, P2). The run's creator jobs now accumulate; each wait still covers only the new job's tree, so the wait cap is not paid again per round. T9 covers open / error / completed. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib_py/loop/runner.py | 6 +++-- .agents/skills/multi-agent-mux-loop/SKILL.md | 2 +- tests/test_issue34_job_tree.py | 27 ++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.agents/skills/lib_py/loop/runner.py b/.agents/skills/lib_py/loop/runner.py index 9b1606c..91e0129 100644 --- a/.agents/skills/lib_py/loop/runner.py +++ b/.agents/skills/lib_py/loop/runner.py @@ -98,7 +98,9 @@ class LoopRunner: self._rounds: List[Dict[str, Any]] = [] self._accepted_round: Optional[Dict[str, Any]] = None self.created_jobs: List[str] = [] - # Issue #34: the creator jobs whose sub-job trees this round's reviewers see + # Issue #34: every creator job of this run (execution, corrections, + # compliance). Kept across rounds: a later creator job does not make an + # earlier one's open or failed sub-job go away - that is the reviewers' call. self.round_roots: List[str] = [] self.wait_open_jobs = False self.failed_submit_jobs: Set[str] = set() @@ -1511,7 +1513,7 @@ class LoopRunner: correct_jid = self._dispatch(self.creator, creator_type, "Worker", correction_prompt) if correct_jid: - self.round_roots = [correct_jid] + self.round_roots.append(correct_jid) if not correct_jid or not self.wait_for_job_tree(correct_jid): log_error("Failed to register Creator correction job.") return 1 diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 13fd684..ade6c91 100644 --- a/.agents/skills/multi-agent-mux-loop/SKILL.md +++ b/.agents/skills/multi-agent-mux-loop/SKILL.md @@ -218,7 +218,7 @@ sequenceDiagram **리뷰 identity (Issue #21 항목 5).** `--worktree`/`--base` 가 있으면 매 리뷰 라운드를 `(loop_id, base, head, tree)` 에 묶습니다. 소스의 미커밋·untracked 상태까지 담은 스냅샷(ref 없는 커밋)을 만들고, 리뷰어가 받는 diff 도 그 스냅샷에서 만듭니다(`base..snapshot`). 라운드가 끝난 뒤 소스가 바뀌었으면 그 라운드의 PASS 는 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 승격 직전에도 한 번 더 확인하며, 승격된 리포트 옆에 `report-.identity.json`(loop_id·base·head·tree·스냅샷 커밋·리뷰어·`transport`·`evidence`: `review-tree`/`source-tree`/`diff-only`)을 둡니다. 추적되지 않는 중첩 git 저장소는 스냅샷에 담을 수 없어 거부합니다. 서브모듈 안의 미커밋·untracked 내용도 스냅샷에는 gitlink 커밋만 남으므로 거부합니다(라운드 시작 시 exit 1, 리뷰 중에 생기면 그 라운드는 stale). `submodule.*.ignore` 설정으로 가려지지 않습니다. 리뷰어가 없으면 creator 셀프 리뷰가 같은 리뷰어 경로(스냅샷 diff·증거 검사·승격 직전 재확인·사이드카)를 거치고, NOT PASS 면 수정 잡 없이 다시 리뷰합니다. 리뷰 잡의 `[워크트리 컨텍스트]` 는 검증 대상을 `[리뷰 identity]` 의 검증 경로 하나로만 지시합니다. -**하위 위임 잡 (Issue #34).** creator 가 작업 일부를 `delegate-job submit --parent-job <자기 job_id>` 로 맡기면, 루프는 creator 잡(실행·수정·순응)이 끝난 뒤 그 잡의 자손(자식·손자…, 폴링마다 다시 읽음)의 레코드가 모두 종결될 때까지 기다린 다음 리뷰로 넘어갑니다. 깊이 제한은 없고 상한은 둘입니다: `MAM_CHILD_WAIT_S`(기본 3900 s, creator 잡 종결부터; 각 하위 잡은 자기 등록 + `timeout_sec`(#33, 기본 3600 s)에 닫히므로 보통 그 전에 끝납니다)와 `MAM_JOB_TREE_MAX`(기본 50 개, 넘으면 기다리지 않음). 그만큼 루프 벽시계가 늘어납니다. 루프를 잡으로 감싸 돌리면 바깥 잡의 3600 s 가 먼저 끝날 수 있습니다. 자식의 결과는 루프를 멈추지 않고 리뷰 근거가 됩니다: 하위 잡이나 귀속되지 않은 열린 잡이 있으면 모든 리뷰 프롬프트(셀프 리뷰 포함) 앞에 `[하위 위임 잡]` 표(상태·사유·깊이)와 `[귀속되지 않은 열린 잡]`(부모 미표기로 이 작업 중 등록된 열린 잡) 줄이 붙고(둘 다 없으면 프롬프트는 전과 같습니다), 리뷰어는 `OPEN`·`CLOSED-UNCONFIRMED`·실패(`error`/`cancelled`/`NOT-DISPATCHED`) 하위 잡이 있으면 creator 리포트가 대체 완료를 입증하지 않는 한 NOT PASS 를 냅니다. `CLOSED-UNCONFIRMED` 는 오케스트레이터가 마감(`deadline_timeout`/`agent_blocked`)으로 닫았거나 사유가 없는 `error` 입니다 — 레코드만 닫혔고 에이전트는 인터럽트되지 않으므로 계속 쓰고 있을 수 있습니다. creator 잡 자체의 실패는 전처럼 exit 1 입니다. +**하위 위임 잡 (Issue #34).** creator 가 작업 일부를 `delegate-job submit --parent-job <자기 job_id>` 로 맡기면, 루프는 creator 잡(실행·수정·순응)이 끝난 뒤 그 잡의 자손(자식·손자…, 폴링마다 다시 읽음)의 레코드가 모두 종결될 때까지 기다린 다음 리뷰로 넘어갑니다. 깊이 제한은 없고 상한은 둘입니다: `MAM_CHILD_WAIT_S`(기본 3900 s, creator 잡 종결부터; 각 하위 잡은 자기 등록 + `timeout_sec`(#33, 기본 3600 s)에 닫히므로 보통 그 전에 끝납니다)와 `MAM_JOB_TREE_MAX`(기본 50 개, 넘으면 기다리지 않음). 그만큼 루프 벽시계가 늘어납니다. 루프를 잡으로 감싸 돌리면 바깥 잡의 3600 s 가 먼저 끝날 수 있습니다. 자식의 결과는 루프를 멈추지 않고 리뷰 근거가 됩니다: 하위 잡이나 귀속되지 않은 열린 잡이 있으면 모든 리뷰 프롬프트(셀프 리뷰 포함) 앞에 `[하위 위임 잡]` 표(상태·사유·깊이)와 `[귀속되지 않은 열린 잡]`(부모 미표기로 이 작업 중 등록된 열린 잡) 줄이 붙고(둘 다 없으면 프롬프트는 전과 같습니다), 리뷰어는 `OPEN`·`CLOSED-UNCONFIRMED`·실패(`error`/`cancelled`/`NOT-DISPATCHED`) 하위 잡이 있으면 creator 리포트가 대체 완료를 입증하지 않는 한 NOT PASS 를 냅니다. 표는 이 실행의 모든 creator 잡(앞 회차의 실행·수정·순응 잡 포함)의 자손을 매 회차 다시 읽어 만듭니다 — 수정 잡이 새로 생겼다고 앞 잡의 열린·실패 하위 잡이 사라지지 않습니다(대기는 새 creator 잡의 자손만 하므로 대기 상한이 회차마다 다시 쌓이지 않습니다). `CLOSED-UNCONFIRMED` 는 오케스트레이터가 마감(`deadline_timeout`/`agent_blocked`)으로 닫았거나 사유가 없는 `error` 입니다 — 레코드만 닫혔고 에이전트는 인터럽트되지 않으므로 계속 쓰고 있을 수 있습니다. creator 잡 자체의 실패는 전처럼 exit 1 입니다. **리뷰 중 소스 변경 = 승인 무효 (모든 모드, Issue #34).** `--worktree`/`--base` 가 없어도 라운드마다 리뷰 요청 직전 소스(= 제어 루트)의 head 와 미커밋·untracked 를 포함한 tree 를 기록하고, 판정 수집 뒤와 승격 직전에 다시 계산합니다. 바뀌었으면 위 identity 모드와 같이 그 라운드의 PASS 를 버리고 수정 잡 없이 다시 리뷰합니다(마지막 반복이면 exit 1). 제어 평면(`.mam`, 잡·감사 로그 디렉터리)은 비교에서 뺍니다. 리뷰어가 ignore 되지 않는 산출물을 소스에 남기면 라운드가 stale 이 되니 산출물은 소스 밖에 둡니다. git 저장소가 아니거나 서브모듈이 dirty 해 지문을 만들 수 없으면 경고(`stale-review guard is off`) 후 기존 동작입니다. 루프가 끝난 **뒤**의 늦은 쓰기는 막을 수 없으며 다음 루프의 diff 에 미커밋 변경으로 드러납니다. diff --git a/tests/test_issue34_job_tree.py b/tests/test_issue34_job_tree.py index 2a399af..8ae1ee3 100644 --- a/tests/test_issue34_job_tree.py +++ b/tests/test_issue34_job_tree.py @@ -278,6 +278,8 @@ else: rule = os.environ.get("FAKE_REVIEW", "pass") unfinished = re.search(r"(열린|미확인 종결|실패) [1-9]", prompt) verdict = "NOT PASS" if rule == "rule" and unfinished else "PASS" + if os.environ.get("FAKE_FIRST_NOT_PASS") and n == 1: # forces a correction round + verdict = "NOT PASS" report = "reviewed\n\n[VERDICT: %s]\n" % verdict late = os.environ.get("FAKE_LATE_WRITE") == str(n) dest = jobs / jid / "agent-reports" @@ -483,3 +485,28 @@ def test_t8_the_stale_review_guard_without_worktree_or_base(tmp_path): assert res_plain.returncode == 0, res_plain.stdout + res_plain.stderr assert "the stale-review guard is off for this run" in res_plain.stdout assert "100% PASS" in res_plain.stdout + + +@pytest.mark.parametrize("child_end, rc", [("open", 1), ("error", 1), ("completed", 0)]) +def test_t9_a_correction_round_keeps_the_earlier_creator_jobs_sub_jobs(tmp_path, child_end, rc): + """Review of round 2 (after a correction job with no sub-job of its own) + still sees the implementation job's sub-job; the correction does not claim + to have replaced it. Success pair: the completed child passes in round 2.""" + ctl = _loop_ctl(tmp_path) + env = _loop_env(ctl, FAKE_WORKER="child", FAKE_REVIEW="rule", FAKE_FIRST_NOT_PASS="1", + MAM_CHILD_WAIT_S="0") + jobs = ctl / ".mam" / "jobs" + on_child = {"open": None, + "error": lambda cid: _publish(env, jobs, cid, "error", "simulated"), + "completed": lambda cid: _publish(env, jobs, cid, "completed", "figures drawn")}[child_end] + if child_end != "open": + env["MAM_CHILD_WAIT_S"] = "60" # wait for the helper to close it + res = _run_loop(ctl, env, "--max-loop", "2", on_child=on_child) + out = res.stdout + res.stderr + assert res.returncode == rc, out + assert "Applying straight bugfixes" in res.stdout # a correction round ran + cid = (ctl / ".mam" / "child-id").read_text().strip() + second = _review_prompt(ctl, 2) + state = {"open": "OPEN", "error": "error", "completed": "completed"}[child_end] + assert re.search(rf"\| {cid} \| .* \| {state} \|", second), second + assert ("100% PASS" in res.stdout) == (rc == 0)