-
Notifications
You must be signed in to change notification settings - Fork 3
Slice 1 (1/4): core engine, workflow ABC, free-form seed + CI #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,13 @@ | ||
| # Editor / Obsidian vault metadata | ||
| .obsidian/ | ||
|
|
||
| # Python | ||
| __pycache__/ | ||
| *.py[cod] | ||
| .venv/ | ||
| .mypy_cache/ | ||
| .pytest_cache/ | ||
| .ruff_cache/ | ||
| *.egg-info/ | ||
| dist/ | ||
| build/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # panopticon | ||
|
|
||
| Orchestrate multiple coding agents across isolated tasks and **configurable workflows**. | ||
|
|
||
| A ground-up rewrite of the [cloude-cade](https://github.com/tildesrc/cloude-cade) | ||
| prototype. The design lives on the [`design-docs`](../../tree/design-docs) branch | ||
| (goals, parity analysis, architecture, roadmap, and ADRs). | ||
|
|
||
| ## Architecture in one paragraph | ||
|
|
||
| A deterministic control plane (the **task service**) owns task state and drives | ||
| per-workflow state machines; a per-machine **runner** spawns task containers and host | ||
| tmux sessions; a **terminal controller** runs the dashboard. **All LLM calls happen | ||
| inside task containers** — the control plane, runner, and dashboard never call a model. | ||
| See the `design-docs` branch for the full picture. | ||
|
|
||
| ## Status | ||
|
|
||
| Early development. Building Milestone 1 in vertical slices (see the roadmap). This slice | ||
| lands the core contracts: | ||
|
|
||
| - `panopticon.core` — domain models, the workflow port (`Workflow` ABC), and the | ||
| deterministic lifecycle engine (state machine, turn tracking, responsibility gating). | ||
| - `panopticon.workflows.FreeFormWorkflow` — the minimal seed workflow. | ||
|
|
||
| ## Development | ||
|
|
||
| ```sh | ||
| uv sync # create the venv and install dev deps | ||
| uv run pytest # run tests | ||
| uv run mypy -p panopticon # type-check | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| [project] | ||
| name = "panopticon" | ||
| version = "0.0.1" | ||
| description = "Orchestrate multiple coding agents across isolated tasks and configurable workflows." | ||
| requires-python = ">=3.11" | ||
| dependencies = [] | ||
|
|
||
| [dependency-groups] | ||
| dev = [ | ||
| "pytest>=8", | ||
| "mypy>=1.11", | ||
| ] | ||
|
|
||
| [build-system] | ||
| requires = ["hatchling"] | ||
| build-backend = "hatchling.build" | ||
|
|
||
| [tool.hatch.build.targets.wheel] | ||
| packages = ["src/panopticon"] | ||
|
|
||
| [tool.pytest.ini_options] | ||
| testpaths = ["tests"] | ||
| addopts = "-ra" | ||
|
|
||
| [tool.mypy] | ||
| python_version = "3.11" | ||
| strict = true | ||
| mypy_path = "src" | ||
| namespace_packages = true | ||
| explicit_package_bases = true |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| """panopticon — keep an eye on your agents.""" | ||
|
|
||
| __version__ = "0.0.1" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """Core domain: models, state classes, and the workflow interface (the state machine). | ||
|
|
||
| Nothing in this package performs I/O or calls an LLM (the determinism invariant — all LLM | ||
| calls happen inside task containers). | ||
| """ | ||
|
|
||
| from panopticon.core.models import ( | ||
| Actor, | ||
| HistoryEntry, | ||
| Repo, | ||
| Responsibility, | ||
| Status, | ||
| Task, | ||
| ) | ||
| from panopticon.core.state import ( | ||
| BaseState, | ||
| Complete, | ||
| Dropped, | ||
| State, | ||
| TerminalState, | ||
| ) | ||
| from panopticon.core.workflow import ( | ||
| IllegalTransition, | ||
| InvalidWorkflow, | ||
| ResponsibilitiesNotMet, | ||
| Workflow, | ||
| ) | ||
|
|
||
| __all__ = [ | ||
| "Actor", | ||
| "BaseState", | ||
| "Complete", | ||
| "Dropped", | ||
| "HistoryEntry", | ||
| "IllegalTransition", | ||
| "InvalidWorkflow", | ||
| "Repo", | ||
| "ResponsibilitiesNotMet", | ||
| "Responsibility", | ||
| "State", | ||
| "Status", | ||
| "Task", | ||
| "TerminalState", | ||
| "Workflow", | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| """Core domain models — pure data, no I/O, no LLM. | ||
|
|
||
| These types are the vocabulary the whole system shares. Most are plain records; the exception | ||
| is :class:`Task`, which carries behavior over **its own record** — fulfilling the | ||
| responsibilities it promised on entry and reporting which remain outstanding. The *rules of | ||
| the state machine* (which transitions are legal, what each state means) live in | ||
| :class:`panopticon.core.workflow.Workflow`, and the state classes live in | ||
| :mod:`panopticon.core.state`. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field | ||
| from enum import Enum | ||
|
|
||
|
|
||
| class Actor(str, Enum): | ||
| """A party that can act on a task: the user or the agent. | ||
|
|
||
| The same two parties answer every "who?" in the model — who holds the turn | ||
| (``Task.turn``, ``State.turn_on_enter``) and who transitions out of a state | ||
| (``State.advanced_by``). | ||
| """ | ||
|
|
||
| USER = "user" | ||
| AGENT = "agent" | ||
|
|
||
|
|
||
| class Status(str, Enum): | ||
| """Resolution status of a single responsibility.""" | ||
|
|
||
| PENDING = "pending" # not yet resolved — blocks handing the turn back | ||
| MET = "met" | ||
| FAILED = "failed" # could not be satisfied; requires a comment | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Responsibility: | ||
| """An agent obligation for a state. | ||
|
|
||
| The workflow supplies these as *definitions* (``status`` ``PENDING``, no comment). The | ||
| agent resolves each to ``MET`` or ``FAILED`` before handing the turn back; a ``FAILED`` | ||
| responsibility must carry a ``comment`` explaining why. They are agent-only — user | ||
| actions drive transitions directly rather than being modelled as responsibilities. | ||
| """ | ||
|
|
||
| key: str | ||
| description: str | ||
| status: Status = Status.PENDING | ||
| comment: str | None = None | ||
|
|
||
| def resolve(self, status: Status, comment: str | None = None) -> Responsibility: | ||
| """Return a resolved copy carrying the definition's ``key``/``description``.""" | ||
| return Responsibility( | ||
| key=self.key, description=self.description, status=status, comment=comment | ||
| ) | ||
|
|
||
|
|
||
| @dataclass | ||
| class Repo: | ||
| """A repository tasks operate on. Owns secret references (added in a later slice).""" | ||
|
|
||
| id: str | ||
| name: str | ||
| git_url: str | ||
| default_base: str = "main" | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class HistoryEntry: | ||
| """One entry in a task's log — recorded when the task *enters* ``to_state``. | ||
|
|
||
| Timestamps are passed in by the caller (the task service stamps them); the core | ||
| never reads the clock, which keeps the state machine deterministic and testable. | ||
|
|
||
| On entry, ``responsibilities`` is seeded with the destination state's obligations, all | ||
| ``PENDING`` — a promise to fulfil them before leaving. The agent then resolves them **one | ||
| at a time**, which replaces entries in this list in place; that is the *only* mutable part | ||
| of an otherwise append-only, frozen record (the transition facts never change). | ||
| """ | ||
|
|
||
| at: str # ISO-8601 timestamp, supplied by the caller | ||
| from_state: str | None | ||
| to_state: str | ||
| trigger: str | None = None # what triggered the transition (e.g. "start", "advance") | ||
| note: str | None = None | ||
| responsibilities: list[Responsibility] = field(default_factory=list) | ||
|
|
||
|
|
||
| @dataclass | ||
| class Task: | ||
| """A unit of work. Identity is the internal ``id``; ``slug`` is a human label set later. | ||
|
|
||
| A task carries behavior over **its own record** — fulfilling the responsibilities it | ||
| promised on entering its current state, and reporting which remain outstanding. It knows | ||
| nothing of the state machine's rules; those live in | ||
| :class:`~panopticon.core.workflow.Workflow`, which drives the task across states. | ||
| """ | ||
|
|
||
| id: str | ||
| repo_id: str | ||
| workflow: str | ||
| state: str | ||
| turn: Actor | ||
| slug: str | None = None | ||
| history: list[HistoryEntry] = field(default_factory=list) | ||
|
|
||
| @property | ||
| def current_entry(self) -> HistoryEntry: | ||
| """The latest history entry — the one recorded on entering the current state.""" | ||
| return self.history[-1] | ||
|
|
||
| def record_responsibility( | ||
| self, *, key: str, status: Status, comment: str | None = None | ||
| ) -> None: | ||
| """Fulfil one responsibility promised on entering the current state, in place. | ||
|
|
||
| Resolves the matching promise on :attr:`current_entry`. ``status`` must be ``MET`` or | ||
| ``FAILED`` (the latter requires a ``comment``). Raises :class:`ValueError` for an | ||
| unknown key, a ``PENDING`` status, or a ``FAILED`` without a comment. | ||
| """ | ||
| if status is Status.PENDING: | ||
| raise ValueError("record a responsibility as MET or FAILED, not PENDING") | ||
| if status is Status.FAILED and not (comment and comment.strip()): | ||
| raise ValueError(f"FAILED responsibility {key!r} requires a comment") | ||
| promised = self.current_entry.responsibilities | ||
| for i, definition in enumerate(promised): | ||
| if definition.key == key: | ||
| promised[i] = definition.resolve(status, comment) | ||
| return | ||
| raise ValueError(f"no responsibility {key!r} promised in state {self.state!r}") | ||
|
|
||
| @property | ||
| def outstanding_responsibilities(self) -> list[Responsibility]: | ||
|
tildesrc marked this conversation as resolved.
|
||
| """Promises on the current entry still unresolved (``PENDING``). | ||
|
|
||
| An empty result means the turn may be handed back and the task may advance. A | ||
| ``FAILED`` promise counts as resolved — :meth:`record_responsibility` already requires | ||
| its comment, so it never lingers here. | ||
| """ | ||
| return [r for r in self.current_entry.responsibilities if r.status is Status.PENDING] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| """Workflow states, expressed as classes (declarative, à la an ORM model). | ||
|
|
||
| A state is a **class** used as a type-level identity (never instantiated). Subclass: | ||
|
|
||
| * :class:`State` — a non-terminal state. It carries an inherited transition to | ||
| :class:`Dropped`, so every task is always droppable; concrete states add their own | ||
| ``transitions`` (which accumulate with the inherited ``Dropped`` across the hierarchy). | ||
| * :class:`TerminalState` — a terminal state (no outgoing transitions). | ||
|
|
||
| ``transitions`` entries are other state **classes** or their ``label`` **strings**; strings | ||
| are resolved to classes when the owning :class:`~panopticon.core.workflow.Workflow` is built | ||
| (forward references — e.g. cycles — must use strings, as with an ORM relationship). | ||
|
|
||
| Each state declares two orthogonal, immutable facts: | ||
|
|
||
| * ``turn_on_enter`` — who holds the turn *on entry* (distinct from ``Task.turn``, the live | ||
| holder, which may differ later within the state); | ||
| * ``advanced_by`` — who moves the task *out* of the state (the user, or the agent once | ||
| satisfied). These are independent: e.g. a plan state is left by the user (approval) yet | ||
| the next state may begin on the agent's turn. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from abc import ABC | ||
| from typing import ClassVar | ||
|
|
||
| from panopticon.core.models import Actor, Responsibility | ||
|
|
||
|
|
||
| class BaseState(ABC): | ||
| """Common base for workflow states — use :class:`State` or :class:`TerminalState`.""" | ||
|
|
||
| #: Stable identifier — persisted in ``Task.state`` and shown on the dashboard. | ||
| label: ClassVar[str] | ||
| #: Who holds the turn upon entering this state. | ||
| turn_on_enter: ClassVar[Actor] | ||
| #: The agent's obligations while in this state (empty = ungated). | ||
| responsibilities: ClassVar[tuple[Responsibility, ...]] = () | ||
|
|
||
|
|
||
| class TerminalState(BaseState): | ||
| """A terminal state: the task is finished here; no outgoing transitions. | ||
|
|
||
| The turn returns to the user once a task is terminal. | ||
| """ | ||
|
|
||
| turn_on_enter: ClassVar[Actor] = Actor.USER | ||
|
|
||
|
|
||
| class Complete(TerminalState): | ||
| """Built-in terminal state for successfully finished tasks.""" | ||
|
|
||
| label: ClassVar[str] = "COMPLETE" | ||
|
|
||
|
|
||
| class Dropped(TerminalState): | ||
| """Built-in terminal state for abandoned tasks. Reachable from every non-terminal state.""" | ||
|
|
||
| label: ClassVar[str] = "DROPPED" | ||
|
|
||
|
|
||
| class State(BaseState): | ||
| """A non-terminal state. | ||
|
|
||
| Concrete states set ``label``, optionally override ``turn_on_enter``/``advanced_by``, and | ||
| add their own ``transitions``. The inherited transition to :class:`Dropped` makes every | ||
| task droppable without each state having to declare it. Defaults suit the common case — the | ||
| agent acts first on entry, then the user reviews and advances — so a state where the | ||
| agent advances itself once satisfied overrides ``advanced_by = Actor.AGENT``. | ||
| """ | ||
|
|
||
| turn_on_enter: ClassVar[Actor] = Actor.AGENT | ||
| advanced_by: ClassVar[Actor] = Actor.USER | ||
| transitions: ClassVar[tuple[type[BaseState] | str, ...]] = (Dropped,) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.