fix(agent-ops): 리뷰 확정 불일치를 다음 loop로 재분류한다

This commit is contained in:
toki 2026-08-03 06:58:28 +09:00
parent 3a3c2aa530
commit eba60a3db7
2 changed files with 149 additions and 3 deletions

View file

@ -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로 이동했다: "

View file

@ -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(
"<!-- task=sim/01_reclassify plan=0 tag=SIM -->\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):