Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion .agents/skills/fleet-queue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,14 @@ ready: 3 task(s) — every one of them goes out now, there is no concurrency cap
Overlap is a risk signal, not a reason to wait — dispatch
them together and let the delivery path reconcile a rebase.

waiting: 1 task(s) — each held by a durable, recorded blocker
waiting: 2 task(s) — each held by a durable, recorded blocker
report-status-honestly/03-render-detected-agent
held by semantic-dependency on .../01-drop-idle-default (queued):
reads the detected_agent field 01 introduces
report-status-honestly/05-read-the-tenant
held by missing-credential outside the queue (az is authenticated for
the mazet tenant) — only `block --clear` releases it: the brief's first
instruction reads Azure and `az account show` fails
```

The upstream's own state rides along in that line — `(queued)` here — because a
Expand Down Expand Up @@ -273,6 +277,59 @@ A blocker clears only when the task it names has **landed** — concluded AND it
artifact merged (§5b). A session that stopped does not clear it, `done` with an
open pull request does not, and neither does an abandoned task.

### When the thing holding a task is not a task — `--condition`

Sometimes a task is ready by every record and unrunnable in fact. On 2026-09-11
`vending-machine-egress-resume/01-vm-identity-reconciliation` was exactly that:
its brief's first instruction reads Azure and `az` was not authenticated. There
was nothing to write down, so `plan` called it ready, the reconciler woke the
lead to dispatch it, and the only honest answer was to refuse in conversation
and leave the record saying nothing.

**That is what the second form of blocker is for.** It names a CONDITION rather
than a task:

```bash
./scripts/queue.sh block vending-machine-egress-resume/01-vm-identity-reconciliation \
--condition 'az is authenticated for the mazet tenant' \
--kind missing-credential \
--why 'the first instruction in the brief reads Azure, and az account show fails'
```

Its kinds are their own closed set, for the same reason the four above are one —
and a separate set because those four all describe a relationship *between
tasks*, which no condition is:

| kind | when |
|---|---|
| `missing-credential` | a login, secret or session the work needs is not present |
| `awaiting-approval` | a person or a process has to say yes before this can run |
| `closed-window` | it may only run inside a window that is not open |
| `broken-dependency` | something outside the queue is broken and has to be fixed |
| `undecided` | the operator has not made a decision this task turns on |
| `other` | another durable thing outside the queue — name it in `--why` |

**Reach for it when the reason is durable and nameable, and not otherwise.**
"I have not authorized this yet" is a fact about this moment, not a property of
the task — leave that one out of the dispatch by naming refs (§4), which records
nothing. "They edit the same file" is still not a blocker of any kind, and
`--condition` is not a way to spell it: use `add --touches`.

**Nothing clears a condition but you.** A task blocker clears when the task it
names lands, which is an event the forge reports. A condition has nothing to
observe, so no timer, no `collect`, no `reap` and no later dispatch appearing to
work will release it:

```bash
./scripts/queue.sh block <ref> --clear --condition 'az is authenticated for the mazet tenant'
```

That is deliberate. A condition that expired on its own would put back exactly
the silence it was recorded to break. The cost is that a stale one holds a task
forever, which is why `--why` is required and why `plan`, `list`, `show`,
`fleet-status.sh` and the TUI pane all carry it in front of you — the pane draws
it as `⊘` rather than `↳`, because the wait it marks has no actor but you.

## 4. Dispatch — the whole ready set, in one go

```bash
Expand Down
16 changes: 14 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,15 @@ The loop, driven by `./scripts/queue.sh`:
incompatible concurrent migration, or another concrete condition that makes
independent progress unsafe — and record it with `queue.sh block`, which
refuses one that names no kind and no reason. A queue that runs one task at
a time is slower than no queue at all.
a time is slower than no queue at all. **A blocker names a task or a
CONDITION**, and the second form is what a task held by something the queue
cannot observe gets written down as — a credential, an approval, a window, a
machine somebody has to fix, a decision nobody has made. `--on <ref>` clears
when that task LANDS; `--condition '<what>'` clears only when somebody runs
`block --clear` naming it back, so nothing — no timer, no `collect`, no
`reap` — can release a task on a guess. Before it existed, a task whose brief
began by reading an Azure nobody was logged into read as ready and the
reconciler woke the lead to dispatch it.
4. Each worker targets a real repo and its own git worktree — the control plane
holds the plan and the log, never the workers' branches. `dispatch` gets each
new session past its agent's trust dialog before it sends the brief
Expand Down Expand Up @@ -226,7 +234,11 @@ and is the full usage. Four things about it are load-bearing:
ready and the command that sends it. Once per transition, never into a lead
mid-turn, and silent when no lead session is running.
`scripts/lib/notify_lead.py` owns those three rules. Notifying is not
deciding: nothing moves, and the choice is still the lead's.
deciding: nothing moves, and the choice is still the lead's. **It says
exactly what `plan` says is ready and derives nothing**, which is how a
condition-held task stays out of the line: `is_ready` never clears a
condition, so the one reading carries the answer and there is no second
opinion here to keep in step.
- **`nudge` is the accelerator and never the guarantee.** A worker's Claude
Code `Stop` hook can call `./scripts/reconcile.sh nudge` to bring the
periodic pass forward; a worker that died on a token limit fires no hook at
Expand Down
56 changes: 49 additions & 7 deletions interface/fleet_queue.lua
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@ local SCROLL_STEP = 3
--- have recorded, and a tree that showed only refs would be hiding the answer to
--- the only question a reader has about it.
---
--- A CONDITION — `queue.py`'s second form of blocker, a wait on something
--- outside the queue — rides in the same field with `!` in front of it, since
--- a task ref can never begin with one. Its text is free prose a person typed,
--- so the probe flattens the two characters this encoding owns (`,` and `|`)
--- to spaces: the pane is the glance and `queue.sh show` is the record.
---
--- The three `publish-*` fields come out of ONE NESTED BLOCK, parsed the way
--- `- task:` / ` kind:` already is: a flag set on `publish:` and cleared by the
--- next top-level key, with the two-space keys read while it is set. The flag is
Expand Down Expand Up @@ -404,6 +410,10 @@ for topic in */; do
pub && /^ method: / { pm = substr($0, 11) }
pub && /^ state: / { ps = substr($0, 10) }
/^- task: / { n = n + 1; refs[n] = substr($0, 9) }
# The condition form, marked with a `!` no task ref can start with, and
# flattened of the two characters this field is built out of.
/^- condition: / { n = n + 1; c = substr($0, 14); gsub(/[,|]/, " ", c)
refs[n] = "!" c }
/^ kind: / { kinds[n] = substr($0, 9) }
END {
for (i = 1; i <= n; i++) bl = bl (i > 1 ? "," : "") refs[i] "|" kinds[i]
Expand Down Expand Up @@ -467,11 +477,27 @@ end
--- reader cannot reconstruct from anywhere else: `touches` overlap is reported
--- and holds nothing up, so an edge here means somebody wrote down a concrete
--- reason that independent progress was unsafe.
---
--- A `!` LEADER IS THE OTHER FORM: `queue.py`'s CONDITION, a wait on something
--- outside the queue. It is kept as a flag rather than as a prefix left on the
--- text, because every reader below asks one question of it — this one does
--- not clear on its own — and none of them wants the punctuation.
---
--- A condition is free prose, so it is the one field here the writer routinely
--- QUOTES: any text holding `: ` comes back from the probe as a quoted YAML
--- scalar. It gets `scalar()` for that reason, exactly as every other field
--- off the probe does; a task ref never needs it.
local function edges(field)
local out = {}
for pair in field:gmatch("[^,]+") do
local ref, kind = pair:match("^(.-)|(.*)$")
out[#out + 1] = { ref = ref or pair, kind = kind or "" }
ref = ref or pair
local condition = ref:sub(1, 1) == "!"
out[#out + 1] = {
ref = condition and scalar(ref:sub(2)) or ref,
kind = kind or "",
condition = condition,
}
end
return out
end
Expand All @@ -492,7 +518,11 @@ local function resolve_states(model)
-- answer that the code is on `main`, not a worker's claim that it
-- opened a pull request. That rule is `queue.py`'s `blocker_cleared`;
-- this is the same rule and not a second opinion about it.
edge.cleared = model.state_of[edge.ref] == "landed"
--
-- AND A CONDITION NEVER CLEARS. There is no upstream to look up, and
-- nothing but `queue.sh block --clear` releases one — same rule,
-- same place in `queue.py`.
edge.cleared = not edge.condition and model.state_of[edge.ref] == "landed"
if not edge.cleared then
held = held or edge.ref
end
Expand Down Expand Up @@ -851,13 +881,19 @@ end

--- The classification vocabulary, in its own order. In one column a group
--- cannot be a colour and a position alone, so the group says its own name.
--- The four blocker kinds `queue.sh block` accepts, in words that fit a column.
--- The set is closed on purpose — queue.py's BLOCKER_KINDS — so an unknown one
--- is shown verbatim rather than mapped to something plausible.
--- The blocker kinds `queue.sh block` accepts, in words that fit a column. Both
--- closed sets — queue.py's BLOCKER_KINDS for a wait on a task, CONDITION_KINDS
--- for a wait on something outside the queue — so an unknown one is shown
--- verbatim rather than mapped to something plausible.
local BLOCKER_KIND = {
["semantic-dependency"] = "consumes",
["shared-external-state"] = "shared state",
["incompatible-migration"] = "migration",
["missing-credential"] = "credential",
["awaiting-approval"] = "approval",
["closed-window"] = "window",
["broken-dependency"] = "broken",
undecided = "undecided",
other = "other",
}

Expand Down Expand Up @@ -1512,14 +1548,20 @@ local function draw(entry, width, spinner)
local edge = entry.edge
local ref = edge.ref
local sibling = task.topic .. "/"
if ref:sub(1, #sibling) == sibling then
if not edge.condition and ref:sub(1, #sibling) == sibling then
ref = ref:sub(#sibling + 1)
end
local kind = BLOCKER_KIND[edge.kind] or edge.kind
-- The prefix is budgeted like everything else: below about a dozen columns
-- the indent and the arrow cost more than the ref they are annotating, so
-- they go and the ref stays.
local lead = " ↳ "
--
-- `⊘` RATHER THAN `↳` IS THE WHOLE DISTINCTION A GLANCE NEEDS. `↳ 02-foo`
-- is a wait with an end: 02 lands and this moves. A condition has no such
-- event — only `queue.sh block --clear` releases one — so the operator
-- reading this row is the actor, and a row that looked like the other kind
-- would be telling them to wait for nobody.
local lead = edge.condition and " ⊘ " or " ↳ "
if widgets.len(lead) + 1 > width then
lead = ""
end
Expand Down
13 changes: 8 additions & 5 deletions orchestration/queue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,14 @@ column, as a reader: it opens these four files and adds nothing to them.

## Three rules worth knowing before you edit anything

**Only `blocked_by` makes a task wait.** It records a kind from a closed set
(`semantic-dependency`, `shared-external-state`, `incompatible-migration`,
`other`) and a reason, and `queue.sh block` refuses one without both. Files two
tasks both expect to change go under `touches`, where `plan` reports them as a
risk beside the ready set and holds nothing up.
**Only `blocked_by` makes a task wait**, and every entry there names EITHER a
`task:` — the wait that ends when that task lands — OR a `condition:` outside
the queue, which nothing clears but `block --clear` naming it back. Never both:
`queue.sh check` reports an entry that carries the two. Each form takes a kind
from its own closed set plus a reason, and `queue.sh block` refuses one without
both; `block --help` lists the kinds of each. Files two tasks both expect to
change go under `touches`, where `plan` reports them as a risk beside the ready
set and holds nothing up.

**`queue.sh watch` closes nothing.** It folds thurbox's event stream into
`progress.jsonl`. A transition says a turn ended, which is not the claim that a
Expand Down
11 changes: 11 additions & 0 deletions scripts/lib/notify_lead.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@
no send, no error and, because the note is deduplicated, one log line
rather than one per pass.

WHAT "READY" MEANS HERE IS THE QUEUE'S ANSWER, NOT A SECOND ONE. This reads
`plan --json`'s ready set and derives nothing, which is what kept it honest
through the defect of 2026-09-11: a task whose brief began by reading Azure was
ready by every record fleet kept, because `block` had no way to record a wait on
an unauthenticated `az`, and the line below was correctly typed about work
nobody could do. The fix is `queue.py`'s second form of blocker — a CONDITION,
which `is_ready` never clears — and it reaches this file for free, through the
one reading it takes. A rule about conditions written HERE as well would be the
second opinion the single reading exists to prevent; `queue-selftest.sh` §20a
asserts the outcome instead, as the case it came from.

WHERE THE STATE LIVES. In the reconciler's own runtime directory, beside its
pid, heartbeat and flags — never on the task. "The lead has been told" is a
fact about one machine's loop and one conversation; it is not part of what a
Expand Down
13 changes: 12 additions & 1 deletion scripts/lib/pane_harness.lua
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,9 @@ _G.thurbox = { taken_at_ms = NOW * 1000, sessions = {}, runs = {} }
-- The queue this renders. It is the shape of the screen the report was about:
-- a RUNNING topic that also holds a task the forge already merged, a chain of
-- blockers of which one is long since cleared, tasks nothing has emitted an
-- event for, and one settled topic underneath.
-- event for, and one settled topic underneath. It also holds a task waiting on
-- a CONDITION outside the queue, which is the blocker nothing will ever clear
-- on its own.
--
-- It also holds the publish state EVERY task passes through and no fixture used
-- to reach: `open`, on a task `shepherd` linked by head branch before `collect`
Expand Down Expand Up @@ -276,7 +278,16 @@ local TOPICS = {
publish = { "no-mistakes", "green", ago(9) },
},
{
-- Held by a CONDITION rather than by a task: the second form of
-- blocker, which no event releases. It is here because the pane is
-- where the operator sees that the wait has no actor but them.
--
-- Quoted, because the probe hands the condition over as the raw YAML
-- scalar and free prose holding `: ` is written quoted on disk. The
-- quotes are the writer's, not the operator's, and the pane draws
-- neither of them.
id = "02-point-the-docs-at-the-pane", state = "queued", title = "Point every document at the pane",
blockers = "!'az login: for the tenant'|missing-credential",
brief = 0, events = 0, moved = ago(48),
},
{
Expand Down
Loading