diff --git a/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py index bc581d1b..d7c7aeb7 100755 --- a/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py +++ b/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py @@ -350,6 +350,15 @@ def validate_pairs( or "" 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, diff --git a/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py index ac3c8ea0..551fa215 100644 --- a/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py +++ b/agent-ops/skills/common/prepare-epic-work-items/tests/test_run_epic_cycle.py @@ -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 = ( + "\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)