Skip to content

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:

5-hour limit · weekly limit · usage limit will reset · too many requests · message limit reached

If a pattern is found:

  1. The runner classifies the limit type (rate_limit_5h, rate_limit_weekly, rate_limit_unknown).
  2. 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").
  3. It writes the pause files with the reset timestamp.
  4. The task is released without an INCOMPLETE marker — 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:

killpg(-pgid, SIGTERM)  →  sleep 5s  →  killpg(-pgid, SIGKILL)

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:

  1. runner/translate-stream.py exits immediately on the result event (rc 0 when is_error: false, rc 1 when is_error: true, rc 2 on EOF without result). This closes the tee pipe so events emitted after result cannot block the runner.
  2. runner/run-backlog.sh starts 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 seen result, claude gets 15 seconds to exit on its own; after that SIGTERM, +3 s, SIGKILL.
  3. The agent's commits and file changes are already on disk by the time result is emitted, so killing a post-result-hanging claude is safe.
  4. 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 result so reader_rc becomes 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-messages on claude spawns — the translator only reacts to assistant/user/result events anyway. Removing the flag dropped task.jsonl size 10× and unsaturated the pipeline reader.
  • Pure-bash JSON parsing in account acquire (_extract_json_field via BASH_REMATCH). Saves ~300–450 ms python cold-start per task spawn.
  • Backlog cache (_parse_backlog_into_cache) — BACKLOG.md parsed once per file mtime, not once per task per wave. State and deps lists cached in associative arrays; _deps_satisfied_cached does hash lookups instead of per-dep grep. 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_progress reuses the result from the main loop's first get_eligible_tasks call instead of recomputing.
  • sched_wait — wake on lock-deletion — uses inotifywait if 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 cachenode --version / npm --version / claude --version results cached in tasks/.tasks-state/.preflight-ok for 1 hour (RUNNER_PREFLIGHT_CACHE_TTL_SEC env 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.