Architecture

System boundaries

The kit is inside the target repository: ralph_orchestrator/ is a folder of the repo it orchestrates (installed with install.sh, committed to the repo). The git repository is the system under control; everything the kit creates is either

  • source-controlled work — the ralph/integration branch, per-ticket ralph/<id> branches and their commits,

  • local, git-excluded run state — the .ralph/ directory (state, worktrees, logs, bus, contracts, handoffs, inbox; added to git info/exclude by the orchestrator itself), and

  • reports — out/run_report.html and, when Playwright is available, out/run_report.pdf.

External systems the kit talks to:

  • the agent CLI (default opencode run) — spawned per attempt with a prompt file and an environment contract (Worker — agent invocation);

  • the model gateway (OpenAI-compatible HTTP) — via LangChain, for the planner, the review panel and contract negotiation (below);

  • SMTP/IMAP mail servers — status mails and reply capture (E-mail loop and watch agent);

  • npm — one-time installation of agent plugins (below).

There is no daemon, no database, no second state system: the dashboard and the report render the same state the scheduler writes (.ralph/state.json), assembled into one JSON snapshot (Dashboard HTTP API).

Component map

flowchart LR subgraph kit["ralph_orchestrator/ (inside the target repo)"] CLI["orch.py
CLI · scheduler · merger · wizard · watch"] W["worker.py
per-ticket loop"] C["collab.py
bus + contracts wiring"] BUS["bus.py
message bus (JSONL)"] CON["contracts.py
interface negotiation"] CONSENSUS["consensus.py
review panel"] PLANNER["planner.py
spec → tickets.yaml"] STATE["state.py
.ralph/state.json"] GRAPH["graph.py
DAG, waves, scopes"] CFG["config.py
config.yaml + defaults"] PROC["proc.py
subprocess + git helpers"] REPORT["report.py
status mail / webhook"] MAILR["mail_reply.py
IMAP reply capture"] DASH["dashboard.py
HTTP server"] HTMLR["html_report.py
snapshot + renderer"] PDFR["pdf_report.py
optional PDF"] end AGENT["agent CLI
(opencode run)"] GW["model gateway
(OpenAI-compatible HTTP)"] GIT["git repo
base · ralph/integration · ralph/<id>"] SMTP["SMTP / IMAP
+ webhook"] BROWSER["browser
dashboard · run report"] CLI -->|launches, bounded by waves + scope overlap| W W -->|spawns per attempt| AGENT W -->|worktree / commit / merge| GIT CLI -->|serial merge + regression| GIT CLI --> C C --> BUS C --> CON W -.->|reads/writes prompt, handoff| BUS CONSENSUS -->|LangChain votes| GW CON -->|drafts + panel vote| GW PLANNER -->|plan call| GW C --> CONSENSUS CLI --> STATE CLI --> REPORT CLI --> MAILR REPORT --> SMTP MAILR --> SMTP DASH --> HTMLR HTMLR --> STATE HTMLR -->|writes| HTMLR2["out/run_report.html"] HTMLR2 --> PDFR CLI --> DASH BROWSER -->|GET /api/run| DASH BROWSER --> HTMLR2 CLI --> PROC W --> PROC

Intra-kit dependencies (import graph, verified): orch.py → {worker, collab, graph, state, report, mail_reply, proc, config}; worker.py → {graph, proc}; collab.py → {bus, contracts, consensus, graph, models}; consensus.py → models; contracts.py → {consensus, models}; planner.py → {graph, models, proc}; report.py → graph (wave plan in the mail body); dashboard.py → {html_report, graph, proc, config}; html_report.py → graph. models.py, state.py, graph.py, proc.py, config.py, bus.py, mail_reply.py, mail_check.py depend only on the stdlib (+ PyYAML), and models.py imports LangChain lazily inside make_llm() so the scheduler runs without it installed.

Model access

models.py is the single gateway into the LLM world:

  • api_key(cfg) resolves the gateway key: environment variable (gateway.api_key_env, default PORTAL_API_KEY) → gateway.api_key in config.yaml → the file in gateway.api_key_file (default ~/.config/opencode/portal.key).

  • make_llm(cfg, spec) builds a LangChain ChatOpenAI bound to gateway.base_url.format(path=spec["path"]) — one templated base URL, a different path per model — with timeout=300, max_retries=2, temperature from the spec (default 0.2).

The agent CLI does not go through this gateway: the opencode run process authenticates with its own provider configuration. The kit passes the full parent environment through to it (proc.py), which is how the spawned agent finds its own credentials.

Runtime execution flow

One orch.py run:

sequenceDiagram participant U as operator participant O as Orchestrator (main thread) participant S as state.json participant W as Worker (thread per ticket) participant A as agent CLI (process) participant G as git participant P as review panel (LLM) participant M as report (mail/webhook) U->>O: orch.py run O->>S: load/construct (crashed "running" → "pending") O->>G: integration worktree from base_branch; .ralph/ excluded O->>M: consume e-mail replies captured before this run loop until no ready ticket fits O->>S: launch ready tickets (waves, scope overlap, dynamic agent limit) par per ticket W->>G: worktree .ralph/wt/<id> on ralph/<id> loop attempts < max W->>G: sync integration branch into ticket branch W->>A: run agent.cmd (RALPH_TICKET / RALPH_MODEL / RALPH_PROMPT_FILE) A-->>W: edited files (rc) W->>G: revert out-of-scope changes; require real diff; reject conflict markers W->>G: add -A && commit (the worker commits, not the agent) W->>W: run accept command (must exit 0) W->>P: panel review of the diff (when consensus enabled) end W-->>O: ok | feedback end O->>G: serial merge --no-ff into ralph/integration O->>G: regression test_cmd (undo merge on failure) O->>S: status: merged | blocked | pending + events O->>M: periodic check-in mail (every report.every_seconds) O->>M: poll IMAP for operator replies (every report.reply_poll_seconds) end O->>S: reason: DONE | BLOCKED | STUCK | BUDGET | API_DOWN | INTERRUPTED O->>M: final report mail (HTML + PDF attached) O-->>U: exit code 0 (DONE) | 2 (BLOCKED) | 1 (other)

The main thread never blocks on an agent: workers run in a ThreadPoolExecutor, the loop waits with a collaboration.poll_seconds timeout, processes the bus in between, and merges are strictly serial (one integration worktree, one merge at a time).

Persistence and the .ralph/ layout

<repo>/.ralph/
  state.json          # THE state: tickets, counters, events, replies (single source of truth)
  watch.json          # watch-agent heartbeat (dashboard badge)
  last_report.txt     # last report mail (subject, message-id, body)
  inbox.json          # captured operator replies (deduped, seen-flagged)
  dynamic.json        # change-request follow-up tickets + added dependencies
  prompts/<id>.md     # the exact prompt given to the agent, per attempt
  logs/<id>.log       # agent output + guard decisions, per attempt
  handoff/<id>.md     # what a merged ticket changed (injected into dependents' prompts)
  contracts/group<k>.json / .md   # agreed interface contracts per parallel wave
  bus/messages.jsonl  # append-only inter-agent message log (flock-protected)
  bus/cursor/<id>     # per-agent "read up to message id" cursor
  bin/bus             # the `bus` wrapper placed on agents' PATH
  wt/<id>/            # git worktree per ticket
  wt/_integration/    # git worktree of ralph/integration (the merge target)
<repo>/out/
  run_report.html     # self-contained report (regenerated on every state change)
  run_report.pdf      # optional printable copy (Playwright Chromium)

state.json is written atomically (tmp file + rename) under a re-entrant lock, with a heartbeat on every save. Its schema and event types are documented in state.json.

Lifecycle

  • Run — resumable: re-running orch.py run continues pending/blocked tickets; merged tickets stay merged; a crashed run’s running tickets are reset to pending at state load.

  • Watch — an always-on correction agent between runs (mail-loop.adoc#watch).

  • Reset — orch.py reset removes worktrees, ralph/* branches (integration branch kept unless --branches) and .ralph/.

  • Dashboard — any time, reads only (never writes run state).