Skip to content

Commit 824a8b0

Browse files
tildesrcclaude
andcommitted
feat(skeleton): stub runner + container entrypoint + end-to-end test; CLAUDE.md
Slice 1, PR 4 of 4 — the walking skeleton proving the four contracts end to end. - panopticon.container.client.TaskServiceClient: a thin REST client used from inside a task container (wraps httpx; a FastAPI TestClient in tests). - panopticon.container.entrypoint.run_task_container: the entrypoint protocol — register (liveness), set the slug if unset (the slug hook; slugs are decided in the container), run work, deregister. No Docker, no LLM in this slice. - panopticon.sessionservice.stub_runner.StubRunner: stands in for the runner by running the entrypoint in-process. - tests/test_skeleton.py: create -> register -> set slug -> transition -> history, end to end over REST, with liveness cleanup asserted. - CLAUDE.md: the operating manual (determinism invariant, module map, conventions, glossary), per the roadmap's per-slice definition of done. Moves httpx to runtime deps (the container client uses it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de83f4e commit 824a8b0

10 files changed

Lines changed: 366 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# CLAUDE.md — operating manual
2+
3+
Guidance for agents working in this repo. The full design lives on the **`design-docs`**
4+
branch (GOALS, PARITY, ARCHITECTURE, ROADMAP, ADRs 0001–0008). This file grows one slice
5+
at a time (see ROADMAP "Definition of done — every slice").
6+
7+
## The one rule that matters most: the determinism invariant
8+
9+
The control plane makes **no LLM calls**. All LLM calls happen **inside task containers**.
10+
11+
- LLM-free packages: `core`, `taskservice`, `sessionservice`, `terminal`, `workflows`.
12+
- The **only** LLM-bearing package is `container/` (the agent runs there).
13+
14+
If you add a package that orchestrates or renders, keep it LLM-free.
15+
16+
## Module map (current)
17+
18+
```
19+
src/panopticon/
20+
core/ # domain models, state classes, the Workflow interface (the state
21+
# machine: resolution, queries, start_task/apply_transition),
22+
# store & artifact interfaces — pure, no I/O
23+
workflows/ # built-in Workflow subclasses (Spike seed for now)
24+
taskservice/ # control plane: TaskService, FastAPI REST API, the SQLAlchemy store
25+
# adapter (in-memory or on-disk SQLite), filesystem artifact store, MCP
26+
sessionservice/ # the runner (stub for now; real Docker+tmux runner later)
27+
container/ # in-container client + entrypoint protocol — the ONLY LLM-bearing pkg
28+
```
29+
30+
## Conventions
31+
32+
- **The state machine is deterministic and clock-free.** Timestamps are passed in by the
33+
caller (the task service stamps them); the workflow never reads the clock. Keep it that way.
34+
- **Identity vs. slug.** A task's identity is its internal `id` (generated by the task
35+
service). The `slug` is a human label, nullable, **set in the container** via a hook
36+
(ARCHITECTURE.md §8.3) — not chosen host-side.
37+
- **All task-state mutations go through the task service**, which enforces transitions via
38+
the workflow before persisting (the store is the single writer; ADR 0006).
39+
- **Interfaces vs. adapters.** Interfaces (ABCs) live in `core`; adapters live in the owning
40+
package. New backends implement an interface; they don't change callers.
41+
42+
## Dev commands
43+
44+
```sh
45+
uv sync # create the venv, install deps
46+
uv run pytest # run the test suite
47+
uv run mypy -p panopticon # type-check (strict)
48+
```
49+
50+
CI (`.github/workflows/ci.yml`) runs `uv sync`, `mypy`, and `pytest` on every PR.
51+
52+
## Tests worth knowing
53+
54+
- `tests/test_workflow.py` — the **golden harness**: every legal/illegal transition, turn
55+
derivation, responsibility gating, and workflow validation. Extend it when you touch the
56+
state machine.
57+
- `tests/test_store.py` — store **contract tests run against in-memory and on-disk SQLite**,
58+
proving the interface is backend-agnostic (and that rows/domain models stay in sync).
59+
- `tests/test_skeleton.py` — the end-to-end walking skeleton (create → register → slug →
60+
transition → history) over the REST API, no Docker.
61+
62+
## Glossary
63+
64+
- **Task** — a unit of work; identity is `id`, label is `slug`.
65+
- **Repo** — a repository tasks operate on (owns secret references, later slices).
66+
- **Workflow** — a `Workflow` subclass whose **states are nested `State` classes**
67+
(declarative). It declares `initial`; states are discovered and their transitions
68+
(class refs or label strings) resolved + validated when the workflow is instantiated.
69+
The lifecycle is code, not hardcoded control flow.
70+
- **State** — a class (`State` non-terminal, inherits a `Dropped` transition; or
71+
`TerminalState`). Carries a `label` (persisted in `Task.state`, shown on the dashboard),
72+
`turn_on_enter`, `advanced_by`, `responsibilities`, and `transitions`. Built-ins:
73+
`Complete`, `Dropped`.
74+
- **Actor** — a party, `user` or `agent`. A state declares `turn_on_enter` (who holds the
75+
turn on entry; seeds `Task.turn`) and `advanced_by` (who transitions out — the default is
76+
`USER`). The two are orthogonal.
77+
- **Responsibility / Status** — an agent obligation for a state. Entering a state seeds its
78+
responsibilities onto that entry's history record, all `PENDING` (a promise); the agent
79+
fulfils each one at a time (`MET`, or `FAILED` with a comment) — mutating that entry — and a
80+
later advance is gated on all being resolved. Agent-only.
81+
- **Registration / liveness** — a container's standing claim that it is working on a task.
82+
- **Task service** — the deterministic control plane (sole DB authority).
83+
- **Session service / runner** — spawns task containers (stubbed for now).
84+
- **Terminal controller** — the user-facing CLI/dashboard (Slice 3).
85+
- **Artifact** — a file-backed per-task document (plan, notes), reachable via REST/FS/MCP.

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,20 @@ See the `design-docs` branch for the full picture.
1616

1717
## Status
1818

19-
Early development. Building Milestone 1 in vertical slices (see the roadmap). This slice
20-
lands the core contracts:
21-
22-
- `panopticon.core` — domain models, the workflow port (`Workflow` ABC), and the
23-
deterministic lifecycle engine (state machine, turn tracking, responsibility gating).
24-
- `panopticon.workflows.FreeFormWorkflow` — the minimal seed workflow.
19+
Early development. Building Milestone 1 in vertical slices (see the roadmap). **Slice 1**
20+
lands the four contracts plus a walking skeleton:
21+
22+
- `panopticon.core` — domain models, state classes, the `Workflow` interface (the
23+
deterministic state machine: resolution, turn tracking, responsibility gating), and the
24+
store & artifact interfaces.
25+
- `panopticon.taskservice` — the control plane: `TaskService`, a FastAPI REST API, the
26+
SQLAlchemy store adapter (in-memory or on-disk SQLite), the filesystem artifact store, and
27+
the MCP surface contract.
28+
- `panopticon.sessionservice` / `panopticon.container` — a stub runner and the container
29+
entrypoint protocol that drive the end-to-end walking skeleton (no Docker, no LLM yet).
30+
- `panopticon.workflows.Spike` — the minimal seed workflow.
31+
32+
See [`CLAUDE.md`](CLAUDE.md) for the operating manual and the determinism invariant.
2533

2634
## Development
2735

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ dependencies = [
77
"fastapi>=0.110",
88
"sqlalchemy>=2.0.50",
99
"uvicorn>=0.29",
10+
"httpx>=0.27",
1011
]
1112

1213
[dependency-groups]
1314
dev = [
1415
"pytest>=8",
1516
"mypy>=1.11",
16-
"httpx>=0.27",
1717
]
1818

1919
[build-system]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""In-container code: the task-service client and the entrypoint protocol.
2+
3+
This is the *only* package permitted to call an LLM (the agent runs here) — the
4+
determinism invariant exempts it. In this slice there is no LLM yet — the entrypoint is a
5+
faithful stub of the connect/register/slug protocol.
6+
"""

src/panopticon/container/client.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""A thin REST client for the task service, used from inside a task container.
2+
3+
Wraps an :class:`httpx.Client` (real, pointed at the runner-injected service URL; or a
4+
FastAPI ``TestClient`` in tests). Skills and the entrypoint use this; agents also have the
5+
MCP surface (later slice).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Any, cast
11+
12+
import httpx
13+
14+
from panopticon.core.models import Status
15+
16+
JsonObj = dict[str, Any]
17+
18+
19+
class TaskServiceClient:
20+
def __init__(self, http: httpx.Client) -> None:
21+
self._http = http
22+
23+
@staticmethod
24+
def _json(resp: httpx.Response) -> JsonObj:
25+
resp.raise_for_status()
26+
return cast(JsonObj, resp.json())
27+
28+
# -- repos / tasks ------------------------------------------------------------
29+
30+
def create_repo(self, repo_id: str, name: str, default_base: str = "main") -> JsonObj:
31+
return self._json(
32+
self._http.post(
33+
"/repos", json={"id": repo_id, "name": name, "default_base": default_base}
34+
)
35+
)
36+
37+
def create_task(self, repo_id: str, workflow: str) -> JsonObj:
38+
return self._json(
39+
self._http.post("/tasks", json={"repo_id": repo_id, "workflow": workflow})
40+
)
41+
42+
def get_task(self, task_id: str) -> JsonObj:
43+
return self._json(self._http.get(f"/tasks/{task_id}"))
44+
45+
def set_slug(self, task_id: str, slug: str) -> JsonObj:
46+
return self._json(self._http.put(f"/tasks/{task_id}/slug", json={"slug": slug}))
47+
48+
def request_transition(
49+
self,
50+
task_id: str,
51+
to_state: str,
52+
*,
53+
trigger: str | None = None,
54+
note: str | None = None,
55+
) -> JsonObj:
56+
body: JsonObj = {"to_state": to_state, "trigger": trigger, "note": note}
57+
return self._json(self._http.post(f"/tasks/{task_id}/transition", json=body))
58+
59+
def resolve_responsibility(
60+
self, task_id: str, key: str, status: Status, comment: str | None = None
61+
) -> JsonObj:
62+
"""Resolve one of the current state's promised responsibilities (MET or FAILED)."""
63+
body: JsonObj = {"key": key, "status": status.value, "comment": comment}
64+
return self._json(self._http.post(f"/tasks/{task_id}/responsibilities", json=body))
65+
66+
# -- artifacts ----------------------------------------------------------------
67+
68+
def put_artifact(self, task_id: str, name: str, content: bytes) -> None:
69+
self._http.put(f"/tasks/{task_id}/artifacts/{name}", content=content).raise_for_status()
70+
71+
def get_artifact(self, task_id: str, name: str) -> bytes:
72+
resp = self._http.get(f"/tasks/{task_id}/artifacts/{name}")
73+
resp.raise_for_status()
74+
return resp.content
75+
76+
# -- liveness -----------------------------------------------------------------
77+
78+
def register(self, task_id: str, container_id: str, runner_id: str | None = None) -> JsonObj:
79+
return self._json(
80+
self._http.post(
81+
f"/tasks/{task_id}/registrations",
82+
json={"container_id": container_id, "runner_id": runner_id},
83+
)
84+
)
85+
86+
def heartbeat(self, registration_id: str) -> JsonObj:
87+
return self._json(self._http.post(f"/registrations/{registration_id}/heartbeat"))
88+
89+
def deregister(self, registration_id: str) -> None:
90+
self._http.delete(f"/registrations/{registration_id}").raise_for_status()
91+
92+
def list_registrations(self, task_id: str) -> list[JsonObj]:
93+
resp = self._http.get(f"/tasks/{task_id}/registrations")
94+
resp.raise_for_status()
95+
return cast("list[JsonObj]", resp.json())
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""The container entrypoint protocol (skeleton form).
2+
3+
A real task container will run an agent (the only place LLMs run). Here we implement the
4+
deterministic *protocol* the entrypoint owns, so it can be exercised without Docker:
5+
6+
1. connect to the task service and **register** (liveness) — and stay registered until done;
7+
2. if the task has no **slug**, set one (the slug hook — slugs are decided in the container,
8+
unlike cloude-cade, per ARCHITECTURE.md §8.3);
9+
3. run the task's work (here, an injected callback stands in for the agent);
10+
4. deregister on exit.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from collections.abc import Callable
16+
17+
from panopticon.container.client import TaskServiceClient
18+
19+
Work = Callable[[TaskServiceClient, str], None]
20+
21+
22+
def run_task_container(
23+
client: TaskServiceClient,
24+
task_id: str,
25+
*,
26+
container_id: str,
27+
runner_id: str | None = None,
28+
proposed_slug: str | None = None,
29+
work: Work | None = None,
30+
) -> None:
31+
"""Run the entrypoint protocol for ``task_id`` against the task service."""
32+
registration = client.register(task_id, container_id=container_id, runner_id=runner_id)
33+
try:
34+
task = client.get_task(task_id)
35+
if task["slug"] is None and proposed_slug is not None:
36+
client.set_slug(task_id, proposed_slug) # the slug hook
37+
client.heartbeat(registration["id"])
38+
if work is not None:
39+
work(client, task_id)
40+
finally:
41+
client.deregister(registration["id"])
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""The session service (runner): spawns task containers and owns their tmux sessions.
2+
3+
Realizes the execution-backend boundary (ADR 0006/0008). The real runner is a host process
4+
that spawns containers on the host Docker daemon; this package currently ships only a stub
5+
runner for the walking skeleton (no Docker). Must remain LLM-free (the determinism invariant).
6+
"""
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""A stub runner for the walking skeleton.
2+
3+
Stands in for the session service: instead of spawning a container on the host Docker
4+
daemon and a tmux session (ADR 0008), it runs the container entrypoint **in-process**, so
5+
the end-to-end path works without Docker. Real adapters replace this behind the same idea.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import itertools
11+
12+
from panopticon.container.client import TaskServiceClient
13+
from panopticon.container.entrypoint import Work, run_task_container
14+
15+
16+
class StubRunner:
17+
def __init__(self, client: TaskServiceClient, *, runner_id: str = "stub-runner") -> None:
18+
self._client = client
19+
self._runner_id = runner_id
20+
self._counter = itertools.count(1)
21+
22+
def spawn(
23+
self, task_id: str, *, proposed_slug: str | None = None, work: Work | None = None
24+
) -> str:
25+
""""Spawn" a fake container for ``task_id`` and return its container id."""
26+
container_id = f"{self._runner_id}-c{next(self._counter)}"
27+
run_task_container(
28+
self._client,
29+
task_id,
30+
container_id=container_id,
31+
runner_id=self._runner_id,
32+
proposed_slug=proposed_slug,
33+
work=work,
34+
)
35+
return container_id

tests/test_skeleton.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Slice 1 acceptance: the walking skeleton, end to end over REST.
2+
3+
Proves the contract path: create a task -> the task service persists it -> a (fake)
4+
container registers (liveness) -> sets a slug -> requests a transition the workflow accepts
5+
-> history reflects it -> liveness is cleaned up. No Docker, no LLM.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from collections.abc import Iterator
11+
from pathlib import Path
12+
13+
import pytest
14+
from fastapi.testclient import TestClient
15+
16+
from panopticon.container.client import TaskServiceClient
17+
from panopticon.core.models import Repo
18+
from panopticon.sessionservice.stub_runner import StubRunner
19+
from panopticon.taskservice.api import create_app
20+
from panopticon.taskservice.artifacts_fs import FilesystemArtifactStore
21+
from panopticon.taskservice.store_sqlalchemy import SqlAlchemyStore
22+
from panopticon.taskservice.service import TaskService
23+
from panopticon.workflows import Spike
24+
25+
26+
@pytest.fixture
27+
def client(tmp_path: Path) -> Iterator[TaskServiceClient]:
28+
service = TaskService(
29+
SqlAlchemyStore(),
30+
{"spike": Spike()},
31+
FilesystemArtifactStore(tmp_path),
32+
)
33+
service.create_repo(Repo(id="r1", name="acme/widgets", git_url="https://x/r1.git"))
34+
with TestClient(create_app(service)) as http:
35+
yield TaskServiceClient(http)
36+
37+
38+
def test_walking_skeleton(client: TaskServiceClient) -> None:
39+
# 1. A task is created and persisted in the workflow's initial state.
40+
task = client.create_task("r1", "spike")
41+
task_id = task["id"]
42+
assert task["state"] == "ITERATING"
43+
assert task["slug"] is None
44+
45+
# 2-4. The runner "spawns" a container that registers, sets the slug, and works.
46+
runner = StubRunner(client)
47+
48+
def work(c: TaskServiceClient, tid: str) -> None:
49+
c.put_artifact(tid, "plan.md", b"# Plan\nfix the widget\n")
50+
c.request_transition(tid, "COMPLETE", trigger="finish")
51+
52+
runner.spawn(task_id, proposed_slug="fix-widget", work=work)
53+
54+
# 5. The persisted record reflects everything the container did.
55+
final = client.get_task(task_id)
56+
assert final["slug"] == "fix-widget" # slug was set in the container
57+
assert final["state"] == "COMPLETE"
58+
assert [h["to_state"] for h in final["history"]] == ["ITERATING", "COMPLETE"]
59+
assert client.get_artifact(task_id, "plan.md") == b"# Plan\nfix the widget\n"
60+
61+
# Liveness registration was cleaned up on container exit.
62+
assert client.list_registrations(task_id) == []
63+
64+
65+
def test_slug_hook_does_not_overwrite_existing_slug(client: TaskServiceClient) -> None:
66+
task_id = client.create_task("r1", "spike")["id"]
67+
client.set_slug(task_id, "chosen-by-user")
68+
StubRunner(client).spawn(task_id, proposed_slug="would-be-overwrite")
69+
assert client.get_task(task_id)["slug"] == "chosen-by-user"
70+
71+
72+
def test_registration_active_during_work(client: TaskServiceClient) -> None:
73+
task_id = client.create_task("r1", "spike")["id"]
74+
seen: list[int] = []
75+
76+
def work(c: TaskServiceClient, tid: str) -> None:
77+
seen.append(len(c.list_registrations(tid))) # registered while working
78+
79+
StubRunner(client).spawn(task_id, work=work)
80+
assert seen == [1]
81+
assert client.list_registrations(task_id) == [] # deregistered after

0 commit comments

Comments
 (0)