diff --git a/coding-agent/docs/adr/0058-local-daemon-control-plane-executor-architecture.md b/coding-agent/docs/adr/0058-local-daemon-control-plane-executor-architecture.md new file mode 100644 index 000000000..952585097 --- /dev/null +++ b/coding-agent/docs/adr/0058-local-daemon-control-plane-executor-architecture.md @@ -0,0 +1,148 @@ +# ADR-0058: Local daemon control plane and executor architecture + +**Status**: Proposed +**Date**: 2026-06-02 + +## Context + +`coding_agent` has grown local CLI, remote HTTP, workspace, sandbox, checkpoint, +approval, and executor behavior from one runtime path. This made product +identity hard to reason about: clients can directly execute runtime work, +control-plane session management owns in-process runtime handles, workspace +placement is encoded through compatibility-oriented execution bindings, and +`coding_agent run` is easy to treat as a first-class product entrypoint even +though it cannot provide daemon durability, replay, or reconnect semantics. + +The first version must stabilize the product boundary before adding cloud +managed execution or user-local attached executors. The durable path should be +run-centric: a client asks a control plane to start a run, the run coordinator +chooses an executor, and the executor owns runtime execution inside a sandboxed +environment over a workspace. + +Existing AgentKit mechanisms remain valid. AgentKit owns the generic pipeline, +hook runtime, tape, plugin, tool, provider, directive, approval, and checkpoint +mechanisms. `coding_agent` owns product policy, session/run metadata, workspace +placement, daemon/server surfaces, executor placement, and sandbox policy. + +## Decision + +Adopt Local Daemon Mode as the first product architecture: + +```text +Client + -> ControlPlane / SessionService + -> RunCoordinator + -> Executor + -> Runtime + -> SandboxedEnvironment + -> Workspace +``` + +The first product path is local daemon first: + +```text +CLI / TUI / IDE Client + -> Local Daemon ControlPlane + -> LocalDaemonExecutor + -> Runtime + -> SandboxedEnvironment + -> Local Workspace +``` + +The durable coordination layer manages session, run, approval, cancel, event, +checkpoint, resume, ownership, and policy metadata. It must not execute the +agent loop, run shell commands, directly read/write workspace contents, or hold +pipeline/runtime task handles as durable session data. + +Clients are display and intent surfaces. They create sessions, start runs, +stream display events, submit approval decisions, cancel runs, and inspect +history/status. They do not own runtime execution. + +Executors own runtime execution. A LocalDaemonExecutor is the first production +executor. ManagedPoolExecutor and local attached executor paths are future +extensions. InlineExecutor is testkit/devkit only and must still flow through +control-plane and run-coordinator abstractions when used for integration tests. + +Introduce `RunTarget` as the canonical placement model: + +```text +RunTarget = WorkspaceRef + ExecutorRef + IsolationPolicy + RunConstraints +``` + +`ExecutionBinding` becomes compatibility terminology. Existing APIs may +continue accepting and persisting it during migration, but new placement code +should use `RunTarget`. The migration must provide an `ExecutionBinding -> +RunTarget` adapter before replacing existing session metadata fields. + +Sandbox is a wrapper policy, not an environment type. The intended relationship +is: + +```text +Runtime -> SandboxedEnvironment -> Workspace +``` + +`coding_agent run` is no longer a target product path. It may remain as a +testkit/devkit/compatibility inline entrypoint while local daemon client +surfaces become the formal local product path. + +Cloud Managed Mode is a future product path using the same architecture: + +```text +Client -> Cloud ControlPlane -> ManagedPoolExecutor -> Runtime + -> Provider Sandbox -> Cloud Workspace +``` + +This ADR does not implement that path. + +## Alternatives Rejected + +- Keep CLI single-process execution as a product path — rejected because it + makes session lifecycle, replay, cancel, approval durability, background + execution, and reconnect behavior ambiguous. +- Keep `SessionManager` as both durable control plane and runtime owner — + rejected because it mixes session metadata, run coordination, event storage, + approval state, workspace policy, pipeline runtime, and async task handles in + one module. +- Continue evolving `ExecutionBinding` as the canonical model — rejected + because it combines workspace surface and executor placement. `RunTarget` + separates `WorkspaceRef`, `ExecutorRef`, `IsolationPolicy`, and + `RunConstraints`. +- Implement Cloud Managed Mode and Local Daemon Mode together — rejected + because the MVP should prove local durable run semantics before introducing + managed executor pools, cloud workspaces, tenant policy, and Postgres + coordination. +- Implement Cloud ControlPlane -> user LocalAttachedExecutor in the first + version — rejected because that requires executor registration, claim + protocol, heartbeat, fencing, event upload, approval/cancel relay, redaction, + workspace privacy policy, and version negotiation. +- Remove `coding_agent run` immediately — rejected because existing tests, + scripts, and dogfood workflows depend on a non-interactive entrypoint. + Demotion should be explicit and staged. + +## Acceptance Criteria + +- [ ] `RunTarget`, `WorkspaceRef`, `ExecutorRef`, `IsolationPolicy`, and + `RunConstraints` exist under `src/coding_agent/runs/`. +- [ ] `ExecutionBinding -> RunTarget` adapter maps local, cloud, + external-worker, and local-attached compatibility bindings without changing + existing serialized `ExecutionBinding` payloads. +- [ ] New code in the run-placement skeleton imports canonical binding models + from `coding_agent.environment.execution_binding`, not server/ui aliases. +- [ ] `test_local_execution_binding_maps_to_local_daemon_run_target` +- [ ] `test_cloud_execution_binding_maps_to_managed_pool_run_target` +- [ ] `test_external_worker_binding_maps_to_external_worker_run_target` +- [ ] `test_local_attached_binding_maps_to_local_attached_run_target` +- [ ] `test_run_target_rejects_empty_annotations_key` +- [ ] `uv run pytest tests/coding_agent/test_run_target.py tests/ui/test_execution_binding.py -v` +- [ ] `uv run ruff check src/coding_agent/runs tests/coding_agent/test_run_target.py` + +## References + +- `docs/adr/0054-executor-runtime-terminology-and-resume-first-direction.md` +- `docs/adr/0055-session-resume-and-interrupted-run-semantics.md` +- `docs/adr/0056-local-cli-control-plane-and-workspace-product-boundaries.md` +- `src/coding_agent/environment/execution_binding.py` +- `src/coding_agent/server/session_manager.py` +- `src/coding_agent/cli/main.py` +- `src/coding_agent/cli/repl.py` +- `src/coding_agent/external_executor.py` diff --git a/coding-agent/src/coding_agent/runs/__init__.py b/coding-agent/src/coding_agent/runs/__init__.py new file mode 100644 index 000000000..c13e1de65 --- /dev/null +++ b/coding-agent/src/coding_agent/runs/__init__.py @@ -0,0 +1,37 @@ +from .target import ( + CloudWorkspaceRef, + ExecutionBindingRunTargetError, + ExecutorRef, + ExternalWorkerExecutorRef, + ExternalWorkerWorkspaceRef, + InlineExecutorRef, + IsolationPolicy, + LocalAttachedExecutorRef, + LocalDaemonExecutorRef, + LocalPathWorkspaceRef, + ManagedPoolExecutorRef, + RunConstraints, + RunTarget, + SnapshotWorkspaceRef, + WorkspaceRef, + run_target_from_execution_binding, +) + +__all__ = [ + "CloudWorkspaceRef", + "ExecutionBindingRunTargetError", + "ExecutorRef", + "ExternalWorkerExecutorRef", + "ExternalWorkerWorkspaceRef", + "InlineExecutorRef", + "IsolationPolicy", + "LocalAttachedExecutorRef", + "LocalDaemonExecutorRef", + "LocalPathWorkspaceRef", + "ManagedPoolExecutorRef", + "RunConstraints", + "RunTarget", + "SnapshotWorkspaceRef", + "WorkspaceRef", + "run_target_from_execution_binding", +] diff --git a/coding-agent/src/coding_agent/runs/target.py b/coding-agent/src/coding_agent/runs/target.py new file mode 100644 index 000000000..390a033a6 --- /dev/null +++ b/coding-agent/src/coding_agent/runs/target.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import ClassVar, Literal, TypeAlias + +from coding_agent.environment.execution_binding import ( + CloudWorkspaceBinding, + ExecutionBinding, + ExternalWorkerBinding, + LocalAttachedExecutionBinding, + LocalExecutionBinding, +) + + +def _require_non_empty(value: str, *, field_name: str) -> None: + if not value.strip(): + raise ValueError(f"{field_name} must be non-empty") + + +def _empty_metadata() -> dict[str, object]: + return {} + + +def _empty_annotations() -> dict[str, str]: + return {} + + +def _copy_metadata(metadata: Mapping[str, object]) -> dict[str, object]: + copied: dict[str, object] = {} + for key, value in metadata.items(): + if not key.strip(): + raise ValueError("metadata keys must be non-empty") + copied[key] = value + return copied + + +def _copy_annotations(annotations: Mapping[str, str]) -> dict[str, str]: + copied: dict[str, str] = {} + for key, value in annotations.items(): + if not key.strip(): + raise ValueError("annotation keys must be non-empty") + copied[key] = value + return copied + + +@dataclass(frozen=True) +class LocalPathWorkspaceRef: + path: str + workspace_provider: str | None = None + provider_instance_id: str | None = None + kind: ClassVar[Literal["local_path"]] = "local_path" + + def __post_init__(self) -> None: + _require_non_empty(self.path, field_name="workspace path") + + +@dataclass(frozen=True) +class CloudWorkspaceRef: + workspace_url: str + workspace_id: str + runtime_profile: str | None = None + workspace_provider: str | None = None + provider_instance_id: str | None = None + kind: ClassVar[Literal["cloud_workspace"]] = "cloud_workspace" + + def __post_init__(self) -> None: + _require_non_empty(self.workspace_url, field_name="workspace_url") + _require_non_empty(self.workspace_id, field_name="workspace_id") + if self.runtime_profile is not None: + _require_non_empty(self.runtime_profile, field_name="runtime_profile") + + +@dataclass(frozen=True) +class ExternalWorkerWorkspaceRef: + ref: Mapping[str, object] = field(default_factory=_empty_metadata) + provider_instance_id: str | None = None + kind: ClassVar[Literal["external_worker_ref"]] = "external_worker_ref" + + def __post_init__(self) -> None: + object.__setattr__(self, "ref", _copy_metadata(self.ref)) + + +@dataclass(frozen=True) +class SnapshotWorkspaceRef: + snapshot_id: str + kind: ClassVar[Literal["snapshot"]] = "snapshot" + + def __post_init__(self) -> None: + _require_non_empty(self.snapshot_id, field_name="snapshot_id") + + +WorkspaceRef: TypeAlias = ( + LocalPathWorkspaceRef + | CloudWorkspaceRef + | ExternalWorkerWorkspaceRef + | SnapshotWorkspaceRef +) + + +@dataclass(frozen=True) +class LocalDaemonExecutorRef: + kind: ClassVar[Literal["local_daemon"]] = "local_daemon" + + +@dataclass(frozen=True) +class ManagedPoolExecutorRef: + pool: str = "default" + kind: ClassVar[Literal["managed_pool"]] = "managed_pool" + + def __post_init__(self) -> None: + _require_non_empty(self.pool, field_name="managed executor pool") + + +@dataclass(frozen=True) +class ExternalWorkerExecutorRef: + executor_kind: str + worker_pool: str = "default" + kind: ClassVar[Literal["external_worker"]] = "external_worker" + + def __post_init__(self) -> None: + _require_non_empty(self.executor_kind, field_name="executor_kind") + _require_non_empty(self.worker_pool, field_name="worker_pool") + + +@dataclass(frozen=True) +class LocalAttachedExecutorRef: + executor_kind: str + worker_pool: str = "default" + kind: ClassVar[Literal["local_attached"]] = "local_attached" + + def __post_init__(self) -> None: + _require_non_empty(self.executor_kind, field_name="executor_kind") + _require_non_empty(self.worker_pool, field_name="worker_pool") + + +@dataclass(frozen=True) +class InlineExecutorRef: + kind: ClassVar[Literal["inline_testkit"]] = "inline_testkit" + + +ExecutorRef: TypeAlias = ( + LocalDaemonExecutorRef + | ManagedPoolExecutorRef + | ExternalWorkerExecutorRef + | LocalAttachedExecutorRef + | InlineExecutorRef +) + + +@dataclass(frozen=True) +class IsolationPolicy: + kind: Literal[ + "default_local_sandbox", + "provider_sandbox", + "external_worker_policy", + "dev_unsafe_disabled", + ] + network: Literal["restricted", "provider_managed", "unrestricted"] = "restricted" + filesystem: Literal["workspace_scoped", "provider_managed", "unrestricted"] = ( + "workspace_scoped" + ) + secrets: Literal["explicit_allowlist", "provider_managed", "unrestricted"] = ( + "explicit_allowlist" + ) + + +@dataclass(frozen=True) +class RunConstraints: + max_steps: int | None = None + timeout_seconds: int | None = None + max_cost_usd: float | None = None + + def __post_init__(self) -> None: + if self.max_steps is not None and self.max_steps <= 0: + raise ValueError("max_steps must be positive") + if self.timeout_seconds is not None and self.timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + if self.max_cost_usd is not None and self.max_cost_usd < 0: + raise ValueError("max_cost_usd must be non-negative") + + +@dataclass(frozen=True) +class RunTarget: + workspace: WorkspaceRef + executor: ExecutorRef + isolation: IsolationPolicy + constraints: RunConstraints = field(default_factory=RunConstraints) + annotations: Mapping[str, str] = field(default_factory=_empty_annotations) + + def __post_init__(self) -> None: + object.__setattr__(self, "annotations", _copy_annotations(self.annotations)) + + +class ExecutionBindingRunTargetError(ValueError): + """Raised when a compatibility execution binding cannot map to RunTarget.""" + + +def run_target_from_execution_binding(binding: ExecutionBinding) -> RunTarget: + if isinstance(binding, LocalExecutionBinding): + return RunTarget( + workspace=LocalPathWorkspaceRef( + path=binding.workspace_root, + workspace_provider=binding.workspace_provider, + provider_instance_id=binding.provider_instance_id, + ), + executor=LocalDaemonExecutorRef(), + isolation=IsolationPolicy(kind="default_local_sandbox"), + ) + + if isinstance(binding, CloudWorkspaceBinding): + return RunTarget( + workspace=CloudWorkspaceRef( + workspace_url=binding.workspace_url, + workspace_id=binding.workspace_id, + runtime_profile=binding.runtime_profile, + workspace_provider=binding.workspace_provider, + provider_instance_id=binding.provider_instance_id, + ), + executor=ManagedPoolExecutorRef(), + isolation=IsolationPolicy( + kind="provider_sandbox", + network="provider_managed", + filesystem="provider_managed", + secrets="provider_managed", + ), + ) + + if isinstance(binding, LocalAttachedExecutionBinding): + return RunTarget( + workspace=ExternalWorkerWorkspaceRef( + ref={} if binding.workspace_ref is None else binding.workspace_ref, + provider_instance_id=binding.provider_instance_id, + ), + executor=LocalAttachedExecutorRef( + executor_kind=binding.executor_kind, + worker_pool=binding.worker_pool, + ), + isolation=IsolationPolicy(kind="external_worker_policy"), + ) + + if isinstance(binding, ExternalWorkerBinding): + return RunTarget( + workspace=ExternalWorkerWorkspaceRef( + ref={} if binding.workspace_ref is None else binding.workspace_ref, + provider_instance_id=binding.provider_instance_id, + ), + executor=ExternalWorkerExecutorRef( + executor_kind=binding.executor_kind, + worker_pool=binding.worker_pool, + ), + isolation=IsolationPolicy(kind="external_worker_policy"), + ) + + raise ExecutionBindingRunTargetError( + f"unsupported execution binding type: {type(binding).__name__}" + ) + + +__all__ = [ + "CloudWorkspaceRef", + "ExecutionBindingRunTargetError", + "ExecutorRef", + "ExternalWorkerExecutorRef", + "ExternalWorkerWorkspaceRef", + "InlineExecutorRef", + "IsolationPolicy", + "LocalAttachedExecutorRef", + "LocalDaemonExecutorRef", + "LocalPathWorkspaceRef", + "ManagedPoolExecutorRef", + "RunConstraints", + "RunTarget", + "SnapshotWorkspaceRef", + "WorkspaceRef", + "run_target_from_execution_binding", +] diff --git a/coding-agent/tests/coding_agent/test_run_target.py b/coding-agent/tests/coding_agent/test_run_target.py new file mode 100644 index 000000000..7ccdda7cc --- /dev/null +++ b/coding-agent/tests/coding_agent/test_run_target.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import pytest + +from coding_agent.environment.execution_binding import ( + CloudWorkspaceBinding, + ExternalWorkerBinding, + LocalAttachedExecutionBinding, + LocalExecutionBinding, +) +from coding_agent.runs import ( + CloudWorkspaceRef, + ExternalWorkerExecutorRef, + ExternalWorkerWorkspaceRef, + LocalAttachedExecutorRef, + LocalDaemonExecutorRef, + LocalPathWorkspaceRef, + ManagedPoolExecutorRef, + RunConstraints, + RunTarget, + IsolationPolicy, + run_target_from_execution_binding, +) + + +def test_local_execution_binding_maps_to_local_daemon_run_target() -> None: + binding = LocalExecutionBinding( + workspace_root="/repo", + workspace_provider="local", + provider_instance_id="local-1", + ) + + target = run_target_from_execution_binding(binding) + + assert isinstance(target.workspace, LocalPathWorkspaceRef) + assert target.workspace.path == "/repo" + assert target.workspace.workspace_provider == "local" + assert target.workspace.provider_instance_id == "local-1" + assert isinstance(target.executor, LocalDaemonExecutorRef) + assert target.executor.kind == "local_daemon" + assert target.isolation.kind == "default_local_sandbox" + assert target.isolation.filesystem == "workspace_scoped" + + +def test_cloud_execution_binding_maps_to_managed_pool_run_target() -> None: + binding = CloudWorkspaceBinding( + workspace_url="docker://workspace/ws-1", + workspace_id="ws-1", + runtime_profile="python", + workspace_provider="docker", + provider_instance_id="docker-1", + ) + + target = run_target_from_execution_binding(binding) + + assert isinstance(target.workspace, CloudWorkspaceRef) + assert target.workspace.workspace_url == "docker://workspace/ws-1" + assert target.workspace.workspace_id == "ws-1" + assert target.workspace.runtime_profile == "python" + assert target.workspace.workspace_provider == "docker" + assert target.workspace.provider_instance_id == "docker-1" + assert isinstance(target.executor, ManagedPoolExecutorRef) + assert target.executor.pool == "default" + assert target.isolation.kind == "provider_sandbox" + assert target.isolation.network == "provider_managed" + + +def test_external_worker_binding_maps_to_external_worker_run_target() -> None: + binding = ExternalWorkerBinding( + executor_kind="local_cli", + worker_pool="pool-a", + workspace_ref={"path": "/repo", "label": "checkout"}, + provider_instance_id="worker-1", + ) + + target = run_target_from_execution_binding(binding) + + assert isinstance(target.workspace, ExternalWorkerWorkspaceRef) + assert target.workspace.ref == {"path": "/repo", "label": "checkout"} + assert target.workspace.provider_instance_id == "worker-1" + assert isinstance(target.executor, ExternalWorkerExecutorRef) + assert target.executor.executor_kind == "local_cli" + assert target.executor.worker_pool == "pool-a" + assert target.isolation.kind == "external_worker_policy" + + +def test_local_attached_binding_maps_to_local_attached_run_target() -> None: + binding = LocalAttachedExecutionBinding( + executor_kind="local_cli", + worker_pool="attached", + workspace_ref={"socket": "local-daemon.sock"}, + ) + + target = run_target_from_execution_binding(binding) + + assert isinstance(target.workspace, ExternalWorkerWorkspaceRef) + assert target.workspace.ref == {"socket": "local-daemon.sock"} + assert isinstance(target.executor, LocalAttachedExecutorRef) + assert target.executor.executor_kind == "local_cli" + assert target.executor.worker_pool == "attached" + assert target.isolation.kind == "external_worker_policy" + + +def test_run_target_rejects_empty_annotations_key() -> None: + with pytest.raises(ValueError, match="annotation keys must be non-empty"): + _ = RunTarget( + workspace=LocalPathWorkspaceRef(path="/repo"), + executor=LocalDaemonExecutorRef(), + isolation=IsolationPolicy(kind="default_local_sandbox"), + annotations={" ": "invalid"}, + ) + + +def test_run_constraints_reject_invalid_limits() -> None: + with pytest.raises(ValueError, match="max_steps must be positive"): + _ = RunConstraints(max_steps=0) + + with pytest.raises(ValueError, match="timeout_seconds must be positive"): + _ = RunConstraints(timeout_seconds=-1) + + with pytest.raises(ValueError, match="max_cost_usd must be non-negative"): + _ = RunConstraints(max_cost_usd=-0.01) + + +def test_external_worker_workspace_ref_copies_input_metadata() -> None: + source = {"path": "/repo"} + + ref = ExternalWorkerWorkspaceRef(ref=source) + source["path"] = "/changed" + + assert ref.ref == {"path": "/repo"}