Runner Lifecycle¶
The runner has five distinct states. Understanding them helps you know which button to press and why.
State diagram¶
Start ──────────────────────────────────────────┐
│ │
▼ │
┌──────┐ Pause ┌──────────┐ Resume │
│ RUNNING ◄──────── │ PAUSED │ ◄──────────────┤
│ │ ─────────► │ │ │
└──────┘ └──────────┘ │
│ │
│ Stop (graceful) │
▼ │
┌──────────┐ all tasks done ┌──────────┐ │
│ STOPPING │ ─────────────────►│ INACTIVE │ ─────┘
│ │ │ │
└──────────┘ └──────────┘
│ Force-stop ▲ ▲
└──────────────────────────────┘ │
│
┌──────────┐ Force / Cleanup │
│ ORPHAN │ ─────────────────────────┘
│ (stale) │
└──────────┘
States¶
RUNNING¶
The runner process is alive and the pause file is absent. The main loop picks eligible tasks from the backlog and dispatches them as parallel Claude agents (up to the configured --parallel limit).
Buttons available: Pause, Stop, Force
PAUSED¶
The runner process is alive but the pause file (tasks/.pause-backlog) is present. The main loop checks the file on each iteration and skips task dispatch while it is set. Already-running tasks continue to completion.
The pause reason is displayed in the status pill:
| Reason | Description |
|---|---|
manual |
You clicked the Pause button |
rate-limit-5h |
Anthropic 5-hour session limit hit; countdown shown |
rate-limit-weekly |
Anthropic weekly limit hit |
rate-limit-unknown |
Rate limit detected; reset time could not be parsed |
Auto-resume: when a tasks/.pause-backlog-resume-at file exists alongside the pause file, the runner checks it on each iteration and removes all pause files once now ≥ resume_at. The dashboard shows a live countdown.
Buttons available: Resume
STOPPING¶
A graceful stop is in progress. The stop file (tasks/.stop-backlog) was created. The main loop no longer dispatches new tasks; it waits via inotifywait (sub-millisecond wake-up when an active lock is released) or falls back to a 2-second poll when inotify-tools is not installed. Once all active lock files are gone the runner exits.
Buttons available: Ångra (cancel — removes the stop file, returns to RUNNING)
INACTIVE¶
No runner process detected (neither via runner.pid file nor process scan). No lock files present.
Buttons available: Start
ORPHAN¶
No runner process detected, but active lock files exist. This means Claude agent sub-processes are running without a parent bash runner to supervise them — typically after a Cleanup that removed runner.pid while tasks were still in flight.
Use Force to kill these orphaned processes. The Force button shows in destructive red for orphans (no double-click needed).
Buttons available: Force
STALE STATE¶
No runner process and no active tasks, but stale state files remain (runner.pid, stop flag, etc.) from a previous crashed or improperly stopped run.
Use Cleanup to remove all stale state files and return to INACTIVE.
Buttons available: Cleanup
Actions¶
Start¶
Calls runner/run-backlog.sh --repo-root <project> --parallel N --model claude-opus-4-7. The runner is spawned with setsid (own process group) so all its children share one pgid.
Stop (graceful)¶
Creates tasks/.stop-backlog. The runner detects this on its next poll iteration (≤30 seconds) and stops dispatching new tasks. It polls until all lock files are gone, then exits and removes the stop file. Duration depends on how long running tasks take to finish.
Ångra (cancel stop)¶
Removes tasks/.stop-backlog. The runner's next poll sees no stop file and resumes dispatching. Only works while the runner is in STOPPING state.
Pause¶
Creates tasks/.pause-backlog with reason manual. The runner stops dispatching new tasks on the next iteration. Running tasks finish normally.
Resume¶
Removes all pause files (tasks/.pause-backlog, tasks/.pause-backlog-resume-at, tasks/.pause-backlog-reason). The runner resumes dispatching on the next iteration.
Force¶
SIGTERM → 5-second wait → SIGKILL sent to the runner's entire process group via killpg(-pgid). This kills the runner bash, all sub-shells, and all running claude processes simultaneously.
If the runner's pgid cannot be found (e.g., it has already exited), the API falls back to scanning /proc for matching bash processes and kills those instead.
After killing, all state files are cleaned up:
- runner.pid, runner.lock
- tasks/.stop-backlog, tasks/.pause-backlog*
- All tasks/.tasks-state/locks/*.lock
Requires a double-click in the dashboard (the button switches to "Bekräfta" after the first click) except when the state is ORPHAN, where Force confirms immediately.
Cleanup¶
Available only in STALE STATE (no active runner, no lock files). Removes:
- runner.pid, runner.lock
- tasks/.stop-backlog
- tasks/.pause-backlog*
Returns the project to INACTIVE. If called while a runner is still alive, the API returns HTTP 409 and the dashboard shows a warning instead of an error.
Auto-pause on rate limit¶
When a Claude agent process exits with a non-zero status, the runner scans its output files for Anthropic rate-limit patterns:
If a pattern is found:
- The runner classifies the limit type (
rate_limit_5h,rate_limit_weekly,rate_limit_unknown). - It attempts to parse the reset timestamp from the output (ISO-8601, human-readable "resets at 14:38", "resets Thursday 11:00", or sliding "in N hr M min").
- It writes the pause files with the reset timestamp.
- The task is released without an
INCOMPLETEmarker — it will be retried after resume.
When the reset timestamp is reached, the runner removes all pause files and resumes dispatching automatically. If the reset time could not be parsed, the dashboard shows rate-limit (manuell resume krävs) and you must click Resume manually.
Process group force-stop¶
The runner is started with setsid so it gets its own process group ID (pgid). When Force is triggered:
This ensures all sub-processes (bash sub-shells running tasks, claude agent processes) are killed simultaneously. Without process groups, a kill $PID would only kill the main bash script, leaving claude processes running as orphans.
Lock files record $BASHPID (the actual sub-shell PID, not the main shell's $$) so stale lock detection can correctly identify orphaned sub-shells.
Dependency enforcement¶
The (beror på: X, Y) clause on a BACKLOG.md row is authoritative — the scheduler never runs a task before all declared deps have status Completed ([x], archived). The implementation lives in runner/dep-utils.sh and is sourced by run-backlog.sh.
| Situation | Behaviour |
|---|---|
(beror på: ingen) or field omitted |
Task runs immediately |
Dep is Queued (not yet started) |
Wait silently, recheck next wave |
Dep is Completed ([x]) |
Task runs |
| Dep is INCOMPLETE this session | Permanently blocked — [CA-T123] BLOCKED: dep X ej klar logged once (marker in tasks/.tasks-state/.session-blocked/); other tasks continue |
| Dep missing from BACKLOG entirely | WARNING: dep X saknas i backlog — antas vara extern och klar logged once; no blocker (so references to feature IDs or archived tasks outside BACKLOG.md work) |
| Circular dependency | Detected at runner startup via Kahn's topological sort. The cycle is printed and the runner exits 1 |
Single-pass parsing (CA-T012)¶
Earlier builds parsed BACKLOG.md once per task per wave with parse_task_id + extract_deps_clause + extract_dep_ids (3+ subprocesses per row, ~8 000 forks on a 740-task backlog — startup phase took 7m29s under load). The current implementation makes a single awk call that emits <task_id>\t<csv_deps> for the entire file, then runs Kahn in-memory. Subprocess count at startup: ~8 000 → 1 regardless of backlog size.
The 24-test bash suite in runner/tests/test-deps.sh still passes verbatim, plus a new test-dep-parse.sh (12 cases) verifies the awk output against every supported format (markdown link, bold prefix, "ingen", Dependencies, em-dash, self-reference). Both wire into cargo test via Rust shims.
Post-result watchdog¶
claude --output-format stream-json does not always exit after emitting its final result event — sessions have been observed where the claude process lingers 15+ hours (likely a background flush of telemetry/cost). Earlier builds blocked the runner pipeline waiting for claude to exit, so locks stayed in place and the dashboard showed "running" long after the task was logically complete.
The current behaviour:
runner/translate-stream.pyexits immediately on theresultevent (rc 0 whenis_error: false, rc 1 whenis_error: true, rc 2 on EOF without result). This closes the tee pipe so events emitted afterresultcannot block the runner.runner/run-backlog.shstarts claude in the background against a fifo. The reader (tee → translator) runs as its own background pipeline. The runner waits on the reader's exit. Once the reader has seenresult, claude gets 15 seconds to exit on its own; after that SIGTERM, +3 s, SIGKILL.- The agent's commits and file changes are already on disk by the time
resultis emitted, so killing a post-result-hanging claude is safe. - mkfifo-fallback: on read-only filesystems the runner falls back to an inline pipe without the watchdog (same behaviour as before, but the translator still exits on
resultsoreader_rcbecomes 0 without waiting for claude).
A new test suite (runner/tests/test-translate-stream.sh, 8 tests) and a Rust shim (src-tauri/tests/runner_translate_stream_test.rs) catch regressions in the translator's exit codes during cargo test.
Hot-path performance¶
Runner-loop micro-optimisations that compound on large backlogs:
- No
--include-partial-messageson claude spawns — the translator only reacts toassistant/user/resultevents anyway. Removing the flag dropped task.jsonl size 10× and unsaturated the pipeline reader. - Pure-bash JSON parsing in
account acquire(_extract_json_fieldviaBASH_REMATCH). Saves ~300–450 ms python cold-start per task spawn. - Backlog cache (
_parse_backlog_into_cache) —BACKLOG.mdparsed once per file mtime, not once per task per wave. State and deps lists cached in associative arrays;_deps_satisfied_cacheddoes hash lookups instead of per-depgrep. On 700-task backlogs, per-wave cost drops from thousands of forks to a handful of hash lookups. - Wave cache for the eligible list —
show_progressreuses the result from the main loop's firstget_eligible_taskscall instead of recomputing. sched_wait— wake on lock-deletion — usesinotifywaitif available (sub-ms wake-up), falls back to 2-second polling (15× faster than the previous 30-second ticker). A chain of 5 dependent tasks loses ~2 minutes of pure dead-time.- Pre-flight TTL cache —
node --version/npm --version/claude --versionresults cached intasks/.tasks-state/.preflight-okfor 1 hour (RUNNER_PREFLIGHT_CACHE_TTL_SECenv override); cache invalidated on pre-flight failure. - Model router removed — specs without a
**Modell:**field now fall back to$DEFAULT_MODEL(Opus) instead of running a serialized "router" LLM call before the task. Chat-Claude always sets the field at spec creation, so the router was mostly dead code.
Cache-warm get_eligible_tasks on a 200-task backlog: ~1.2 s → 118 ms per wave (~10×).
inotify-tools is optional; without it the runner falls back to 2-s polling, which is still 15× faster than the previous behaviour.