The per-ticket worker
worker.py implements the per-ticket loop: it owns one ticket’s git worktree and branch,
runs the agent CLI attempt after attempt, enforces the guards, and hands the orchestrator
either a clean commit on ralph/<id> or concrete feedback for the next attempt.
The attempt loop
.ralph/wt/<id> on ralph/<id>
branched from ralph/integration] --> B{attempts < max?} B -->|no| Z[feedback or
'attempts exhausted'] B -->|yes| C{global run budget
exhausted?} C -->|yes| BUD[stop: BUDGET] C -->|no| D[sync: merge ralph/integration
into the ticket branch;
conflict markers become feedback] D --> E[write prompt .ralph/prompts/<id>.md:
ticket + criteria + scope + handoffs
+ bus/contract block + previous feedback] E --> F[run agent.cmd in the worktree
env: RALPH_TICKET RALPH_MODEL RALPH_PROMPT_FILE RALPH_WORKTREE] F --> G{rc ≠ 0 and output shows
connection refused / could not resolve?} G -->|yes| H[infrastructure failure:
attempt NOT charged,
run counters decremented] H --> B G -->|no| I[enforce_scope:
revert everything outside scope,
inside the kit, or forbidden] I --> J{changed files
vs integration?} J -->|none| K[feedback: 'No change was produced'] J -->|yes| L{conflict markers
in changed files?} L -->|yes| K2[feedback: markers remain in ...] L -->|no| M[git add -A && commit
'<id>: <title> (attempt n)'] M --> N[run accept command
in the worktree] N -->|rc ≠ 0| O[feedback: 'acceptance command failed'
+ output tail] N -->|rc = 0| P{consensus enabled?} P -->|no| Q[write handoff note
→ OK] P -->|yes| R[panel review of the diff] R -->|rejected| S[feedback: panel report] R -->|approved| Q K --> B K2 --> B O --> B S --> B Q --> T[ok: committed on ralph/<id>,
not yet merged]
Key properties:
-
The worker commits, the agent does not. The agent’s prompt explicitly says Do not commit; the orchestrator commits and merges for you. After the guards pass, the worker runs
git add -A && git commit -m "<id>: <title> (attempt n)". -
Sync before every attempt — the integration branch is merged into the ticket branch, so an agent always builds on the newest merged work. If that merge conflicts, the markers are left in the tree and the conflict file list becomes the attempt’s note: the agent resolves them (this is how parallel tickets pick up each other’s merged work).
-
Every attempt is logged to
.ralph/logs/<id>.log(agent output tail, guard decisions, acceptance output, panel report) and the exact prompt to.ralph/prompts/<id>.md.
Agent invocation
The agent is any shell command (agent.cmd, run with /bin/bash, cwd = the ticket’s
worktree, timeout orchestrator.iter_timeout). The worker sets:
| Env var | Value |
|---|---|
|
the ticket id (this is also the agent’s identity on the message bus) |
|
the model id for the ticket’s complexity ( |
|
path of the prompt file for this attempt |
|
path of the worktree the agent works in |
The parent environment is passed through unchanged (that is how the agent CLI finds its own
PATH, HOME and credentials). When the bus is enabled, .ralph/bin is prepended to PATH so
the agent can type bus … directly. Subprocesses run in their own process group
(start_new_session), so a timeout (return code 124) or a stop (Ctrl-C) kills the whole
tree, not just the wrapper.
The scope guard
After each agent run the worker computes the changed files against the integration branch
(tracked diff + untracked, git ls-files --others --exclude-standard) and reverts anything
that
-
matches the ticket’s scope globs only (
graph.in_scope:dir/,dir/**prefixes andfnmatch), or -
is inside the kit (
ralph_orchestrator/), inside.ralph/, or insideproject.forbidden_paths.
Reverted files are checked out from (or removed against) the integration branch. Reversions are reported to the agent as feedback ("You changed files outside the scope; they were reverted: …") and the attempt is charged. The agent prompt states the same rule up front — the guard is the enforcement, the prompt is the courtesy.
Acceptance
accept is a shell command run by the worker in the worktree after the commit. It must
exit 0 for the attempt to proceed. Its output tail is fed back on failure. Nothing the agent
claims is believed: a ticket whose accept is true would pass the gate, which is why
orch.py check and the planner rules reject vacuous acceptance commands
(see tickets.yaml and The planner).
Handoff notes
On success the worker writes .ralph/handoff/<id>.md (ticket, changed files, agent output
summary). When a dependent ticket’s prompt is composed, the handoffs of its merged
dependencies are injected (first 2500 chars each) under Work already merged that you build
on — this is the primary mechanism by which sequential tickets see each other’s results
without sharing a conversation.
Attempt budget
max_attempts = the ticket’s max_attempts when non-zero, else
orchestrator.default_max_attempts (default 4). An operator e-mail reply that mentions a
blocked ticket resets its attempts to 0 and unlocks it (mail-loop.adoc#consume).