Data models and file formats

tickets.yaml

The plan: a list of tickets under a top-level tickets: key (ralph_orchestrator/tickets.yaml, generated by orch.py plan or hand-written; example in templates/tickets.example.yaml).

Field Required Type Meaning

id

yes

string

stable identifier, unique in the file (T01, …​)

title

yes

string

one-line imperative description

accept

yes

string (shell)

executable acceptance command; exits 0 only when the work is done; run by the orchestrator in the ticket’s worktree

scope

yes

list of globs

the only paths the ticket may change; dir/ and dir/** match whole trees, other entries use fnmatch; parallel tickets need disjoint scopes

description

no

string

what to change and why (default "")

depends_on

no

list of ids

upstream tickets that must be merged first (default []); must form a DAG of known ids

complexity

no

S | M | L

picks the agent model (agent.models) and the scheduling weight 1/2/3 (default M)

max_attempts

no

int

per-ticket attempt budget; 0 = use orchestrator.default_max_attempts

criteria

no

list of strings

checkable acceptance criteria injected into the agent prompt and the reviewer prompt (default [])

Example (real file, templates/tickets.example.yaml):

tickets:
- id: T02
  title: Backend change
  description: Extend src/service.py (do not rewrite it) following the design note.
  criteria: [new behaviour covered by an integration test that persists and reloads data]
  depends_on: [T01]
  complexity: M
  scope: [src/service.py, tests/new/test_service.py]
  accept: python3 -m pytest -q tests/new/test_service.py
  max_attempts: 4

Validation (graph.validate, run by orch.py check, plan, run, watch): no empty file, no duplicate ids, non-empty accept and scope, known dependency ids, no self-dependency, no dependency cycle, complexity ∈ {S, M, L}.

state.json

.ralph/state.json — the single source of truth (Architecture). Written atomically (tmp + rename) under a lock; the heartbeat refreshes on every save.

{
  "started": 1761012000.0,      // epoch, run start
  "heartbeat": 1761012345.6,    // epoch, last save
  "pid": 50368,                 // orchestrator pid
  "runs": 12,                   // total agent invocations (decremented on infra failures)
  "peak_agents": 2,             // max simultaneous workers observed
  "reason": "",                 // final reason, "" while running (DONE/BLOCKED/STUCK/BUDGET/API_DOWN/INTERRUPTED)
  "finished": false,
  "bus_seen": 1761012300000000000,   // last bus message id processed by the orchestrator
  "replies": [                  // consumed operator replies (cap 50)
    {"id": "<mail-id>", "from": "me@example.com", "date": "...", "subject": "Re: [ralph] ...",
     "body": "T4: the key is ...", "at": 1761012300.0, "report_delivered": false}
  ],
  "events": [ ... ],            // global event list (cap 500) - see below
  "tickets": {
    "T1": {
      "status": "merged",       // pending | running | merged | blocked | failed
      "attempts": 1,
      "model": "tef-qwen3-8/qwen3.8-27b",
      "feedback": "",
      "branch": "ralph/T1",
      "started_at": 1761012010.0,    // first "running" of this cycle
      "finished_at": 1761012090.0,   // terminal status
      "last_attempt_at": 1761012080.0,
      "updated_at": 1761012090.0,
      "events": [ ... ]             // per-ticket events (cap 500)
    }
  }
}

Events

Every state transition and attempt is recorded (per ticket and globally; last 500 kept):

type Fields When

status

from, to, optional note (first feedback line, ≤ 200 chars)

any status change, including crash recovery (running → pending, note "crashed run: reset to pending")

attempt

attempt (1-based), model

a new attempt starts

reply

from, note

an operator reply consumed for the ticket

run

reason (on ticket: null entries)

the run’s final reason

merged, blocked, failed are the terminal statuses (state.TERMINAL). At load time, a ticket still marked running (crashed run) is reset to pending with a recorded event — this is the resume mechanism.

Ticket lifecycle

stateDiagram-v2 [*] --> pending pending --> running: launched (deps merged, scope free) running --> pending: guard/accept/panel failed, attempts left (feedback) running --> pending: infra failure (not charged) running --> merged: accept ok + panel ok + merge + regression ok running --> blocked: attempts exhausted blocked --> pending: operator e-mail reply (attempts reset to 0) running --> pending: crash recovery (state load) merged --> [*] blocked --> [*]

Note: a change request against a merged ticket never re-opens it — it creates a separate <id>-fixN ticket that depends on it (collaboration.adoc#change-requests).

inbox.json

.ralph/inbox.json — captured operator replies (list, cap 50, deduped by id):

{
  "id": "<message-id>",          // or "file-<sha1[:12]>" for --file imports
  "from": "me@example.com",
  "date": "Wed, 21 Oct 2026 15:06:00 +0000",
  "subject": "Re: [ralph] DONE: ...",
  "body": "T4: the key is 'exchange_rate', not 'rate'.",
  "captured_at": 1761012345.6,
  "seen": false,                 // flipped to true by consume_replies
  "consumed_at": null            // set when consumed
}

See consumption semantics (ticket mentions, feedback append, blocked-ticket unlock).

dynamic.json

.ralph/dynamic.json — change-request follow-ups created mid-run (collaboration.adoc#change-requests):

{
  "tickets": [
    {"id": "T2-fix1", "title": "Fix requested by T5 in T2",
     "description": "<the request>\n\nOriginal ticket: ...",
     "criteria": ["<the request>"], "depends_on": ["T2"], "complexity": "M",
     "max_attempts": 4, "scope": ["src/service.py"], "accept": "python3 -m pytest -q ..."}
  ],
  "extra_deps": { "T7": ["T2-fix1"] }   // dependencies added to existing pending tickets
}

Applied on every load() and by the dashboard’s snapshot builder, so fix tickets and their new edges survive restarts and appear in the graph.

Bus messages

.ralph/bus/messages.jsonl — one JSON object per line, flock-protected appends (format and commands). Per-agent read cursors in .ralph/bus/cursor/<id>; the orchestrator’s own position in state.bus_seen.

Handoffs

.ralph/handoff/<id>.md — written by the worker after a successful attempt:

Ticket T1: Add a greeting function
Files: greet.py
Agent notes:
<last 2000 chars of the agent's output>

Injected (first 2500 chars each) into the prompts of dependent tickets under Work already merged that you build on, and used by the orchestrator’s automatic bus answers.

Contracts

.ralph/contracts/group<k>.json (k = wave index) — the agreed interface contract of one parallel group:

{
  "group": 1,
  "tickets": ["T2", "T3"],
  "drafts": {
    "T2": {
      "provides": [{"name": "greet.farewell", "signature": "farewell(name: str) -> str", "notes": ""}],
      "requires": [{"name": "greet.greet", "from": "T1", "signature": "greet(name: str) -> str"}]
    }
  },
  "resolved": ["T2 adapts 'greet.greet' to T1's signature 'greet(name: str) -> str'"],
  "decided_by": "consensus",        // or "owner (no agreement after rounds)"
  "panel": "gpt-oss: APPROVE - ..."  // the panel's report
}

plus a rendered group<k>.md. for_ticket extracts the binding part per ticket for its prompt.