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

flowchart TD A[worktree_add:
.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

RALPH_TICKET

the ticket id (this is also the agent’s identity on the message bus)

RALPH_MODEL

the model id for the ticket’s complexity (agent.models[complexity])

RALPH_PROMPT_FILE

path of the prompt file for this attempt

RALPH_WORKTREE

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 and fnmatch), or

  • is inside the kit (ralph_orchestrator/), inside .ralph/, or inside project.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).