Skip to content

Running Waves

This chapter explains the wave loop from an operator's perspective: how koryph selects work each cycle, dispatches agents, and lands the results.

The wave loop

koryph run --project <id> executes wave cycles until the frontier is empty:

scan (bd ready) → build wave → preflight → dispatch → poll → stages → review → merge → repeat

Each iteration is one wave: a conflict-free batch of at most max_concurrent_slots (or --max) beads dispatched in parallel. Between waves the engine checks the quota governor and re-scans the frontier.

To run exactly one wave, pass --once. To see what would be dispatched without actually sending anything, pass --dry-run.

To build one specific bead instead of the whole frontier, pass --only <bead-id>: the wave is narrowed to that bead, and the run drains once it closes. To cap the run's spend, pass --budget <USD>: once the run's projected cost reaches the ceiling, no new agents are dispatched (active ones finish) and the run pauses with a budget-cap reason. Projected cost is settled spend plus each in-flight agent's dispatch-time estimate — including an agent flagged silent/stuck (process still alive, no heartbeat/commit/CPU activity). A stale agent is only interrupted when its process snapshot has no child, so a long gate/test still counts as live work. The check is re-evaluated per bead within a wave (dispatch stops mid-wave the moment the projection crosses the cap), and a requeue is refused once the budget is exhausted (the slot parks needs-attention rather than spending another attempt). This is a per-run ceiling, separate from the account cost governor and the global concurrency governor.

Dispatch mode: wave vs rolling

Two dispatch loops share every scan/preflight/dispatch/poll primitive above; they differ only in when the next scan happens:

  • wave — the loop described above: dispatch a batch, then wait for every slot in it to land before scanning again. Simple and predictable, at the cost of idling a slot that frees early while its wave-mates are still running.
  • rolling (default) — continuously refills: every poll tick it re-checks the governor, recomputes free capacity from the currently-running count, and tops off any slot that has freed up — without waiting for the rest of the batch. A slot that lands early is refilled on the next tick instead of sitting idle.

Rolling is the engine default (it became so after the 2026-07-03 self-build burn-in). Select a mode explicitly with dispatch_mode in koryph.project.json ("wave" or "rolling") or per run with --dispatch-mode wave|rolling (the flag wins over the config; an unrecognized value is a usage error). --once runs the exact same single-pass semantics — one dispatch pass, poll to idle, exit — in both modes, so a validation/canary invocation behaves identically either way. Every other flag (--only, --budget, --dry-run, --resume, quota governor levels, footprint gating) applies identically in rolling mode; footprint conflicts against a currently in-flight bead are still deferred (and re-checked on the next tick) so two conflicting beads never run at once, whichever mode is active.

The bd ready frontier

Every wave starts with bd ready, which returns all issues whose dependencies are closed. Container beads (epics, features, decisions, merge-requests) and beads with a gt:* gate label are structurally skipped — they will never dispatch as-is, so the engine reports each one once per run with a fix hint (skipped <id>: … — file as task/bug/chore; area:* label; drop gt:*). Beads labeled no-dispatch or refactor-core, already-active beads, container beads with open children, footprint collisions, resource capacity collisions, and the width cap are deferred: the engine prints a per-wave deferred N bead(s): … summary. Under --dry-run every deferral is listed in full alongside the would-dispatch set, so you can see exactly why a ready bead is not running before committing to a wave. The remainder are sorted by priority (P0 first) and passed to the conflict filter.

Scope a run to a single epic:

koryph run --project myproject --parent beads-001

Footprint labels: fp:* and area:*

The scheduler prevents two agents from touching the same code at once via footprint tokens. Footprints are split into read and write token sets and follow RWMutex semantics: two beads sharing a token only conflict when at least one holds it as a write. Two readers of the same token co-run freely — a docs bead that only reads engine code no longer excludes an unrelated engine writer.

fp:read:<token> labels (new) — produce read tokens; beads that only read a surface run alongside any other reader:

fp:read:engine fp:read:docs   →  reads: ["docs", "engine"], writes: []

A bead carrying fp:read:engine does not conflict with another bead that merely reads engine; it does conflict with a bead that writes engine.

fp:<token> labels (plain suffix) — produce write tokens (existing grammar, unchanged); a token declared as both read and write collapses to write-only:

fp:auth fp:billing   →  reads: [], writes: ["auth", "billing"]

fp:* and area:* labels compose — they do not override each other. Every area:* label contributes its mapped write tokens, every fp:read:<token> label contributes a read token, and every other fp:<token> label contributes a write token; the bead's footprint is the union of all of it (a token present in both the read and write sets collapses to write-only, since a write already excludes readers). Only when a bead carries none of the above — no area:* and no fp:* label at all — does the catch-all domain:unknown apply.

area: labels — resolved through koryph.project.json's area_map as write tokens:

"area_map": { "api": ["auth", "billing", "routes"] }

A bead with area:api gets write tokens ["auth", "billing", "routes"] and conflicts with any bead carrying any of those tokens (whether via fp:* or another area:* mapping). A bead labeled area:api fp:read:go:signing gets write tokens ["auth", "billing", "routes"] and the read token go:signing — both areas' worth of protection stack, they don't compete. (Before koryph-2im, fp:* used to suppress area:* outright; that behavior silently dropped write tokens on mixed fp:read: + area: beads and was fixed — see internal/sched/footprint.go's FootprintFor doc comment for the full history. If you were narrowing an over-broad area:* with an fp:* label under the old precedence rule, drop the area:* label instead — that is the one authoring pattern the fix costs.)

No footprint label — the bead receives the catch-all write token domain:unknown, which conflicts with every other unknown bead. Unknowns serialize: only one runs per wave.

Resource labels: res:\<kind>

Footprints prevent two agents from touching the same code. They don't know anything about what an agent starts running on the host — a kind/k8s dev cluster, a docker compose stack, a long-lived dev server — and those live outside the agent's process tree and worktree. Resources are a second, additive admission dimension for exactly that: a bead labels res:<kind> per external resource kind it will provision, and the scheduler and governor treat each kind as a counted capacity (default 1, i.e. exclusive, unless the machine is configured otherwise) rather than a read/write lock. A bead with no res:* labels — the common case — is unaffected; declaring nothing never serializes it against anything.

A capacity-exhausted kind produces a deferral at one of two points:

  • Wave packing (sched.BuildWave, project-local): resource <kind> at capacity (held by <id>) — the candidate is skipped and packing continues past it, so a lower-priority resource-free bead behind it still dispatches.
  • Global admission (govern.Store.Acquire, cross-project, under the flock): bead <id>: deferred — resource <kind> at capacity (N/N, held by <project>/<bead>) — the authoritative, cross-engine check; a bead that cleared wave packing can still be denied here by another engine's holdings.

Both are per-bead skips, not batch-wide breaks: a resource-heavy bead deferring never stalls the lightweight beads behind it in the same wave. See Machine: resources for the label grammar, capacity/reservation semantics, and the agent contract, and Global governor for the governor.json schema and admission clauses.

Model labels

The model resolved at dispatch time controls which Claude tier runs the bead. Precedence (highest first):

Label Scope
model:implement:<tier> this bead, implement stage only
model:<tier> this bead, all stages
--default-model <tier> all label-less beads in this run
runtimes.<name>.default_model / .default_equivalent label-less beads routed to that runtime
top-level default_model / default_equivalent label-less beads routed to default_runtime
(none) stage default (configured in koryph.project.json)

<tier> is a model ID such as sonnet, opus, or a full model string. Stage-scoped labels (model:implement:*) take precedence over bare model:* labels so a bead can pin the implement tier without affecting review.

default_equivalent uses the portable frontier|standard|light:<effort> grammar, such as frontier:xhigh; it maps through the selected runtime's model_map and effort_map. It is mutually exclusive with default_model at the same project or runtime scope.

Run-scoped runtime policies

Use one of these mutually exclusive flags when this engine session must run a particular runtime:

Flag Effect
--runtime-only <runtime> Dispatch only beads whose normal bead/project routing already resolves to <runtime>; all other ready beads are skipped for this run.
--runtime-equivalent <runtime> Dispatch the whole eligible frontier on <runtime>, translating each normal source choice through portable capability tiers and effort mappings.

--runtime-equivalent never guesses a tier for an ambiguous or custom native model. Use equiv:<tier>:<effort> on the bead or default_equivalent in the project to make the requested capability explicit.

Typed recovery

Retry count never changes the implementation model. Koryph classifies the observed outcome and either continues a bounded same-tier session, starts a standard-tier repair, or parks the candidate with a precise reason and its work preserved. Frontier capacity is reserved for advanced planning and explicitly authorized structured security or recovery analysis; analysis cannot silently become frontier implementation.

Capability requests and capability blocks

A dispatched worker does not receive the shared BEADS_DIR. When it discovers that its own scheduling metadata is incomplete, it asks the orchestrator to apply one narrowly scoped addition:

koryph phase request label-add --label area:docs
koryph phase request label-add --label fp:docs-nav
koryph phase request label-add --label res:kind-cluster

The request cannot select another bead, remove labels, or mutate dependencies, status, routing, or policy labels. The engine validates the current phase and performs the update through its Beads adapter.

A worker may also ask the orchestrator to validate another registered runtime without receiving that runtime's credentials:

koryph phase request runtime-canary --runtime claude

The engine projects the target runtime's registered profile only into a fixed standard-tier canary process. The canary verifies identity and proves a headless shell action with an unpredictable phase-local proof file; requester text cannot change its prompt, command, environment, or expected result.

If a required host action has no supported bridge, the worker reports it structurally:

koryph phase block --capability beads-metadata \
  --detail "dependency edge required before implementation can continue"

Capability blocks are not implementation failures. Koryph preserves the branch/worktree, marks the bead visibly blocked, emits the ERROR-level engine.slot.capability_blocked event, and releases only that slot. Unrelated slots continue and the run finishes normally. A project-level capability hold survives later runs: changing only the bead status or update timestamp cannot dispatch another backend. A changed candidate/base, runtime/config/build fingerprint, passing named probe, or explicit operator nudge admits one bounded retry. The hold stores digests only, never raw configuration, probe output, or operator text. It does not promote the task to a frontier model.

Historical model evidence

koryph models (the two-word models learn still works as an alias) can inspect historical or explicitly recorded escalation provenance by (area:* label, size bucket):

$ koryph models            # dry run: show recommendations + evidence
$ koryph models --apply    # label matching ready beads

Typed recovery does not generate evidence merely because an attempt number increased. Treat --apply and the legacy adaptive_escalation project option as explicit routing overrides: audit any resulting frontier labels before dispatch. To undo one, remove its model:* and model-learned:* labels.

"adaptive_escalation": { "enabled": true, "min_evidence": 2 }

in koryph.project.json. The engine then labels matching frontier beads before each wave builds, so they dispatch on the learned tier immediately; the pass is throttled, best-effort, and visible both in progress output and as engine.bead.model_learned telemetry.

Merge policies

After an agent finishes, the engine applies a merge policy:

Policy Behaviour
auto Merge automatically when --auto-merge is passed and review is clean
manual Leave slot in merge-pending; operator runs koryph merge
pr Push the agent branch and open a GitHub PR; slot ends pr-opened

Epic label wins over project config. Add merge:auto, merge:manual, or merge:pr to an epic bead and every child bead under that epic inherits that policy, overriding merge_policy in koryph.project.json.

When an issue has no epic or the epic carries no merge label, the project config merge_policy applies.

Auto-merge never fires without --auto-merge on the command line, even when the policy says auto. This keeps CI-only runs safe by default.

Owner override — --direct. koryph run --direct is the escape hatch for an owner/select-maintainer who wants to skip PRs entirely: it forces the effective policy to auto (direct ff-merge + push to the default branch) even on a merge:pr epic. koryph does not gate on org role — the push to a protected default branch still succeeds only if the pushing identity is on the branch-protection bypass allowlist. A blocking --review verdict still downgrades to manual, so the safety path is not bypassed.

pr — pull-request merges for protected branches

merge_policy: pr is the path for a default branch you never push to directly (branch protection, required reviews). It runs the same preflight as an auto-merge — protected-path check, signature verification, sync of the local default branch to origin, rebase onto it, and the green gate — but then pushes the agent branch (agent/<bead-id>) and opens a PR against the default branch instead of fast-forwarding it. The PR title is conventional-commit-shaped and the body carries the bead id, title, and acceptance criteria.

  • The slot ends in pr-opened (terminal for the run); the worktree and branch are kept so a later fast-forward landing step can resume them. Nothing is pushed to the default branch.
  • Opening a PR does not require --auto-merge — it is the safe alternative to a direct merge, so setting the policy is the opt-in.
  • Requires a git remote and an authenticated gh CLI. Without either, the bead is blocked with a clear reason (never crashed or silently dropped), and the branch is kept so a --resume retries once the remote or gh is available.
  • A re-run reuses an already-open PR for the branch rather than opening a duplicate.

Landing an opened PR (fast-forward only)

Once a PR is opened, a maintainer lands it with:

koryph land --project myproject <bead-id>

This re-verifies the branch against the (possibly advanced) default branch, runs the gate, and lands it fast-forward-only — then flips the slot to merged and closes the bead.

Why not the GitHub merge button? GitHub has no true fast-forward merge. Every native method breaks the koryph merge contract of preserving the exact gate-checked, reviewed, SSH-signed commits: a merge commit adds an unsigned commit, and squash / rebase merges rewrite SHAs and the committer identity, destroying the signatures signing.required mandates. koryph therefore lands with mechanism (a): a local git merge --ff-only + push by the engine's signing identity — the only method that keeps the signed SHAs byte-for-byte.

Self-healing generated-file conflicts

The pre-merge rebase can trip over a derived file — a migrations lockfile, a secrets baseline — that two beads each regenerated from their own view of a directory. The inputs merge cleanly but git reads the divergent checksum block as a conflict. A footprint label is the first fix (serialize the beads so the collision never happens); for the residual case, declare a merge_reconcilers entry so koryph regenerates the derived file from the post-merge tree and continues instead of aborting. See Merge reconcilers.

  • Base moved. If the base advanced, koryph land rebases the branch onto it and re-verifies; a genuine conflict is reported (the bead is rebased/re-run, never rewrite-merged). A clean rebase re-signs the rewritten commits with the engine's signing key, so signatures still verify.
  • Override. merge_method in koryph.project.json (or --method per run) selects the landing method: ff (default) or squash. A non-ff method is refused with a clear error while signing.required is set, because it rewrites the signed commits.
  • Required branch-protection ruleset shape. Protect the default branch (require pull requests / disallow direct pushes for everyone) and add the engine's signing identity to the ruleset bypass allowlist ("Allow specified actors to bypass required pull requests"). The engine runs the same green gate locally before it pushes, so required status checks stay satisfied; GitHub marks the PR merged automatically once its commits land on the base.

Protected-path blocks

When a branch touches a protected path the merge is refused and the phase is blocked — and the block reason now names the way out:

  • If every touched path is in the liftable subset (.github/, Makefile), the reason prints the exact one-command landing to run yourself: koryph merge --project <p> --allow-protected --push --close-bead <id> --reason 'operator-approved protected-path landing' <branch>.
  • If the touch includes a governance default (.claude/, CLAUDE.md, hooks/, …) or a project-declared protected_paths entry, the reason says manual review is required and that --allow-protected will not lift it — so you don't waste an attempt on a flag that still refuses.

--allow-protected is operator-only (never available to a dispatched agent) and lifts only the routine CI/build subset; governance and project protections always refuse.

Merging while a loop is running

koryph merge/koryph land check the project's koryph.lock before touching the worktree: a live koryph run engine holds that lock for its whole lifetime, and racing its own git activity in the same worktree previously hung the manual command silently with no indication of what it was waiting on. Now, if a live engine owns the project, the merge names the holder (pid, and the run id when known) and fails fast by default:

koryph merge: a live engine (pid 12345, run 2026-07-22T19-09-50) owns myproject —
refusing to merge concurrently; pass --wait to wait for it to finish

Pass --wait to poll instead of failing fast — it prints progress every few seconds until the engine's lock releases, then proceeds with the merge.

When you land a bead by hand with koryph merge --close-bead <id> while a loop is running, the command also drops a merged directive into that run's operator-override sidecar (overrides.json, beside ledger.json). The engine reads the sidecar every cycle and folds the directive into its in-memory ledger, so the row it rewrites shows the bead merged — you no longer have to hand-edit ledger.json (which the engine's single-writer rewrite would immediately revert), and the manual land is not clobbered. The directive is idempotent and only ever marks a not-yet-terminal slot terminal, so it can never re-touch a slot that legitimately went back to work.

Reviewing other people's PRs

koryph review-pr is a human-in-the-loop tool for reviewing pull requests authored by someone else (including contributors who used koryph). koryph analyzes — it never approves on its own:

# 1. Analyze: koryph runs its reviewer over the PR head and prints its findings.
koryph review-pr --project myproject 42

# 2. You read the analysis, examine the flagged code, and decide (you may override koryph).

# 3. Instruct approval — this registers YOUR approving review on the PR.
koryph review-pr --project myproject 42 --approve --body "Looks good, thanks"
  • Analysis checks out the PR head into an ephemeral worktree, runs the reviewer over its diff, and prints a verdict plus findings (severity · file · summary). It records no approval — the decision is yours.
  • Approval is a separate, explicit instruction. The approving review is registered under your identity, so it works for others' PRs regardless of who authored them. You can approve even when the analysis flagged issues (you own the call).
  • Approving your own PR is refused with a clear error — GitHub rejects self-approval; land your own work directly instead (koryph land, or merge_policy: auto / --direct with a branch-protection bypass).

Clearing the queue. koryph review-pr --project myproject --all analyzes every open PR in turn, skipping drafts and PRs you authored (each skip is logged with its reason). It only analyzes — approve each PR individually afterwards. Ctrl-C stops the loop cleanly after the current PR.

Inline comments. koryph review-pr --project myproject 42 --comment posts koryph's line-anchored findings as inline review comments on the PR (findings without a line fold into the review body). Add your own with a repeatable --comment-on path:line:message:

koryph review-pr --project myproject 42 --comment \
  --comment-on "internal/foo.go:88:this needs a nil check" \
  --comment-on "cmd/bar.go:12:rename for clarity"

Comments post as a single COMMENT review anchored to the PR head commit — no approval. Approve separately with --approve once you're satisfied.

IDE handoff loop. The analysis is persisted, so you can review in koryph, switch to your IDE to examine the flagged files (and add manual comments there or via --comment-on), then come back:

koryph review-pr --project myproject 42            # analyze (saves state)
# ...open the flagged files in your IDE, think it over...
koryph review-pr --project myproject 42 --resume   # replay the saved analysis (no re-run)
koryph review-pr --project myproject 42 --approve   # or --close --body "superseded"

--resume replays the saved findings without re-running the (costly) reviewer, and warns if the PR head moved since the analysis. --close [--body "..."] closes the PR from koryph. A PR closed or merged by any means (koryph, the GitHub UI, or another tool) is reflected on the next review-pr because state is read live from GitHub — an action on a terminal PR is a no-op that reports the state and clears the stale saved analysis.

Reconciling engine-opened PRs. For PRs koryph itself opened (merge_policy: pr, parked in the pr-opened slot), koryph pr-sync --project myproject checks each one's live state and reconciles the ledger: a PR that merged (landed by anyone) marks the slot merged and closes the bead; one closed without merging marks the slot blocked. Nothing is left stranded in pr-opened when a PR ends outside koryph.

Post-implement stages

If the project declares a pipeline, each stage runs sequentially in the same worktree once the implementer finishes and before review/merge — a persona agent (docs, tests, changelog, …) that may add its own commits on the branch. A failed non-optional stage blocks the bead rather than merging incomplete work; an optional stage logs and continues. Stage cost counts toward the quota governor, and a review bounce re-runs the whole pipeline on the updated code.

Review bounces

Pass --review to insert a reviewer pass (Opus) between implementation and merge:

koryph run --project myproject --review --auto-merge

If the reviewer reports blocking findings, the bead is bounced back to a fresh implementer dispatch that receives the review report. Up to two bounces are allowed; on the third blocking result the engine forces manual policy regardless of the epic or project setting, and the slot lands in merge-pending for operator inspection.

Degraded reviewer output (model error, empty report) is treated as non-blocking and does not delay the merge.

Recovery and resume

For the one-page overview of the whole failure story — detection, typed bounded transitions, and the operator toolkit — see Typed recovery. This section covers the run-level mechanics.

Interrupt a run (Ctrl-C, host sleep, etc.) and resume where it left off:

koryph run --project myproject --resume

On resume the engine classifies the latest run's slots:

Slot state Action
Agent still alive Reattach — poll resumes from live PID
Dead, has commits or SUMMARY.md Re-dispatch with the branch HEAD as resume SHA; if a Claude session ID was recorded, the agent resumes that session natively
Dead, no commits, attempts < 3 Re-dispatch fresh with exponential backoff
Dead, attempts ≥ 3 Blocked — requires operator intervention

Nothing is lost: committed checkpoints survive the interruption and are replayed into the new dispatch context.

You can resume at a different width than the interrupted run. A stalled run that was dispatching at --max 6 can be resumed with --max 2: the re-dispatch of dead slots is capped at the resuming run's effective width and gated through the global concurrency governor exactly like fresh frontier work, so recovered beads land at most --max at a time and refill as slots free. (Agents that are still alive are always reattached regardless of width — koryph never kills a running agent to fit a lower cap; the freed capacity applies to the re-dispatched dead slots.) Beads that do not fit the width immediately are parked queued and promoted at later scheduling boundaries, so you never have to resume at the original thread count just to recover work.

Every requeue also refreshes the worktree onto the current default branch first, so a retried agent never runs against a checkout that predates a main-side fix: a bead with no commits is rebuilt from a fresh checkout, and one carrying commits is rebased onto the advanced base before re-dispatch.

Keeping the engine running detached

The engine runs in the foreground and shares its parent shell's lifetime — if that shell (or the terminal, or an SSH session, or a harness that spawned it) is reaped, the engine goes with it. For a long unattended run, detach it from the shell so it survives:

nohup koryph run --project myproject --auto-merge --review > run.log 2>&1 &
disown

nohup decouples it from the controlling terminal (disown drops it from the shell's job table so a shell exit does not signal it); > run.log 2>&1 captures both the human progress and the structured records for later tail/grep. It resumes cleanly after any interruption with --resume (above), so a killed detached run loses nothing.

To add one bead to a running loop — even a bead outside the current --parent scope — inject it without a restart:

koryph inject --project myproject <bead-id>

The engine merges it into the frontier on its next wave and dispatches it once it is ready (the command tells you whether it is ready now or waiting on dependencies). An injection can only widen the frontier to a genuinely-ready bead — it never force-dispatches a bd-blocked bead. A newly-ready bead already inside the run's scope is picked up automatically anyway (the engine re-reads bd ready every wave). Injection does not change --max; to raise the width, stop and --resume at the new width. koryph nudge delivers a note to a specific running agent and does not change the frontier.

Budget-killed agents

An agent stopped by --max-budget-usd (see Billing and quota) is classified distinctly from a crash or rate-limit death and gets its own warm-resume policy:

Situation Action
Budget-killed, first time on this bead Warm-resume requeue: worktree and branch are preserved (not rebuilt), so --resume --fork-session reattaches to the live Claude session and any uncommitted WIP snapshot is cited in the resume prompt
Budget-killed a second consecutive time Parked blocked with a needs-attention note instead of spending a third cap — raise the account's per-agent budget or split the bead
Budget-killed with zero commits and pathological token volume (thrash guard) Parked immediately, skipping even the first warm resume — the attempt is judged unrecoverable rather than retried

Parked budget-kill slots surface the same way as any other blocked slot — via koryph board, the TUI, and the health-patrol channels — with the note prefixed needs-attention: and the accumulated CostUSD so far.

Self-parked beads and stale in_progress claims

bd ready unconditionally excludes in_progress issues — by design, so a bead an agent has claimed is never handed to a second agent. But that also means an agent that gets stuck mid-task and simply leaves the bead in_progress with an explanatory note — instead of wiring a formal dependency edge and releasing its claim — makes that bead invisible to bd's otherwise-correct, live-recomputed dependency engine permanently: nothing re-checks it, and there is no event, re-scan, or expiry. Every dispatched agent's instructions cover this case explicitly (see the dispatch preamble): when it determines it cannot proceed, it is expected to either

  • wire the blocker as a real dependency edge and reopen the bead (bd dep add <id> --blocked-by <blocker> then bd update <id> --status open) so bd ready re-surfaces it automatically once the blocker closes, or
  • label the bead no-dispatch, reopen it, and explain the blocker in a note, when the blocker is not something any bead represents (an operator action, unscoped future work).

As a backstop for beads that self-park anyway, the health patrol's stale-claims check periodically scans every in_progress issue in the project (not just the current run's slots) and warns when one has gone stale — its updated_at older than stale_claim_warn_hours (default 24; set in koryph.project.json) — with no live agent found in any recent run. This is report-only: it never resets the bead itself, because an in_progress bead can also be correctly parked on something bd cannot represent, and an automatic reset would just get it redispatched into the same blocker.

Zombie runs: koryph ops reconcile

--resume (above) is the right recovery tool when you want the engine to re-dispatch dead work — it is a live engine process that classifies the latest run and requeues or reattaches. But sometimes a run loop was killed (or the host slept, or the process was reaped) and you do not want anything re-dispatched — you just want the ledger to stop lying: a slot can be left status=running with a dead agent pid forever, because nothing outside a fresh engine run ever revisits it. That zombie is cosmetic but misleading (koryph board/koryph status/the TUI still show it as running), it strands the run un-finalized, and it can pin stale governor leases/demand that count against the project's fair share.

koryph ops reconcile is the dispatch-free fix — it never starts an engine and never sends any bead to an agent:

koryph ops reconcile --project myproject            # apply
koryph ops reconcile --project myproject --dry-run  # preview only

It loads the project's latest run and, for every non-terminal slot:

Slot state Action
A live engine (koryph.lock's pid) still owns this project Report and exit — reconcile never races a running engine's own recovery path
Agent pid still alive Left alone and reported (the same signal --resume would reattach to)
Agent pid dead Parked blocked with a note recording how many commits were preserved on its branch (reconciled: agent dead, loop gone; N commits preserved on <branch>); its global governor lease is released

Once every slot is terminal the run is finalized (matching FinalizeRun's stale-running fix). A blocked slot reconciled this way keeps its worktree and branch exactly like any other blocked slot — nothing is deleted, and a later koryph run --resume (or a manual koryph merge) can still recover its commits.

As a best-effort cross-check (platform-permitting, via ps -o etime=), an agent pid that is still alive but whose process start time is well after the slot's recorded dispatch time is flagged with a warning — the known kill(0) false positive where an OS eventually recycles a dead agent's pid for an unrelated later process. The slot is still left alone (the report is a hint to verify manually, not an automatic reclassification).

Dead runs render as dead (unreconciled), not running

The zombie-slot check above flags a dead agent under a live engine. Its run-level analog catches a dead engine: if the whole run loop is killed abruptly (a harness group-kill, kill -9, a host that sleeps and never wakes the process), the engine never finalizes the ledger, which freezes at status=running. Nothing outside a fresh engine run ever revisits it, so koryph status, koryph board, koryph roster, koryph cockpit, and the TUI would otherwise show a phantom running run forever.

These read-only surfaces now derive run liveness from the engine pid recorded in koryph.lock. A status=running run whose lock pid is not alive (or whose lock is gone entirely) renders as dead (unreconciled) with the one-command fix inline:

⚠ run is marked running but the owning engine is dead (killed without finalizing). Reconcile: koryph ops reconcile

This is purely a read — the surfaces never mutate the ledger or reclaim the lock (a reader must not become a writer). Only a status=running run is ever flagged: intentionally parked runs (paused-quota, hard-stop-quota) legitimately have no live engine and render as themselves. Run koryph ops reconcile (above) to park the dead slots and finalize the run, or koryph run --resume to re-adopt the work.

A graceful shutdown never produces this state: koryph run now converts an operator/loop SIGTERM (and Ctrl-C SIGINT) into a clean interrupt — every active slot is checkpointed, engine.run.end is written, and koryph.lock is released — leaving the run recoverable by --resume. Only an unhandleable abrupt SIGKILL leaves the phantom, which the read-side derivation above is the backstop for.

Poll interval

The engine polls each running slot's status.json heartbeat every 10 seconds by default. To tune this per project, set poll_seconds in koryph.project.json:

{ "poll_seconds": 20 }

A lower value increases poll frequency (more responsive to fast agents; slightly higher filesystem load). A higher value is useful for long-running models where frequent polling adds noise. The environment variable KORYPH_POLL_SEC and the programmatic Options.PollSec field take precedence over the project config, in that order.

Exit code 4 — drained

exit 4  →  Outcome.Drained = true

The engine exits 4 when bd ready returns no eligible beads, no agents are running, and the scheduler cannot form a wave. This is the normal end-of-work signal. Outer loops (systemd timers, CI jobs, shell while) should treat exit 4 as success and stop re-invoking until new work is pushed.

Exit 0 is also success but indicates the run stopped for another reason (quota pause, --once, interrupted) with work potentially remaining.

Nudge, stop, and tail

nudge — append an operator message to a running agent's INBOX.md. The agent polls the inbox between steps and adjusts course. For a capability-blocked bead, the same command records a Beads audit comment, arms its one hashed evidence-gated retry, and reopens it; the comment does not become worker prompt scope and no dead INBOX.md is written. A queued bead has no live inbox: update its description, design, or acceptance criteria instead so the next dispatch receives a canonical task contract:

koryph nudge --project myproject beads-042 "prefer the interface approach from issue 38"

stop — send SIGTERM to an agent's process group. koryph stop is a terminal operator intent, not a crash: koryph records the stop before it signals, so when the engine detects the exit it parks the phase (blocked, with an operator-stopped reason) instead of auto-retrying it — and it does not auto-merge any partial work the agent had already committed. Both suppressions are deliberate: a silent retry or an auto-merge of half-finished work can race a fix you are landing by hand on the same files. Re-dispatch the bead explicitly with koryph run when you want it worked again:

koryph stop --project myproject beads-042

Add --force to send SIGKILL instead — the agent is killed immediately and any uncommitted in-progress work is lost:

koryph stop --project myproject beads-042 --force

To stop every live agent at once, use --all (combine with --force for SIGKILL). On its own --all sweeps every managed project; add --project to scope the sweep to one project:

koryph stop --all                              # every agent, every project
koryph stop --all --project myproject          # every agent in one project
koryph stop --all --force

tail — inspect a running or recently finished agent's output without attaching:

koryph tail --project myproject beads-042          # last 40 lines
koryph tail --project myproject beads-042 -n 100   # last 100 lines

Output includes session.log (human-readable progress), stderr.log, and the path to stream.jsonl (the raw Claude event stream, useful for cost and token breakdowns).

Drain and resize

koryph gives you three levers for slowing or stopping a loop, at three different scopes. Pick the narrowest one that does what you need:

Command Scope Effect
koryph drain the whole loop stop all new starts (fresh dispatch and retries); let whatever is running finish; then exit
koryph stop <phase-id> one agent SIGTERM that agent; the phase is parked (operator intent), never auto-retried or auto-merged
koryph stop <phase-id> --force (or --all --force) one agent, or every agent SIGKILL immediately; uncommitted work is lost

drain — request a graceful wind-down of the loop itself, without touching any running process:

koryph drain --project myproject

The engine checks for a drain request at every scheduling boundary (every wave in wave mode, every refill tick in rolling mode — see Dispatch mode above): once seen, no new agent is started, but any agent already running is left completely alone to finish its current attempt. "No new start" covers retries too, not only fresh pulls from bd ready: if a running agent dies while the drain is active, its phase is parked (blocked, "drain active") rather than requeued — re-dispatch it after the drain completes. The moment the last active slot lands, the run exits through the normal drained path with reason operator-drain (distinct from the ordinary drained reason, which means the frontier itself was empty) — even if more work is still ready, it is left for the next invocation. The request is one-shot: it consumes itself on that exit, so the next koryph run starts clean. Use --all to drain every registered project at once:

koryph drain --all

If nothing is currently running when the drain fires, the run exits immediately — there is nothing to wait for. A drain request left behind by a run that never got back around to a boundary (e.g. the host died) is treated as stale and cleared at the start of the next run, with a log line noting it — a leftover request can never silently prevent a fresh, intentional run from doing any work.

resize — change a running loop's dispatch width without restarting it:

koryph resize --project myproject --max 5

Like the drain request, the override is re-read at every scheduling boundary, so it takes effect on the very next wave or refill tick. It is clamped to [1, max_concurrent_slots] unless you pass --force (useful for a deliberate short-lived burst above the project's normal cap). 0 is not a valid width — that is what drain is for. Remove the override and revert to the project's configured width with:

koryph resize --project myproject --clear

The override persists across runs — it stays in effect until you --clear it, like a project-config change. To keep a leftover override from silently pinning a new run's width, an explicit koryph run --max N outranks a resize override that was already in place when that run started (the run logs a one-line notice naming the ignored override so --clear is discoverable). A koryph resize issued while a loop is running still takes effect immediately, even if that loop was started with an explicit --max — that is the whole point of live resize. A run with no explicit --max continues to inherit the persisted override as its width.

--all applies the same --max/--clear to every registered project. Both drain and resize are recorded in the central audit log (~/.koryph/audit.jsonl), same as other operator actions.

Per-account concurrency pools

--max and resize bound a single project's wave width. The concurrency governor is the machine-wide cap that sits above them: it limits how many agents run at once across every koryph run on the host, so independent loops cannot collectively breach an account's API rate limits. That cap lives in ~/.koryph/governor.json and is keyed per account — the same account identity the quota ledger already uses (a project's quota_profile, defaulting to its account_profile).

Because two accounts have independent rate limits, they get independent pools: a larger subscription can run more agents than a smaller work seat, and running both at once does not sum into a shared machine ceiling (the memory floor and the resource governor protect the machine — concurrency pools protect each account's rate limit). Set a per-account cap with:

koryph governor set --account personal --max-global 12   # a 20x Max subscription
koryph governor set --account work     --max-global 4    # a smaller work seat
koryph governor show                                     # lists every pool's cap + live leases

--account names the pool; omit it (or the whole flag) to configure the default anthropic pool. An account with no configured cap defaults to the built-in ceiling until you set one.

Migration. Before per-account pools, koryph governor set --max-global N configured one shared anthropic pool. A project whose account_profile is a named account (e.g. personal or work) now resolves to its own pool, so re-assert its cap with koryph governor set --account <name> --max-global N. A project with no account profile keeps using the default anthropic pool unchanged. koryph governor show reveals which pools exist.

Memory admission floor

Every dispatched agent is a separate claude subprocess plus a git worktree, so a wide wave — especially with the adaptive concurrency overlay probing the cap upward — can exhaust host RAM and OOM the machine. The memory admission floor is a machine-wide guard: when the host's available memory drops below the floor, the scheduler defers new dispatches to a later wave (running agents are never touched), exactly like a concurrency-cap denial. It is a soft safety rail — a missing or unreadable memory signal always fails open (dispatch proceeds).

A bead that declares res:<kind> labels sharpens this check from reactive to demand-aware: the floor comparison also subtracts every other live lease's outstanding memory reservation for its declared kinds, so a wave of cluster-provisioning beads reserves memory for the ones already admitted instead of waiting for the host to actually feel the pressure. See Machine: resources for the mechanics.

A bead that declares no res:<kind> footprint still costs a claude subprocess and a worktree, so it too carries a reservation: the per-agent memory reserve (default 1536 MB). Without it, N kind-less agents each cleared the floor individually against a snapshot that assumed zero cost for the others, so a burst could collectively admit past the real headroom and start swapping — the exact macOS OOM this guard closes. With it, admitting K kind-less agents reserves K × the per-agent estimate against the floor, the same way declared kinds reserve their mem_mb. Tune it per pool (or turn it off) with:

koryph governor set --est-per-agent-mb 2048            # reserve 2 GB per kind-less agent
koryph governor set --est-per-agent-mb 0               # reset to the 1536 MB default
koryph governor set --est-per-agent-mb -1              # disable the per-agent reserve

KORYPH_EST_PER_AGENT_MB overrides it for a single run (same value grammar). Beads that declare res:<kind> are unaffected — they keep reserving their declared per-kind mem_mb.

The floor is a machine property (like the global concurrency cap), so it lives in ~/.koryph/governor.json, per provider pool. It is on by default, sized to physical memory (~1/8 of total RAM, clamped to a 1–8 GB band) — e.g. ~3 GB on a 24 GB host. Override or turn it off with:

koryph governor set --min-free-memory-mb 4096          # explicit: defer while < 4 GB free
koryph governor set --min-free-memory-mb 0             # reset to the auto (sized) floor
koryph governor set --min-free-memory-mb -1            # disable the gate entirely

koryph governor show reports the active floor (auto, explicit, or disabled). For a one-off run without editing governor.json, set KORYPH_MIN_FREE_MEMORY_MB in the environment (same values: a positive floor, 0 for auto, negative to disable) — it overrides the configured floor for that run. The available-memory signal is read from /proc/meminfo (Linux) or sysctl + vm_stat (macOS); a platform with no probe fails open (gate off). On macOS the estimate counts only the promptly reclaimable page classes (free + speculative + purgeable) and deliberately excludes inactive pages, which the kernel cannot hand out without first writing them back — counting them over-reported headroom and admitted agents into an already-swapping host.

Every pool ends up with an explicit floor (koryph-4rk6.1). The 2026-07-21 incident happened because the anthropic pool shipped an explicit min_free_memory_mb, but the personal/work pools had none at all and quietly rode the implicit auto-sized floor above — which wasn't tight enough to stop 11+ agents from OOMing the host. To make the floor an operator-visible, uniform number instead of an implicit fallback, koryph run now backfills DefaultMinFreeMemoryMB (2048 MB) onto any pool whose min_free_memory_mb is still the raw, never-set 0 at startup, and koryph governor set --max-global seeds the same default onto any pool it creates fresh. One consequence: --min-free-memory-mb 0 ("reset to auto") is a same-session reset only — the next koryph run backfills that pool back up to the uniform default, since a raw 0 is indistinguishable from "never configured." Use --min-free-memory-mb -1 for a floor that genuinely stays disabled across runs. koryph doctor flags any pool still missing an explicit floor.

Corpus audit: koryph plan

Before running the loop — or after changing area_map in koryph.project.json — run the corpus audit to see how well your bead corpus parallelizes under the current scheduler rules (the two-word plan audit still works as an alias):

koryph plan --project myproject

The audit is read-only (no bd mutations, no loop-behavior change). It reports:

Section What it means
UNLABELED Beads whose footprint resolves to domain:unknown — they serialize one-per-wave. Add area:* or fp:* labels to unlock concurrency.
NON-DISPATCHABLE Beads that will never dispatch as-is: wrong issue_type (epic/feature/decision/merge-request), gt:* gate label, no-dispatch, or refactor-core.
CONFLICTING PAIRS Every pair of open, dependency-unordered beads whose footprints conflict under the scheduler's rules. A dependency-unordered pair could in principle run simultaneously but their footprints prevent it. The shared conflict tokens and the mode (write-write, write-read, or mixed) are named for each pair.
PARALLEL WIDTH Current: maximum beads that can run simultaneously with current labels (greedy, no concurrency cap). Potential: same metric after virtually re-labeling every domain:unknown bead — shows the concurrency recoverable by labeling.
CORPUS STATS Counts of refactor-core (orchestrator-authored on main; never loop-dispatched) and no-dispatch (manually deferred) beads.

Machine-readable output. Pass --json to get a structured JSON report for agent consumption (e.g., for a koryph-replan skill that automatically files label-fix beads):

koryph plan --project myproject --json | jq .parallel_width

Typical workflow after changing area_map. Refinining the area map changes which tokens each area:* label resolves to, which can reveal new conflicts or unlock new parallelism:

# 1. Edit koryph.project.json: add/modify area_map entries.
# 2. Audit the corpus to see the impact:
koryph plan --project myproject
# 3. File labeling tasks for beads with domain:unknown, or split conflicting beads.
# 4. Re-run the audit to confirm the improvement.