fix(agent-ops): Epic 준비 재시도 복구 정보를 보강한다

This commit is contained in:
toki 2026-08-10 01:34:30 +09:00
parent ba9c0717cf
commit 26bd023c86
2 changed files with 80 additions and 0 deletions

View file

@ -350,6 +350,15 @@ def validate_pairs(
or "<milestone-slug>" in unresolved_contents
):
raise CycleError(f"unresolved template token: {plan.parent}")
for path in (plan, review):
whitespace = run(
["git", "diff", "--no-index", "--check", "--", "/dev/null", str(path)],
cwd=workspace,
check=False,
)
if whitespace.returncode not in {0, 1}:
detail = (whitespace.stderr or whitespace.stdout or "").strip()
raise CycleError(f"plan whitespace validation failed: {path}: {detail}")
if dispatcher.is_file():
run(
[sys.executable, str(dispatcher), "--workspace", str(workspace), "--validate-plan", str(plan)],
@ -379,6 +388,16 @@ def active_task_user_reviews(workspace: Path, task_group: str) -> list[Path]:
return sorted(root.glob("USER_REVIEW.md")) + sorted(root.glob("*/USER_REVIEW.md"))
def active_task_finalization_artifacts(workspace: Path, task_group: str) -> list[Path]:
root = workspace / "agent-task" / task_group
if not root.exists():
return []
values: set[Path] = set()
for pattern in ("complete.log", "plan_*.log", "code_review_*.log"):
values.update(root.rglob(pattern))
return sorted(values)
def sdd_user_review(workspace: Path, phase_slug: str, milestone_slug: str) -> Path:
return workspace / "agent-roadmap" / "sdd" / phase_slug / milestone_slug / "USER_REVIEW.md"
@ -392,6 +411,7 @@ def stage_prompt(
task_group: str,
base_head: str,
checkpoint_head: str | None,
recovery_reason: str | None = None,
) -> str:
common = f"""You are a fresh child agent launched for one bounded Epic preparation stage, not the caller or monitor.
Work only in {workspace}.
@ -403,6 +423,13 @@ Target Epic: [{epic.epic_id}] {epic.title}
Allowed Milestone Task ids: {','.join(epic.task_ids)}
Active task group: agent-task/{task_group}
Keep every change inside this Epic and preserve user changes. Final in Korean.
"""
if recovery_reason:
common += f"""
This is an explicit retry after the parent runtime rejected the previous stage output.
Resolve the exact recovery condition below before finishing, while preserving valid work:
{recovery_reason}
Do not repeat a forbidden finalization action merely because its artifact is already present.
"""
if stage == "materialize":
return common + f"""
@ -760,6 +787,15 @@ def cycle(args: argparse.Namespace) -> int:
if stage in {"materialize", "refine"}
else reviewer_target
)
recovery_details: list[str] = []
if args.retry and prior_cycle_status == "failed":
recovery_details.append(str(state.get("reason", "unknown parent failure")))
finalized = active_task_finalization_artifacts(workspace, task_group)
if finalized:
recovery_details.append(
"Remove forbidden preparation-time finalization artifacts and restore valid active pairs: "
+ ",".join(str(path.relative_to(workspace)) for path in finalized)
)
prompt = stage_prompt(
stage=stage,
workspace=workspace,
@ -768,6 +804,7 @@ def cycle(args: argparse.Namespace) -> int:
task_group=task_group,
base_head=str(state["base_head"]),
checkpoint_head=state.get("checkpoint_head"),
recovery_reason="\n".join(recovery_details) or None,
)
result_path = state_root / "attempts" / f"{stage}.json"
state.update(
@ -824,6 +861,12 @@ def cycle(args: argparse.Namespace) -> int:
)
if refreshed_scope.task_ids != epic.task_ids:
raise CycleError(f"target Epic Task ids changed unexpectedly: stage={stage}")
finalized = active_task_finalization_artifacts(workspace, task_group)
if finalized:
raise CycleError(
"preparation agent created forbidden finalization artifacts: "
+ ",".join(str(path.relative_to(workspace)) for path in finalized)
)
pairs, task_union = validate_pairs(
workspace,
task_group,

View file

@ -308,6 +308,43 @@ class EpicCycleContractTest(unittest.TestCase):
with self.assertRaisesRegex(MODULE.CycleError, "unresolved template token"):
MODULE.validate_pairs(workspace, "m-sample", {"inside"})
def test_validate_pair_rejects_untracked_whitespace_error(self) -> None:
with tempfile.TemporaryDirectory() as raw:
workspace = Path(raw)
command(workspace, "git", "init")
task = workspace / "agent-task" / "m-sample" / "01_work"
task.mkdir(parents=True)
header = (
"<!-- task=m-sample/01_work plan=0 tag=TEST "
"milestone-task=inside -->\n"
)
(task / "PLAN-local-G01.md").write_text(
header + "# Plan\n",
encoding="utf-8",
)
(task / "CODE_REVIEW-local-G01.md").write_text(
header + "# Review\n\n",
encoding="utf-8",
)
with self.assertRaisesRegex(MODULE.CycleError, "whitespace validation failed"):
MODULE.validate_pairs(workspace, "m-sample", {"inside"})
def test_retry_prompt_includes_parent_failure(self) -> None:
prompt = MODULE.stage_prompt(
stage="initial-review",
workspace=Path("/workspace"),
milestone=Path("/workspace/milestone.md"),
epic=MODULE.Epic("sample", "Sample", ("inside",), ("inside",), ""),
task_group="m-sample",
base_head="abc123",
checkpoint_head=None,
recovery_reason="git diff --cached --check failed",
)
self.assertIn("explicit retry", prompt)
self.assertIn("git diff --cached --check failed", prompt)
def test_validate_pair_rejects_foreign_pair_outside_selected_batch(self) -> None:
with tempfile.TemporaryDirectory() as raw:
workspace = Path(raw)