sync: to agentic-framework v1.1.186
This commit is contained in:
parent
3d5480264e
commit
c8ceef3791
9 changed files with 422 additions and 23 deletions
|
|
@ -1 +1 @@
|
|||
1.1.185
|
||||
1.1.186
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
|
|||
- 실행 identity는 `<milestone-slug>:<epic-id>`다.
|
||||
- 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 cycle의 batch Task id 합집합은 바꾸지 않는다. 준비 terminal 뒤에는 같은 Epic을 이후 단독/복수 batch의 일부로 다시 검증할 수 있다.
|
||||
- `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을 만들지 않는다.
|
||||
|
|
|
|||
|
|
@ -660,6 +660,7 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
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)
|
||||
):
|
||||
|
|
|
|||
|
|
@ -294,6 +294,22 @@ class EpicCycleContractTest(unittest.TestCase):
|
|||
state = MODULE.read_state(state_path)
|
||||
self.assertEqual(state["event"], "EPIC_WORK_ITEMS_READY")
|
||||
|
||||
reused_in_larger_batch = MODULE.main(
|
||||
[
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--milestone",
|
||||
str(milestone.relative_to(workspace)),
|
||||
"--epic",
|
||||
"sample-epic",
|
||||
"--planner-agent",
|
||||
"codex",
|
||||
"--batch-task-ids",
|
||||
"large-task,later-task",
|
||||
]
|
||||
)
|
||||
self.assertEqual(reused_in_larger_batch, 0)
|
||||
|
||||
task_root = workspace / "agent-task" / "m-sample-milestone"
|
||||
for path in task_root.iterdir():
|
||||
path.unlink()
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
---
|
||||
name: prepare-milestone-workspace
|
||||
description: 명시된 절대 또는 repository-root-relative workspace에 계획 상태의 Milestone을 동기화하고 Git Flow feature branch/worktree를 준비한 뒤, 선택한 한 개 또는 여러 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../iop-s1 위치에 X 작업 준비해", "X에 Y 작업준비해. Epic은 두 번째까지 진행해" 요청에서 사용한다.
|
||||
description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 이미 준비된 현재 feature workspace에서 선택한 한 개 또는 여러 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../iop-s1 위치에 X 작업 준비해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해" 요청에서 사용한다.
|
||||
---
|
||||
|
||||
# Prepare Milestone Workspace
|
||||
|
||||
`<workspace>에 <milestone> 작업 준비해` 요청은 workspace만 만드는 요청이 아니다. 대상 Milestone 정합성 확인부터 선택 Epic 준비와 dispatcher 전환까지 수행하는 전체 흐름으로 해석한다.
|
||||
`<workspace>에 <milestone> 작업 준비해`는 workspace 생성 모드로, `현|<이름> 마일스톤에 <범위> Epic 작업 시작해`는 현재 workspace 실행 모드로 해석한다. 두 모드 모두 정합성 확인부터 선택 Epic 준비와 dispatcher 전환까지 수행한다.
|
||||
|
||||
## 목적
|
||||
|
||||
계획 가능한 Milestone 하나를 검증된 `feature/<milestone-slug>` workspace로 전환하고, 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다.
|
||||
계획 가능한 Milestone 하나를 검증된 `feature/<milestone-slug>` workspace로 전환하거나 이미 준비된 동일 branch를 재사용하고, 선택 Epic 전체가 준비된 뒤에만 구현 dispatcher를 시작한다. 의미 정합성은 roadmap 스킬이, branch/worktree/current와 batch lifecycle은 번들 스크립트가 소유한다.
|
||||
|
||||
## 입력
|
||||
|
||||
- `target-milestone`: 활성 Milestone 이름, id, slug 또는 경로 (필수)
|
||||
- `workspace`: feature worktree로 사용할 절대 경로 또는 develop repository root 기준 상대 경로 (필수)
|
||||
- `workspace`: 생성 모드에서는 feature worktree 절대 경로 또는 develop repository root 기준 상대 경로가 필수다. 현재 workspace 실행 모드에서는 현재 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 기본 모델을 사용한다. (선택)
|
||||
|
|
@ -23,12 +23,13 @@ description: 명시된 절대 또는 repository-root-relative workspace에 계
|
|||
- `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는 develop repository root 기준으로 해석한다. `두 번째 Epic까지`는 `1..2`, `세 번째부터 네 번째 Epic까지`는 `3..4`로 변환한다. workspace가 생략되거나 둘 이상의 경로로 해석되면 임의로 `current`나 기본 경로를 사용하지 않고 사용자에게 확인한다.
|
||||
생성 모드의 첫 번째 위치 표현(`<workspace>`)은 workspace로, 두 번째 표현(`<milestone>`)은 대상 Milestone으로 각각 확정한다. 상대 workspace는 develop repository root 기준으로 해석한다. 현재 workspace 실행 모드의 `현 마일스톤`은 `current.md`와 현재 feature branch가 함께 가리키는 Milestone으로, 이름을 지정하면 같은 workspace의 branch/current와 정확히 일치해야 한다. `두 번째 Epic`은 `2..2`, `두 번째 Epic까지`와 `1,2번째 Epic까지`는 `1..2`, `세 번째부터 네 번째 Epic까지`는 `3..4`로 변환한다.
|
||||
|
||||
## 사전 조건
|
||||
|
||||
- 대상 checkout은 Git Flow develop branch이고 tracked/untracked 변경이 없어야 한다.
|
||||
- 대상 Milestone은 정확히 `[계획]`, `구현 잠금: 해제`, `결정 필요: 없음`이어야 한다.
|
||||
- 생성 모드는 clean Git Flow develop checkout과 정확히 `[계획]`인 Milestone을 요구한다.
|
||||
- 현재 workspace 실행 모드는 target slug와 일치하는 `feature/<milestone-slug>` branch, 일치하는 local `current.md`, clean/upstream-synced workspace를 요구하고 Milestone `[계획]` 또는 `[진행중]`을 허용한다. 기록된 active batch 재개만 상태 소유 변경을 허용한다.
|
||||
- 두 모드 모두 `구현 잠금: 해제`, `결정 필요: 없음`이어야 한다.
|
||||
- `sync-milestone-workstate mode=consistency-check`가 `ready`여야 한다.
|
||||
- remote와 `gitflow.branch.develop`, `gitflow.prefix.feature`를 확인할 수 있어야 한다.
|
||||
- 선택 agent의 비대화식 one-shot capability probe가 branch 생성 전에 성공해야 한다.
|
||||
|
|
@ -41,8 +42,8 @@ description: 명시된 절대 또는 repository-root-relative workspace에 계
|
|||
- 기계적으로 고칠 수 있는 drift는 보고된 owner 스킬로 갱신하고 commit/push한 뒤 consistency check를 다시 실행한다.
|
||||
- 사용자만 결정할 수 있는 drift는 `roadmap-sdd mode=review-ready`의 `agent-roadmap/sdd/<phase-slug>/<milestone-slug>/USER_REVIEW.md`로 남기고 commit/push한 뒤 `USER_REVIEW`로 멈춘다. pre-plan 문제에 `agent-task/**/USER_REVIEW.md`를 만들지 않는다.
|
||||
|
||||
2. **workspace를 준비한다**
|
||||
- 아래 스크립트를 foreground로 한 번 실행한다. 실행 중 caller LLM이 timer polling, `ps`, state 파일 검사 또는 중복 실행을 하지 않는다.
|
||||
2. **실행 모드를 선택한다**
|
||||
- workspace 위치를 명시한 준비 요청은 생성 모드로 실행한다.
|
||||
|
||||
```bash
|
||||
python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py \
|
||||
|
|
@ -58,6 +59,18 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
|
|||
- branch는 Milestone id가 아니라 파일 basename을 사용한 `feature/<milestone-slug>`다.
|
||||
- 기존 branch/worktree는 정확히 같은 branch·경로이고 clean할 때만 재개한다.
|
||||
- remote branch 생성 뒤 후속 단계가 실패해도 branch/worktree를 자동 삭제하지 않는다.
|
||||
- `Epic 작업 시작해` 요청은 현재 workspace 실행 모드로 foreground 실행한다.
|
||||
|
||||
```bash
|
||||
python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py \
|
||||
--existing-workspace \
|
||||
--workspace "$CURRENT_WORKSPACE" \
|
||||
--milestone "$MILESTONE" \
|
||||
--epics "$EPICS"
|
||||
```
|
||||
|
||||
- 현재 workspace가 target feature branch/current와 다르면 다른 worktree를 탐색하거나 branch를 바꾸지 않고 `FAILED`로 멈춘다.
|
||||
- 두 모드 모두 실행 중 caller LLM이 timer polling, `ps`, state 파일 검사 또는 중복 실행을 하지 않는다.
|
||||
|
||||
3. **선택 Epic batch를 준비한다**
|
||||
- `WORKSPACE_READY` 뒤 스크립트가 선택 Epic을 문서 순서대로 하나씩 `prepare-epic-work-items`에 전달한다.
|
||||
|
|
@ -111,6 +124,7 @@ Milestone workspace preparation
|
|||
- `[계획]`이 아니거나 잠긴 Milestone의 branch를 만들지 않는다.
|
||||
- consistency check의 `refresh-required`를 `ready`로 간주하지 않는다.
|
||||
- 사용자 소유 변경이 있는 develop checkout이나 기존 workspace를 덮어쓰지 않는다.
|
||||
- 현재 workspace 실행 모드에서 target이 다른 branch/current를 자동 전환하지 않는다.
|
||||
- 선택 Epic 중 하나라도 attention/terminal failure 상태면 dispatcher를 시작하지 않는다.
|
||||
- 복수 Epic batch에서 개별 `EPIC_WORK_ITEMS_READY`만 보고 dispatcher를 먼저 시작하지 않는다.
|
||||
- `git push --force`, destructive rollback, branch/worktree 자동 삭제를 하지 않는다.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
interface:
|
||||
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."
|
||||
display_name: "Start Milestone Epic Work"
|
||||
short_description: "Prepare selected Milestone Epics and run dispatcher"
|
||||
default_prompt: "Use $prepare-milestone-workspace to start the selected Epic range for the current Milestone workspace and run its dispatcher."
|
||||
|
|
|
|||
|
|
@ -130,6 +130,28 @@ def milestone_contract(path: Path) -> dict[str, str]:
|
|||
}
|
||||
|
||||
|
||||
def existing_milestone_contract(path: Path) -> dict[str, str]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
status_body = section(text, "상태")
|
||||
status_match = re.search(r"^\[(.+?)\]\s*$", status_body, re.MULTILINE)
|
||||
status = status_match.group(1).strip() if status_match else ""
|
||||
lock = section(text, "구현 잠금")
|
||||
lock_state = re.search(r"^- 상태:\s*(.+?)\s*$", lock, re.MULTILINE)
|
||||
decision = re.search(r"^- 결정 필요:\s*(.+?)\s*$", lock, re.MULTILINE)
|
||||
if status not in {"계획", "진행중"}:
|
||||
raise PreparationError(
|
||||
f"Milestone work requires [계획] or [진행중]: actual={status or 'missing'}"
|
||||
)
|
||||
if not lock_state or lock_state.group(1).strip() != "해제":
|
||||
raise PreparationError("milestone implementation lock is not 해제")
|
||||
if not decision or decision.group(1).strip() != "없음":
|
||||
raise PreparationError("milestone has unresolved 결정 필요")
|
||||
return {
|
||||
"title": first_heading(text, "# Milestone:"),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def parse_epics(text: str) -> list[Epic]:
|
||||
lines = text.splitlines()
|
||||
starts: list[tuple[int, re.Match[str]]] = []
|
||||
|
|
@ -686,11 +708,23 @@ def coordinate_batch(
|
|||
}
|
||||
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}"
|
||||
)
|
||||
mismatched = [key for key, expected in identity.items() if state.get(key) != expected]
|
||||
if mismatched and state.get("status") == "completed":
|
||||
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)
|
||||
elif mismatched:
|
||||
key = mismatched[0]
|
||||
raise PreparationError(
|
||||
f"active Milestone preparation batch identity changed: field={key} "
|
||||
f"state={state.get(key)} requested={identity[key]}"
|
||||
)
|
||||
if state.get("status") == "completed":
|
||||
emit(
|
||||
"MILESTONE_PREPARATION_COMPLETED",
|
||||
|
|
@ -979,9 +1013,14 @@ def coordinate_batch(
|
|||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser(description=__doc__)
|
||||
value.add_argument("--repo", required=True)
|
||||
value.add_argument("--repo")
|
||||
value.add_argument("--milestone", required=True)
|
||||
value.add_argument("--workspace", required=True)
|
||||
value.add_argument("--workspace")
|
||||
value.add_argument(
|
||||
"--existing-workspace",
|
||||
action="store_true",
|
||||
help="start selected Epic work in the current prepared feature workspace",
|
||||
)
|
||||
value.add_argument(
|
||||
"--planner-agent",
|
||||
choices=sorted(VALID_AGENTS),
|
||||
|
|
@ -1019,8 +1058,152 @@ def apply_defaults(args: argparse.Namespace) -> argparse.Namespace:
|
|||
return args
|
||||
|
||||
|
||||
def prepare_existing(args: argparse.Namespace) -> int:
|
||||
apply_defaults(args)
|
||||
if not args.epics:
|
||||
raise PreparationError("--epics is required with --existing-workspace")
|
||||
if args.skip_agent_probe and not (
|
||||
args.dry_run or os.environ.get("AGENT_OPS_TESTING") == "1"
|
||||
):
|
||||
raise PreparationError(
|
||||
"--skip-agent-probe is test-only; use AGENT_OPS_TESTING=1"
|
||||
)
|
||||
workspace = resolve_repo(args.workspace or ".")
|
||||
if args.repo is not None and resolve_repo(args.repo) != workspace:
|
||||
raise PreparationError("--repo must match --workspace in existing-workspace mode")
|
||||
milestone_path, milestone_match = resolve_milestone(workspace, args.milestone)
|
||||
milestone = existing_milestone_contract(milestone_path)
|
||||
milestone_slug = milestone_match.group("slug")
|
||||
phase_slug = milestone_match.group("phase")
|
||||
selected = select_epics(parse_epics(milestone_path.read_text(encoding="utf-8")), args.epics)
|
||||
batch_ids = [task_id for epic in selected for task_id in epic.task_ids]
|
||||
identity = {
|
||||
"milestone": str(milestone_path),
|
||||
"workspace": str(workspace),
|
||||
"selected_epics": [epic.epic_id for epic in selected],
|
||||
"batch_task_ids": batch_ids,
|
||||
}
|
||||
develop = git(workspace, "config", "--get", "gitflow.branch.develop")
|
||||
feature_prefix = git(workspace, "config", "--get", "gitflow.prefix.feature")
|
||||
branch = git(workspace, "branch", "--show-current")
|
||||
expected_branch = f"{feature_prefix}{milestone_slug}" if feature_prefix else ""
|
||||
if not develop or not feature_prefix or branch != expected_branch:
|
||||
raise PreparationError(
|
||||
f"current workspace must use the target Milestone feature branch: "
|
||||
f"expected={expected_branch or 'missing-gitflow-config'} actual={branch or 'detached'}"
|
||||
)
|
||||
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 PreparationError(
|
||||
f"workspace-local current does not select target Milestone: {current_path}"
|
||||
)
|
||||
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
|
||||
)
|
||||
for agent in {args.planner_agent, reviewer_agent}:
|
||||
command = AGENT_COMMAND[agent]
|
||||
if shutil.which(command) is None and not args.skip_agent_probe:
|
||||
raise PreparationError(f"agent command not found: agent={agent} command={command}")
|
||||
|
||||
common = git_common_dir(workspace)
|
||||
state_root = common / "milestone-work-preparation" / milestone_slug
|
||||
state_root.mkdir(parents=True, exist_ok=True)
|
||||
state_path = state_root / "workspace-state.json"
|
||||
|
||||
with (state_root / "workspace.lock").open("a+", encoding="utf-8") as lock:
|
||||
try:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise PreparationError(
|
||||
f"workspace preparation already running: {state_root / 'workspace.lock'}"
|
||||
) from exc
|
||||
batch_state = read_json(state_root / "batch-state.json")
|
||||
batch_matches = bool(
|
||||
batch_state
|
||||
and all(batch_state.get(key) == expected for key, expected in identity.items())
|
||||
)
|
||||
resuming_batch = bool(
|
||||
batch_matches and batch_state and batch_state.get("status") != "completed"
|
||||
)
|
||||
if batch_state and batch_state.get("status") != "completed" and not batch_matches:
|
||||
raise PreparationError("another active Epic batch owns the current Milestone workspace")
|
||||
if not resuming_batch:
|
||||
ensure_clean(workspace, "feature workspace")
|
||||
upstream = git(
|
||||
workspace,
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"--symbolic-full-name",
|
||||
"@{u}",
|
||||
)
|
||||
if not upstream.endswith(f"/{branch}"):
|
||||
raise PreparationError(
|
||||
f"feature branch upstream mismatch: branch={branch} upstream={upstream}"
|
||||
)
|
||||
if not resuming_batch and not args.dry_run:
|
||||
remote_name = upstream.split("/", 1)[0]
|
||||
git(workspace, "fetch", remote_name, branch)
|
||||
head = git(workspace, "rev-parse", "HEAD")
|
||||
upstream_head = git(workspace, "rev-parse", "@{u}")
|
||||
if head != upstream_head:
|
||||
if not resuming_batch or run(
|
||||
["git", "merge-base", "--is-ancestor", "@{u}", "HEAD"],
|
||||
cwd=workspace,
|
||||
check=False,
|
||||
).returncode != 0:
|
||||
raise PreparationError("current feature branch is not synchronized with its upstream")
|
||||
emit(
|
||||
"PREFLIGHT_READY",
|
||||
branch=branch,
|
||||
milestone=str(milestone_path),
|
||||
workspace=str(workspace),
|
||||
existing_workspace=True,
|
||||
)
|
||||
if args.dry_run:
|
||||
return 0
|
||||
if not args.skip_agent_probe and not resuming_batch:
|
||||
probe_agents(
|
||||
workspace,
|
||||
args.planner_agent,
|
||||
reviewer_agent,
|
||||
args.planner_model,
|
||||
reviewer_model,
|
||||
args.reasoning_effort,
|
||||
args.pi_provider,
|
||||
)
|
||||
ensure_clean(workspace, "feature workspace after agent probe")
|
||||
state = {
|
||||
"status": "workspace-ready",
|
||||
"milestone": str(milestone_path),
|
||||
"milestone_slug": milestone_slug,
|
||||
"branch": branch,
|
||||
"workspace": str(workspace),
|
||||
"planner_agent": args.planner_agent,
|
||||
"review_agent": reviewer_agent,
|
||||
"reasoning_effort": args.reasoning_effort,
|
||||
"existing_workspace": True,
|
||||
}
|
||||
atomic_json(state_path, state)
|
||||
emit("WORKSPACE_READY", **state)
|
||||
return coordinate_batch(
|
||||
args=args,
|
||||
workspace=workspace,
|
||||
milestone=milestone_path,
|
||||
milestone_slug=milestone_slug,
|
||||
phase_slug=phase_slug,
|
||||
common=common,
|
||||
)
|
||||
|
||||
|
||||
def prepare(args: argparse.Namespace) -> int:
|
||||
apply_defaults(args)
|
||||
if args.repo is None or args.workspace is None:
|
||||
raise PreparationError("--repo and --workspace are required unless --existing-workspace is used")
|
||||
if args.skip_agent_probe and not (
|
||||
args.dry_run or os.environ.get("AGENT_OPS_TESTING") == "1"
|
||||
):
|
||||
|
|
@ -1205,7 +1388,7 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
args = apply_defaults(parser().parse_args(argv))
|
||||
try:
|
||||
return prepare(args)
|
||||
return prepare_existing(args) if args.existing_workspace else prepare(args)
|
||||
except (OSError, PreparationError) as exc:
|
||||
emit("FAILED", reason=str(exc))
|
||||
return 2
|
||||
|
|
|
|||
|
|
@ -169,6 +169,117 @@ class PrepareWorkspaceTest(unittest.TestCase):
|
|||
self.assertIn("sample-milestone.md", current)
|
||||
self.assertFalse(command(worktree, "git", "status", "--porcelain=v1"))
|
||||
|
||||
def test_existing_feature_workspace_starts_selected_epic_batch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
remote = root / "remote.git"
|
||||
workspace = root / "workspace"
|
||||
command(root, "git", "init", "--bare", str(remote))
|
||||
command(root, "git", "init", "-b", "dev", str(workspace))
|
||||
command(workspace, "git", "config", "user.name", "Test Agent")
|
||||
command(workspace, "git", "config", "user.email", "agent@example.test")
|
||||
command(workspace, "git", "config", "gitflow.branch.develop", "dev")
|
||||
command(workspace, "git", "config", "gitflow.prefix.feature", "feature/")
|
||||
command(workspace, "git", "remote", "add", "origin", str(remote))
|
||||
milestone = (
|
||||
workspace
|
||||
/ "agent-roadmap"
|
||||
/ "phase"
|
||||
/ "phase-one"
|
||||
/ "milestones"
|
||||
/ "sample-milestone.md"
|
||||
)
|
||||
milestone.parent.mkdir(parents=True)
|
||||
milestone.write_text(
|
||||
"# Milestone: Sample\n\n"
|
||||
"## 상태\n\n[계획]\n\n"
|
||||
"## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\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",
|
||||
)
|
||||
(workspace / ".gitignore").write_text(
|
||||
"agent-roadmap/current.md\n", encoding="utf-8"
|
||||
)
|
||||
command(workspace, "git", "add", ".gitignore", "agent-roadmap")
|
||||
command(workspace, "git", "commit", "-m", "init")
|
||||
command(workspace, "git", "push", "-u", "origin", "dev")
|
||||
command(workspace, "git", "switch", "-c", "feature/sample-milestone")
|
||||
command(workspace, "git", "push", "-u", "origin", "feature/sample-milestone")
|
||||
(workspace / "agent-roadmap" / "current.md").write_text(
|
||||
"# Current\n\n"
|
||||
"- [Sample](phase/phase-one/milestones/sample-milestone.md)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
output = io.StringIO()
|
||||
with (
|
||||
contextlib.redirect_stdout(output),
|
||||
mock.patch.dict(os.environ, {"AGENT_OPS_TESTING": "1"}),
|
||||
mock.patch.object(MODULE, "coordinate_batch", return_value=0) as coordinate,
|
||||
):
|
||||
result = MODULE.main(
|
||||
[
|
||||
"--existing-workspace",
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--milestone",
|
||||
str(milestone.relative_to(workspace)),
|
||||
"--epics",
|
||||
"2..2",
|
||||
"--skip-agent-probe",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 0, output.getvalue())
|
||||
self.assertIn('"existing_workspace": true', output.getvalue())
|
||||
self.assertEqual(coordinate.call_args.kwargs["workspace"], workspace)
|
||||
self.assertEqual(coordinate.call_args.kwargs["milestone"], milestone)
|
||||
self.assertEqual(coordinate.call_args.kwargs["args"].epics, "2..2")
|
||||
self.assertFalse(command(workspace, "git", "status", "--porcelain=v1"))
|
||||
|
||||
def test_existing_workspace_mode_refuses_non_target_branch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
workspace = Path(raw) / "workspace"
|
||||
command(workspace.parent, "git", "init", "-b", "dev", str(workspace))
|
||||
command(workspace, "git", "config", "gitflow.branch.develop", "dev")
|
||||
command(workspace, "git", "config", "gitflow.prefix.feature", "feature/")
|
||||
milestone = (
|
||||
workspace
|
||||
/ "agent-roadmap"
|
||||
/ "phase"
|
||||
/ "phase-one"
|
||||
/ "milestones"
|
||||
/ "sample.md"
|
||||
)
|
||||
milestone.parent.mkdir(parents=True)
|
||||
milestone.write_text(
|
||||
"# Milestone: Sample\n\n"
|
||||
"## 상태\n\n[계획]\n\n"
|
||||
"## 구현 잠금\n\n- 상태: 해제\n- 결정 필요: 없음\n\n"
|
||||
"## 기능\n\n### Epic: [first] First\n\n- [ ] [first-task] first\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(workspace / "agent-roadmap" / "current.md").write_text(
|
||||
"phase/phase-one/milestones/sample.md\n", encoding="utf-8"
|
||||
)
|
||||
output = io.StringIO()
|
||||
with contextlib.redirect_stdout(output):
|
||||
result = MODULE.main(
|
||||
[
|
||||
"--existing-workspace",
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--milestone",
|
||||
str(milestone.relative_to(workspace)),
|
||||
"--epics",
|
||||
"1..1",
|
||||
"--dry-run",
|
||||
"--skip-agent-probe",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 2)
|
||||
self.assertIn("current workspace must use", output.getvalue())
|
||||
|
||||
def test_two_epic_batch_opens_dispatcher_barrier_once_after_both(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
|
|
@ -409,6 +520,77 @@ class PrepareWorkspaceTest(unittest.TestCase):
|
|||
self.assertEqual(result, 3)
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_completed_batch_can_start_a_different_epic_selection(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- [x] [first-task] first\n\n"
|
||||
"### Epic: [second] Second\n\n- [x] [second-task] second\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
args = MODULE.apply_defaults(
|
||||
MODULE.parser().parse_args(
|
||||
[
|
||||
"--milestone",
|
||||
str(milestone),
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--epics",
|
||||
"2..2",
|
||||
]
|
||||
)
|
||||
)
|
||||
batch_state = common / "milestone-work-preparation" / "sample" / "batch-state.json"
|
||||
MODULE.atomic_json(
|
||||
batch_state,
|
||||
{
|
||||
"milestone": str(milestone),
|
||||
"workspace": str(workspace),
|
||||
"selected_epics": ["first"],
|
||||
"batch_task_ids": ["first-task"],
|
||||
"status": "completed",
|
||||
"epic_events": {"first": "EPIC_COMPLETED"},
|
||||
},
|
||||
)
|
||||
|
||||
def fake_run(command: list[str], **_: object) -> subprocess.CompletedProcess[str]:
|
||||
if "--validate-only" not in command:
|
||||
epic_state = (
|
||||
common
|
||||
/ "epic-work-preparation"
|
||||
/ "sample"
|
||||
/ "second"
|
||||
/ "state.json"
|
||||
)
|
||||
MODULE.atomic_json(
|
||||
epic_state,
|
||||
{"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=fake_run),
|
||||
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)
|
||||
self.assertEqual(MODULE.read_json(batch_state)["selected_epics"], ["second"])
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_recovered_cross_epic_review_still_validates_and_publishes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
|
|
|
|||
|
|
@ -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 작업준비해, X에 Y 마일스톤 작업 준비해, X에 Y 작업 준비하고 Epic은 N번째까지 진행해 | `agent-ops/skills/common/prepare-milestone-workspace/SKILL.md` |
|
||||
| X에 Y 작업 준비해, X 위치에 Y 작업 준비해, 현 마일스톤에 N번째 에픽 작업 시작해, 현 마일스톤에 N번째 에픽까지 작업 시작해, Y 마일스톤에 1,2번째 에픽까지 작업 시작해 | `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,9 @@
|
|||
|
||||
라우팅 우선순위:
|
||||
|
||||
- `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 위치가 없으면 이 흐름으로 라우팅하지 않고 확인을 요청한다.
|
||||
- `X에 Y 작업 준비해`처럼 workspace 위치와 대상 Milestone이 함께 명시되면 `prepare-milestone-workspace` 생성 모드를 선택한다. 상대 workspace는 develop repository root 기준으로 해석한다. 이 형식에서 workspace 위치가 없으면 확인을 요청한다.
|
||||
- `현 마일스톤에 N번째 에픽 작업 시작해` 또는 `Y 마일스톤에 1,2번째 에픽까지 작업 시작해`는 같은 스킬의 현재 workspace 실행 모드를 선택한다. `현 마일스톤`은 current/feature branch의 단일 일치 target, 이름 있는 Milestone은 현재 workspace branch/current와 정확히 일치하는 target만 허용한다. `N번째`는 `N..N`, `N번째까지`는 `1..N`, `1,2번째까지`는 `1..2`로 해석한다. 현재 workspace가 준비되지 않았거나 target과 다르면 workspace를 추정·전환하지 않고 거부한다.
|
||||
- 두 모드 모두 선택 Epic을 각각 `prepare-epic-work-items`로 준비하되 전체 `MILESTONE_WORK_ITEMS_READY` 전에는 dispatcher를 시작하지 않는다.
|
||||
- 한 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