iop/agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py

945 lines
41 KiB
Python
Executable file

#!/usr/bin/env python3
"""Run the event-driven preparation cycle for exactly one Milestone Epic."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
import sys
from typing import Any, Iterable
STAGES = ("materialize", "initial-review", "refine", "final-review")
DEFAULT_PLANNER_AGENT = "codex"
DEFAULT_PLANNER_MODEL = "gpt-5.6-sol"
DEFAULT_REASONING_EFFORT = "xhigh"
PLAN_PATTERN = "PLAN-*-G??.md"
REVIEW_PATTERN = "CODE_REVIEW-*-G??.md"
HEADER = re.compile(r"^<!--\s+(?P<body>.*?)\s+-->$")
MILESTONE_PATTERN = re.compile(
r"^agent-roadmap/phase/(?P<phase>[a-z0-9-]+)/milestones/(?P<slug>[a-z0-9-]+)\.md$"
)
EPIC_HEADING = re.compile(r"^### Epic:\s*\[(?P<id>[a-z0-9-]+)\]\s*(?P<title>.+?)\s*$")
TASK_LINE = re.compile(r"^- \[(?P<done>[ xX])\] \[(?P<id>[a-z0-9-]+)\]\s+(?P<body>.+)$")
SENSITIVE_PARTS = {".env", "secret", "secrets", "credential", "credentials", "password", "passwords"}
SENSITIVE_SUFFIXES = {".pem", ".key", ".p12"}
class CycleError(RuntimeError):
"""A fail-closed Epic cycle error."""
class TrackingRequired(CycleError):
"""A one-shot agent is still owned by an existing execution handle."""
def __init__(self, stage: str, result: dict[str, Any]) -> None:
self.stage = stage
self.result = result
super().__init__(f"agent execution is still running: stage={stage}")
class TrackingRecoveryRequired(TrackingRequired):
"""A detached execution ended and its artifacts need explicit adoption."""
@dataclass(frozen=True)
class Epic:
epic_id: str
title: str
task_ids: tuple[str, ...]
incomplete_ids: tuple[str, ...]
body: str
def emit(event: str, **payload: Any) -> None:
print(json.dumps({"event": event, **payload}, ensure_ascii=False, sort_keys=True), flush=True)
def run(
command: list[str],
*,
cwd: Path,
check: bool = True,
capture: bool = True,
) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
cwd=cwd,
text=True,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
check=False,
)
if check and result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
raise CycleError(f"command failed ({result.returncode}): {' '.join(command)}: {detail}")
return result
def git(workspace: Path, *arguments: str, check: bool = True) -> str:
result = run(["git", *arguments], cwd=workspace, check=check)
return (result.stdout or "").strip()
def resolve_workspace(raw: str) -> Path:
workspace = Path(raw).expanduser().resolve()
if not workspace.is_dir():
raise CycleError(f"workspace not found: {workspace}")
top = Path(git(workspace, "rev-parse", "--show-toplevel")).resolve()
if top != workspace:
raise CycleError(f"workspace must be git root: expected={top} actual={workspace}")
branch = git(workspace, "branch", "--show-current")
if not branch:
raise CycleError("detached HEAD is not supported")
develop = git(workspace, "config", "--get", "gitflow.branch.develop")
feature_prefix = git(workspace, "config", "--get", "gitflow.prefix.feature")
if not develop or not feature_prefix or not branch.startswith(feature_prefix):
raise CycleError(f"Epic preparation requires a Git Flow feature branch: actual={branch}")
return workspace
def resolve_milestone(workspace: Path, raw: str) -> tuple[Path, re.Match[str]]:
candidate = Path(raw).expanduser()
path = (workspace / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
try:
relative = path.relative_to(workspace).as_posix()
except ValueError as exc:
raise CycleError(f"milestone outside workspace: {path}") from exc
match = MILESTONE_PATTERN.fullmatch(relative)
if match is None or not path.is_file():
raise CycleError(f"active milestone path required: {relative}")
return path, match
def section(text: str, heading: str) -> str:
match = re.search(
rf"^## {re.escape(heading)}\s*$\n(?P<body>.*?)(?=^##\s|\Z)",
text,
re.MULTILINE | re.DOTALL,
)
return match.group("body").strip() if match else ""
def verify_milestone_gate(text: str) -> None:
status_body = section(text, "상태")
status_match = re.search(r"^\[(.+?)\]\s*$", status_body, re.MULTILINE)
status = status_match.group(1).strip() if status_match else ""
if status not in {"계획", "진행중"}:
raise CycleError(f"milestone must be [계획] or [진행중]: actual={status or 'missing'}")
lock = section(text, "구현 잠금")
if not re.search(r"^- 상태:\s*해제\s*$", lock, re.MULTILINE):
raise CycleError("milestone implementation lock is not 해제")
if not re.search(r"^- 결정 필요:\s*없음\s*$", lock, re.MULTILINE):
raise CycleError("milestone has unresolved 결정 필요")
def parse_epics(text: str) -> list[Epic]:
lines = text.splitlines()
starts: list[tuple[int, re.Match[str]]] = []
for index, line in enumerate(lines):
match = EPIC_HEADING.fullmatch(line)
if match:
starts.append((index, match))
epics: list[Epic] = []
for position, (start, match) in enumerate(starts):
end = starts[position + 1][0] if position + 1 < len(starts) else len(lines)
body_lines = lines[start + 1 : end]
tasks = [TASK_LINE.fullmatch(line) for line in body_lines]
task_matches = [value for value in tasks if value is not None]
epics.append(
Epic(
epic_id=match.group("id"),
title=match.group("title"),
task_ids=tuple(value.group("id") for value in task_matches),
incomplete_ids=tuple(
value.group("id") for value in task_matches if value.group("done") == " "
),
body="\n".join(body_lines).strip(),
)
)
return epics
def select_epic(epics: list[Epic], selector: str) -> Epic:
exact_id = [epic for epic in epics if epic.epic_id == selector]
if len(exact_id) == 1:
return exact_id[0]
normalized = selector.casefold().strip()
exact_title = [epic for epic in epics if epic.title.casefold().strip() == normalized]
if len(exact_title) == 1:
return exact_title[0]
raise CycleError(f"target Epic must resolve exactly once: selector={selector}")
def git_common_dir(workspace: Path) -> Path:
raw = Path(git(workspace, "rev-parse", "--git-common-dir"))
return (workspace / raw).resolve() if not raw.is_absolute() else raw.resolve()
def atomic_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(temporary, path)
def read_state(path: Path) -> dict[str, Any] | None:
if not path.exists():
return None
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CycleError(f"invalid cycle state: {path}: {exc}") from exc
if not isinstance(value, dict):
raise CycleError(f"cycle state must be an object: {path}")
return value
def process_start_token(pid: int) -> str | None:
stat = Path(f"/proc/{pid}/stat")
try:
remainder = stat.read_text(encoding="utf-8").rsplit(")", 1)[1].split()
return f"proc:{remainder[19]}"
except (OSError, IndexError):
return None
def process_is_same(pid: object, expected_token: object) -> bool:
if not isinstance(pid, int) or pid <= 0:
return False
try:
os.kill(pid, 0)
except (OSError, ValueError):
return False
actual_token = process_start_token(pid)
if expected_token is None or actual_token is None:
return True
return actual_token == expected_token
def stage_result(path: Path, *, workspace: Path, label: str) -> dict[str, Any] | None:
value = read_state(path)
if value is None:
return None
if value.get("workspace") != str(workspace) or value.get("label") != label:
raise CycleError(f"agent result identity mismatch: {path}")
locator = value.get("locator")
if not isinstance(locator, str) or not Path(locator).is_file():
raise CycleError(f"agent result locator is missing: {path}")
return value
def changed_paths(workspace: Path) -> list[str]:
unmerged = git(workspace, "diff", "--name-only", "--diff-filter=U")
if unmerged:
raise CycleError(f"workspace has unmerged paths: {','.join(unmerged.splitlines())}")
values: set[str] = set()
for arguments in (
("diff", "--name-only", "--no-renames", "-z", "HEAD"),
("ls-files", "--others", "--exclude-standard", "-z"),
):
result = subprocess.run(
["git", *arguments],
cwd=workspace,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
raise CycleError(result.stderr.decode("utf-8", "replace").strip())
values.update(
part.decode("utf-8", "surrogateescape")
for part in result.stdout.split(b"\0")
if part
)
return sorted(values)
def sensitive(path: str) -> bool:
candidate = Path(path)
lowered = {part.casefold() for part in candidate.parts}
if lowered & SENSITIVE_PARTS:
return True
name = candidate.name.casefold()
return any(token in name for token in ("secret", "credential", "password")) or candidate.suffix.casefold() in SENSITIVE_SUFFIXES
def parse_header(path: Path) -> dict[str, str]:
try:
first = path.read_text(encoding="utf-8").splitlines()[0]
except (OSError, IndexError) as exc:
raise CycleError(f"missing first-line metadata: {path}") from exc
match = HEADER.fullmatch(first)
if not match:
raise CycleError(f"invalid first-line metadata: {path}")
values: dict[str, str] = {}
for token in match.group("body").split():
key, separator, value = token.partition("=")
if separator:
values[key] = value
required = {"task", "plan", "tag", "milestone-task"}
missing = sorted(required - values.keys())
if missing:
raise CycleError(f"metadata fields missing in {path}: {','.join(missing)}")
return values
def active_pairs(workspace: Path, task_group: str) -> list[tuple[Path, Path, dict[str, str]]]:
root = workspace / "agent-task" / task_group
if not root.exists():
return []
directories = [root, *sorted(path for path in root.iterdir() if path.is_dir())]
pairs: list[tuple[Path, Path, dict[str, str]]] = []
for directory in directories:
plans = sorted(directory.glob(PLAN_PATTERN))
reviews = sorted(directory.glob(REVIEW_PATTERN))
if not plans and not reviews:
continue
if len(plans) != 1 or len(reviews) != 1:
raise CycleError(f"active PLAN/CODE_REVIEW pair required: {directory}")
plan_header = parse_header(plans[0])
review_header = parse_header(reviews[0])
if plan_header != review_header:
raise CycleError(f"PLAN/CODE_REVIEW metadata mismatch: {directory}")
if plan_header["task"].split("/", 1)[0] != task_group:
raise CycleError(f"task group mismatch: {plans[0]}")
pairs.append((plans[0], reviews[0], plan_header))
return pairs
def validate_pairs(
workspace: Path,
task_group: str,
epic_task_ids: set[str],
allowed_task_ids: set[str] | None = None,
) -> tuple[list[tuple[Path, Path, dict[str, str]]], set[str]]:
all_pairs = active_pairs(workspace, task_group)
allowed = set(epic_task_ids) if allowed_task_ids is None else set(allowed_task_ids)
if not epic_task_ids <= allowed:
raise CycleError("target Epic Task ids must be inside the selected batch")
pairs: list[tuple[Path, Path, dict[str, str]]] = []
union: set[str] = set()
project_dispatcher = (
workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
)
private_dispatcher = (
workspace / "agent-ops" / "skills" / "private" / "orchestrate-agent-task-loop"
)
if project_dispatcher.is_dir() and private_dispatcher.is_dir():
dispatcher_root = private_dispatcher
elif project_dispatcher.is_dir():
dispatcher_root = project_dispatcher
else:
dispatcher_root = (
workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
)
dispatcher = dispatcher_root / "scripts" / "dispatch.py"
for plan, review, header in all_pairs:
ids = header["milestone-task"].split(",")
if not ids or any(not value for value in ids) or len(ids) != len(set(ids)):
raise CycleError(f"invalid milestone-task list: {plan}")
pair_ids = set(ids)
outside = sorted(pair_ids - allowed)
if outside:
raise CycleError(
f"plan includes Task ids outside selected Epic batch: {plan}: {','.join(outside)}"
)
target_ids = pair_ids & epic_task_ids
if target_ids and target_ids != pair_ids:
raise CycleError(f"plan crosses target Epic boundary: {plan}")
if target_ids:
pairs.append((plan, review, header))
union.update(target_ids)
contents = plan.read_text(encoding="utf-8") + "\n" + review.read_text(encoding="utf-8")
if "[TODO" in contents or "<task_group>" in contents or "<milestone-slug>" in contents:
raise CycleError(f"unresolved template token: {plan.parent}")
if dispatcher.is_file():
run(
[sys.executable, str(dispatcher), "--workspace", str(workspace), "--validate-plan", str(plan)],
cwd=workspace,
)
git(workspace, "diff", "--check")
return pairs, union
def batch_task_ids(raw: str | None, epic_task_ids: tuple[str, ...]) -> set[str]:
target = set(epic_task_ids)
if raw is None:
return target
values = raw.split(",")
if not values or any(not value for value in values) or len(values) != len(set(values)):
raise CycleError("--batch-task-ids must be a unique comma-separated Task id list")
allowed = set(values)
if not target <= allowed:
raise CycleError("--batch-task-ids does not include every target Epic Task id")
return allowed
def active_task_user_reviews(workspace: Path, task_group: str) -> list[Path]:
root = workspace / "agent-task" / task_group
if not root.exists():
return []
return sorted(root.glob("USER_REVIEW.md")) + sorted(root.glob("*/USER_REVIEW.md"))
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"
def stage_prompt(
*,
stage: str,
workspace: Path,
milestone: Path,
epic: Epic,
task_group: str,
base_head: str,
checkpoint_head: str | 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}.
Read the repository AGENTS.md completely, then read agent-ops/skills/common/router.md and only the skills required for this stage.
Do not start subagents, orchestration dispatchers, prepare-milestone-workspace, prepare-epic-work-items, or any monitoring loop.
Do not commit or push; the parent runtime owns Git checkpoints.
Target Milestone: {milestone}
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 stage == "materialize":
return common + f"""
Materialize this Epic once. Read current source, tests, SDD, matching spec and contracts required by AGENTS.md.
Classify cohesive slices as direct-small only when each is one bounded change, has explicit verification, changes no API/wire/schema/migration/external side effect/responsibility boundary, needs no user decision, and does not collide with planned work. Treat every uncertain slice as large.
Implement and verify all direct-small slices first. Then, against that updated source, use agent-ops/skills/common/plan/SKILL.md in write mode to create valid PLAN/CODE_REVIEW pairs for every remaining large slice. Preserve exact milestone-task ids and let plan perform final routing. Do not use official code-review on unimplemented stubs.
If a genuine product/scope decision is required, use roadmap-sdd review-ready for this Milestone and stop without inventing a decision.
Starting HEAD: {base_head}
"""
if stage == "initial-review":
return common + f"""
Review everything produced for this Epic since {base_head}, including direct code/test/document changes and every active PLAN/CODE_REVIEW stub. This is the explicit self-review request: review the work and fix every material omission you find.
Re-run appropriate verification for direct-small work. For semantic plan defects, use the plan skill's explicit write/replan path so routing and paired files remain valid. Do not append an official code-review verdict.
Run sync-milestone-workstate mode=sync only for exact Task ids whose direct work is fully implemented and evidenced; never complete an id that still has pending plan scope.
If no material defect exists, leave correct artifacts unchanged.
"""
if stage == "refine":
return common + """
Read agent-ops/skills/common/refine-plans/SKILL.md and apply it once to every eligible unstarted active pair in the target task group whose milestone-task ids belong to this Epic. Preserve original scope and do not re-read source/tests or run verification. A justified no-change decision is valid. Do not recursively split a child created in this pass.
"""
if stage == "final-review":
return common + f"""
Review the refined active pair set for this Epic from a fresh context. Compare the child scope union, milestone-task union, write sets, verification, dependencies, indices, PLAN/CODE_REVIEW metadata, and routing against the Milestone, SDD, current source, and the pre-refine intent at checkpoint {checkpoint_head or base_head}. Fix every material defect using the owning plan/refine procedure; do not append an official code-review verdict. If a semantic replan replaces a pair, apply refine-plans once to that replacement when it remains eligible. Finish only with valid unstarted pairs or no pairs when all Epic work was direct-small.
"""
raise CycleError(f"unsupported stage: {stage}")
def run_agent_stage(
*,
workspace: Path,
state_root: Path,
identity: str,
stage: str,
prompt: str,
agent: str,
model: str | None,
reasoning_effort: str | None,
pi_provider: str | None,
prior_cycle_status: str,
retry: bool,
) -> Path:
runner = Path(__file__).resolve().with_name("run_agent_once.py")
prompt_path = state_root / "prompts" / f"{stage}.txt"
result_path = state_root / "attempts" / f"{stage}.json"
prompt_path.parent.mkdir(parents=True, exist_ok=True)
prompt_path.write_text(prompt, encoding="utf-8")
label = f"{identity}-{stage}"
previous = stage_result(result_path, workspace=workspace, label=label)
if previous is not None:
previous_status = previous.get("status")
is_live = previous_status in {"running", "tracking"} and process_is_same(
previous.get("agent_pid"), previous.get("agent_process_start_token")
)
if is_live:
raise TrackingRequired(stage, previous)
if previous_status == "succeeded" and not (
retry and prior_cycle_status == "failed"
):
emit(
"AGENT_RESULT_RECOVERED",
stage=stage,
locator=previous["locator"],
result="succeeded",
)
return result_path
if previous_status in {"running", "tracking"} and prior_cycle_status in {
"running",
"tracking",
}:
if not retry:
raise TrackingRecoveryRequired(stage, previous)
emit(
"AGENT_RESULT_RECOVERED",
stage=stage,
locator=previous["locator"],
result="detached-artifacts",
)
return result_path
if not retry:
raise CycleError(
f"prior agent result requires --retry: stage={stage} status={previous_status}"
)
command = [
sys.executable,
str(runner),
"--agent",
agent,
"--workspace",
str(workspace),
"--prompt-file",
str(prompt_path),
"--label",
label,
"--result-file",
str(result_path),
]
if model:
command.extend(["--model", model])
if reasoning_effort:
command.extend(["--reasoning-effort", reasoning_effort])
if pi_provider:
command.extend(["--pi-provider", pi_provider])
result = run(command, cwd=workspace, check=False, capture=False)
if result.returncode != 0:
if result.returncode == 3:
tracked = stage_result(result_path, workspace=workspace, label=label)
if tracked is None:
raise CycleError(f"agent tracking result missing: stage={stage}")
raise TrackingRequired(stage, tracked)
raise CycleError(f"agent stage failed: stage={stage} exit={result.returncode}")
final = stage_result(result_path, workspace=workspace, label=label)
if final is None or final.get("status") != "succeeded":
raise CycleError(f"agent stage returned without succeeded result: stage={stage}")
return result_path
def publish(workspace: Path, epic: Epic, phase: str) -> str:
paths = changed_paths(workspace)
if paths:
unsafe = [path for path in paths if sensitive(path)]
if unsafe:
raise CycleError(f"sensitive path refused: {','.join(unsafe)}")
for path in paths:
git(workspace, "add", "--", path)
git(workspace, "diff", "--cached", "--check")
message = (
f"feat(epic): {epic.epic_id} 작업을 준비한다"
if phase == "initial"
else f"chore(epic): {epic.epic_id} 준비 결과를 검증한다"
)
git(workspace, "commit", "-m", message)
branch = git(workspace, "branch", "--show-current")
upstream = git(workspace, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
if not upstream:
raise CycleError(f"feature branch has no upstream: {branch}")
if not upstream.endswith(f"/{branch}"):
raise CycleError(f"feature branch upstream mismatch: branch={branch} upstream={upstream}")
git(workspace, "push")
if changed_paths(workspace):
raise CycleError("workspace is dirty after publish")
return git(workspace, "rev-parse", "HEAD")
def parser() -> argparse.ArgumentParser:
value = argparse.ArgumentParser(description=__doc__)
value.add_argument("--workspace", required=True)
value.add_argument("--milestone", required=True)
value.add_argument("--epic", required=True)
value.add_argument(
"--planner-agent",
choices=("codex", "claude", "gemini", "pi"),
default=DEFAULT_PLANNER_AGENT,
)
value.add_argument("--review-agent", choices=("codex", "claude", "gemini", "pi"))
value.add_argument("--planner-model")
value.add_argument("--review-model")
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
value.add_argument("--pi-provider")
value.add_argument(
"--batch-task-ids",
help="internal selected-Epic Task id union; permits earlier Epic pairs in the same batch",
)
value.add_argument(
"--validate-only",
action="store_true",
help="validate the selected Epic against the current batch without running an agent",
)
value.add_argument("--retry", action="store_true")
return value
def apply_defaults(args: argparse.Namespace) -> argparse.Namespace:
if args.planner_model is None and args.planner_agent == DEFAULT_PLANNER_AGENT:
args.planner_model = DEFAULT_PLANNER_MODEL
if args.reasoning_effort is None:
args.reasoning_effort = DEFAULT_REASONING_EFFORT
return args
def cycle(args: argparse.Namespace) -> int:
apply_defaults(args)
workspace = resolve_workspace(args.workspace)
milestone_path, milestone_match = resolve_milestone(workspace, args.milestone)
milestone_text = milestone_path.read_text(encoding="utf-8")
verify_milestone_gate(milestone_text)
epic = select_epic(parse_epics(milestone_text), args.epic)
milestone_slug = milestone_match.group("slug")
phase_slug = milestone_match.group("phase")
task_group = f"m-{milestone_slug}"
identity = f"{milestone_slug}:{epic.epic_id}"
if not epic.task_ids:
raise CycleError(f"target Epic has no Task ids: {epic.epic_id}")
allowed_task_ids = batch_task_ids(args.batch_task_ids, epic.task_ids)
feature_prefix = git(workspace, "config", "--get", "gitflow.prefix.feature")
branch = git(workspace, "branch", "--show-current")
expected_branch = f"{feature_prefix}{milestone_slug}"
if branch != expected_branch:
raise CycleError(f"workspace branch does not match Milestone slug: expected={expected_branch} actual={branch}")
current_path = workspace / "agent-roadmap" / "current.md"
expected_current_target = f"phase/{phase_slug}/milestones/{milestone_slug}.md"
if not current_path.is_file() or expected_current_target not in current_path.read_text(encoding="utf-8"):
raise CycleError(f"workspace-local current does not select target Milestone: {current_path}")
if args.validate_only:
pairs, task_union = validate_pairs(
workspace,
task_group,
set(epic.task_ids),
allowed_task_ids,
)
completed_with_plan = sorted(task_union - set(epic.incomplete_ids))
if completed_with_plan:
raise CycleError(
"completed Task ids still have active plans: " + ",".join(completed_with_plan)
)
remaining_without_plan = sorted(set(epic.incomplete_ids) - task_union)
if remaining_without_plan:
raise CycleError(
"incomplete Epic Task ids have neither completion sync nor active plans: "
+ ",".join(remaining_without_plan)
)
emit(
"EPIC_BATCH_VALIDATED",
identity=identity,
event="EPIC_COMPLETED" if not epic.incomplete_ids else "EPIC_WORK_ITEMS_READY",
plans=len(pairs),
)
return 0
state_root = git_common_dir(workspace) / "epic-work-preparation" / milestone_slug / epic.epic_id
state_path = state_root / "state.json"
state_root.mkdir(parents=True, exist_ok=True)
with (state_root / "cycle.lock").open("a+", encoding="utf-8") as lock:
try:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise CycleError(f"Epic cycle already running: {identity}") from exc
state = read_state(state_path)
current_head = git(workspace, "rev-parse", "HEAD")
if state and state.get("identity") != identity:
raise CycleError(f"cycle state identity mismatch: {state_path}")
if (
state
and state.get("task_ids") is not None
and state.get("task_ids") != list(epic.task_ids)
):
raise CycleError("target Epic Task ids changed after cycle scope was fixed")
if (
state
and state.get("status") != "completed"
and state.get("batch_task_ids") is not None
and state.get("batch_task_ids") != sorted(allowed_task_ids)
):
raise CycleError("selected Epic batch Task ids changed after cycle scope was fixed")
if state and state.get("status") == "completed":
if changed_paths(workspace):
raise CycleError("completed cycle requires a clean feature workspace")
refreshed = select_epic(
parse_epics(milestone_path.read_text(encoding="utf-8")), epic.epic_id
)
pairs, task_union = validate_pairs(
workspace,
task_group,
set(refreshed.task_ids),
allowed_task_ids,
)
completed_with_plan = sorted(task_union - set(refreshed.incomplete_ids))
if completed_with_plan:
raise CycleError(
"completed Task ids still have active plans: " + ",".join(completed_with_plan)
)
remaining_without_plan = sorted(set(refreshed.incomplete_ids) - task_union)
if remaining_without_plan:
raise CycleError(
"completed cycle no longer has evidence for incomplete Task ids; run workstate sync or recover plans: "
+ ",".join(remaining_without_plan)
)
terminal = "EPIC_COMPLETED" if not refreshed.incomplete_ids else "EPIC_WORK_ITEMS_READY"
state.update(event=terminal, head=current_head)
atomic_json(state_path, state)
emit(terminal, identity=identity, resumed=True, head=current_head, plans=len(pairs))
return 0
review_path = sdd_user_review(workspace, phase_slug, milestone_slug)
if state and state.get("status") == "user-review" and review_path.exists():
emit("USER_REVIEW", identity=identity, path=str(review_path), resumed=True)
return 2
prior_cycle_status = str(state.get("status")) if state else "new"
if state and state.get("status") == "failed" and not args.retry:
raise CycleError(f"prior terminal failure requires --retry: {state.get('reason', 'unknown')}")
if state is None:
if changed_paths(workspace):
raise CycleError("clean feature workspace required before a new Epic cycle")
if review_path.exists():
emit("USER_REVIEW", identity=identity, path=str(review_path))
return 2
if active_task_user_reviews(workspace, task_group):
raise CycleError("preparation cannot resume from agent-task USER_REVIEW")
if not epic.incomplete_ids:
existing, _ = validate_pairs(
workspace,
task_group,
set(epic.task_ids),
allowed_task_ids,
)
if existing:
raise CycleError(
"completed Epic still has active PLAN/CODE_REVIEW pairs; reconcile them before completion"
)
state = {
"identity": identity,
"task_ids": list(epic.task_ids),
"batch_task_ids": sorted(allowed_task_ids),
"status": "completed",
"event": "EPIC_COMPLETED",
"head": current_head,
}
atomic_json(state_path, state)
emit("EPIC_COMPLETED", identity=identity)
return 0
existing, _ = validate_pairs(
workspace,
task_group,
set(epic.task_ids),
allowed_task_ids,
)
if existing:
raise CycleError("active pair already exists before new Epic cycle; select recovery explicitly")
state = {
"identity": identity,
"task_ids": list(epic.task_ids),
"batch_task_ids": sorted(allowed_task_ids),
"status": "active",
"next_stage": STAGES[0],
"base_head": current_head,
"checkpoint_head": None,
"pre_refine_ids": [],
}
atomic_json(state_path, state)
emit(
"EPIC_SCOPE_RESOLVED",
identity=identity,
task_ids=list(epic.task_ids),
incomplete_ids=list(epic.incomplete_ids),
)
elif changed_paths(workspace) and not args.retry:
raise CycleError("dirty recovery state requires explicit --retry")
reviewer_agent = args.review_agent or args.planner_agent
reviewer_model = args.review_model or (
args.planner_model if reviewer_agent == args.planner_agent else None
)
start_index = STAGES.index(str(state.get("next_stage", STAGES[0])))
for stage in STAGES[start_index:]:
stage_head = git(workspace, "rev-parse", "HEAD")
event_prefix = stage.upper().replace("-", "_")
emit(f"{event_prefix}_STARTED", identity=identity)
agent = args.planner_agent if stage in {"materialize", "refine"} else reviewer_agent
model = args.planner_model if stage in {"materialize", "refine"} else reviewer_model
prompt = stage_prompt(
stage=stage,
workspace=workspace,
milestone=milestone_path,
epic=epic,
task_group=task_group,
base_head=str(state["base_head"]),
checkpoint_head=state.get("checkpoint_head"),
)
result_path = state_root / "attempts" / f"{stage}.json"
state.update(
status="running",
current_stage=stage,
active_result=str(result_path),
)
atomic_json(state_path, state)
try:
run_agent_stage(
workspace=workspace,
state_root=state_root,
identity=identity.replace(":", "-"),
stage=stage,
prompt=prompt,
agent=agent,
model=model,
reasoning_effort=args.reasoning_effort,
pi_provider=args.pi_provider,
prior_cycle_status=prior_cycle_status,
retry=args.retry,
)
except TrackingRequired as exc:
state.update(
status="tracking",
current_stage=stage,
active_result=str(result_path),
locator=exc.result.get("locator"),
)
atomic_json(state_path, state)
if isinstance(exc, TrackingRecoveryRequired):
state["recovery_required"] = True
atomic_json(state_path, state)
emit(
"AGENT_RECOVERY_REQUIRED",
identity=identity,
stage=stage,
locator=exc.result.get("locator"),
action="inspect locator, then rerun with --retry to adopt artifacts",
)
else:
state.pop("recovery_required", None)
atomic_json(state_path, state)
emit(
"AGENT_TRACKING",
identity=identity,
stage=stage,
locator=exc.result.get("locator"),
pid=exc.result.get("agent_pid"),
)
return 3
if git(workspace, "rev-parse", "HEAD") != stage_head:
raise CycleError(f"child agent committed unexpectedly: stage={stage}")
refreshed_scope = select_epic(
parse_epics(milestone_path.read_text(encoding="utf-8")), epic.epic_id
)
if refreshed_scope.task_ids != epic.task_ids:
raise CycleError(f"target Epic Task ids changed unexpectedly: stage={stage}")
pairs, task_union = validate_pairs(
workspace,
task_group,
set(epic.task_ids),
allowed_task_ids,
)
if active_task_user_reviews(workspace, task_group):
raise CycleError("preparation agent created forbidden agent-task USER_REVIEW")
if review_path.exists():
head = publish(workspace, epic, "user-review")
state.update(status="user-review", event="USER_REVIEW", head=head, next_stage=stage)
atomic_json(state_path, state)
emit("USER_REVIEW", identity=identity, path=str(review_path), head=head)
return 2
if stage == "materialize" and not changed_paths(workspace) and not pairs:
raise CycleError("materialize produced neither direct work nor PLAN pairs")
if stage == "refine":
expected_union = set(state.get("pre_refine_ids", []))
if task_union != expected_union:
raise CycleError(
f"refine changed milestone-task union: before={sorted(expected_union)} after={sorted(task_union)}"
)
if stage == "final-review":
expected_union = set(state.get("pre_refine_ids", []))
if task_union != expected_union:
raise CycleError(
f"final review changed milestone-task union: before={sorted(expected_union)} after={sorted(task_union)}"
)
emit(f"{event_prefix}_FINISHED", identity=identity, plans=len(pairs))
state.pop("current_stage", None)
state.pop("active_result", None)
state.pop("locator", None)
state.pop("recovery_required", None)
if stage == "initial-review":
state["pre_refine_ids"] = sorted(task_union)
checkpoint = publish(workspace, epic, "initial")
state["checkpoint_head"] = checkpoint
emit("INITIAL_CHECKPOINT_PUSHED", identity=identity, head=checkpoint)
elif stage == "final-review":
final_head = publish(workspace, epic, "final")
emit("FINAL_ARTIFACTS_PUSHED", identity=identity, head=final_head)
refreshed = select_epic(
parse_epics(milestone_path.read_text(encoding="utf-8")), epic.epic_id
)
completed_with_plan = sorted(task_union - set(refreshed.incomplete_ids))
if completed_with_plan:
raise CycleError(
"completed Task ids still have active plans: "
+ ",".join(completed_with_plan)
)
remaining_without_plan = sorted(set(refreshed.incomplete_ids) - task_union)
if remaining_without_plan:
raise CycleError(
"incomplete Epic Task ids have neither completion sync nor active plans: "
+ ",".join(remaining_without_plan)
)
terminal = "EPIC_COMPLETED" if not refreshed.incomplete_ids else "EPIC_WORK_ITEMS_READY"
state.update(status="completed", event=terminal, head=final_head, next_stage=None)
atomic_json(state_path, state)
emit(terminal, identity=identity, head=final_head, plans=len(pairs))
return 0
next_index = STAGES.index(stage) + 1
state["next_stage"] = STAGES[next_index]
state["status"] = "active"
atomic_json(state_path, state)
prior_cycle_status = "active"
raise CycleError("cycle ended without terminal state")
def main(argv: Iterable[str] | None = None) -> int:
args = apply_defaults(parser().parse_args(argv))
state_path: Path | None = None
identity = "unknown"
try:
return cycle(args)
except (CycleError, OSError, ValueError) as exc:
try:
if not args.validate_only:
workspace = Path(args.workspace).expanduser().resolve()
milestone = Path(args.milestone)
if not milestone.is_absolute():
milestone = (workspace / milestone).resolve()
match = MILESTONE_PATTERN.fullmatch(milestone.relative_to(workspace).as_posix())
if match:
selected = select_epic(
parse_epics(milestone.read_text(encoding="utf-8")), args.epic
)
identity = f"{match.group('slug')}:{selected.epic_id}"
state_path = (
git_common_dir(workspace)
/ "epic-work-preparation"
/ match.group("slug")
/ selected.epic_id
/ "state.json"
)
prior = read_state(state_path) or {"identity": identity}
prior.update(status="failed", event="FAILED", reason=str(exc))
atomic_json(state_path, prior)
except Exception:
pass
emit("FAILED", identity=identity, reason=str(exc), state=str(state_path) if state_path else None)
return 2
if __name__ == "__main__":
raise SystemExit(main())