Fleet Loops

A fleet runs its goal once with mur fleet run. With --loop it keeps going — and the interesting question becomes when does it stop?

mur fleet send builder "cargo nextest run -p mur-core"   # queue work
mur fleet run builder --loop --deadline 30m              # drain it
mur fleet set-loop builder --done-when queue-empty       # persist the policy
mur fleet stop builder                                   # kill-switch

Three ways a loop ends

A loop needs to answer "is this done?" every iteration. There are three answers, and which one fits depends on what the fleet is for.

done_whenEnds whenCost
(empty)the router judges DONE each iterationone model call per iteration, and it can misjudge
queue-emptyan iteration finds nothing queuednone — the check runs before any model call
marker:<TEXT>a member emits <TEXT> alone on a linenone — plain string matching

queue-empty is the one to reach for when you feed a fleet with mur fleet send. Without it, a scheduled fleet that wakes to an empty queue re-sends its standing goal every iteration until the cap runs out. Stuck-detection does not catch that: a member replying "what should I run?" counts as activity, so the clock resets and the loop keeps paying.

marker: needs a second half. The marker only works if something taught an agent to emit that exact text — usually a line in the fleet's goal, or a skill a member loads. Set it in fleet.yaml alongside whatever does the teaching; the Hub deliberately will not author one for you, because it cannot write the other half.

Marker matching is whole-line, not substring. A member that mentions the marker in prose ("I'll print DONE when finished") does not converge the loop — stopping early on a false positive is worse than running one extra iteration.

The bounds

Every loop is bounded whether or not it converges — by the three knobs of Execution Limits, resolved across --flags → fleet.yaml limits: → config.yaml limits: → built-in, and printed at start (bounds: deadline 2h ← fleet.yaml; stuck 10m):

  • Deadline — --deadline 30m, relative to when the loop started, not a calendar date; built-in 1h. Checked between iterations, so it stops the loop starting another round rather than interrupting one mid-flight. Every member the loop delegates to inherits the remaining time.
  • Stuck — how long since the last agent-authored channel event; built-in 10m. A router-only iteration is not progress.
  • Cost — --budget-usd (limits.cost_usd), from real per-token spend, checked before an iteration it cannot afford. Applies only to a fleet on metered models; a local fleet is bounded by its deadline and told so.
  • Iteration cap — gone. --max-iterations and loop.max_iterations are ignored with a notice; a 10 000 diagnostic ceiling remains.
  • Kill-switch — mur fleet stop <name> writes a sentinel honored by the loop, the daemon, and manual runs alike. mur fleet start clears it.
  • Fan-out bound — at most three members run at once by default. Delegated steps are not CPU-bound: each one is another agent's turn against the same local model gateway and the same upstream quota, so an unbounded fan-out is a self-DoS. A six-member fleet dialing at once produced upstream 429s, local connect refusals, and six failed steps. Raise it with MUR_FLEET_FANOUT; a 0 or a nonsense value falls back to the default rather than to unbounded, because unbounded is the failure mode.

Settings that used to mean something else

mur fleet set-loop refuses a value that would be silently reinterpreted. Each of these once looked configured and did nothing:

You writeIt used to mean
--deadline 2026-12-31no deadline at all
--trigger "cron:0 9 31 2 *"never scheduled — February has no 31st
--budget-usd -1no ceiling
--done-when DONErouter judgment — the marker: prefix is required

Validation reuses the same parsers execution uses, so what the check accepts and what the scheduler does cannot drift apart. Only the flags you pass are judged: a fleet already carrying a bad value from an older version still loads, and still accepts an unrelated edit.

A fixed step graph

By default a router model decides, each run, which member does what. When you already know the shape of the work, write it down instead: add procedure: to ~/.mur/fleets/<name>/fleet.yaml and the fleet runs exactly that graph, with no router call.

procedure:
  - id: draft
    member: writer
    task: Draft the answer.
  - id: review-a
    member: critic
    task: Find the weakest claim in the draft.
    depends_on: [draft]
  - id: final
    member: writer
    task: Revise using the review.
    depends_on: [review-a]

Each step gets the full goal plus its own task. A step sees earlier output labelled only by step id, so use neutral ids when members shouldn't know who said what.

RejectedWhen
a member not in members:, an empty or duplicate id, a depends_on naming no step, more than 32 steps, procedure: together with parallel:when the fleet loads
a dependency cyclewhen the run starts

A bad procedure fails outright. It is never quietly replaced by "send the goal to everyone". Remove procedure: to go back to router planning.

Unattended runs

A fleet with a cron: or interval: trigger can be run by the daemon with nobody watching. That path is deliberately harder to reach than the manual one:

  • It is off by default. Set MUR_FLEET_AUTORUN=1, or fleet.autorun in config.yaml.
  • It requires the fleet's limits: to resolve. Every fleet is bounded by its deadline (built-in 1h) or a cost cap on a billable model, so what this gate catches is a limits: block that does not parse — that fleet never auto-runs on a default it did not ask for.
  • It honors the kill-switch, and it fails closed: if governance state cannot be read, the fleet is treated as halted rather than as permitted.

Manual runs need only the first two: mur fleet run and mur fleet run --loop work with nothing set — the built-in bounds apply.

A loop that reaches a step needing human approval does not burn its window against an empty room: the request is parked, the step is reported blocked, and the loop stops with awaiting-approval rather than paying for an iteration that would ask again. Approve it whenever you get to it and the next run continues from there. See Unattended Approvals — including the hitl: policy block that lets a fleet state its own answer.

Watching a run

When a fleet is kicked off from chat, the murmur TUI shows the run live automatically: a per-member status rail plus milestone lines (delegations, member summaries, outcomes, approval gates) streamed from the fleet's signed channel. See Agent CLI → Live fleet progress.

The loop is one run with a handle: mur fleet run <name> --loop --run-id <id> records it under an id you chose (the fleet_run tool mints one and returns it at once), mur_job_status <id> follows it — state plus a liveness verdict — and mur fleet status <name> shows the latest. See Execution Limits → Long work returns a handle.

mur fleet jobs <name> --since <run-id> narrows the job list to that one dispatch instead of the whole queue — the id is a prefix match, so it also picks up every --loop iteration (<run-id>-0, <run-id>-1, …), not just the first. Without --since, jobs shows the queue's queued/running jobs (add --all for the full history), which mixes in whatever an earlier dispatch left behind.