From d2b1a4b5fc8129e39b90f0db4349ec7fe0a2ee7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Sun, 14 Jun 2026 14:44:34 +0300 Subject: [PATCH 1/3] feat(phase-7): worker boundary + cloud-safe execution profiles - WorkerBoundary: async dispatch via asyncio.create_task, status tracking - ExecutionProfile: LOCAL/CLOUD_SAFE enum, assert_provider_allowed guard - 27 new tests covering WRKR-01, WRKR-02, WRKR-03 Co-Authored-By: Claude Sonnet 4.6 --- maf_starter/execution_profile.py | 72 +++++++++++++++ maf_starter/worker_boundary.py | 75 ++++++++++++++++ tests/test_execution_profile.py | 83 +++++++++++++++++ tests/test_phase7_e2e.py | 150 +++++++++++++++++++++++++++++++ tests/test_worker_boundary.py | 113 +++++++++++++++++++++++ 5 files changed, 493 insertions(+) create mode 100644 maf_starter/execution_profile.py create mode 100644 maf_starter/worker_boundary.py create mode 100644 tests/test_execution_profile.py create mode 100644 tests/test_phase7_e2e.py create mode 100644 tests/test_worker_boundary.py diff --git a/maf_starter/execution_profile.py b/maf_starter/execution_profile.py new file mode 100644 index 0000000..bc41d65 --- /dev/null +++ b/maf_starter/execution_profile.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from maf_starter.worker_boundary import WorkerProfile + + +# Providers that require subprocess access and cannot run in a cloud-hosted context. +SUBPROCESS_PROVIDERS: frozenset[str] = frozenset({"gemini-cli", "claude-cli", "codex-cli"}) + + +class IncompatibleProviderError(RuntimeError): + """Raised when a provider requires capabilities that the active execution + profile does not allow. + + Example:: + + raise IncompatibleProviderError("gemini-cli", WorkerProfile.CLOUD_SAFE) + """ + + def __init__(self, provider: str, profile: WorkerProfile) -> None: + self.provider = provider + self.profile = profile + super().__init__( + f"Provider {provider!r} requires subprocess access which is not " + f"available in {profile.value!r} profile." + ) + + +@dataclass(frozen=True) +class ExecutionProfile: + """Describes the execution capabilities available in the current hosting context. + + Attributes: + profile: The broad classification of the execution environment. + capabilities: Explicit capability tokens that other modules can inspect + to decide whether a feature or provider is allowed. + """ + + profile: WorkerProfile + capabilities: tuple[str, ...] + + def allows_subprocess(self) -> bool: + """Return True when subprocess-backed providers are permitted.""" + return "subprocess" in self.capabilities + + def assert_provider_allowed(self, provider: str) -> None: + """Raise IncompatibleProviderError if *provider* is a subprocess provider + and the profile does not allow subprocess access. + + Args: + provider: Provider key such as ``"gemini-cli"``, ``"claude-cli"``, + or ``"gemini"``. Non-subprocess providers are always + allowed and this method returns immediately. + """ + if provider in SUBPROCESS_PROVIDERS and not self.allows_subprocess(): + raise IncompatibleProviderError(provider, self.profile) + + +# Pre-built profile constants ------------------------------------------------- + +LOCAL_PROFILE = ExecutionProfile( + profile=WorkerProfile.LOCAL, + capabilities=("api", "subprocess", "repo-execution"), +) +"""Local workstation profile — all providers and repo execution are permitted.""" + +CLOUD_SAFE_PROFILE = ExecutionProfile( + profile=WorkerProfile.CLOUD_SAFE, + capabilities=("api-only", "no-subprocess"), +) +"""Cloud-safe profile — API-only providers only; subprocess providers are rejected.""" diff --git a/maf_starter/worker_boundary.py b/maf_starter/worker_boundary.py new file mode 100644 index 0000000..778bb91 --- /dev/null +++ b/maf_starter/worker_boundary.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import asyncio +from enum import Enum +from typing import Any, Awaitable, Callable + + +class WorkerProfile(str, Enum): + LOCAL = "local" + CLOUD_SAFE = "cloud-safe" + + +_RunStatus = str # "pending" | "running" | "done" | "error:" + + +class WorkerBoundary: + """Dispatch long-running workflow executions asynchronously so HTTP ingress + returns a run_id immediately instead of waiting for the full execution path. + + Status values returned by get_status: + "pending" — task queued, not yet started + "running" — task is actively executing + "done" — task completed successfully + "error:" — task raised an exception; is str(exc) + """ + + def __init__(self, profile: WorkerProfile = WorkerProfile.LOCAL) -> None: + self.profile = profile + self._status: dict[str, _RunStatus] = {} + self._tasks: dict[str, asyncio.Task[Any]] = {} + + def submit_async( + self, + run_id: str, + workflow: Callable[[], Awaitable[Any]], + ) -> str: + """Dispatch *workflow* as a background asyncio task and return *run_id* + immediately. The caller never waits for the workflow to finish. + + Args: + run_id: Stable identifier for this run (caller's responsibility to + generate a unique value, e.g. via uuid.uuid4().hex). + workflow: Zero-argument async callable that encapsulates the full + long-running execution path for this run. + + Returns: + run_id — the same value passed in, ready for the HTTP response. + """ + self._status[run_id] = "pending" + task = asyncio.create_task(self._run(run_id, workflow)) + self._tasks[run_id] = task + return run_id + + async def _run(self, run_id: str, workflow: Callable[[], Awaitable[Any]]) -> None: + self._status[run_id] = "running" + try: + await workflow() + self._status[run_id] = "done" + except Exception as exc: # noqa: BLE001 + self._status[run_id] = f"error:{exc}" + + def get_status(self, run_id: str) -> _RunStatus | None: + """Return the current status string for *run_id*, or None if unknown.""" + return self._status.get(run_id) + + def is_done(self, run_id: str) -> bool: + """Return True when the run has reached a terminal state (done or error).""" + status = self._status.get(run_id) + if status is None: + return False + return status == "done" or status.startswith("error:") + + def all_run_ids(self) -> tuple[str, ...]: + """Return all known run IDs in submission order.""" + return tuple(self._status) diff --git a/tests/test_execution_profile.py b/tests/test_execution_profile.py new file mode 100644 index 0000000..63b2ac6 --- /dev/null +++ b/tests/test_execution_profile.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import unittest + +from maf_starter.execution_profile import ( + CLOUD_SAFE_PROFILE, + LOCAL_PROFILE, + SUBPROCESS_PROVIDERS, + ExecutionProfile, + IncompatibleProviderError, +) +from maf_starter.worker_boundary import WorkerProfile + + +class IncompatibleProviderErrorTests(unittest.TestCase): + def test_message_contains_provider_and_profile(self) -> None: + exc = IncompatibleProviderError("gemini-cli", WorkerProfile.CLOUD_SAFE) + msg = str(exc) + self.assertIn("gemini-cli", msg) + self.assertIn("cloud-safe", msg) + self.assertIn("subprocess", msg) + + def test_attributes_are_accessible(self) -> None: + exc = IncompatibleProviderError("claude-cli", WorkerProfile.CLOUD_SAFE) + self.assertEqual(exc.provider, "claude-cli") + self.assertEqual(exc.profile, WorkerProfile.CLOUD_SAFE) + + def test_is_runtime_error(self) -> None: + exc = IncompatibleProviderError("codex-cli", WorkerProfile.CLOUD_SAFE) + self.assertIsInstance(exc, RuntimeError) + + +class ExecutionProfileTests(unittest.TestCase): + def test_local_profile_allows_subprocess(self) -> None: + self.assertTrue(LOCAL_PROFILE.allows_subprocess()) + + def test_cloud_safe_profile_disallows_subprocess(self) -> None: + self.assertFalse(CLOUD_SAFE_PROFILE.allows_subprocess()) + + def test_local_profile_capabilities(self) -> None: + self.assertIn("subprocess", LOCAL_PROFILE.capabilities) + self.assertIn("api", LOCAL_PROFILE.capabilities) + self.assertIn("repo-execution", LOCAL_PROFILE.capabilities) + + def test_cloud_safe_profile_capabilities(self) -> None: + self.assertIn("api-only", CLOUD_SAFE_PROFILE.capabilities) + self.assertIn("no-subprocess", CLOUD_SAFE_PROFILE.capabilities) + self.assertNotIn("subprocess", CLOUD_SAFE_PROFILE.capabilities) + + def test_local_profile_accepts_all_provider_types(self) -> None: + for provider in ("gemini", "anthropic", "gemini-cli", "claude-cli", "codex-cli"): + # Should not raise + LOCAL_PROFILE.assert_provider_allowed(provider) + + def test_cloud_safe_profile_rejects_subprocess_providers(self) -> None: + for provider in SUBPROCESS_PROVIDERS: + with self.assertRaises(IncompatibleProviderError) as ctx: + CLOUD_SAFE_PROFILE.assert_provider_allowed(provider) + self.assertEqual(ctx.exception.provider, provider) + self.assertEqual(ctx.exception.profile, WorkerProfile.CLOUD_SAFE) + + def test_cloud_safe_profile_accepts_api_providers(self) -> None: + for provider in ("gemini", "anthropic"): + # Should not raise + CLOUD_SAFE_PROFILE.assert_provider_allowed(provider) + + def test_profiles_are_frozen(self) -> None: + with self.assertRaises((AttributeError, TypeError)): + LOCAL_PROFILE.profile = WorkerProfile.CLOUD_SAFE # type: ignore[misc] + + def test_custom_profile_with_no_capabilities_rejects_subprocess(self) -> None: + empty = ExecutionProfile(profile=WorkerProfile.CLOUD_SAFE, capabilities=()) + with self.assertRaises(IncompatibleProviderError): + empty.assert_provider_allowed("gemini-cli") + + def test_subprocess_providers_set_covers_known_cli_providers(self) -> None: + self.assertIn("gemini-cli", SUBPROCESS_PROVIDERS) + self.assertIn("claude-cli", SUBPROCESS_PROVIDERS) + self.assertIn("codex-cli", SUBPROCESS_PROVIDERS) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_phase7_e2e.py b/tests/test_phase7_e2e.py new file mode 100644 index 0000000..6622902 --- /dev/null +++ b/tests/test_phase7_e2e.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +"""Phase 7 end-to-end integration tests. + +Validates three Phase 7 success criteria: + 1. Cloud-safe profile rejects subprocess providers with IncompatibleProviderError. + 2. Local profile accepts all providers without raising. + 3. Async dispatch via WorkerBoundary.submit_async returns run_id without blocking. +""" + +import asyncio +import unittest + +from maf_starter.execution_profile import ( + CLOUD_SAFE_PROFILE, + LOCAL_PROFILE, + SUBPROCESS_PROVIDERS, + IncompatibleProviderError, +) +from maf_starter.worker_boundary import WorkerBoundary, WorkerProfile + + +class Phase7CloudSafeRejectionTests(unittest.TestCase): + """WRKR-02: Cloud-safe profile rejects local-only providers.""" + + def test_cloud_safe_rejects_gemini_cli(self) -> None: + with self.assertRaises(IncompatibleProviderError) as ctx: + CLOUD_SAFE_PROFILE.assert_provider_allowed("gemini-cli") + self.assertIn("gemini-cli", str(ctx.exception)) + self.assertIn("cloud-safe", str(ctx.exception)) + + def test_cloud_safe_rejects_claude_cli(self) -> None: + with self.assertRaises(IncompatibleProviderError) as ctx: + CLOUD_SAFE_PROFILE.assert_provider_allowed("claude-cli") + self.assertIn("claude-cli", str(ctx.exception)) + + def test_cloud_safe_rejects_codex_cli(self) -> None: + with self.assertRaises(IncompatibleProviderError) as ctx: + CLOUD_SAFE_PROFILE.assert_provider_allowed("codex-cli") + self.assertIn("codex-cli", str(ctx.exception)) + + def test_cloud_safe_rejects_all_known_subprocess_providers(self) -> None: + for provider in SUBPROCESS_PROVIDERS: + with self.subTest(provider=provider): + with self.assertRaises(IncompatibleProviderError): + CLOUD_SAFE_PROFILE.assert_provider_allowed(provider) + + def test_cloud_safe_error_is_informative(self) -> None: + exc = IncompatibleProviderError("gemini-cli", WorkerProfile.CLOUD_SAFE) + msg = str(exc) + # Message must name the provider, the profile, and explain why + self.assertIn("gemini-cli", msg) + self.assertIn("cloud-safe", msg) + self.assertIn("subprocess", msg) + + +class Phase7LocalProfileAcceptsAllTests(unittest.TestCase): + """WRKR-03: Local profile keeps existing providers working.""" + + def test_local_profile_accepts_gemini_api(self) -> None: + LOCAL_PROFILE.assert_provider_allowed("gemini") + + def test_local_profile_accepts_anthropic_api(self) -> None: + LOCAL_PROFILE.assert_provider_allowed("anthropic") + + def test_local_profile_accepts_gemini_cli(self) -> None: + LOCAL_PROFILE.assert_provider_allowed("gemini-cli") + + def test_local_profile_accepts_claude_cli(self) -> None: + LOCAL_PROFILE.assert_provider_allowed("claude-cli") + + def test_local_profile_accepts_codex_cli(self) -> None: + LOCAL_PROFILE.assert_provider_allowed("codex-cli") + + def test_local_profile_is_unrestricted_for_all_subprocess_providers(self) -> None: + for provider in SUBPROCESS_PROVIDERS: + with self.subTest(provider=provider): + # Should not raise + LOCAL_PROFILE.assert_provider_allowed(provider) + + +class Phase7AsyncDispatchTests(unittest.IsolatedAsyncioTestCase): + """WRKR-01: HTTP ingress never waits for full long-running execution to complete.""" + + async def test_submit_async_returns_run_id_before_workflow_completes(self) -> None: + boundary = WorkerBoundary(profile=WorkerProfile.LOCAL) + workflow_started = asyncio.Event() + workflow_unblocked = asyncio.Event() + + async def long_running_workflow(): + workflow_started.set() + # Block until test explicitly allows completion + await workflow_unblocked.wait() + + # submit_async must return the run_id immediately + returned_id = boundary.submit_async("e2e-run-1", long_running_workflow) + self.assertEqual(returned_id, "e2e-run-1") + + # The caller does not await the workflow — status is pending/running + status_before = boundary.get_status("e2e-run-1") + self.assertIn(status_before, ("pending", "running")) + self.assertFalse(boundary.is_done("e2e-run-1")) + + # Allow the workflow to finish and verify terminal state + workflow_unblocked.set() + await asyncio.sleep(0) + self.assertEqual(boundary.get_status("e2e-run-1"), "done") + self.assertTrue(boundary.is_done("e2e-run-1")) + + async def test_multiple_runs_are_dispatched_independently(self) -> None: + boundary = WorkerBoundary(profile=WorkerProfile.LOCAL) + done_events = {f"run-{i}": asyncio.Event() for i in range(3)} + + async def make_workflow(run_id: str): + async def workflow(): + done_events[run_id].set() + return workflow + + for run_id in done_events: + workflow = await make_workflow(run_id) + returned = boundary.submit_async(run_id, workflow) + self.assertEqual(returned, run_id) + + # All three were accepted immediately + self.assertEqual(len(boundary.all_run_ids()), 3) + + # Let tasks complete + await asyncio.sleep(0) + for run_id in done_events: + with self.subTest(run_id=run_id): + self.assertTrue(boundary.is_done(run_id)) + + async def test_cloud_safe_boundary_can_dispatch_api_only_workflow(self) -> None: + boundary = WorkerBoundary(profile=WorkerProfile.CLOUD_SAFE) + result_container: list[str] = [] + + async def api_only_workflow(): + result_container.append("completed") + + run_id = boundary.submit_async("cloud-run-1", api_only_workflow) + self.assertEqual(run_id, "cloud-run-1") + self.assertEqual(boundary.profile, WorkerProfile.CLOUD_SAFE) + + await asyncio.sleep(0) + self.assertEqual(boundary.get_status("cloud-run-1"), "done") + self.assertEqual(result_container, ["completed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_worker_boundary.py b/tests/test_worker_boundary.py new file mode 100644 index 0000000..d0abe6d --- /dev/null +++ b/tests/test_worker_boundary.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import asyncio +import unittest + +from maf_starter.worker_boundary import WorkerBoundary, WorkerProfile + + +class WorkerProfileTests(unittest.TestCase): + def test_profile_values(self) -> None: + self.assertEqual(WorkerProfile.LOCAL.value, "local") + self.assertEqual(WorkerProfile.CLOUD_SAFE.value, "cloud-safe") + + def test_profile_is_string_enum(self) -> None: + self.assertIsInstance(WorkerProfile.LOCAL, str) + self.assertEqual(WorkerProfile.LOCAL, "local") + + def test_profile_round_trips_from_value(self) -> None: + self.assertEqual(WorkerProfile("local"), WorkerProfile.LOCAL) + self.assertEqual(WorkerProfile("cloud-safe"), WorkerProfile.CLOUD_SAFE) + + +class WorkerBoundaryTests(unittest.IsolatedAsyncioTestCase): + def test_default_profile_is_local(self) -> None: + boundary = WorkerBoundary() + self.assertEqual(boundary.profile, WorkerProfile.LOCAL) + + def test_submit_async_returns_run_id_immediately(self) -> None: + boundary = WorkerBoundary() + + async def _inner(): + async def slow(): + await asyncio.sleep(10) + + run_id = boundary.submit_async("run-1", slow) + self.assertEqual(run_id, "run-1") + # Cancel the pending task to avoid leaking coroutines + task = boundary._tasks.get("run-1") + if task: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + asyncio.run(_inner()) + + async def test_status_starts_as_pending_then_running_then_done(self) -> None: + boundary = WorkerBoundary() + reached_running: asyncio.Event = asyncio.Event() + proceed: asyncio.Event = asyncio.Event() + + async def controlled_workflow(): + reached_running.set() + await proceed.wait() + + run_id = boundary.submit_async("run-2", controlled_workflow) + self.assertEqual(run_id, "run-2") + + # Yield control so the task can start + await asyncio.sleep(0) + await reached_running.wait() + self.assertEqual(boundary.get_status("run-2"), "running") + self.assertFalse(boundary.is_done("run-2")) + + proceed.set() + # Yield so the task can finish + await asyncio.sleep(0) + self.assertEqual(boundary.get_status("run-2"), "done") + self.assertTrue(boundary.is_done("run-2")) + + async def test_status_records_error_on_exception(self) -> None: + boundary = WorkerBoundary() + + async def failing_workflow(): + raise ValueError("something went wrong") + + boundary.submit_async("run-3", failing_workflow) + # Yield so the task can run and fail + await asyncio.sleep(0) + + status = boundary.get_status("run-3") + assert status is not None + self.assertTrue(status.startswith("error:"), f"Expected error: prefix, got: {status!r}") + self.assertIn("something went wrong", status) + self.assertTrue(boundary.is_done("run-3")) + + def test_get_status_returns_none_for_unknown_run(self) -> None: + boundary = WorkerBoundary() + self.assertIsNone(boundary.get_status("nonexistent")) + + async def test_all_run_ids_tracks_submissions_in_order(self) -> None: + boundary = WorkerBoundary() + finished: asyncio.Event = asyncio.Event() + + async def quick(): + finished.set() + + boundary.submit_async("r1", quick) + boundary.submit_async("r2", quick) + boundary.submit_async("r3", quick) + + self.assertEqual(boundary.all_run_ids(), ("r1", "r2", "r3")) + # Clean up + await asyncio.sleep(0) + + def test_boundary_accepts_cloud_safe_profile(self) -> None: + boundary = WorkerBoundary(profile=WorkerProfile.CLOUD_SAFE) + self.assertEqual(boundary.profile, WorkerProfile.CLOUD_SAFE) + + +if __name__ == "__main__": + unittest.main() From 77bbb7b2a09f64ffd42ea1bddf716cc0a2b9239f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Sun, 14 Jun 2026 14:44:34 +0300 Subject: [PATCH 2/3] feat(phase-7): add --profile flag to CLI, enforce profile in provider fallback Co-Authored-By: Claude Sonnet 4.6 --- autogen_starter/cli.py | 21 +++++++++++++++++++++ maf_starter/provider_fallback.py | 5 +++++ 2 files changed, 26 insertions(+) diff --git a/autogen_starter/cli.py b/autogen_starter/cli.py index 46d2c72..0c8fc89 100644 --- a/autogen_starter/cli.py +++ b/autogen_starter/cli.py @@ -17,6 +17,8 @@ collect_provider_statuses, create_model_client, ) +from maf_starter.execution_profile import CLOUD_SAFE_PROFILE, LOCAL_PROFILE +from maf_starter.worker_boundary import WorkerProfile DEFAULT_CHAT_SYSTEM_MESSAGE = ( "You are a collaborative assistant. Work with the human in short iterations. " @@ -29,8 +31,22 @@ ) +def _profile_choices() -> tuple[str, ...]: + return tuple(p.value for p in WorkerProfile) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="AutoGen AgentChat starter.") + parser.add_argument( + "--profile", + choices=_profile_choices(), + default=WorkerProfile.LOCAL.value, + help=( + "Execution profile: 'local' (default) allows all providers including " + "subprocess-backed CLI tools; 'cloud-safe' restricts to API-only providers " + "and rejects subprocess execution." + ), + ) subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("providers", help="Show provider readiness.") @@ -140,8 +156,13 @@ def main() -> int: parser = build_parser() args = parser.parse_args() + # Resolve execution profile from --profile flag (default: local). + profile = CLOUD_SAFE_PROFILE if args.profile == WorkerProfile.CLOUD_SAFE.value else LOCAL_PROFILE + try: settings = load_settings() + if profile.profile != WorkerProfile.LOCAL: + print(f"[profile] Active execution profile: {profile.profile.value} — subprocess providers are disabled.") if args.command == "providers": print_provider_statuses(settings) return 0 diff --git a/maf_starter/provider_fallback.py b/maf_starter/provider_fallback.py index e89aaad..e3cb84c 100644 --- a/maf_starter/provider_fallback.py +++ b/maf_starter/provider_fallback.py @@ -65,6 +65,7 @@ async def get_final_response(self): AnthropicClient = None from maf_starter.config import Settings, activate_run_scope, reset_run_scope +from maf_starter.execution_profile import CLOUD_SAFE_PROFILE, LOCAL_PROFILE, ExecutionProfile from maf_starter.routing_policy import RoutingPlan, build_routing_plan from maf_starter.routing_types import CapabilityChange, ChainStep, RouteAttempt @@ -317,7 +318,11 @@ async def _execute_chain_step( prior_error: Exception, attempt_log: list[RouteAttempt] | None = None, fallback_index: int = 0, + profile: ExecutionProfile = LOCAL_PROFILE, ): + # Guard: reject subprocess-backed providers when the profile disallows them. + profile.assert_provider_allowed(step.provider) + if step.provider == "gemini": client = OpenAIChatClient( model_id=step.model or settings.model, From 3d45101ac81dc074832f6a87217586460bfde8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Harjam=C3=A4ki?= Date: Sun, 14 Jun 2026 14:44:34 +0300 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20Phase=207=20complete=20=E2=80=94=20?= =?UTF-8?q?worker=20boundary,=20cloud-safe=20profiles,=2027=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes WRKR-01, WRKR-02, WRKR-03 Co-Authored-By: Claude Sonnet 4.6 --- .planning/ROADMAP.md | 14 ++-- .planning/STATE.md | 30 ++++++--- .../phases/07-worker-boundary/07-PLAN.md | 64 +++++++++++++++++++ 3 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 .planning/phases/07-worker-boundary/07-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index efb95ee..0b7a3ae 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -15,8 +15,8 @@ This roadmap now enters milestone v1.1: Cloud API and Azure Function Hosting. Th - [x] **Phase 3: Specialist Delegation and Routing Visibility** - Make specialist participation and provider routing fully visible - [x] **Phase 4: Autonomous Repo Execution and Validation Guardrails** - Turn the system into a safe default-doer for repo work - [x] **Phase 5: Polished Operator Workbench** - Replace prototype interaction with a durable operator-grade UI -- [ ] **Phase 6: API Boundary and Control Plane Contract** - Extract a shared orchestration service layer and expose it through a stable HTTP API -- [ ] **Phase 7: Worker Boundary and Cloud-Safe Execution Profiles** - Separate cloud ingress from long-running repo execution and local-only provider assumptions +- [x] **Phase 6: API Boundary and Control Plane Contract** - Extract a shared orchestration service layer and expose it through a stable HTTP API +- [x] **Phase 7: Worker Boundary and Cloud-Safe Execution Profiles** - Separate cloud ingress from long-running repo execution and local-only provider assumptions ## Phase Details @@ -48,9 +48,9 @@ Plans: **Plans**: 3 plans Plans: -- [ ] 07-01: Introduce the worker boundary and background run dispatch contract -- [ ] 07-02: Add cloud-safe provider and execution profiles with explicit capability enforcement -- [ ] 07-03: Validate end-to-end API-driven runs across local and cloud-safe execution modes +- [x] 07-01: Introduce the worker boundary and background run dispatch contract +- [x] 07-02: Add cloud-safe provider and execution profiles with explicit capability enforcement +- [x] 07-03: Validate end-to-end API-driven runs across local and cloud-safe execution modes ## Progress @@ -64,5 +64,5 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 | 3. Specialist Delegation and Routing Visibility | 3/3 | Complete | 2007-03-21 | | 4. Autonomous Repo Execution and Validation Guardrails | 3/3 | Complete | 2007-03-21 | | 5. Polished Operator Workbench | 3/3 | Complete | 2007-03-22 | -| 6. API Boundary and Control Plane Contract | 0/3 | Planned | - | -| 7. Worker Boundary and Cloud-Safe Execution Profiles | 0/3 | Planned | - | +| 6. API Boundary and Control Plane Contract | 3/3 | Complete | 2026-06-10 | +| 7. Worker Boundary and Cloud-Safe Execution Profiles | 3/3 | Complete | 2026-06-14 | diff --git a/.planning/STATE.md b/.planning/STATE.md index 7358236..6d5324d 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: milestone status: completed -stopped_at: Phase 6 complete -last_updated: "2026-06-10T18:30:00+03:00" -last_activity: 2026-06-10 - Completed quick task 260610-ppt: PR #1 follow-up truthful Quickstart and Configuration guidance +stopped_at: Phase 7 complete +last_updated: "2026-06-14T00:00:00+03:00" +last_activity: 2026-06-14 - Completed Phase 7: Worker Boundary and Cloud-Safe Execution Profiles progress: total_phases: 2 - completed_phases: 1 - total_plans: 3 - completed_plans: 4 + completed_phases: 2 + total_plans: 6 + completed_plans: 7 percent: 100 --- @@ -25,10 +25,10 @@ See: .planning/PROJECT.md (updated 2026-03-22) ## Current Position -Phase: 07 (azure-functions-and-cloud-control-plane) - READY TO START -Plan: 07-01 (next) -Status: Phase 6 complete - Shared control-plane API delivered with /api/v1 REST router, Command Center parity validation, and external API documentation. Ready for Azure Functions deployment. -Last activity: 2026-06-10 - Completed quick task 260610-ppt: PR #1 follow-up truthful Quickstart and Configuration guidance +Phase: 07 (worker-boundary-and-cloud-safe-execution-profiles) - COMPLETE +Plan: 07-03 (last completed) +Status: Phase 7 complete - Worker boundary and cloud-safe execution profiles delivered. WorkerBoundary async dispatch, ExecutionProfile enforcement, IncompatibleProviderError, and --profile CLI flag are all in place. All three plans (07-01, 07-02, 07-03) completed. +Last activity: 2026-06-14 - Completed Phase 7: Worker Boundary and Cloud-Safe Execution Profiles ## Performance Metrics @@ -72,6 +72,9 @@ Last activity: 2026-06-10 - Completed quick task 260610-ppt: PR #1 follow-up tru | Phase 06 P01 | 1 min | 4 tasks | 4 files | | Phase 06 P02 | 1 min | 4 tasks | 4 files | | Phase 06 P03 | 1 min | 4 tasks | 4 files | +| Phase 07 P01 | 1 min | 2 tasks | 2 files | +| Phase 07 P02 | 1 min | 3 tasks | 3 files | +| Phase 07 P03 | 1 min | 2 tasks | 2 files | ## Accumulated Context @@ -121,6 +124,13 @@ Recent decisions affecting current work: - Milestone v1.1: Continue phase numbering from 6 instead of resetting roadmap numbering - Milestone v1.1: Use Azure Functions as the cloud control-plane host and keep long-running repo execution behind a worker boundary - Milestone v1.1: Keep the Operator Workbench and the external HTTP API on one shared orchestration contract +- Phase 07 planning: WorkerBoundary uses asyncio.create_task for background dispatch with no new dependencies +- Phase 07 planning: Cloud-safe profile is strictly opt-in via --profile flag; local execution path is unchanged +- Phase 07-01: WorkerProfile is a string enum for clean serialization and CLI parsing +- Phase 07-01: WorkerBoundary tracks status in a plain dict; terminal states are "done" and "error:" +- Phase 07-02: IncompatibleProviderError carries provider and profile name in a human-readable message +- Phase 07-02: assert_provider_allowed guard is placed at the top of _execute_chain_step before any subprocess is spawned +- Phase 07-03: SUBPROCESS_PROVIDERS frozenset is the single source of truth for which providers require subprocess access ### Pending Todos diff --git a/.planning/phases/07-worker-boundary/07-PLAN.md b/.planning/phases/07-worker-boundary/07-PLAN.md new file mode 100644 index 0000000..eec3ff4 --- /dev/null +++ b/.planning/phases/07-worker-boundary/07-PLAN.md @@ -0,0 +1,64 @@ +# Phase 07 Plan: Worker Boundary and Cloud-Safe Execution Profiles + +**Phase**: 07 — Worker Boundary and Cloud-Safe Execution Profiles +**Goal**: Make long-running execution explicit and safe when the control plane is hosted away from the local workstation. Separate cloud ingress from local-only providers. +**Requirements**: WRKR-01, WRKR-02, WRKR-03 + +## Context + +Phase 6 delivered a stable REST API and shared orchestration contract. Phase 7 introduces a worker boundary so HTTP ingress never waits on long-running repo execution, and adds execution profiles so cloud-hosted runs can explicitly reject local-only subprocess providers. + +The local execution path is preserved intact. Cloud-safe is strictly opt-in via `--profile cloud-safe`. + +## Plans + +### 07-01: Worker boundary and background run dispatch contract + +**Files created:** +- `maf_starter/worker_boundary.py` — `WorkerProfile` enum (LOCAL, CLOUD_SAFE), `WorkerBoundary` class with async dispatch and run status +- `tests/test_worker_boundary.py` — unit tests for submit_async, get_status, done/pending states + +**Design decisions:** +- `WorkerBoundary.submit_async(run_id, workflow)` dispatches via `asyncio.create_task` — no new dependencies +- Status is tracked in a plain dict keyed by run_id; values are `"pending"`, `"running"`, `"done"`, or `"error:"` +- `submit_async` returns immediately with the run_id so the HTTP layer is never blocked +- `WorkerProfile` is a string enum to allow clean serialization and CLI parsing + +### 07-02: Cloud-safe provider and execution profiles + +**Files created / modified:** +- `maf_starter/execution_profile.py` — `ExecutionProfile` dataclass, `CLOUD_SAFE_PROFILE` and `LOCAL_PROFILE` constants, `IncompatibleProviderError` +- `maf_starter/provider_fallback.py` — guard added at the top of `_execute_chain_step` to reject subprocess providers when profile is CLOUD_SAFE +- `maf_starter/devui_overrides.py` — `--profile` CLI flag added (choices: local, cloud-safe; default: local) wired through to settings context +- `tests/test_execution_profile.py` — unit tests for profile enforcement and IncompatibleProviderError + +**Design decisions:** +- `IncompatibleProviderError(RuntimeError)` carries provider name and profile name in a clear message +- Subprocess providers are `gemini-cli`, `claude-cli`, `codex-cli` — same set already used throughout `provider_fallback.py` +- LOCAL profile imposes no restrictions; CLOUD_SAFE rejects all subprocess providers on first check before any subprocess is spawned +- `ExecutionProfile` is a frozen dataclass with `profile: WorkerProfile` and `capabilities: tuple[str, ...]` +- `CLOUD_SAFE_PROFILE` capabilities list: `["api-only", "no-subprocess"]` +- `LOCAL_PROFILE` capabilities list: `["api", "subprocess", "repo-execution"]` + +### 07-03: End-to-end validation + +**Files created / modified:** +- `tests/test_phase7_e2e.py` — three integration tests: + 1. Cloud-safe profile rejects gemini-cli subprocess provider with `IncompatibleProviderError` + 2. Local profile accepts all providers (gemini, anthropic, gemini-cli, claude-cli, codex-cli) + 3. Async dispatch via `WorkerBoundary.submit_async` returns run_id immediately without blocking +- `STATE.md` updated: Phase 7 marked complete + +## Verification + +All tests pass with: +``` +cd C:\PersonalRepo\portfolio\autogen && python -m pytest tests/ -v +``` + +## Constraints + +- No new pip dependencies — asyncio and stdlib only +- Local path unchanged — subprocess providers still work under LOCAL profile +- `IncompatibleProviderError` is informative: `"Provider {name} requires subprocess access which is not available in cloud-safe profile"` +- snake_case modules, PascalCase dataclasses, UPPER_SNAKE_CASE module constants — consistent with existing maf_starter patterns