Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Advanced External Worker Control Plane Foundations Task Packet

## Goal

Implement the first advanced external-worker control-plane slice for worker
process metadata, durable approval interaction management, and workspace-sync
extension metadata.

## Scope

- Persist optional worker instance/process/capability/workspace-sync metadata
from external-worker claim and heartbeat requests.
- Expose that metadata through worker status APIs and CLI output.
- Add durable runtime interaction list/inspect/resolve HTTP APIs.
- Add remote client and CLI commands for interaction list/inspect/resolve.
- Reuse the existing session approval path for interaction resolution.

## Out of Scope

- Restoring an already-running local process after reconnect.
- Cross-machine workspace file synchronization.
- A dedicated worker registry table.
- Worker pool management UI.

## ADRs

- `docs/adr/0051-external-worker-execution-control-plane.md`
- `docs/adr/0052-external-worker-usable-control-plane.md`
- `docs/adr/0053-advanced-external-worker-control-plane-foundations.md`

## Target Tests

```bash
uv run pytest tests/ui/test_http_server.py -k "external_worker or interactions" -v
uv run pytest tests/cli/test_remote_client.py -k "remote_interaction or remote_worker or external_worker" -v
uv run pytest tests/coding_agent/test_pg_runtime_store.py -v
uv run ruff check docs/adr/0053-advanced-external-worker-control-plane-foundations.md src/coding_agent/server/http_server.py src/coding_agent/server/session_manager.py src/coding_agent/server/schemas.py src/coding_agent/remote/client.py src/coding_agent/remote/worker.py src/coding_agent/cli/remote_commands.py tests/ui/test_http_server.py tests/cli/test_remote_client.py tests/coding_agent/test_pg_runtime_store.py
```

## Stop Conditions

- Stop if process restoration requires supervising or attaching to an existing
local process.
- Stop if workspace sync requires a new file transport protocol.
- Stop if approval resolution cannot reuse the existing session approval flow.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# ADR-0053: Advanced external worker control-plane foundations

**Status**: Accepted
**Date**: 2026-05-31

## Context

ADR-0051 and ADR-0052 made `external_worker` sessions usable: the server can
record run requests, local workers can claim and execute them, and users can
inspect sessions, runs, events, and derived worker health. The next step is to
leave concrete extension points for reconnect, durable approval interaction
management, and future workspace sync without prematurely implementing process
checkpointing or bidirectional workspace replication.

The control plane already persists runtime runs and approval interactions. A
first advanced slice should reuse those records so API and CLI users can see and
resolve pending approvals consistently, while workers report enough metadata for
operators to distinguish a logical `worker_id` from a particular local process.

## Decision

Extend external-worker claim and heartbeat requests with optional
`worker_instance_id`, `process_id`, `capabilities`, and `workspace_sync`
metadata. The server stores these fields in run metadata and exposes them in
worker status responses. This is metadata-only reconnect support: it allows
operators to see which process owns a lease and what the worker advertises, but
it does not restore an already-running local process after a disconnect.

Expose durable runtime interaction APIs for active approvals:
`GET /runs/{run_id}/interactions`, `GET /interactions`, `GET
/interactions/{interaction_id}`, and `POST /interactions/{interaction_id}/resolve`.
Resolution delegates to the existing session approval path so the runtime
message bus, in-memory approval coordinator, and persisted interaction record
remain one state machine. If the matching approval is no longer pending, the API
returns a conflict instead of directly mutating the database.

Add remote client and CLI commands for listing, inspecting, and resolving
interactions. Workspace sync remains a declared metadata envelope (`mode`,
workspace reference, and capability data) until a later ADR defines actual sync
protocol semantics.

## Alternatives Rejected

- Add a separate approval queue table - rejected because `agent_interactions`
already persists approval request/response state and splitting it would create
reconciliation problems.
- Resolve approvals by updating `agent_interactions` directly - rejected because
it would not wake the waiting worker approval request or publish a runtime
approval decision.
- Implement process restoration now - rejected because reconnecting to an
already-running local process needs a local process registry, durable worker
supervisor semantics, and recovery rules beyond this slice.
- Implement cross-machine workspace sync now - rejected because live sync needs a
separate conflict model and transport protocol.

## Acceptance Criteria

- [ ] `test_external_worker_worker_metadata_surfaces_in_status`
- [ ] `test_runtime_run_interactions_endpoint_lists_interactions`
- [ ] `test_runtime_interaction_resolve_uses_session_approval_flow`
- [ ] `test_remote_interactions_list_and_resolve`
- [ ] `uv run pytest tests/ui/test_http_server.py -k "external_worker or interactions" -v`
- [ ] `uv run pytest tests/cli/test_remote_client.py -k "remote_interaction or remote_worker or external_worker" -v`

## References

- `docs/adr/0051-external-worker-execution-control-plane.md`
- `docs/adr/0052-external-worker-usable-control-plane.md`
- `src/coding_agent/server/http_server.py`
- `src/coding_agent/server/session_manager.py`
- `src/coding_agent/runtime_store.py`
- `src/coding_agent/remote/client.py`
- `src/coding_agent/cli/remote_commands.py`
95 changes: 95 additions & 0 deletions coding-agent/src/coding_agent/cli/remote_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,101 @@ def remote_events(name: str, run_id: str) -> None:
)


@remote.command("interactions")
@click.argument("name")
@click.option("--session", "session_id", help="Filter by remote session ID.")
@click.option("--run", "run_id", help="Filter by remote run ID.")
@click.option("--status", "status", help="Filter by interaction status.")
def remote_interactions(
name: str,
session_id: str | None,
run_id: str | None,
status: str | None,
) -> None:
"""List durable remote runtime interactions."""
from coding_agent.remote.client import get_remote, list_remote_interactions

endpoint = get_remote(name)
interactions = list_remote_interactions(
endpoint,
session_id=session_id,
run_id=run_id,
status=status,
)
if not interactions:
click.echo("No remote interactions found.")
return
for interaction in interactions:
metadata = interaction.get("metadata")
request_id = (
metadata.get("request_id")
if isinstance(metadata, dict)
and isinstance(metadata.get("request_id"), str)
else ""
)
click.echo(
"\t".join(
[
str(interaction.get("interaction_id", "")),
str(interaction.get("status", "")),
str(interaction.get("interaction_kind", "")),
request_id,
str(interaction.get("run_id", "")),
str(interaction.get("created_at", "")),
]
)
)


@remote.command("interaction")
@click.argument("name")
@click.argument("interaction_id")
def remote_interaction(name: str, interaction_id: str) -> None:
"""Show one durable remote runtime interaction."""
from coding_agent.remote.client import get_remote, get_remote_interaction

endpoint = get_remote(name)
_print_mapping(get_remote_interaction(endpoint, interaction_id))


@remote.command("resolve-interaction")
@click.argument("name")
@click.argument("interaction_id")
@click.option("--approve", "approve", is_flag=True, help="Approve the interaction.")
@click.option("--reject", "reject", is_flag=True, help="Reject the interaction.")
@click.option("--feedback", "feedback", help="Optional approval feedback.")
@click.option(
"--scope",
"scope",
type=click.Choice(["once", "session", "always"]),
default="once",
show_default=True,
)
def remote_resolve_interaction(
name: str,
interaction_id: str,
approve: bool,
reject: bool,
feedback: str | None,
scope: str,
) -> None:
"""Resolve a pending approval interaction."""
from coding_agent.remote.client import get_remote, resolve_remote_interaction

if approve == reject:
raise click.ClickException("Pass exactly one of --approve or --reject.")
endpoint = get_remote(name)
_print_mapping(
resolve_remote_interaction(
endpoint,
interaction_id,
approved=approve,
feedback=feedback,
scope=scope,
)
)


@remote.command("workers")
@click.argument("name")
def remote_workers(name: str) -> None:
Expand Down
78 changes: 78 additions & 0 deletions coding-agent/src/coding_agent/remote/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from collections.abc import Mapping
from pathlib import Path
from typing import cast
from urllib.parse import quote, urlencode

import click
import httpx
Expand Down Expand Up @@ -220,6 +221,83 @@ def list_remote_run_events(
return [dict(_expect_mapping(item, "Remote run event entry")) for item in events]


def list_remote_run_interactions(
endpoint: RemoteEndpoint,
run_id: str,
*,
status: str | None = None,
) -> list[dict[str, object]]:
path = f"/runs/{run_id}/interactions"
if status is not None:
path = f"{path}?status={quote(status)}"
data = _get_remote_json(endpoint, path, "list remote run interactions")
interactions = data.get("interactions")
if not isinstance(interactions, list):
raise click.ClickException("Remote interactions response missing interactions")
return [
dict(_expect_mapping(item, "Remote interaction entry")) for item in interactions
]


def list_remote_interactions(
endpoint: RemoteEndpoint,
*,
session_id: str | None = None,
run_id: str | None = None,
status: str | None = None,
) -> list[dict[str, object]]:
params: list[tuple[str, str]] = []
if session_id is not None:
params.append(("session_id", session_id))
if run_id is not None:
params.append(("run_id", run_id))
if status is not None:
params.append(("status", status))
path = "/interactions"
if params:
path = f"{path}?{urlencode(params)}"
data = _get_remote_json(endpoint, path, "list remote interactions")
interactions = data.get("interactions")
if not isinstance(interactions, list):
raise click.ClickException("Remote interactions response missing interactions")
return [
dict(_expect_mapping(item, "Remote interaction entry")) for item in interactions
]


def get_remote_interaction(
endpoint: RemoteEndpoint,
interaction_id: str,
) -> dict[str, object]:
return _get_remote_json(
endpoint,
f"/interactions/{quote(interaction_id, safe='')}",
"get remote interaction",
)


def resolve_remote_interaction(
endpoint: RemoteEndpoint,
interaction_id: str,
*,
approved: bool,
feedback: str | None = None,
scope: str = "once",
) -> dict[str, object]:
payload: dict[str, object] = {
"approved": approved,
"scope": scope,
}
if feedback is not None:
payload["feedback"] = feedback
return _post_remote_json(
endpoint,
f"/interactions/{quote(interaction_id, safe='')}/resolve",
json=payload,
action="resolve remote interaction",
)


def list_remote_workers(endpoint: RemoteEndpoint) -> list[dict[str, object]]:
data = _get_remote_json(endpoint, "/workers", "list remote workers")
workers = data.get("workers")
Expand Down
Loading