diff --git a/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py b/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py index 6736e679..753b968e 100755 --- a/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py +++ b/agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_workspace.py @@ -426,11 +426,50 @@ def epic_cycle_script(workspace: Path) -> Path: def dispatcher_script(workspace: Path) -> Path: - 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 + skills_root = workspace / "agent-ops" / "skills" + project_root = skills_root / "project" / "orchestrate-agent-task-loop" + project_dispatcher = project_root / "scripts" / "dispatch.py" + if project_dispatcher.is_file(): + private_root = skills_root / "private" / "orchestrate-agent-task-loop" + private_dispatcher = private_root / "scripts" / "dispatch.py" + if (private_root / "SKILL.md").is_file() and private_dispatcher.is_file(): + return private_dispatcher + return project_dispatcher + common_dispatcher = ( + skills_root / "common" / "orchestrate-agent-task-loop" / "scripts" / "dispatch.py" + ) + if not common_dispatcher.is_file(): + raise PreparationError(f"dispatcher script not found: {common_dispatcher}") + return common_dispatcher + + +def dispatcher_command( + *, + workspace: Path, + dispatcher: Path, + task_group: str, + execution_catalog: str, +) -> list[str]: + command = [ + sys.executable, + str(dispatcher), + "--workspace", + str(workspace), + "--task-group", + task_group, + ] + common_dispatcher = ( + workspace + / "agent-ops" + / "skills" + / "common" + / "orchestrate-agent-task-loop" + / "scripts" + / "dispatch.py" + ) + if dispatcher.resolve() == common_dispatcher.resolve(): + command.extend(["--execution-catalog", execution_catalog]) + return command def epic_cycle_command( @@ -883,18 +922,15 @@ def coordinate_batch( dispatcher = dispatcher_script(workspace) task_group = f"m-{milestone_slug}" if not state.get("dispatcher_dry_run_done"): + dry_run_command = dispatcher_command( + workspace=workspace, + dispatcher=dispatcher, + task_group=task_group, + execution_catalog=args.execution_catalog, + ) + dry_run_command.append("--dry-run") dry_run = run( - [ - sys.executable, - str(dispatcher), - "--workspace", - str(workspace), - "--task-group", - task_group, - "--execution-catalog", - args.execution_catalog, - "--dry-run", - ], + dry_run_command, cwd=workspace, check=False, capture=False, @@ -907,16 +943,12 @@ def coordinate_batch( 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, - "--execution-catalog", - args.execution_catalog, - ] + command = dispatcher_command( + workspace=workspace, + dispatcher=dispatcher, + task_group=task_group, + execution_catalog=args.execution_catalog, + ) if resume_blocked_dispatcher and args.retry: command.append("--retry-blocked") try: diff --git a/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py b/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py index 1a799f62..e31a2fa9 100644 --- a/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py +++ b/agent-ops/skills/common/prepare-milestone-workspace/tests/test_prepare_workspace.py @@ -51,6 +51,60 @@ class PrepareWorkspaceTest(unittest.TestCase): Path("/tmp/example/sample-feature-worktree"), ) + def test_dispatcher_prefers_project_override_and_private_pair(self) -> None: + with tempfile.TemporaryDirectory() as raw: + workspace = Path(raw) + common = ( + workspace + / "agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py" + ) + project = ( + workspace + / "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py" + ) + private_root = ( + workspace / "agent-ops/skills/private/orchestrate-agent-task-loop" + ) + private = private_root / "scripts/dispatch.py" + for path in (common, project, private): + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + self.assertEqual(MODULE.dispatcher_script(workspace), project) + + (private_root / "SKILL.md").touch() + self.assertEqual(MODULE.dispatcher_script(workspace), private) + + project.unlink() + self.assertEqual(MODULE.dispatcher_script(workspace), common) + + def test_dispatcher_command_injects_catalog_only_for_common_runtime(self) -> None: + workspace = Path("/repo") + common = ( + workspace + / "agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py" + ) + project = ( + workspace + / "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py" + ) + + common_command = MODULE.dispatcher_command( + workspace=workspace, + dispatcher=common, + task_group="m-sample", + execution_catalog="/runtime/catalog.json", + ) + project_command = MODULE.dispatcher_command( + workspace=workspace, + dispatcher=project, + task_group="m-sample", + execution_catalog="/runtime/catalog.json", + ) + + self.assertIn("--execution-catalog", common_command) + self.assertNotIn("--execution-catalog", project_command) + def test_epic_document_range_is_one_based_and_inclusive(self) -> None: epics = MODULE.parse_epics( """## 기능