sync: to agentic-framework v1.1.185
This commit is contained in:
parent
1616b03a87
commit
3d5480264e
9 changed files with 1330 additions and 66 deletions
|
|
@ -1 +1 @@
|
|||
1.1.184
|
||||
1.1.185
|
||||
|
|
|
|||
|
|
@ -20,13 +20,16 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
|
|||
- `reasoning-effort`: 지원하는 provider의 reasoning/thinking override. 생략 시 `xhigh` (선택)
|
||||
- `pi-provider`: Pi provider override (선택)
|
||||
- `retry`: terminal failure의 원인을 사용자가 해소한 뒤 같은 Epic 상태를 재개할 때만 사용한다. (선택)
|
||||
- `batch-task-ids`: 상위 `prepare-milestone-workspace`가 고정한 선택 Epic Task id 합집합. 직접 호출에서는 사용하지 않는다. (내부 선택)
|
||||
|
||||
## 범위 계약
|
||||
|
||||
- 한 실행은 Epic 하나만 다룬다. Epic 범위 요청은 caller가 문서 순서대로 하나씩 실행한다.
|
||||
- 한 실행은 Epic 하나만 다룬다. Epic 범위 요청은 `prepare-milestone-workspace` coordinator가 문서 순서대로 하나씩 실행한다.
|
||||
- 실행 identity는 `<milestone-slug>:<epic-id>`다.
|
||||
- 다음 Epic은 현재 Epic의 모든 Task가 workstate sync에서 완료된 `EPIC_COMPLETED` event 뒤에만 시작한다.
|
||||
- standalone 사이클의 다음 Epic은 현재 Epic의 모든 Task가 workstate sync에서 완료된 `EPIC_COMPLETED` 뒤에 시작한다.
|
||||
- 상위 coordinator가 고정한 batch에서는 현재 Epic의 `EPIC_WORK_ITEMS_READY`도 다음 선택 Epic 준비를 허용한다. 이때 현재 Epic pair는 유지하고, 다음 Epic cycle은 batch Task id 합집합 안의 앞선 pair를 구조 검증하되 소유하거나 변경하지 않는다.
|
||||
- `EPIC_WORK_ITEMS_READY`는 큰 작업 plan이 준비됐다는 뜻이며 구현 완료가 아니다.
|
||||
- 개별 `EPIC_WORK_ITEMS_READY`는 dispatcher 시작 신호가 아니다. 복수 선택의 dispatcher gate는 상위 coordinator의 `MILESTONE_WORK_ITEMS_READY` 하나다.
|
||||
- 같은 identity를 다시 실행하면 active pair, USER_REVIEW, runtime state를 먼저 대조하고 중복 plan을 만들지 않는다.
|
||||
|
||||
작은 작업은 아래를 모두 만족해야 한다.
|
||||
|
|
@ -42,7 +45,7 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
|
|||
1. **Epic을 고정한다**
|
||||
- target Milestone이 `[계획]` 또는 `[진행중]`, 구현 잠금 해제인지 확인한다.
|
||||
- `### Epic: [<epic-id>] <title>`을 정확히 하나 찾고 그 아래 Task id를 고정한다.
|
||||
- 다른 Epic의 active pair가 있거나 target이 모호하면 `FAILED`로 멈춘다.
|
||||
- standalone에서 다른 Epic의 active pair가 있거나 target이 모호하면 `FAILED`로 멈춘다. 상위 batch에서는 선택 Task id 합집합 밖 pair 또는 Epic 경계를 가로지르는 pair만 거부한다.
|
||||
|
||||
2. **foreground 사이클을 실행한다**
|
||||
- 아래 스크립트를 한 번 실행하고 execution-layer event wait를 유지한다.
|
||||
|
|
@ -56,6 +59,7 @@ python3 agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.p
|
|||
|
||||
- 기본값은 `codex / gpt-5.6-sol / xhigh`다. 다른 provider를 지정하면 모델을 별도로 주지 않는 한 해당 provider의 CLI 기본 모델을 사용한다.
|
||||
- 다른 agent, model, reasoning, Pi provider override와 `--retry`는 해당 입력이 있을 때만 전달한다.
|
||||
- 상위 batch에서 호출할 때만 고정된 Task id 합집합을 `--batch-task-ids`로 전달한다.
|
||||
- 스크립트는 각 agent를 새 one-shot session으로 실행한다. Codex, Claude, Gemini(`agy` adapter), Pi를 같은 normalized runner 계약으로 지원한다.
|
||||
- model stdout/stderr는 git common dir의 locator log에만 저장한다. caller stdout에는 lifecycle/attention event만 출력한다.
|
||||
|
||||
|
|
@ -85,6 +89,7 @@ python3 agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.p
|
|||
- `FINAL_REVIEW_STARTED`, `FINAL_REVIEW_FINISHED`
|
||||
- `FINAL_ARTIFACTS_PUSHED`
|
||||
- `AGENT_TRACKING`, `AGENT_RECOVERY_REQUIRED`, `AGENT_RESULT_RECOVERED`
|
||||
- `EPIC_BATCH_VALIDATED` (상위 batch의 deterministic barrier 검증)
|
||||
- `EPIC_WORK_ITEMS_READY`, `EPIC_COMPLETED`, `USER_REVIEW`, `FAILED`
|
||||
|
||||
routine event는 caller 판단을 요구하지 않는다. caller는 `USER_REVIEW`, `AGENT_RECOVERY_REQUIRED`, 복구 불가능한 `FAILED`, terminal completion에서만 깨어난다.
|
||||
|
|
@ -93,6 +98,7 @@ routine event는 caller 판단을 요구하지 않는다. caller는 `USER_REVIEW
|
|||
|
||||
- [ ] active PLAN/CODE_REVIEW가 항상 pair이고 첫 줄 metadata가 일치하는가
|
||||
- [ ] 모든 `milestone-task`가 target Epic Task id의 비어 있지 않은 부분집합인가
|
||||
- [ ] 상위 batch 호출이면 다른 pair도 선택 batch 합집합 안에 있고 target Epic 경계를 가로지르지 않는가
|
||||
- [ ] 모든 PLAN이 dispatcher `--validate-plan`을 통과하는가
|
||||
- [ ] refine 전후 Task id 합집합과 scope가 보존됐는가
|
||||
- [ ] repository에 unresolved template token이나 preparation runtime state가 추적되지 않는가
|
||||
|
|
|
|||
|
|
@ -317,26 +317,45 @@ 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]]:
|
||||
pairs = active_pairs(workspace, task_group)
|
||||
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()
|
||||
dispatcher = (
|
||||
workspace
|
||||
/ "agent-ops"
|
||||
/ "skills"
|
||||
/ "common"
|
||||
/ "orchestrate-agent-task-loop"
|
||||
/ "scripts"
|
||||
/ "dispatch.py"
|
||||
project_dispatcher = (
|
||||
workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
|
||||
)
|
||||
for plan, review, header in pairs:
|
||||
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}")
|
||||
outside = sorted(set(ids) - epic_task_ids)
|
||||
pair_ids = set(ids)
|
||||
outside = sorted(pair_ids - allowed)
|
||||
if outside:
|
||||
raise CycleError(f"plan includes Task ids outside target Epic: {plan}: {','.join(outside)}")
|
||||
union.update(ids)
|
||||
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}")
|
||||
|
|
@ -349,6 +368,19 @@ def validate_pairs(
|
|||
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():
|
||||
|
|
@ -538,6 +570,15 @@ def parser() -> argparse.ArgumentParser:
|
|||
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
|
||||
|
||||
|
|
@ -563,6 +604,7 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
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}"
|
||||
|
|
@ -572,6 +614,31 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
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)
|
||||
|
|
@ -591,13 +658,24 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
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("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))
|
||||
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(
|
||||
|
|
@ -630,7 +708,12 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
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))
|
||||
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"
|
||||
|
|
@ -638,6 +721,7 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
state = {
|
||||
"identity": identity,
|
||||
"task_ids": list(epic.task_ids),
|
||||
"batch_task_ids": sorted(allowed_task_ids),
|
||||
"status": "completed",
|
||||
"event": "EPIC_COMPLETED",
|
||||
"head": current_head,
|
||||
|
|
@ -645,12 +729,18 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
atomic_json(state_path, state)
|
||||
emit("EPIC_COMPLETED", identity=identity)
|
||||
return 0
|
||||
existing, _ = validate_pairs(workspace, task_group, set(epic.task_ids))
|
||||
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,
|
||||
|
|
@ -744,7 +834,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}")
|
||||
pairs, task_union = validate_pairs(workspace, task_group, set(epic.task_ids))
|
||||
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():
|
||||
|
|
@ -818,26 +913,27 @@ def main(argv: Iterable[str] | None = None) -> int:
|
|||
return cycle(args)
|
||||
except (CycleError, OSError, ValueError) as exc:
|
||||
try:
|
||||
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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -161,6 +161,54 @@ class EpicCycleContractTest(unittest.TestCase):
|
|||
with self.assertRaises(MODULE.CycleError):
|
||||
MODULE.validate_pairs(workspace, "m-sample", {"inside"})
|
||||
|
||||
def test_validate_pair_allows_earlier_epic_pair_in_selected_batch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
workspace = Path(raw)
|
||||
command(workspace, "git", "init")
|
||||
root = workspace / "agent-task" / "m-sample"
|
||||
first = root / "01_first"
|
||||
second = root / "02_second"
|
||||
first.mkdir(parents=True)
|
||||
second.mkdir(parents=True)
|
||||
for task, task_id in ((first, "first-task"), (second, "second-task")):
|
||||
header = (
|
||||
f"<!-- task=m-sample/{task.name} plan=0 tag=TEST "
|
||||
f"milestone-task={task_id} -->\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", encoding="utf-8"
|
||||
)
|
||||
pairs, union = MODULE.validate_pairs(
|
||||
workspace,
|
||||
"m-sample",
|
||||
{"second-task"},
|
||||
{"first-task", "second-task"},
|
||||
)
|
||||
self.assertEqual([pair[0].parent.name for pair in pairs], ["02_second"])
|
||||
self.assertEqual(union, {"second-task"})
|
||||
|
||||
def test_validate_pair_rejects_foreign_pair_outside_selected_batch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
workspace = Path(raw)
|
||||
task = workspace / "agent-task" / "m-sample" / "03_foreign"
|
||||
task.mkdir(parents=True)
|
||||
header = (
|
||||
"<!-- task=m-sample/03_foreign plan=0 tag=TEST "
|
||||
"milestone-task=foreign-task -->\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", encoding="utf-8"
|
||||
)
|
||||
with self.assertRaisesRegex(MODULE.CycleError, "outside selected Epic batch"):
|
||||
MODULE.validate_pairs(
|
||||
workspace,
|
||||
"m-sample",
|
||||
{"second-task"},
|
||||
{"first-task", "second-task"},
|
||||
)
|
||||
|
||||
def test_full_cycle_with_fresh_fake_codex_passes_and_pushes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,29 @@
|
|||
---
|
||||
name: prepare-milestone-workspace
|
||||
description: 명시된 workspace에 계획 상태의 Milestone을 현재 작업현황과 동기화한 뒤 Git Flow feature branch를 Milestone slug로 생성·push하고 worktree로 준비해 첫 Epic 작업 사이클로 넘길 때 사용한다. "workspace에 milestone 작업 준비해" 요청에서 사용한다.
|
||||
description: 명시된 절대 또는 repository-root-relative workspace에 계획 상태의 Milestone을 동기화하고 Git Flow feature branch/worktree를 준비한 뒤, 선택한 한 개 또는 여러 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../iop-s1 위치에 X 작업 준비해", "X에 Y 작업준비해. Epic은 두 번째까지 진행해" 요청에서 사용한다.
|
||||
---
|
||||
|
||||
# Prepare Milestone Workspace
|
||||
|
||||
`<workspace>에 <milestone> 작업 준비해` 요청은 workspace만 추정하거나 만드는 요청이 아니라, 지정된 workspace에 대상 Milestone의 정합성 확인부터 첫 Epic 준비 사이클 연결까지 수행하는 전체 흐름으로 해석한다.
|
||||
`<workspace>에 <milestone> 작업 준비해` 요청은 workspace만 만드는 요청이 아니다. 대상 Milestone 정합성 확인부터 선택 Epic 준비와 dispatcher 전환까지 수행하는 전체 흐름으로 해석한다.
|
||||
|
||||
## 목적
|
||||
|
||||
계획 가능한 Milestone 하나를 검증된 `feature/<milestone-slug>` workspace로 전환한다. 의미 정합성은 기존 roadmap 스킬이, branch/worktree/current와 lifecycle event는 번들 스크립트가 소유한다.
|
||||
계획 가능한 Milestone 하나를 검증된 `feature/<milestone-slug>` workspace로 전환하고, 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다.
|
||||
|
||||
## 입력
|
||||
|
||||
- `target-milestone`: 활성 Milestone 이름, id, slug 또는 경로 (필수)
|
||||
- `workspace`: feature worktree로 사용할 절대 경로 (필수)
|
||||
- `workspace`: feature worktree로 사용할 절대 경로 또는 develop repository root 기준 상대 경로 (필수)
|
||||
- `planner-agent`: `codex`, `claude`, `gemini`, `pi` 중 하나 (생략 시 `codex`)
|
||||
- `review-agent`: 생략하면 `planner-agent`와 같다. (선택)
|
||||
- `planner-model`, `review-model`: provider별 model override. Codex 기본 사용 시 `planner-model` 생략 시 `gpt-5.6-sol`, 다른 provider는 해당 CLI 기본 모델을 사용한다. (선택)
|
||||
- `reasoning-effort`: 지원하는 provider의 reasoning/thinking override. 생략 시 `xhigh` (선택)
|
||||
- `pi-provider`: Pi provider override (선택)
|
||||
- `target-epic`: 준비 직후 실행할 정확한 Epic id 또는 이름. 생략하면 첫 미완료 Epic을 선택한다. (선택)
|
||||
- `target-epics`: `first-incomplete`, 정확한 Epic id/title의 comma list, 또는 문서 순서의 1-based inclusive range `N..M`. 생략하면 `first-incomplete`를 사용한다. (선택)
|
||||
- `retry`: 기록된 attention/recovery 조건을 사용자가 해소한 뒤 batch를 재개할 때만 사용한다. (선택)
|
||||
|
||||
트리거의 첫 번째 위치 표현(`<workspace>`)은 workspace 절대 경로로, 두 번째 표현(`<milestone>`)은 대상 Milestone으로 각각 확정한다. workspace가 생략되거나 둘 이상의 경로로 해석되면 임의로 `current`나 기본 경로를 사용하지 않고 사용자에게 확인한다.
|
||||
트리거의 첫 번째 위치 표현(`<workspace>`)은 workspace로, 두 번째 표현(`<milestone>`)은 대상 Milestone으로 각각 확정한다. 상대 workspace는 develop repository root 기준으로 해석한다. `두 번째 Epic까지`는 `1..2`, `세 번째부터 네 번째 Epic까지`는 `3..4`로 변환한다. workspace가 생략되거나 둘 이상의 경로로 해석되면 임의로 `current`나 기본 경로를 사용하지 않고 사용자에게 확인한다.
|
||||
|
||||
## 사전 조건
|
||||
|
||||
|
|
@ -47,7 +48,8 @@ description: 명시된 workspace에 계획 상태의 Milestone을 현재 작업
|
|||
python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py \
|
||||
--repo "$REPO" \
|
||||
--milestone "$MILESTONE" \
|
||||
--workspace "$WORKSPACE"
|
||||
--workspace "$WORKSPACE" \
|
||||
--epics "$EPICS"
|
||||
```
|
||||
|
||||
- 기본값은 `codex / gpt-5.6-sol / xhigh`다. 다른 provider를 지정하면 모델을 별도로 주지 않는 한 해당 provider의 CLI 기본 모델을 사용한다.
|
||||
|
|
@ -57,17 +59,26 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
|
|||
- 기존 branch/worktree는 정확히 같은 branch·경로이고 clean할 때만 재개한다.
|
||||
- remote branch 생성 뒤 후속 단계가 실패해도 branch/worktree를 자동 삭제하지 않는다.
|
||||
|
||||
3. **Epic 사이클로 handoff한다**
|
||||
- `WORKSPACE_READY` event에서만 대상 workspace로 이동한다.
|
||||
- target Epic을 명시했으면 그대로, 없으면 Milestone 문서 순서상 첫 미완료 Epic 하나를 선택한다.
|
||||
- 같은 caller turn에서 `prepare-epic-work-items`를 정확히 한 번 시작하고 그 foreground 실행을 기다린다.
|
||||
- `USER_REVIEW`, 복구 불가능한 `FAILED`, `EPIC_WORK_ITEMS_READY`, `EPIC_COMPLETED` 외 routine event에는 caller가 개입하지 않는다.
|
||||
3. **선택 Epic batch를 준비한다**
|
||||
- `WORKSPACE_READY` 뒤 스크립트가 선택 Epic을 문서 순서대로 하나씩 `prepare-epic-work-items`에 전달한다.
|
||||
- 각 Epic은 별도 fresh one-shot context를 사용한다. 앞선 Epic의 `EPIC_WORK_ITEMS_READY` plan은 같은 batch의 다음 Epic 준비 동안 유지한다.
|
||||
- 두 개 이상이면 마지막 Epic 준비 뒤 fresh cross-Epic review를 한 번 수행해 scope union, dependency, write set, metadata, routing과 검증을 교정하고 push한다.
|
||||
- `USER_REVIEW`, `AGENT_RECOVERY_REQUIRED`, `AGENT_TRACKING`, `FAILED`가 발생하면 batch를 멈추고 dispatcher를 시작하지 않는다.
|
||||
|
||||
4. **전체 준비 배리어 뒤 dispatcher로 전환한다**
|
||||
- 모든 선택 Epic이 `EPIC_WORK_ITEMS_READY` 또는 `EPIC_COMPLETED`이고 deterministic batch validation과 모든 push가 끝난 경우에만 `MILESTONE_WORK_ITEMS_READY`를 낸다.
|
||||
- active plan이 있으면 private/project/common 우선순위로 `orchestrate-agent-task-loop` dispatcher를 선택하고 같은 task group `m-<milestone-slug>`에 `--dry-run`을 먼저 실행한 뒤 live를 정확히 한 번 시작한다.
|
||||
- 모든 선택 Epic이 `EPIC_COMPLETED`이면 dispatcher를 생략한다.
|
||||
- foreground dispatcher가 종료될 때까지 caller는 timer polling이나 상태 파일 검사를 하지 않는다. batch/dispatcher PID와 start token은 git common dir 상태에 기록해 재진입 중복 실행을 막는다.
|
||||
|
||||
## lifecycle event
|
||||
|
||||
- `PREFLIGHT_READY`: mutation 전 검증 완료
|
||||
- `FEATURE_BRANCH_PUSHED`: remote feature branch 준비 완료
|
||||
- `WORKSPACE_READY`: worktree와 local current 준비 완료
|
||||
- `MILESTONE_WORK_ITEMS_READY`: 선택 Epic 전체 준비와 cross-Epic 검증·push 완료
|
||||
- `DISPATCHER_DRY_RUN_FINISHED`, `DISPATCHER_STARTED`, `DISPATCHER_TRACKING`, `DISPATCHER_BLOCKED`
|
||||
- `MILESTONE_PREPARATION_COMPLETED`: 선택 Epic 구현과 workstate sync까지 완료, 또는 all-direct라 dispatcher 생략
|
||||
- `USER_REVIEW`: 사전 정합화 또는 연결된 Epic 준비에서 사용자 결정이 필요해 정상 정지
|
||||
- `FAILED`: 안전하게 계속할 수 없음
|
||||
|
||||
|
|
@ -77,6 +88,9 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
|
|||
- [ ] branch가 remote에 존재하고 upstream이 연결됐는가
|
||||
- [ ] 요청 workspace가 정확히 해당 branch의 clean worktree인가
|
||||
- [ ] workspace-local `agent-roadmap/current.md`가 대상 Milestone만 가리키는가
|
||||
- [ ] 선택 Epic의 Task id 합집합 밖 active pair가 없는가
|
||||
- [ ] 복수 Epic이면 cross-Epic review와 push가 완료됐는가
|
||||
- [ ] dispatcher dry-run이 live보다 먼저이고 `MILESTONE_WORK_ITEMS_READY` 전에는 live가 시작되지 않았는가
|
||||
- [ ] state와 locator가 repository 문서가 아니라 git common dir 아래에 있는가
|
||||
- 검증 실패 시: branch/worktree를 삭제하지 않고 `FAILED` event와 복구 조건을 보고한다.
|
||||
|
||||
|
|
@ -87,8 +101,9 @@ Milestone workspace preparation
|
|||
- milestone: <path>
|
||||
- branch: <feature/milestone-slug>
|
||||
- workspace: <absolute path>
|
||||
- event: <WORKSPACE_READY | USER_REVIEW | FAILED>
|
||||
- next: <prepare-epic-work-items target | user action | recovery condition>
|
||||
- epics: <selected Epic ids in document order>
|
||||
- event: <MILESTONE_PREPARATION_COMPLETED | USER_REVIEW | AGENT_RECOVERY_REQUIRED | DISPATCHER_BLOCKED | FAILED>
|
||||
- next: <completed | user action | recovery condition>
|
||||
```
|
||||
|
||||
## 금지 사항
|
||||
|
|
@ -96,5 +111,7 @@ Milestone workspace preparation
|
|||
- `[계획]`이 아니거나 잠긴 Milestone의 branch를 만들지 않는다.
|
||||
- consistency check의 `refresh-required`를 `ready`로 간주하지 않는다.
|
||||
- 사용자 소유 변경이 있는 develop checkout이나 기존 workspace를 덮어쓰지 않는다.
|
||||
- 선택 Epic 중 하나라도 attention/terminal failure 상태면 dispatcher를 시작하지 않는다.
|
||||
- 복수 Epic batch에서 개별 `EPIC_WORK_ITEMS_READY`만 보고 dispatcher를 먼저 시작하지 않는다.
|
||||
- `git push --force`, destructive rollback, branch/worktree 자동 삭제를 하지 않는다.
|
||||
- caller LLM이 주기적으로 process나 상태 파일을 모니터링하지 않는다.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
interface:
|
||||
display_name: "Prepare Milestone Workspace"
|
||||
short_description: "Prepare a Git Flow workspace for one Milestone"
|
||||
default_prompt: "Use $prepare-milestone-workspace to prepare the named Milestone in the explicitly provided workspace and start its first Epic."
|
||||
display_name: "Prepare Milestone Work"
|
||||
short_description: "Prepare selected Epics and start their dispatcher"
|
||||
default_prompt: "Use $prepare-milestone-workspace to prepare the named Milestone in the given workspace through the selected Epic range and start its dispatcher."
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import re
|
|||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Iterable, NamedTuple
|
||||
|
||||
|
||||
VALID_AGENTS = {"codex", "claude", "gemini", "pi"}
|
||||
|
|
@ -23,12 +23,24 @@ DEFAULT_REASONING_EFFORT = "xhigh"
|
|||
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>.+)$")
|
||||
EPIC_RANGE = re.compile(r"^(?P<start>[1-9][0-9]*)\.\.(?P<end>[1-9][0-9]*)$")
|
||||
SENSITIVE_PARTS = {".env", "secret", "secrets", "credential", "credentials", "password", "passwords"}
|
||||
SENSITIVE_SUFFIXES = {".pem", ".key", ".p12"}
|
||||
|
||||
|
||||
class PreparationError(RuntimeError):
|
||||
"""A fail-closed preparation error."""
|
||||
|
||||
|
||||
class Epic(NamedTuple):
|
||||
epic_id: str
|
||||
title: str
|
||||
task_ids: tuple[str, ...]
|
||||
incomplete_ids: tuple[str, ...]
|
||||
|
||||
|
||||
def emit(event: str, **payload: Any) -> None:
|
||||
print(json.dumps({"event": event, **payload}, ensure_ascii=False, sort_keys=True), flush=True)
|
||||
|
||||
|
|
@ -118,6 +130,76 @@ def milestone_contract(path: Path) -> dict[str, str]:
|
|||
}
|
||||
|
||||
|
||||
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)
|
||||
task_matches = [
|
||||
value
|
||||
for value in (TASK_LINE.fullmatch(line) for line in lines[start + 1 : end])
|
||||
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") == " "
|
||||
),
|
||||
)
|
||||
)
|
||||
return epics
|
||||
|
||||
|
||||
def select_epics(epics: list[Epic], selector: str) -> list[Epic]:
|
||||
if not epics:
|
||||
raise PreparationError("target Milestone has no Epic")
|
||||
if selector == "first-incomplete":
|
||||
selected = next((epic for epic in epics if epic.incomplete_ids), None)
|
||||
if selected is None:
|
||||
raise PreparationError("target Milestone has no incomplete Epic")
|
||||
return [selected]
|
||||
range_match = EPIC_RANGE.fullmatch(selector)
|
||||
if range_match:
|
||||
start = int(range_match.group("start"))
|
||||
end = int(range_match.group("end"))
|
||||
if start > end:
|
||||
raise PreparationError(f"Epic range start must not exceed end: {selector}")
|
||||
if end > len(epics):
|
||||
raise PreparationError(
|
||||
f"Epic range exceeds document order: requested={selector} available={len(epics)}"
|
||||
)
|
||||
return epics[start - 1 : end]
|
||||
selected: list[Epic] = []
|
||||
for raw in selector.split(","):
|
||||
value = raw.strip()
|
||||
if not value:
|
||||
raise PreparationError("--epics contains an empty selector")
|
||||
matches = [
|
||||
epic
|
||||
for epic in epics
|
||||
if epic.epic_id == value or epic.title.casefold().strip() == value.casefold()
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise PreparationError(f"Epic selector must resolve exactly once: {value}")
|
||||
if matches[0] in selected:
|
||||
raise PreparationError(f"duplicate Epic selector: {value}")
|
||||
selected.append(matches[0])
|
||||
return [epic for epic in epics if epic in selected]
|
||||
|
||||
|
||||
def resolve_workspace(repo: Path, raw: str) -> Path:
|
||||
candidate = Path(raw).expanduser()
|
||||
return (repo / candidate).resolve() if not candidate.is_absolute() else candidate.resolve()
|
||||
|
||||
|
||||
def phase_contract(repo: Path, phase_slug: str) -> dict[str, str]:
|
||||
path = repo / "agent-roadmap" / "phase" / phase_slug / "PHASE.md"
|
||||
if not path.is_file():
|
||||
|
|
@ -143,6 +225,78 @@ def atomic_json(path: Path, value: dict[str, Any]) -> None:
|
|||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def read_json(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 PreparationError(f"invalid state file: {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise PreparationError(f"state file must contain 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 changed_paths(workspace: Path) -> list[str]:
|
||||
unmerged = git(workspace, "diff", "--name-only", "--diff-filter=U")
|
||||
if unmerged:
|
||||
raise PreparationError(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 PreparationError(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 ref_exists(repo: Path, ref: str) -> bool:
|
||||
return run(["git", "show-ref", "--verify", "--quiet", ref], cwd=repo, check=False).returncode == 0
|
||||
|
||||
|
|
@ -246,6 +400,583 @@ def render_current(
|
|||
"""
|
||||
|
||||
|
||||
def epic_cycle_script(workspace: Path) -> Path:
|
||||
path = (
|
||||
workspace
|
||||
/ "agent-ops"
|
||||
/ "skills"
|
||||
/ "common"
|
||||
/ "prepare-epic-work-items"
|
||||
/ "scripts"
|
||||
/ "run_epic_cycle.py"
|
||||
)
|
||||
if not path.is_file():
|
||||
raise PreparationError(f"Epic cycle script not found: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def dispatcher_script(workspace: Path) -> Path:
|
||||
project = workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
|
||||
private = workspace / "agent-ops" / "skills" / "private" / "orchestrate-agent-task-loop"
|
||||
if project.is_dir() and private.is_dir():
|
||||
root = private
|
||||
elif project.is_dir():
|
||||
root = project
|
||||
else:
|
||||
root = workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
|
||||
path = root / "scripts" / "dispatch.py"
|
||||
if not path.is_file():
|
||||
raise PreparationError(f"dispatcher script not found: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def epic_cycle_command(
|
||||
*,
|
||||
args: argparse.Namespace,
|
||||
workspace: Path,
|
||||
milestone: Path,
|
||||
epic: Epic,
|
||||
batch_ids: list[str],
|
||||
validate_only: bool = False,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(epic_cycle_script(workspace)),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--milestone",
|
||||
str(milestone),
|
||||
"--epic",
|
||||
epic.epic_id,
|
||||
"--planner-agent",
|
||||
args.planner_agent,
|
||||
"--batch-task-ids",
|
||||
",".join(batch_ids),
|
||||
]
|
||||
if args.review_agent:
|
||||
command.extend(["--review-agent", args.review_agent])
|
||||
if args.planner_model:
|
||||
command.extend(["--planner-model", args.planner_model])
|
||||
if args.review_model:
|
||||
command.extend(["--review-model", args.review_model])
|
||||
if args.reasoning_effort:
|
||||
command.extend(["--reasoning-effort", args.reasoning_effort])
|
||||
if args.pi_provider:
|
||||
command.extend(["--pi-provider", args.pi_provider])
|
||||
if validate_only:
|
||||
command.append("--validate-only")
|
||||
elif args.retry:
|
||||
command.append("--retry")
|
||||
return command
|
||||
|
||||
|
||||
def validate_batch(
|
||||
*,
|
||||
args: argparse.Namespace,
|
||||
workspace: Path,
|
||||
milestone: Path,
|
||||
epics: list[Epic],
|
||||
batch_ids: list[str],
|
||||
) -> None:
|
||||
for epic in epics:
|
||||
result = run(
|
||||
epic_cycle_command(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
epic=epic,
|
||||
batch_ids=batch_ids,
|
||||
validate_only=True,
|
||||
),
|
||||
cwd=workspace,
|
||||
check=False,
|
||||
capture=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise PreparationError(
|
||||
f"selected Epic batch validation failed: epic={epic.epic_id} exit={result.returncode}"
|
||||
)
|
||||
|
||||
|
||||
def publish_batch_review(workspace: Path) -> str:
|
||||
paths = changed_paths(workspace)
|
||||
if paths:
|
||||
unsafe = [path for path in paths if sensitive(path)]
|
||||
if unsafe:
|
||||
raise PreparationError(f"sensitive path refused: {','.join(unsafe)}")
|
||||
for path in paths:
|
||||
git(workspace, "add", "--", path)
|
||||
git(workspace, "diff", "--cached", "--check")
|
||||
git(workspace, "commit", "-m", "chore(milestone): 복수 Epic 준비 결과를 검증한다")
|
||||
git(workspace, "push")
|
||||
if changed_paths(workspace):
|
||||
raise PreparationError("workspace is dirty after batch review publish")
|
||||
return git(workspace, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def cross_epic_review(
|
||||
*,
|
||||
args: argparse.Namespace,
|
||||
workspace: Path,
|
||||
milestone: Path,
|
||||
milestone_slug: str,
|
||||
phase_slug: str,
|
||||
epics: list[Epic],
|
||||
batch_ids: list[str],
|
||||
common: Path,
|
||||
) -> tuple[int, dict[str, Any] | None]:
|
||||
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
|
||||
)
|
||||
state_root = common / "milestone-work-preparation" / milestone_slug
|
||||
prompt_path = state_root / "prompts" / "cross-epic-review.txt"
|
||||
result_path = (
|
||||
common
|
||||
/ "epic-work-preparation"
|
||||
/ milestone_slug
|
||||
/ "_batch"
|
||||
/ "cross-epic-review.json"
|
||||
)
|
||||
label = f"{milestone_slug}-cross-epic-review"
|
||||
selected = "\n".join(
|
||||
f"- [{epic.epic_id}] {epic.title}: {','.join(epic.task_ids)}" for epic in epics
|
||||
)
|
||||
prompt = f"""You are a fresh child agent launched for one bounded cross-Epic preparation review.
|
||||
Work only in {workspace}.
|
||||
Read the repository AGENTS.md completely, then read agent-ops/skills/common/router.md and only the plan/refine skills required to correct artifacts.
|
||||
Do not start subagents, orchestration dispatchers, preparation scripts, or monitoring loops.
|
||||
Do not commit or push; the parent runtime owns the checkpoint.
|
||||
Target Milestone: {milestone}
|
||||
Selected Epics in document order:
|
||||
{selected}
|
||||
Allowed Milestone Task ids: {','.join(batch_ids)}
|
||||
|
||||
Review the complete prepared artifact union across these Epics from a fresh context. Check scope coverage, cross-Epic assumptions and dependencies, write-set collisions, PLAN/CODE_REVIEW pairing and first-line metadata, task ids, indices, routing, and verification. Fix every material defect through the owning plan/refine procedure without adding an official code-review verdict. Do not implement active PLAN work. If a genuine product or scope decision is required, create the Milestone SDD USER_REVIEW artifact and stop without inventing a decision. Final in Korean.
|
||||
"""
|
||||
prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
prompt_path.write_text(prompt, encoding="utf-8")
|
||||
previous = read_json(result_path)
|
||||
if previous is not None:
|
||||
if previous.get("workspace") != str(workspace) or previous.get("label") != label:
|
||||
raise PreparationError(f"cross-Epic review result identity mismatch: {result_path}")
|
||||
if previous.get("status") == "succeeded":
|
||||
emit(
|
||||
"AGENT_RESULT_RECOVERED",
|
||||
stage="cross-epic-review",
|
||||
locator=previous.get("locator"),
|
||||
result="succeeded",
|
||||
)
|
||||
elif previous.get("status") in {"running", "tracking"} and process_is_same(
|
||||
previous.get("agent_pid"), previous.get("agent_process_start_token")
|
||||
):
|
||||
emit(
|
||||
"AGENT_TRACKING",
|
||||
stage="cross-epic-review",
|
||||
locator=previous.get("locator"),
|
||||
pid=previous.get("agent_pid"),
|
||||
)
|
||||
return 3, previous
|
||||
elif not args.retry:
|
||||
emit(
|
||||
"AGENT_RECOVERY_REQUIRED",
|
||||
stage="cross-epic-review",
|
||||
locator=previous.get("locator"),
|
||||
action="inspect locator, then rerun with --retry",
|
||||
)
|
||||
return 3, previous
|
||||
record = previous
|
||||
if record is None or record.get("status") != "succeeded":
|
||||
runner = (
|
||||
workspace
|
||||
/ "agent-ops"
|
||||
/ "skills"
|
||||
/ "common"
|
||||
/ "prepare-epic-work-items"
|
||||
/ "scripts"
|
||||
/ "run_agent_once.py"
|
||||
)
|
||||
command = [
|
||||
sys.executable,
|
||||
str(runner),
|
||||
"--agent",
|
||||
reviewer_agent,
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--prompt-file",
|
||||
str(prompt_path),
|
||||
"--label",
|
||||
label,
|
||||
"--result-file",
|
||||
str(result_path),
|
||||
]
|
||||
if reviewer_model:
|
||||
command.extend(["--model", reviewer_model])
|
||||
if args.reasoning_effort:
|
||||
command.extend(["--reasoning-effort", args.reasoning_effort])
|
||||
if reviewer_agent == "pi" and args.pi_provider:
|
||||
command.extend(["--pi-provider", args.pi_provider])
|
||||
starting_head = git(workspace, "rev-parse", "HEAD")
|
||||
result = run(command, cwd=workspace, check=False, capture=False)
|
||||
if git(workspace, "rev-parse", "HEAD") != starting_head:
|
||||
raise PreparationError("cross-Epic reviewer committed unexpectedly")
|
||||
record = read_json(result_path)
|
||||
if result.returncode != 0 or record is None or record.get("status") != "succeeded":
|
||||
return (3 if result.returncode == 3 else 2), record
|
||||
review_path = (
|
||||
workspace
|
||||
/ "agent-roadmap"
|
||||
/ "sdd"
|
||||
/ phase_slug
|
||||
/ milestone_slug
|
||||
/ "USER_REVIEW.md"
|
||||
)
|
||||
if review_path.exists():
|
||||
head = publish_batch_review(workspace)
|
||||
emit("USER_REVIEW", path=str(review_path), head=head)
|
||||
return 2, record
|
||||
task_group = workspace / "agent-task" / f"m-{milestone_slug}"
|
||||
if list(task_group.glob("USER_REVIEW.md")) + list(task_group.glob("*/USER_REVIEW.md")):
|
||||
raise PreparationError("cross-Epic reviewer created forbidden agent-task USER_REVIEW")
|
||||
validate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
epics=epics,
|
||||
batch_ids=batch_ids,
|
||||
)
|
||||
head = publish_batch_review(workspace)
|
||||
emit("CROSS_EPIC_REVIEW_FINISHED", head=head, epics=[epic.epic_id for epic in epics])
|
||||
return 0, record
|
||||
|
||||
|
||||
def coordinate_batch(
|
||||
*,
|
||||
args: argparse.Namespace,
|
||||
workspace: Path,
|
||||
milestone: Path,
|
||||
milestone_slug: str,
|
||||
phase_slug: str,
|
||||
common: Path,
|
||||
) -> int:
|
||||
selected = select_epics(parse_epics(milestone.read_text(encoding="utf-8")), args.epics)
|
||||
empty = [epic.epic_id for epic in selected if not epic.task_ids]
|
||||
if empty:
|
||||
raise PreparationError(f"selected Epic has no Task ids: {','.join(empty)}")
|
||||
batch_ids = [task_id for epic in selected for task_id in epic.task_ids]
|
||||
duplicates = sorted({value for value in batch_ids if batch_ids.count(value) > 1})
|
||||
if duplicates:
|
||||
raise PreparationError(f"selected Epic Task ids are not unique: {','.join(duplicates)}")
|
||||
identity = {
|
||||
"milestone": str(milestone),
|
||||
"workspace": str(workspace),
|
||||
"selected_epics": [epic.epic_id for epic in selected],
|
||||
"batch_task_ids": batch_ids,
|
||||
}
|
||||
state_path = common / "milestone-work-preparation" / milestone_slug / "batch-state.json"
|
||||
state = read_json(state_path)
|
||||
if state is None:
|
||||
state = {
|
||||
**identity,
|
||||
"status": "active",
|
||||
"epic_events": {},
|
||||
"cross_epic_review_done": False,
|
||||
"dispatcher_dry_run_done": False,
|
||||
"dispatcher_live_started": False,
|
||||
}
|
||||
atomic_json(state_path, state)
|
||||
else:
|
||||
for key, expected in identity.items():
|
||||
if state.get(key) != expected:
|
||||
raise PreparationError(
|
||||
f"Milestone preparation batch identity changed: field={key} state={state.get(key)} requested={expected}"
|
||||
)
|
||||
if state.get("status") == "completed":
|
||||
emit(
|
||||
"MILESTONE_PREPARATION_COMPLETED",
|
||||
milestone=milestone_slug,
|
||||
epics=identity["selected_epics"],
|
||||
resumed=True,
|
||||
head=state.get("head"),
|
||||
)
|
||||
return 0
|
||||
resume_blocked_dispatcher = state.get("status") == "dispatcher-blocked"
|
||||
if state.get("status") == "dispatching" and process_is_same(
|
||||
state.get("dispatcher_pid"), state.get("dispatcher_process_start_token")
|
||||
):
|
||||
emit(
|
||||
"DISPATCHER_TRACKING",
|
||||
pid=state.get("dispatcher_pid"),
|
||||
task_group=f"m-{milestone_slug}",
|
||||
)
|
||||
return 3
|
||||
if state.get("status") in {"dispatching", "dispatcher-tracking"} and not args.retry:
|
||||
emit(
|
||||
"DISPATCHER_RECOVERY_REQUIRED",
|
||||
task_group=f"m-{milestone_slug}",
|
||||
action="inspect dispatcher state, then rerun with --retry",
|
||||
)
|
||||
return 3
|
||||
if state.get("status") == "dispatcher-blocked" and not args.retry:
|
||||
emit(
|
||||
"DISPATCHER_BLOCKED",
|
||||
task_group=f"m-{milestone_slug}",
|
||||
exit_code=state.get("dispatcher_exit_code"),
|
||||
resumed=True,
|
||||
)
|
||||
return 2
|
||||
|
||||
epic_events = state.get("epic_events")
|
||||
if not isinstance(epic_events, dict):
|
||||
raise PreparationError(f"invalid Epic event map: {state_path}")
|
||||
unknown_events = sorted(set(epic_events) - set(identity["selected_epics"]))
|
||||
if unknown_events:
|
||||
raise PreparationError(f"Epic event map contains unselected ids: {','.join(unknown_events)}")
|
||||
for epic in selected:
|
||||
if epic_events.get(epic.epic_id) in {"EPIC_WORK_ITEMS_READY", "EPIC_COMPLETED"}:
|
||||
continue
|
||||
state.update(status="epic-preparing", current_epic=epic.epic_id)
|
||||
atomic_json(state_path, state)
|
||||
result = run(
|
||||
epic_cycle_command(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
epic=epic,
|
||||
batch_ids=batch_ids,
|
||||
),
|
||||
cwd=workspace,
|
||||
check=False,
|
||||
capture=False,
|
||||
)
|
||||
epic_state_path = (
|
||||
common
|
||||
/ "epic-work-preparation"
|
||||
/ milestone_slug
|
||||
/ epic.epic_id
|
||||
/ "state.json"
|
||||
)
|
||||
epic_state = read_json(epic_state_path) or {}
|
||||
terminal = epic_state.get("event")
|
||||
if result.returncode != 0 or terminal not in {"EPIC_WORK_ITEMS_READY", "EPIC_COMPLETED"}:
|
||||
status = epic_state.get("status")
|
||||
if terminal == "USER_REVIEW" or status == "user-review":
|
||||
state.update(status="user-review", event="USER_REVIEW", current_epic=epic.epic_id)
|
||||
atomic_json(state_path, state)
|
||||
return 2
|
||||
if result.returncode == 3 or status == "tracking":
|
||||
state.update(status="epic-tracking", event="AGENT_TRACKING", current_epic=epic.epic_id)
|
||||
atomic_json(state_path, state)
|
||||
return 3
|
||||
state.update(
|
||||
status="failed",
|
||||
event="FAILED",
|
||||
current_epic=epic.epic_id,
|
||||
reason=epic_state.get("reason", f"Epic cycle exit={result.returncode}"),
|
||||
)
|
||||
atomic_json(state_path, state)
|
||||
return 2
|
||||
epic_events[epic.epic_id] = terminal
|
||||
state.update(status="active", epic_events=epic_events)
|
||||
state.pop("current_epic", None)
|
||||
atomic_json(state_path, state)
|
||||
|
||||
if len(selected) > 1 and not state.get("cross_epic_review_done"):
|
||||
state.update(status="cross-epic-review")
|
||||
atomic_json(state_path, state)
|
||||
result, record = cross_epic_review(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
milestone_slug=milestone_slug,
|
||||
phase_slug=phase_slug,
|
||||
epics=selected,
|
||||
batch_ids=batch_ids,
|
||||
common=common,
|
||||
)
|
||||
if result != 0:
|
||||
review_path = (
|
||||
workspace
|
||||
/ "agent-roadmap"
|
||||
/ "sdd"
|
||||
/ phase_slug
|
||||
/ milestone_slug
|
||||
/ "USER_REVIEW.md"
|
||||
)
|
||||
user_review = result == 2 and review_path.exists()
|
||||
state.update(
|
||||
status=(
|
||||
"user-review"
|
||||
if user_review
|
||||
else "cross-epic-review-tracking"
|
||||
if result == 3
|
||||
else "failed"
|
||||
),
|
||||
event=(
|
||||
"USER_REVIEW"
|
||||
if user_review
|
||||
else "AGENT_TRACKING"
|
||||
if result == 3
|
||||
else "FAILED"
|
||||
),
|
||||
review_locator=record.get("locator") if record else None,
|
||||
)
|
||||
atomic_json(state_path, state)
|
||||
return result
|
||||
state.update(
|
||||
status="active",
|
||||
cross_epic_review_done=True,
|
||||
review_locator=record.get("locator") if record else None,
|
||||
)
|
||||
atomic_json(state_path, state)
|
||||
|
||||
validate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
epics=selected,
|
||||
batch_ids=batch_ids,
|
||||
)
|
||||
refreshed_epics = {
|
||||
epic.epic_id: epic for epic in parse_epics(milestone.read_text(encoding="utf-8"))
|
||||
}
|
||||
for epic_id in identity["selected_epics"]:
|
||||
refreshed = refreshed_epics.get(epic_id)
|
||||
if refreshed is None:
|
||||
raise PreparationError(f"selected Epic disappeared before batch barrier: {epic_id}")
|
||||
if not refreshed.incomplete_ids:
|
||||
epic_events[epic_id] = "EPIC_COMPLETED"
|
||||
state.update(
|
||||
status="batch-ready",
|
||||
event="MILESTONE_WORK_ITEMS_READY",
|
||||
epic_events=epic_events,
|
||||
)
|
||||
atomic_json(state_path, state)
|
||||
emit(
|
||||
"MILESTONE_WORK_ITEMS_READY",
|
||||
milestone=milestone_slug,
|
||||
epics=[epic.epic_id for epic in selected],
|
||||
terminals=epic_events,
|
||||
)
|
||||
|
||||
if all(event == "EPIC_COMPLETED" for event in epic_events.values()):
|
||||
state.update(
|
||||
status="completed",
|
||||
event="MILESTONE_PREPARATION_COMPLETED",
|
||||
dispatcher="skipped-no-active-plans",
|
||||
head=git(workspace, "rev-parse", "HEAD"),
|
||||
)
|
||||
atomic_json(state_path, state)
|
||||
emit(
|
||||
"MILESTONE_PREPARATION_COMPLETED",
|
||||
milestone=milestone_slug,
|
||||
epics=[epic.epic_id for epic in selected],
|
||||
dispatcher="skipped-no-active-plans",
|
||||
)
|
||||
return 0
|
||||
|
||||
dispatcher = dispatcher_script(workspace)
|
||||
task_group = f"m-{milestone_slug}"
|
||||
if not state.get("dispatcher_dry_run_done"):
|
||||
dry_run = run(
|
||||
[
|
||||
sys.executable,
|
||||
str(dispatcher),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--task-group",
|
||||
task_group,
|
||||
"--dry-run",
|
||||
],
|
||||
cwd=workspace,
|
||||
check=False,
|
||||
capture=False,
|
||||
)
|
||||
if dry_run.returncode != 0:
|
||||
state.update(status="failed", event="FAILED", reason=f"dispatcher dry-run exit={dry_run.returncode}")
|
||||
atomic_json(state_path, state)
|
||||
return 2
|
||||
state.update(dispatcher_dry_run_done=True, dispatcher=str(dispatcher))
|
||||
atomic_json(state_path, state)
|
||||
emit("DISPATCHER_DRY_RUN_FINISHED", task_group=task_group)
|
||||
|
||||
command = [
|
||||
sys.executable,
|
||||
str(dispatcher),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--task-group",
|
||||
task_group,
|
||||
]
|
||||
if resume_blocked_dispatcher and args.retry:
|
||||
command.append("--retry-blocked")
|
||||
try:
|
||||
process = subprocess.Popen(command, cwd=workspace, start_new_session=True)
|
||||
except OSError as exc:
|
||||
raise PreparationError(f"dispatcher launch failed: {exc}") from exc
|
||||
state.update(
|
||||
status="dispatching",
|
||||
event="DISPATCHER_STARTED",
|
||||
dispatcher_live_started=True,
|
||||
dispatcher_pid=process.pid,
|
||||
dispatcher_process_start_token=process_start_token(process.pid),
|
||||
dispatcher_command=command,
|
||||
)
|
||||
atomic_json(state_path, state)
|
||||
emit("DISPATCHER_STARTED", task_group=task_group, pid=process.pid)
|
||||
try:
|
||||
exit_code = process.wait()
|
||||
except KeyboardInterrupt:
|
||||
state.update(status="dispatcher-tracking", event="DISPATCHER_TRACKING")
|
||||
atomic_json(state_path, state)
|
||||
emit("DISPATCHER_TRACKING", task_group=task_group, pid=process.pid)
|
||||
return 3
|
||||
state.update(dispatcher_exit_code=exit_code)
|
||||
if exit_code == 3:
|
||||
state.update(status="dispatcher-tracking", event="DISPATCHER_TRACKING")
|
||||
atomic_json(state_path, state)
|
||||
emit("DISPATCHER_TRACKING", task_group=task_group, exit_code=exit_code)
|
||||
return 3
|
||||
if exit_code != 0:
|
||||
state.update(status="dispatcher-blocked", event="DISPATCHER_BLOCKED")
|
||||
atomic_json(state_path, state)
|
||||
emit("DISPATCHER_BLOCKED", task_group=task_group, exit_code=exit_code)
|
||||
return 2
|
||||
|
||||
validate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
epics=selected,
|
||||
batch_ids=batch_ids,
|
||||
)
|
||||
refreshed = {epic.epic_id: epic for epic in parse_epics(milestone.read_text(encoding="utf-8"))}
|
||||
incomplete = [
|
||||
epic_id
|
||||
for epic_id in identity["selected_epics"]
|
||||
if epic_id not in refreshed or refreshed[epic_id].incomplete_ids
|
||||
]
|
||||
if incomplete:
|
||||
raise PreparationError(
|
||||
"dispatcher exited successfully but selected Epic workstate is incomplete: "
|
||||
+ ",".join(incomplete)
|
||||
)
|
||||
state.update(
|
||||
status="completed",
|
||||
event="MILESTONE_PREPARATION_COMPLETED",
|
||||
head=git(workspace, "rev-parse", "HEAD"),
|
||||
)
|
||||
atomic_json(state_path, state)
|
||||
emit(
|
||||
"MILESTONE_PREPARATION_COMPLETED",
|
||||
milestone=milestone_slug,
|
||||
epics=identity["selected_epics"],
|
||||
dispatcher_exit_code=exit_code,
|
||||
head=state["head"],
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser(description=__doc__)
|
||||
value.add_argument("--repo", required=True)
|
||||
|
|
@ -261,6 +992,15 @@ def parser() -> argparse.ArgumentParser:
|
|||
value.add_argument("--review-model")
|
||||
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
|
||||
value.add_argument("--pi-provider")
|
||||
value.add_argument(
|
||||
"--epics",
|
||||
help="prepare and dispatch one/list/range selector; use first-incomplete or 1..N",
|
||||
)
|
||||
value.add_argument(
|
||||
"--retry",
|
||||
action="store_true",
|
||||
help="resume a stopped batch after its recorded recovery condition was handled",
|
||||
)
|
||||
value.add_argument("--remote", default="origin")
|
||||
value.add_argument(
|
||||
"--skip-agent-probe",
|
||||
|
|
@ -293,7 +1033,7 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
phase_slug = milestone_match.group("phase")
|
||||
milestone_slug = milestone_match.group("slug")
|
||||
phase = phase_contract(repo, phase_slug)
|
||||
workspace = Path(args.workspace).expanduser().resolve()
|
||||
workspace = resolve_workspace(repo, args.workspace)
|
||||
if workspace == repo:
|
||||
raise PreparationError("feature workspace must differ from the develop checkout")
|
||||
try:
|
||||
|
|
@ -314,6 +1054,8 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
common = git_common_dir(repo)
|
||||
state_root = common / "milestone-work-preparation" / milestone_slug
|
||||
state_path = state_root / "workspace-state.json"
|
||||
batch_state_path = state_root / "batch-state.json"
|
||||
target_milestone_path = workspace / milestone_path.relative_to(repo)
|
||||
state_root.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = state_root / "workspace.lock"
|
||||
with lock_path.open("a+", encoding="utf-8") as lock:
|
||||
|
|
@ -382,6 +1124,13 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
|
||||
local_exists = ref_exists(repo, local_ref)
|
||||
remote_exists = ref_exists(repo, remote_ref)
|
||||
prior_batch = read_json(batch_state_path) if args.epics else None
|
||||
resuming_batch = bool(
|
||||
prior_batch
|
||||
and prior_batch.get("status") != "completed"
|
||||
and prior_batch.get("workspace") == str(workspace)
|
||||
and prior_batch.get("milestone") == str(target_milestone_path)
|
||||
)
|
||||
if not local_exists:
|
||||
if remote_exists:
|
||||
git(repo, "branch", "--track", branch, f"{args.remote}/{branch}")
|
||||
|
|
@ -394,7 +1143,12 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
).returncode != 0:
|
||||
raise PreparationError(f"feature branch does not contain current {args.remote}/{develop}")
|
||||
if remote_exists and git(repo, "rev-parse", local_ref) != git(repo, "rev-parse", remote_ref):
|
||||
raise PreparationError("existing local and remote feature branches differ")
|
||||
if not resuming_batch or run(
|
||||
["git", "merge-base", "--is-ancestor", remote_ref, local_ref],
|
||||
cwd=repo,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
raise PreparationError("existing local and remote feature branches differ")
|
||||
if not remote_exists:
|
||||
git(repo, "push", "--set-upstream", args.remote, branch)
|
||||
else:
|
||||
|
|
@ -402,7 +1156,8 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
emit("FEATURE_BRANCH_PUSHED", branch=branch, remote=args.remote)
|
||||
|
||||
if branch_worktrees:
|
||||
ensure_clean(workspace, "feature workspace")
|
||||
if not resuming_batch:
|
||||
ensure_clean(workspace, "feature workspace")
|
||||
else:
|
||||
if workspace.exists() and any(workspace.iterdir()):
|
||||
raise PreparationError(f"workspace exists and is not empty: {workspace}")
|
||||
|
|
@ -421,8 +1176,8 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
ensure_clean(workspace, "feature workspace")
|
||||
target_milestone_path = workspace / milestone_path.relative_to(repo)
|
||||
if not resuming_batch:
|
||||
ensure_clean(workspace, "feature workspace")
|
||||
state = {
|
||||
"status": "workspace-ready",
|
||||
"milestone": str(target_milestone_path),
|
||||
|
|
@ -435,6 +1190,15 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
}
|
||||
atomic_json(state_path, state)
|
||||
emit("WORKSPACE_READY", **state)
|
||||
if args.epics:
|
||||
return coordinate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=target_milestone_path,
|
||||
milestone_slug=milestone_slug,
|
||||
phase_slug=phase_slug,
|
||||
common=common,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,39 @@ def command(cwd: Path, *args: str) -> str:
|
|||
|
||||
|
||||
class PrepareWorkspaceTest(unittest.TestCase):
|
||||
def test_relative_workspace_is_resolved_from_repository_root(self) -> None:
|
||||
repo = Path("/tmp/example/iop")
|
||||
self.assertEqual(
|
||||
MODULE.resolve_workspace(repo, "../iop-s1"),
|
||||
Path("/tmp/example/iop-s1"),
|
||||
)
|
||||
|
||||
def test_epic_document_range_is_one_based_and_inclusive(self) -> None:
|
||||
epics = MODULE.parse_epics(
|
||||
"""## 기능
|
||||
|
||||
### Epic: [first] First
|
||||
|
||||
- [ ] [first-task] first
|
||||
|
||||
### Epic: [second] Second
|
||||
|
||||
- [ ] [second-task] second
|
||||
|
||||
### Epic: [third] Third
|
||||
|
||||
- [ ] [third-task] third
|
||||
"""
|
||||
)
|
||||
self.assertEqual(
|
||||
[epic.epic_id for epic in MODULE.select_epics(epics, "1..2")],
|
||||
["first", "second"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[epic.epic_id for epic in MODULE.select_epics(epics, "second,first")],
|
||||
["first", "second"],
|
||||
)
|
||||
|
||||
def test_workspace_defaults_to_codex_top_model_and_reasoning(self) -> None:
|
||||
args = MODULE.parser().parse_args(
|
||||
["--repo", "/repo", "--milestone", "milestone.md", "--workspace", "/workspace"]
|
||||
|
|
@ -136,6 +169,306 @@ class PrepareWorkspaceTest(unittest.TestCase):
|
|||
self.assertIn("sample-milestone.md", current)
|
||||
self.assertFalse(command(worktree, "git", "status", "--porcelain=v1"))
|
||||
|
||||
def test_two_epic_batch_opens_dispatcher_barrier_once_after_both(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
workspace = root / "workspace"
|
||||
common = root / "git-common"
|
||||
milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
|
||||
milestone.parent.mkdir(parents=True)
|
||||
|
||||
def write_milestone(done: bool = False) -> None:
|
||||
mark = "x" if done else " "
|
||||
milestone.write_text(
|
||||
"# Milestone: Sample\n\n## 기능\n\n"
|
||||
"### Epic: [first] First\n\n"
|
||||
f"- [{mark}] [first-task] first\n\n"
|
||||
"### Epic: [second] Second\n\n"
|
||||
f"- [{mark}] [second-task] second\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
write_milestone()
|
||||
args = MODULE.apply_defaults(
|
||||
MODULE.parser().parse_args(
|
||||
[
|
||||
"--repo",
|
||||
str(workspace),
|
||||
"--milestone",
|
||||
str(milestone),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--epics",
|
||||
"1..2",
|
||||
]
|
||||
)
|
||||
)
|
||||
order: list[str] = []
|
||||
|
||||
def fake_run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
|
||||
if "--validate-only" in command:
|
||||
order.append("validate")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
if "--epic" in command:
|
||||
epic_id = command[command.index("--epic") + 1]
|
||||
order.append(f"epic:{epic_id}")
|
||||
state_path = common / "epic-work-preparation" / "sample" / epic_id / "state.json"
|
||||
MODULE.atomic_json(
|
||||
state_path,
|
||||
{
|
||||
"identity": f"sample:{epic_id}",
|
||||
"status": "completed",
|
||||
"event": "EPIC_WORK_ITEMS_READY",
|
||||
},
|
||||
)
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
if "--dry-run" in command:
|
||||
order.append("dry-run")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
raise AssertionError(command)
|
||||
|
||||
class FakeDispatcher:
|
||||
pid = 999_999_991
|
||||
|
||||
def __init__(self, command: list[str], **_: object) -> None:
|
||||
order.append("live")
|
||||
|
||||
def wait(self) -> int:
|
||||
write_milestone(done=True)
|
||||
return 0
|
||||
|
||||
def fake_cross_review(**_: object) -> tuple[int, dict[str, object]]:
|
||||
order.append("cross-review")
|
||||
return 0, {}
|
||||
|
||||
with (
|
||||
mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
|
||||
mock.patch.object(MODULE, "dispatcher_script", return_value=Path("/dispatch.py")),
|
||||
mock.patch.object(MODULE, "run", side_effect=fake_run),
|
||||
mock.patch.object(MODULE, "cross_epic_review", side_effect=fake_cross_review),
|
||||
mock.patch.object(MODULE, "git", return_value="head"),
|
||||
mock.patch.object(MODULE.subprocess, "Popen", FakeDispatcher),
|
||||
):
|
||||
first = MODULE.coordinate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
milestone_slug="sample",
|
||||
phase_slug="phase-one",
|
||||
common=common,
|
||||
)
|
||||
second = MODULE.coordinate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
milestone_slug="sample",
|
||||
phase_slug="phase-one",
|
||||
common=common,
|
||||
)
|
||||
|
||||
self.assertEqual((first, second), (0, 0))
|
||||
self.assertLess(order.index("epic:first"), order.index("epic:second"))
|
||||
self.assertLess(order.index("epic:second"), order.index("cross-review"))
|
||||
self.assertLess(order.index("cross-review"), order.index("dry-run"))
|
||||
self.assertLess(order.index("dry-run"), order.index("live"))
|
||||
self.assertEqual(order.count("live"), 1)
|
||||
|
||||
def test_all_direct_batch_skips_dispatcher_and_user_review_stops_batch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
workspace = root / "workspace"
|
||||
common = root / "git-common"
|
||||
milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
|
||||
milestone.parent.mkdir(parents=True)
|
||||
milestone.write_text(
|
||||
"# Milestone: Sample\n\n## 기능\n\n"
|
||||
"### Epic: [first] First\n\n- [ ] [first-task] first\n\n"
|
||||
"### Epic: [second] Second\n\n- [ ] [second-task] second\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
args = MODULE.apply_defaults(
|
||||
MODULE.parser().parse_args(
|
||||
[
|
||||
"--repo",
|
||||
str(workspace),
|
||||
"--milestone",
|
||||
str(milestone),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--epics",
|
||||
"1..2",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def all_direct(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
|
||||
if "--validate-only" in command:
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
epic_id = command[command.index("--epic") + 1]
|
||||
state_path = common / "epic-work-preparation" / "sample" / epic_id / "state.json"
|
||||
MODULE.atomic_json(
|
||||
state_path,
|
||||
{"status": "completed", "event": "EPIC_COMPLETED"},
|
||||
)
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
with (
|
||||
mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
|
||||
mock.patch.object(MODULE, "run", side_effect=all_direct),
|
||||
mock.patch.object(MODULE, "cross_epic_review", return_value=(0, {})),
|
||||
mock.patch.object(MODULE, "git", return_value="head"),
|
||||
mock.patch.object(MODULE.subprocess, "Popen") as popen,
|
||||
):
|
||||
result = MODULE.coordinate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
milestone_slug="sample",
|
||||
phase_slug="phase-one",
|
||||
common=common,
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
popen.assert_not_called()
|
||||
|
||||
other_common = root / "other-common"
|
||||
|
||||
def user_review(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
|
||||
epic_id = command[command.index("--epic") + 1]
|
||||
state_path = other_common / "epic-work-preparation" / "sample" / epic_id / "state.json"
|
||||
MODULE.atomic_json(
|
||||
state_path,
|
||||
{"status": "user-review", "event": "USER_REVIEW"},
|
||||
)
|
||||
return subprocess.CompletedProcess(command, 2)
|
||||
|
||||
with (
|
||||
mock.patch.object(MODULE, "epic_cycle_script", return_value=Path("/cycle.py")),
|
||||
mock.patch.object(MODULE, "run", side_effect=user_review),
|
||||
mock.patch.object(MODULE.subprocess, "Popen") as blocked_popen,
|
||||
):
|
||||
blocked = MODULE.coordinate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
milestone_slug="sample",
|
||||
phase_slug="phase-one",
|
||||
common=other_common,
|
||||
)
|
||||
self.assertEqual(blocked, 2)
|
||||
blocked_popen.assert_not_called()
|
||||
|
||||
def test_live_dispatcher_state_prevents_duplicate_launch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
workspace = root / "workspace"
|
||||
common = root / "git-common"
|
||||
milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
|
||||
milestone.parent.mkdir(parents=True)
|
||||
milestone.write_text(
|
||||
"# Milestone: Sample\n\n## 기능\n\n"
|
||||
"### Epic: [first] First\n\n- [ ] [first-task] first\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
args = MODULE.apply_defaults(
|
||||
MODULE.parser().parse_args(
|
||||
[
|
||||
"--repo",
|
||||
str(workspace),
|
||||
"--milestone",
|
||||
str(milestone),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--epics",
|
||||
"1..1",
|
||||
]
|
||||
)
|
||||
)
|
||||
state_path = common / "milestone-work-preparation" / "sample" / "batch-state.json"
|
||||
MODULE.atomic_json(
|
||||
state_path,
|
||||
{
|
||||
"milestone": str(milestone),
|
||||
"workspace": str(workspace),
|
||||
"selected_epics": ["first"],
|
||||
"batch_task_ids": ["first-task"],
|
||||
"status": "dispatching",
|
||||
"epic_events": {"first": "EPIC_WORK_ITEMS_READY"},
|
||||
"dispatcher_pid": os.getpid(),
|
||||
"dispatcher_process_start_token": MODULE.process_start_token(os.getpid()),
|
||||
},
|
||||
)
|
||||
with mock.patch.object(MODULE.subprocess, "Popen") as popen:
|
||||
result = MODULE.coordinate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
milestone_slug="sample",
|
||||
phase_slug="phase-one",
|
||||
common=common,
|
||||
)
|
||||
self.assertEqual(result, 3)
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_recovered_cross_epic_review_still_validates_and_publishes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
workspace = root / "workspace"
|
||||
common = root / "git-common"
|
||||
milestone = workspace / "agent-roadmap/phase/phase-one/milestones/sample.md"
|
||||
milestone.parent.mkdir(parents=True)
|
||||
milestone.write_text("# Milestone: Sample\n", encoding="utf-8")
|
||||
epics = [
|
||||
MODULE.Epic("first", "First", ("first-task",), ("first-task",)),
|
||||
MODULE.Epic("second", "Second", ("second-task",), ("second-task",)),
|
||||
]
|
||||
args = MODULE.apply_defaults(
|
||||
MODULE.parser().parse_args(
|
||||
[
|
||||
"--repo",
|
||||
str(workspace),
|
||||
"--milestone",
|
||||
str(milestone),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
]
|
||||
)
|
||||
)
|
||||
result_path = (
|
||||
common
|
||||
/ "epic-work-preparation"
|
||||
/ "sample"
|
||||
/ "_batch"
|
||||
/ "cross-epic-review.json"
|
||||
)
|
||||
MODULE.atomic_json(
|
||||
result_path,
|
||||
{
|
||||
"workspace": str(workspace),
|
||||
"label": "sample-cross-epic-review",
|
||||
"status": "succeeded",
|
||||
"locator": str(result_path),
|
||||
},
|
||||
)
|
||||
with (
|
||||
mock.patch.object(MODULE, "validate_batch") as validate,
|
||||
mock.patch.object(MODULE, "publish_batch_review", return_value="head") as publish,
|
||||
mock.patch.object(MODULE, "run") as run,
|
||||
):
|
||||
result, _ = MODULE.cross_epic_review(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone,
|
||||
milestone_slug="sample",
|
||||
phase_slug="phase-one",
|
||||
epics=epics,
|
||||
batch_ids=["first-task", "second-task"],
|
||||
common=common,
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
run.assert_not_called()
|
||||
validate.assert_called_once()
|
||||
publish.assert_called_once_with(workspace)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
| 이 마일스톤은 X가 끝나야 가능해, A 전까지 B 잠가둬, 잠금 해제 조건은 X야, X 프로젝트 작업 뒤에 현재 마일스톤 진행, 의존성 설정해, 외부 의존 잠금 | `agent-ops/skills/common/update-roadmap/SKILL.md` |
|
||||
| roadmap dependency 확인, locks.yaml 판별, 외부 의존 잠금 확인, unlock-ready 판별, 잠금 해제 조건 충족 여부 확인, roadmap-dependency-checker.sh | `agent-ops/skills/common/check-roadmap-dependency/SKILL.md` |
|
||||
| 지금 작업이 뭐지?, 현재 작업 분석, 어디까지 했지?, 로드맵상 현 위치, 현재 마일스톤 위치, current 기준 breadcrumb | `agent-ops/skills/common/analyze-roadmap-position/SKILL.md` |
|
||||
| X에 Y 작업 준비해, X에 Y 작업준비해, X에 Y 마일스톤 작업 준비해 | `agent-ops/skills/common/prepare-milestone-workspace/SKILL.md` |
|
||||
| X에 Y 작업 준비해, X 위치에 Y 작업 준비해, X에 Y 작업준비해, X에 Y 마일스톤 작업 준비해, X에 Y 작업 준비하고 Epic은 N번째까지 진행해 | `agent-ops/skills/common/prepare-milestone-workspace/SKILL.md` |
|
||||
| 현 마일스톤 Epic 작업 준비해, 마일스톤 Epic 작업 준비해, 이 Epic의 작은 작업은 바로 처리하고 큰 작업은 plan으로 작성해, Epic 작업을 작은 작업과 plan으로 나눠 | `agent-ops/skills/common/prepare-epic-work-items/SKILL.md` |
|
||||
| 계획 세워줘, 계획 작성해, 계획 만들어줘, 구현 계획, PLAN.md, plan, plan 작성해, plan 만들어줘 | `agent-ops/skills/common/plan/SKILL.md` |
|
||||
| 현재 plan들 세분화해, 현재 plan 세분화, 기존 plan 더 나눠, task 세분화해, plan 분리해 | `agent-ops/skills/common/refine-plans/SKILL.md` |
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
|
||||
라우팅 우선순위:
|
||||
|
||||
- `X에 Y 작업 준비해`처럼 workspace 위치와 대상 Milestone이 함께 명시되면 `prepare-milestone-workspace`를 선택하고, `WORKSPACE_READY` 뒤 정확히 한 Epic에 `prepare-epic-work-items`를 연결한다. workspace 위치가 없으면 이 흐름으로 라우팅하지 않고 확인을 요청한다.
|
||||
- `X에 Y 작업 준비해`처럼 workspace 위치와 대상 Milestone이 함께 명시되면 `prepare-milestone-workspace`를 선택한다. 상대 workspace는 develop repository root 기준으로 해석한다. Epic 범위가 없으면 첫 미완료 Epic 하나, `N번째 Epic까지`이면 문서 순서 `1..N`을 선택한다. 선택 Epic을 각각 `prepare-epic-work-items`로 준비하되 전체 `MILESTONE_WORK_ITEMS_READY` 전에는 dispatcher를 시작하지 않는다. workspace 위치가 없으면 이 흐름으로 라우팅하지 않고 확인을 요청한다.
|
||||
- 한 Epic 안에서 작은 작업 직접 처리와 큰 작업 plan 작성을 함께 요청하면 `prepare-epic-work-items`를 선택한다. 이미 존재하는 plan만 세분화하는 요청과 새로운 plan만 작성하는 요청에는 이 스킬을 선택하지 않는다.
|
||||
- 이미 생성된 미착수 pair의 분할만 요청하면 lane과 관계없이 `refine-plans`를 선택한다. 새 plan 작성이나 구현 범위 재분석이 포함되면 `plan`을 선택한다.
|
||||
- `refine-plans` 대상이 아닌 PLAN/CODE_REVIEW 작성 또는 재작성이 요청 범위에 포함되면 `plan`을 선택한다. `plan`이 최종 단계에서 `finalize-task-routing`을 필수 호출한다.
|
||||
|
|
|
|||
Loading…
Reference in a new issue