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
ghCLI. Without either, the bead is blocked with a clear reason (never crashed or silently dropped), and the branch is kept so a--resumeretries once the remote orghis 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 landrebases 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_methodinkoryph.project.json(or--methodper run) selects the landing method:ff(default) orsquash. A non-ffmethod is refused with a clear error whilesigning.requiredis 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-declaredprotected_pathsentry, the reason says manual review is required and that--allow-protectedwill 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, ormerge_policy: auto/--directwith 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>thenbd update <id> --status open) sobd readyre-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 Nconfigured one sharedanthropicpool. A project whoseaccount_profileis a named account (e.g.personalorwork) now resolves to its own pool, so re-assert its cap withkoryph governor set --account <name> --max-global N. A project with no account profile keeps using the defaultanthropicpool unchanged.koryph governor showreveals 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
anthropicpool shipped an explicitmin_free_memory_mb, but thepersonal/workpools 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 runnow backfillsDefaultMinFreeMemoryMB(2048 MB) onto any pool whosemin_free_memory_mbis still the raw, never-set0at startup, andkoryph governor set --max-globalseeds 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 nextkoryph runbackfills that pool back up to the uniform default, since a raw0is indistinguishable from "never configured." Use--min-free-memory-mb -1for a floor that genuinely stays disabled across runs.koryph doctorflags 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.