diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py index 7edd4eb3..8cc1d178 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py @@ -6081,10 +6081,22 @@ async def run_review( banner("작업완료", task.name, [f"archive={outcome['path']}", f"locator={locator}"]) return outcome["path"] if outcome["verdict"] == "UNKNOWN" or outcome["state"] == "changed": - raise RuntimeError( - "official review가 판정과 다음 파일 상태를 materialize하지 않았다; " - f"locator={locator}" + # The review agent may have changed the active pair without + # materializing a verdict/finalization in the same one-shot. + # This is task-local review-finalization recovery: return normally + # so dispatch_with_store clears this attempt and reclassifies only + # this task on the next loop. Raising here incorrectly promoted a + # recoverable review state to a dispatcher-wide exit-3 condition. + banner( + "디스패치추적대기", + task.name, + [ + "reason=review-finalization-recovery", + "active PLAN/CODE_REVIEW pair를 다음 loop에서 재분류", + f"locator={locator}", + ], ) + return None if outcome["state"] == "archived": raise RuntimeError( f"PASS가 아닌 review가 완료 archive로 이동했다: " diff --git a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py index 7e6fc46e..8fed538b 100644 --- a/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py +++ b/agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py @@ -4859,6 +4859,47 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase): finally: store.close() + async def test_review_finalization_mismatch_is_reclassified_without_raising(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / ".git").mkdir() + task = TaskStageTest().make_task(root) + store = dispatch.StateStore(root) + locator = root / "locator.json" + try: + with ( + mock.patch.object( + dispatch, + "run_escalating", + new=mock.AsyncMock(return_value=(True, locator)), + ), + mock.patch.object( + dispatch, + "task_signature", + side_effect=["before", "after"], + ), + mock.patch.object( + dispatch, "review_fingerprints", return_value=set() + ), + mock.patch.object( + dispatch, + "review_outcome", + return_value={ + "verdict": "UNKNOWN", + "state": "changed", + "path": str(task.directory), + "review_log": "unknown", + }, + ), + ): + result = await dispatch.run_review(root, store, task) + + self.assertIsNone(result) + self.assertIsNone(store.task_state(task).get("blocked")) + self.assertEqual(store.task_state(task).get("review_no_progress"), 0) + finally: + store.close() + class BlockerDrainTest(unittest.IsolatedAsyncioTestCase): async def test_user_review_only_holds_its_dependency_closure(self): @@ -8160,6 +8201,99 @@ class DispatcherConvergenceSimulationTest(unittest.IsolatedAsyncioTestCase): "final timeline\n", ) + async def test_review_finalization_mismatch_keeps_dispatcher_running(self): + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + (workspace / ".git").mkdir() + (workspace / "agent-task").mkdir() + self.write_task(workspace, "sim/01_reclassify", "src/reclassify.go") + + review_attempts = 0 + + async def fake_worker(workspace_path, store, task, *args, **kwargs): + decision = { + "work_unit_id": dispatch.work_unit_id_from_file(task.plan), + "stage": "worker", + "selected": { + "adapter": "codex", + "target": "gpt-5.6-sol", + "execution_class": "cloud_model", + "selfcheck_required": False, + }, + } + store.update_task( + task, + worker_done=True, + worker_cli="codex", + worker_model="gpt-5.6-sol", + completing_decision=decision, + execution_class="cloud_model", + selfcheck_done=True, + blocked=None, + ) + + async def fake_run_escalating( + workspace_path, store, task, role, spec, **kwargs + ): + nonlocal review_attempts + review_attempts += 1 + locator = workspace_path / f"review-{review_attempts}.json" + if review_attempts == 1: + target = workspace_path / "src" / "reclassify.go" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("reclassified\n", encoding="utf-8") + return True, locator + + task.review.write_text( + "\n" + "## Code Review Result\n\n" + "- Overall Verdict: PASS\n", + encoding="utf-8", + ) + (task.directory / "complete.log").write_text( + "simulation complete\n", encoding="utf-8" + ) + archive = ( + workspace_path + / "agent-task" + / "archive" + / "2026" + / "08" + / "sim" + / "01_reclassify" + ) + archive.parent.mkdir(parents=True, exist_ok=True) + task.directory.rename(archive) + return True, locator + + args = SimpleNamespace( + workspace=str(workspace), + task_group="sim", + dry_run=False, + retry_blocked=False, + ) + review_spec = dispatch.AgentSpec( + "codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh" + ) + with ( + mock.patch.object(dispatch, "run_worker", new=fake_worker), + mock.patch.object( + dispatch, "run_escalating", new=fake_run_escalating + ), + mock.patch.object( + dispatch, + "persisted_execution_decision", + return_value=({}, review_spec), + ), + mock.patch.object(dispatch, "ensure_review_shared_state"), + ): + result = await asyncio.wait_for( + dispatch.dispatch(args), timeout=2 + ) + + self.assertEqual(result, 0) + self.assertEqual(review_attempts, 2) + class DynamicFailoverBudgetTest(unittest.TestCase):