dos-dispatch-loop — the generic dispatch⇄replan cadence

SkillDev tools

Run recurring `dos-dispatch` cycles, switching to `dos-replan` when the backlog drains and stopping on the kernel's loop verdict. Use for unattended dispatch->replan->dispatch work across disjoint lanes.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the dos-dispatch-loop — the generic dispatch⇄replan cadence skill

What this skill tells your AI

The instructions your AI receives, as published by anthony-chaudhary/fak in .agents/skills/dos-dispatch-loop/SKILL.md and read by ahel’s review.

The unattended plan-and-ship loop. It runs /dos-dispatch repeatedly and falls to /dos-replan when a lane drains, stopping on a typed, kernel-decided condition (not a prose guess). The stop/continue logic is the kernel's loop_decide.decide — the loop carries counters, the kernel decides. Several loops on disjoint lanes run in parallel, each holding its own lane lease.

The stop conditions are the kernel's, in one place:

  1. iteration cap — reached max_iterations (default 10).
  2. drained-twice — a DRAIN after a productive /dos-replan that itself followed a DRAIN (the lane is genuinely exhausted).
  3. consecutive-unclear — the dispatch subprocess is failing systematically.
  4. rate-limited — a usage window is exhausted (don't burn launches).
  5. launch-failed — a subprocess never started.
  6. pick-held-invariant — the next unit is held ONLY by a reason a re-dispatch cannot change (draft-class / operator-gated / soak-open / dependency-unmet); re-dispatching it would re-block identically, so honest-STOP + surface the hold.
  7. pick-cooldown — the next unit was attempted-and-didn't-move inside its cooldown window AND nothing fresher is offerable; re-dispatching it would re-storm a known drain (the ~5%-shipping re-pick loop the bare loop hit).
  8. not-ratcheting — the OUTER RATCHET (docs/351): the loop has run too many iterations in a row with no witnessed net gain — the reconcile-VERIFIED ship-count (Step 3) is not rising, even though each iteration reported SHIPPED. The loop is running but not improving (the "spinning, narrating progress while net-shipping nothing real" failure that worsens the longer the loop runs). The verdict is the kernel's improve ESCALATE, read VERBATIM — not the loop's self-report. Hand the judgment back to a human.

Conditions 6–7 are the docs/207 anti-churn rungs (the loop stops re-picking work it cannot move); condition 8 is the docs/351 ratchet (the loop stops when it is moving but not improving — RSI made first-class here, the same improve keep-gate the /dos-self-improve loop uses, now gating each dispatch iteration's net gain).

Inputs

  • --lane <name> (optional) — focus the whole loop on one lane (fixed for the run; a bare loop auto-picks a free lane at Step 0).
  • --gate hard|soft|drive (default hard) — the verdict policy. hard routes a non-LIVE verdict through /dos-replan; soft/drive stop on a true DRAIN; drive self-heals a STALE-STAMP inline.
  • --max-iterations <N> (default 10).

Step 0 — Pre-flight: take the lane, read the taxonomy

dos doctor --workspace . --json
python tools/dos_fleet_lease.py --workspace . acquire --lane <LANE> --kind cluster

The fleet lease wrapper materializes the live lease union, feeds it to the kernel acquire, then republishes this node's held set. For ordinary shard lanes this is the same-host fast path; for the three global lanes (abi, release, global) it uses the GitRefStore cross-node transport by default (#21). A REFUSE means a sibling loop already holds an overlapping lane — pick a free one from kernel.free_clusters or exit. Initialise the loop counters (iteration=1, the breakers at 0).

dos lease-lane acquire is the lease AUTHORITY; dos arbitrate is ADVISORY. Only acquire folds the durable lane-journal WAL under the lease mutex and journals the grant, so only its verdict binds. Honor its REFUSE; never start editing a tree on the strength of an arbitrate GO alone (arbitrate holds no lock, so even a correct GO can be stale by acquire time).

The advisory has two surfaces and they do not behave the same:

  • dos arbitrate (the CLI) reads the live WAL by default, so it agrees with acquire. Passing --leases '[]' opts out into an empty world.
  • The MCP tool dos_arbitrate reads nothing — it arbitrates only against the live_leases you hand it, and an omitted argument means "the world is empty", so every lane reads free. Observed live: MCP dos_arbitrate answered "cluster lane 'tools' free — admitted." for a lane dos lease-lane acquire refused the same second as "already held by a live loop" (dos-kernel#246). Until that lands, either use the CLI form or pass the real set explicitly (dos lease-lane live / fak leaseref live). A FREE from a checker that cannot see the store is not evidence of a free lane.

Step 1 — Pick-selection: skip held + cooled units (the anti-churn gate)

Before a dispatch iteration offers a unit, screen the candidate unit set so the loop never re-storms work it cannot move (the docs/207 §6 throughline). For each candidate, in order:

dos pickable <UNIT> --state '<host-gathered state>'   # OFFERABLE=0; HELD=per-reason code
dos cooldown <UNIT>                                    # CLEAR=0; RECENTLY_ATTEMPTED=3
  • A pickable exit of 0 (OFFERABLE) and a cooldown exit of 0 (CLEAR) → this unit is dispatchable; offer it and proceed to the iteration.
  • A pickable HELD by a re-dispatch-CURABLE reason (IN_FLIGHT / SOFT_CLAIMED / STALE_CLAIM / UNPARSEABLE) or a cooldown of 3 (RECENTLY_ATTEMPTED) → skip this unit, try the next candidate (the skip-to-next is pick-selection's job).
  • A pickable HELD by a re-dispatch-INVARIANT reason (DRAFT_CLASS=10 / OPERATOR_GATED=11 / SOAK_OPEN=12 / DEPENDENCY_UNMET=13) → carry that verdict into Step 2; the kernel will honest-STOP on it (don't re-dispatch a unit a re-dispatch cannot un-gate).

When EVERY remaining candidate is skipped (all cooled / curably-held), carry the last cooldown RECENTLY_ATTEMPTED (or the invariant pickable hold) into Step 2 as the loop's pre-dispatch evidence — the kernel turns "nothing fresh is offerable" into the pick-cooldown / pick-held-invariant honest-STOP. Do not re-dispatch the cooled/held unit yourself — that is the re-pick storm this gate prevents.

Step 1b — Run one iteration

For a dispatch iteration, invoke /dos-dispatch --lane <LANE> (it snapshots, gates, and ships). For a replan iteration, invoke /dos-replan. Capture the iteration's outcome:

  • a dispatch iteration that reached the gate carries a typed verdict — get it from dos gate over the packet's dispositions sidecar (LIVE/DRAIN/STALE-STAMP/ BLOCKED/RACE).
  • a replan iteration carries a productivity signal (did it refill/garden?).

Step 2 — Decide continue / replan / stop (the kernel decides)

This is the load-bearing step: the decision is a kernel mechanism, not prose. Feed the iteration outcome + the carried counters to the loop decider. In code a host calls dos.loop_decide.decide(state, outcome); the screenplay's job is to construct the typed IterationOutcome and read the returned LoopDecision:

  • action: "continue" → run the next iteration in next_mode (dispatch or replan); if reconcile is set (a soft/drive STALE-STAMP), run an inline stamp-reconcile pass first. Carry next_state forward (the updated counters).
  • action: "stop" → the loop ends; report stop_reason (one of the five above) and surface (whether it needs operator attention).
  • action: "retry-same-iter" → a transient overload; sleep backoff_seconds and re-run the SAME iteration.

The drained-twice rule is the kernel's: a DRAIN counts toward an early stop ONLY after a productive /dos-replan. A STALE-STAMP or BLOCKED gate routes to /dos-replan (under hard) but never arms a false drained-twice stop — that is the structural fix the typed gate exists for.

The pre-dispatch evidence from Step 1 rides into the decision too: the kernel reads the carried Pickability (→ pick-held-invariant stop) and Cooldown (→ pick-cooldown stop). So the loop's continue/stop is driven END-TO-END by kernel rungs — the honest-STOP that used to be a per-run human override is now a kernel rule, not prose the loop re-applies each iteration.

Step 2b — Gather the outer-ratchet verdict (docs/351, the RSI rung)

One more piece of in-flight evidence rides into decide(): the outer ratchet, the verdict on whether this iteration produced a witnessed net gain or the loop is spinning while narrating progress. The work-metric is the cross-run KEEP the loop already computes in Step 3 — the count of picks dos reconcile confirms VERIFIED (exit 0) against git ancestry, NOT the SHIPPED self-report. Carry a cumulative baseline of VERIFIED picks across iterations and ask the kernel:

# work = the carried cumulative VERIFIED count + this iteration's VERIFIED picks;
# baseline_work = the carried cumulative count BEFORE this iteration.
# suite/truth come from the gate being LIVE and reconcile not flagging QUIET_INCOMPLETE.
dos improve --workspace . \
  $( [ "$GATE_LIVE" = 1 ] && echo --suite-passed ) \
  $( [ "$RECONCILE_CLEAN" = 1 ] && echo --truth-clean ) \
  --work "$VERIFIED_TOTAL" --baseline-work "$VERIFIED_BASELINE" \
  --consecutive-reverts "$RATCHET_REVERTS" --max-reverts 3 --json

Branch on the verdict (the exit code): 0 KEEP — net gain witnessed, raise the baseline (VERIFIED_BASELINE=$VERIFIED_TOTAL), reset RATCHET_REVERTS=0; 3 REVERT — no net gain this iteration, bump RATCHET_REVERTS; 4 ESCALATE — the breaker is open. Pass the verdict as loop_decide.decide's ratchet evidence. When it is ESCALATE the kernel STOPs the loop with not-ratcheting (condition 8): N iterations of "SHIPPED" that all reconcile QUIET_INCOMPLETE accrue reverts and stop the loop instead of burning the cap — the exact failure the bare loop hit (motion without measured progress). This is the same improve keep-gate the /dos-self-improve loop uses, now gating each dispatch iteration's net gain; the metric is the non-forgeable reconcile-VERIFIED count (git ancestry), never the loop's word.

Step 3 — Reconcile each claimed pick (the cross-run KEEP)

A dispatch iteration that SHIPPED claims picks done — but a claim is a self-report. Before the archive drops a claimed pick from the residual, reconcile its claim against ground truth so a quietly-incomplete pick re-enters the pickable set next iteration, flagged:

dos reconcile <UNIT> --claimed-done --plan <PLAN> --phase <PHASE>   # oracle from git

Branch on the exit code (the verdict IS the code):

  • 0 VERIFIED → the oracle confirms it shipped; it leaves the residual.
  • 3 QUIET_INCOMPLETE → CLAIMED done but the oracle says NOT_SHIPPED — KEEP it in the residual, flagged; it re-enters the pickable set next iteration so the host routes it (a verifier pass / /dos-replan / a finding). Do NOT believe the claim — only ground truth removes work (the FQ-336 touch-counts-as-ship false-DRAIN is exactly what this catches).
  • 4 HONEST_OPEN → not claimed, not shipped; honest open work, stays in the residual.

This is the cross-run KEEP wired at the boundary that runs the write (the CLAUDE.md "wire the contract into the step that runs the write" rule).

Step 4 — Archive + release (and leave the lane's tree clean)

When the loop stops, write a loop record under paths.runs (the run dir from dos doctor --json: the per-iteration verdicts, the reconcile flags, the stop reason) and release the lane lease. Commit with a generic subject read from config — no hardcoded prefix.

Leave the tree clean for the lane you held — an unattended loop must not strand its own writes. A loop that ships work but leaves it uncommitted is the bug the oracle catches next session: dos verify answers from git ancestry, so an uncommitted change is a phase the kernel reports NOT_SHIPPED (the "a commit IS the ship-stamp" contract). So at close-out, commit your lane's writes — driven by the same dos verbs the loop already uses, with generic git:

  • Commit your lane's writes by explicit pathspec — stage and commit exactly the files under the lane region you leased (the tree globs dos lease-lane acquire granted) via fak sync check and fak commit --path <lane paths> -m "<subject>" [--push] (or fak sync push). (Fallback: git commit -s -m "<subject>" -- <lane paths>). Never a bare git add -A — when sibling loops hold disjoint lanes on the same tree, a blanket add sweeps another loop's in-flight edits into your commit. The lane lease you held names exactly which paths are yours; commit only those.
  • Carry your worker id as a (fak-worker <id>) trailer (best-effort, #2065). If FLEET_WORKER_ID is set in your environment (the dispatcher stamps it), append a (fak-worker <FLEET_WORKER_ID>) trailer line to the commit body so a dispatched worker's witnessed ships are attributable on the shared trunk. This is additive and best-effort: it does NOT replace the (fak <leaf>) ship stamp or the Fixes #N reference, and it is an attribution aid, not a witnessdispatch_status.py folds it read-only (git log --grep='(fak-worker ') and nothing is gated on it, so a missing trailer never blocks a ship. Skip it silently when FLEET_WORKER_ID is unset.
  • Confirm the tree is clean for your lane before you exit. git status --porcelain -- <lane paths> over the region you leased should come back empty once you have committed. If it does not, you stranded durable work — log it and commit it (or, if a path turns out to belong to a still-live sibling lease — check dos lease-lane live/the lane journal — leave it for that loop). Either way the loop must not exit leaving its own lane dirty off a self-reported "done".
  • Scratch is not stranded work. Short-lived probe output (a host's scratch convention — temp dirs, *.err, leading-underscore probes) is deletable noise, not a phase to ship; rm it or leave it gitignored, don't commit it into the lane.

This close-out is what keeps an unattended fleet's tree clean and well-organized across runs: each loop commits its own lane and confirms it left nothing behind, so the trunk never accumulates anonymous WIP from a loop that stopped mid-write.

DOS-repo note (not part of the generic skill): when this loop runs in the DOS kernel repo itself, that repo ships an advisory scripts/git_hygiene.py --strict that mechanizes the "is my lane clean?" check above (exit 1 on stranded durable work, lease-aware). It is a DOS-repo convenience, not a dos verb — a foreign workspace uses the generic git status form above. Both express the same discipline.

What this skill deliberately does NOT do (no silent gap, CLAUDE.md heavy tier)

  • No soft-claim lease core. It coordinates loops by lane lease (dos lease-lane acquire), not the per-pick soft-claim machinery that stays host-side. log this when a sibling lane is busy rather than waiting on a soft-claim.
  • No value-greedy focus scheduler. It picks by lane order + the gate verdict, not a host's per-iteration focus ranking. That scheduler is the heavy tier.
  • No rate-limit predictive monitor / resume manifest. It stops on a RATE_LIMITED outcome and reports it; it does not pre-empt or auto-resume. A host adds that as a driver concern.

It logs each of these the first time it would have used them, so the capability gap is named, never silent.

Worked example (live transcript)

The cadence loop, by hand. Take a lane, keep the WAL beat alive, ask the kernel if the run is moving. Every line is a real dos verb; the heartbeat is the HEARTBEAT op the trajectory-audit cross-signal reads.

Step 0 — take a lane. You ask for src; a live lease made it contended, so the arbiter auto-picks a free cluster lane instead (it never double-books a region):

$ dos arbitrate --workspace . --lane src
{"auto_picked":true,"free_clusters":[],"lane":"benchmark","lane_kind":"cluster","outcome":"acquire","pick_count":null,"reason":"auto-picked free cluster lane benchmark (requested src was refused: lane src would edit the orchestrator's own running code … (SELF_MODIFY) …).","tree":["benchmark/**"]}

→ exit 0 (acquire); the redirect IS the admission kernel refusing an inadmissible region, with the real reason named in the parenthetical (a free, admissible lane you name is granted directly).

Keep the beat alive across iterations — acquire once, heartbeat each pass, release at the end. The HEARTBEAT op is what makes SPINNING reachable from real evidence (a beat is not an event):

$ python tools/dos_fleet_lease.py --workspace . acquire   --lane benchmark    # writes ACQUIRE, then publishes this node's held set
$ python tools/dos_fleet_lease.py --workspace . heartbeat --lane benchmark    # writes HEARTBEAT, then republishes
$ python tools/dos_fleet_lease.py --workspace . release   --lane benchmark    # writes RELEASE, then republishes

Ask the kernel if the run is moving (the temporal verdict, from git delta — never the loop's "making progress" self-report):

$ dos liveness --workspace . --run-id R --start-sha 80d4f30

→ exit 0 = ADVANCING · 3 = SPINNING · 4 = STALLED (2 contract-error). Drive the loop off the exit code, not stdout prose.

Read the beat back from the WAL — an ACQUIRE/HEARTBEAT/REFUSE/RELEASE sequence on generic lanes (ILLUSTRATIVE shape; dos journal replay folds it):

$ dos journal --workspace . tail 4
{"op":"ACQUIRE","lane":"benchmark","tree":["benchmark/**"]}
{"op":"HEARTBEAT","lane":"benchmark"}
{"op":"REFUSE","lane":"benchmark","reason":"lane benchmark is already held by a live loop — pick a different --lane or wait."}
{"op":"RELEASE","lane":"benchmark"}

→ the REFUSE row is the WAL recording the arbiter's "no" — a sibling loop's overlapping lane, fossilized for dos decisions to surface.

Anti-patterns

  • ❌ Re-implementing the stop conditions in prose — route every outcome through the kernel loop decision; the counters/breakers/cap are the kernel's.
  • ❌ Counting a STALE-STAMP/BLOCKED toward drained-twice — only a true DRAIN after a productive replan counts (the kernel enforces this; don't second-guess it).
  • ❌ Running two loops on overlapping lanes — the arbiter REFUSEs the second; honor it, don't --force.

Signals

GitHub stars
38
Forks
15
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
dos-dispatch-loop
Source
github.com/anthony-chaudhary/fak