From c694a36f6470fba679bcb1f14e626f6bdc853b0e Mon Sep 17 00:00:00 2001 From: LeTuR Date: Fri, 11 Sep 2026 10:13:50 +0200 Subject: [PATCH 1/3] feat(queue): let a blocker name a condition outside the queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `block` took only `--on `, so a task held by something that is not a task had no state it could be recorded in. On 2026-09-11 vending-machine-egress-resume/01-vm-identity-reconciliation was ready by every record fleet keeps and unrunnable in fact — its brief's first instruction reads Azure and `az` was not authenticated. `plan` listed it as ready and the reconciler correctly woke the lead to dispatch it; the only honest answer left was to refuse in conversation and leave the record silent. A blocker may now name a CONDITION instead of a task, with its own closed set of kinds and the same required `--why`. It puts the task in `waiting` and out of `ready`, so `notify_lead.py` stops counting it, and nothing clears it but `block --clear` naming it back — no timer, no `collect`, no `reap`. --- .agents/skills/fleet-queue/SKILL.md | 59 +++++- AGENTS.md | 16 +- interface/fleet_queue.lua | 51 +++++- scripts/lib/notify_lead.py | 11 ++ scripts/lib/pane_harness.lua | 8 +- scripts/lib/queue.py | 268 ++++++++++++++++++++++++---- scripts/pane-selftest.sh | 14 ++ scripts/queue-selftest.sh | 242 +++++++++++++++++++++++++ scripts/queue.sh | 14 ++ 9 files changed, 640 insertions(+), 43 deletions(-) diff --git a/.agents/skills/fleet-queue/SKILL.md b/.agents/skills/fleet-queue/SKILL.md index 4057953..e70b067 100644 --- a/.agents/skills/fleet-queue/SKILL.md +++ b/.agents/skills/fleet-queue/SKILL.md @@ -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 @@ -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 --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 diff --git a/AGENTS.md b/AGENTS.md index 760caea..1eefc56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` clears + when that task LANDS; `--condition ''` 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 @@ -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 diff --git a/interface/fleet_queue.lua b/interface/fleet_queue.lua index f2a548e..3b5b625 100644 --- a/interface/fleet_queue.lua +++ b/interface/fleet_queue.lua @@ -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 @@ -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] @@ -467,11 +477,22 @@ 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. 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 ref:sub(2) or ref, + kind = kind or "", + condition = condition, + } end return out end @@ -492,7 +513,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 @@ -851,13 +876,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", } @@ -1512,14 +1543,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 diff --git a/scripts/lib/notify_lead.py b/scripts/lib/notify_lead.py index 26b8d4c..bc2fccb 100644 --- a/scripts/lib/notify_lead.py +++ b/scripts/lib/notify_lead.py @@ -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 diff --git a/scripts/lib/pane_harness.lua b/scripts/lib/pane_harness.lua index 334dcff..eb33ff3 100644 --- a/scripts/lib/pane_harness.lua +++ b/scripts/lib/pane_harness.lua @@ -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` @@ -276,7 +278,11 @@ 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. 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), }, { diff --git a/scripts/lib/queue.py b/scripts/lib/queue.py index 321ef87..082b820 100644 --- a/scripts/lib/queue.py +++ b/scripts/lib/queue.py @@ -131,6 +131,50 @@ def _load_forge(): "other": "another concrete condition that makes independent progress unsafe", } +# THE SECOND FORM OF BLOCKER: a task held by something that is not a task. +# +# WHY IT EXISTS, measured. On 2026-09-11 +# `vending-machine-egress-resume/01-vm-identity-reconciliation` was ready by +# every record fleet keeps and unrunnable in fact — its brief's first +# instruction reads Azure and `az` was not authenticated. `block` took only +# `--on `, so there was nothing to write down: `plan` listed the task as +# ready and `notify_lead.py` correctly woke the lead to dispatch it. The only +# honest answer was to refuse in conversation and leave the record silent, +# which is the one thing this queue exists not to do. +# +# A condition is a durable, nameable reason a task is not ready that no task +# will ever satisfy: a credential, an approval, a window, a machine somebody +# has to fix, a decision nobody has made. Its kinds are their own closed set +# rather than a reuse of the four above, because those four all describe a +# relationship BETWEEN TASKS — every condition would land on `other` and the +# set would stop saying anything. +# +# AND NOTHING CLEARS ONE BUT A HAND. A task blocker clears when the task it +# names LANDS, which is an event the forge reports. A condition has nothing to +# observe, so `blocker_cleared` answers False for it forever and `block +# --clear --condition ...` is the only way out. A condition that expired +# on a timer, or on a later dispatch appearing to work, would put back exactly +# the silence it was recorded to break. +CONDITION_KINDS = { + "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", +} + + +def blocker_condition(blocker: dict) -> str: + """The condition an entry names, or "" when it names a task instead. + + The ONE test for which form a `blocked_by` entry is, so the eight readers + of that list cannot come to eight opinions about it. An entry carries + `task:` or `condition:` and never both — `cmd_block` writes exactly one. + """ + return str(blocker.get("condition") or "").strip() + + # `stuck` and `failed` are the worker's own verdicts, not an observation of its # session. Nothing here derives a state from a transition. # @@ -820,8 +864,10 @@ def write_yaml(path: str, doc: dict, header: str) -> None: # which is yours; this is the index entry. # # blocked_by is the ONLY thing that makes this task wait, and every entry names -# a kind from a closed set plus a reason. Overlapping files are recorded under -# `touches` instead, where they are reported as a risk and hold nothing up. +# a kind from a closed set plus a reason. An entry names EITHER a `task:`, +# which clears when that task lands, OR a `condition:` outside the queue, which +# clears only when somebody runs `block --clear`. Overlapping files are recorded +# under `touches` instead, where they are reported as a risk and hold nothing up. """ TOPIC_HEADER = """\ @@ -993,6 +1039,13 @@ def is_ready(self, task: Task) -> bool: return all(self.blocker_cleared(b) for b in task.blockers) def blocker_cleared(self, blocker: dict) -> bool: + # A CONDITION NEVER CLEARS ITSELF. There is no upstream to observe, so + # there is no event this could read, and inventing one — a timer, a + # later dispatch that happened to work — would release a task on a + # guess. `block --clear` is the only release, and it is a person + # saying the thing is no longer true. + if blocker_condition(blocker): + return False try: return self.get(blocker["task"]).state == "landed" except QueueError: @@ -1042,18 +1095,24 @@ def blocker_view(q: Queue, task: Task, blocker: dict) -> dict: unclearable the upstream can never land, so there is no release path unknown the upstream is not in this queue at all holding the ordinary case — a merge that can still come + outside a CONDITION, which no event releases — only `block --clear` `cleared` is kept as a field of its own because `is_ready` asks exactly - that question and nothing else. + that question and nothing else. `outside` is the one status that is never + `cleared`: the record exists for exactly as long as the condition is true, + and removing it is how it stops being true. """ - ref = blocker.get("task") + condition = blocker_condition(blocker) + ref = None if condition else blocker.get("task") try: - upstream = q.get(ref).state + upstream = None if condition else q.get(ref).state except QueueError: upstream = None if task.state in CONCLUDED_STATES: status = "moot" + elif condition: + status = "outside" elif upstream is None: status = "unknown" elif upstream == "landed": @@ -1065,6 +1124,7 @@ def blocker_view(q: Queue, task: Task, blocker: dict) -> dict: view = { "task": ref, + "condition": condition or None, "kind": blocker.get("kind"), "why": blocker.get("why"), "upstream_state": upstream, @@ -1082,9 +1142,24 @@ def blocker_line(view: dict) -> str: X" and "held by X, which is abandoned" are the difference between a wait and a dead end, and only the second one tells the reader to go and do something about it. + + A CONDITION'S LINE SAYS WHO RELEASES IT, for the same reason. There is no + upstream state to carry and no merge coming, so the fact a reader needs in + its place is that nothing here is going to change on its own. """ - what = f"{view['kind']} on {view['task']}" why = view["why"] or "no reason recorded" + condition = view.get("condition") + if condition: + if view["status"] == "moot": + return ( + f"was held by {view['kind']} outside the queue ({condition}); " + "this task concluded, so it holds nothing" + ) + return ( + f"held by {view['kind']} outside the queue ({condition}) — only " + f"`block --clear` releases it: {why}" + ) + what = f"{view['kind']} on {view['task']}" if view["status"] == "moot": return f"was held by {what}; this task concluded, so it holds nothing" if view["status"] == "cleared": @@ -1623,46 +1698,81 @@ def render_brief(task: Task, topic: dict, body: str | None) -> str: {result_note}""" -def blocker_kind_refusal() -> str: +def blocker_kind_refusal(condition: bool = False) -> str: """The paragraph a missed `--kind` or `--why` gets, in one place. The half that matters is the last sentence: overlapping files are the commonest thing someone reaches for `block` to express, and they are not a blocker. `--touches` records them and `plan` reports them as a risk beside the ready set, holding nothing up. + + The two forms get the two closed sets, not the union. A `--condition` that + was answered with the four task kinds would be answered with four + relationships between tasks, none of which it can be. """ kinds = "\n".join(f" {k:<24} {v}" for k, v in BLOCKER_KINDS.items()) - return ( + conditions = "\n".join(f" {k:<24} {v}" for k, v in CONDITION_KINDS.items()) + head = ( "a blocker needs --kind and --why, because it is the one thing that\n" "makes work wait and it has to survive the next planning pass.\n" - f"--kind is one of:\n{kinds}\n" + ) + if condition: + return ( + head + + f"--kind, for a --condition, is one of:\n{conditions}\n" + "A condition is something OUTSIDE the queue — a credential, an approval,\n" + "a window, a machine somebody has to fix. Nothing clears one but\n" + "`block --clear --condition ...`." + ) + return ( + head + + f"--kind is one of:\n{kinds}\n" "Overlapping files are not on that list. Record them with `add --touches`;\n" - "they are reported as a risk beside the ready set and hold nothing up." + "they are reported as a risk beside the ready set and hold nothing up.\n" + f"Waiting on something that is not a task at all? `--condition` instead\n" + "of `--on`, with one of:\n" + conditions ) def blocker_kind(value: str) -> str: """`--kind`'s validator, so a wrong one still gets the whole paragraph. - `choices` beside it is what puts the four values in `--help` — they used to + `choices` beside it is what puts the values in `--help` — they used to appear only in the refusal you got after guessing wrong, and the lead guessed twice in one session. `choices` alone would then answer `invalid choice` and lose the sentence about `--touches`, so the message stays here and argparse never reaches its own. + + It accepts the UNION of the two sets, because which set applies depends on + whether `--on` or `--condition` came with it and argparse has neither yet. + `cmd_block` is what refuses a kind from the wrong set, where it can say so. """ - if value in BLOCKER_KINDS: + if value in BLOCKER_KINDS or value in CONDITION_KINDS: return value raise argparse.ArgumentTypeError("\n" + blocker_kind_refusal()) def cmd_block(args) -> int: + """Record — or remove — one of the two things that make a task wait. + + ONE ENTRY NAMES A TASK OR A CONDITION, NEVER BOTH. `--on` and `--condition` + are mutually exclusive in the parser above, and everything downstream tests + which by asking `blocker_condition`. + """ q = Queue(queue_root()) task = q.get(args.ref) + condition = (args.condition or "").strip() + + if condition: + return block_on_condition(q, task, condition, args) + target = q.get(args.on) if args.clear: before = len(task.blockers) - task.doc["blocked_by"] = [b for b in task.blockers if b["task"] != target.ref] + task.doc["blocked_by"] = [ + b for b in task.blockers if b.get("task") != target.ref + ] task.save() print(f"{task.ref}: {before - len(task.doc['blocked_by'])} blocker(s) cleared") return 0 @@ -1673,7 +1783,7 @@ def cmd_block(args) -> int: raise QueueError(f"{task.ref} cannot block itself") task.doc.setdefault("blocked_by", []) - task.doc["blocked_by"] = [b for b in task.blockers if b["task"] != target.ref] + task.doc["blocked_by"] = [b for b in task.blockers if b.get("task") != target.ref] task.doc["blocked_by"].append( {"task": target.ref, "kind": args.kind, "why": args.why.strip(), "recorded": now()} ) @@ -1681,7 +1791,7 @@ def cmd_block(args) -> int: cycle = find_cycle(Queue(queue_root())) if cycle: - task.doc["blocked_by"] = [b for b in task.blockers if b["task"] != target.ref] + task.doc["blocked_by"] = [b for b in task.blockers if b.get("task") != target.ref] task.save() raise QueueError("that blocker closes a cycle: " + " -> ".join(cycle)) @@ -1689,6 +1799,55 @@ def cmd_block(args) -> int: return 0 +def block_on_condition(q: Queue, task: Task, condition: str, args) -> int: + """The condition form: a wait on something the queue cannot observe. + + NO CYCLE CHECK, because a condition is not an edge — `find_cycle` walks + `task:` entries and this adds none. And no landing to wait for: the record + stands until `--clear` names the same condition back. + """ + if args.clear: + before = len(task.blockers) + task.doc["blocked_by"] = [ + b for b in task.blockers if blocker_condition(b) != condition + ] + task.save() + gone = before - len(task.doc["blocked_by"]) + if not gone: + held = [blocker_condition(b) for b in task.blockers if blocker_condition(b)] + raise QueueError( + f"{task.ref} records no condition {condition!r}. It holds: " + + (", ".join(repr(h) for h in held) if held else "no condition at all") + ) + print(f"{task.ref}: {gone} blocker(s) cleared") + return 0 + + if args.kind not in CONDITION_KINDS or not (args.why or "").strip(): + raise QueueError(blocker_kind_refusal(condition=True)) + + task.doc.setdefault("blocked_by", []) + task.doc["blocked_by"] = [ + b for b in task.blockers if blocker_condition(b) != condition + ] + task.doc["blocked_by"].append( + { + "condition": condition, + "kind": args.kind, + "why": args.why.strip(), + "recorded": now(), + } + ) + task.save() + + print( + f"{task.ref} waits on a condition outside the queue ({args.kind}): " + f"{condition}\n" + f" Nothing clears this but you: ./scripts/queue.sh block {task.ref} " + f"--clear --condition {shlex.quote(condition)}" + ) + return 0 + + def find_cycle(q: Queue) -> list | None: colour: dict[str, int] = {} stack: list[str] = [] @@ -1697,8 +1856,10 @@ def walk(ref: str) -> list | None: colour[ref] = 1 stack.append(ref) for b in q.tasks[ref].blockers: - nxt = b["task"] - if nxt not in q.tasks: + # A condition names no task, so it is not an edge in this graph and + # cannot be part of a cycle. + nxt = b.get("task") + if nxt is None or nxt not in q.tasks: continue if colour.get(nxt) == 1: return stack[stack.index(nxt):] + [nxt] @@ -5269,15 +5430,23 @@ def run_facts(q: Queue, slug: str) -> str: f"| `{t.id}` — {cell(d.get('title'))} | `{cell(where_it_runs(t))}` " f"| `{cell(d.get('branch'))}` | {state} | {cell(d.get('artifact'))} |" ) - held = sorted({(t.id, bl["task"], bl["kind"], bl["why"]) + held = sorted({(t.id, blocker_condition(bl) or bl.get("task") or "", + bool(blocker_condition(bl)), bl["kind"], bl["why"]) for t in tasks for bl in t.blockers}) # The two things about ordering worth having in a run log, and the # reason the last one was typed by hand: what waited and on what # condition, and what went out together anyway despite touching one # file. The second is the queue's whole doctrine, so it is recorded # here rather than left to a lead remembering to mention it. - for tid, on, kind, why in held: - lines += ["", f"- **`{tid}` waits on `{on}`** — {kind}: {why}"] + # + # A CONDITION IS NAMED AS ONE, in words rather than as a ref in + # backticks: a reader of this log who cannot tell "waited for 01" from + # "waited for somebody to log into Azure" has lost the fact that makes + # the second one worth writing down. + for tid, on, is_condition, kind, why in held: + what = (f"a condition outside the queue — {on}" if is_condition + else f"`{on}`") + lines += ["", f"- **`{tid}` waits on {what}** — {kind}: {why}"] for o in q.overlaps(tasks): refs = ", ".join(f"`{r.split('/')[-1]}`" for r in o["tasks"]) lines += ["", f"- **Overlap on `{o['touches']}`** — {refs}. A risk that " @@ -5837,11 +6006,22 @@ def cmd_check(args) -> int: if d.get("id") != t.id: problems.append(f"{ref}: id {d.get('id')!r} disagrees with its directory") for b in t.blockers: - if b.get("kind") not in BLOCKER_KINDS: + # The two forms are validated against their own closed sets, and a + # condition is checked for none of the three things that are only + # true of a task: there is no upstream record to find. + cond = blocker_condition(b) + kinds = CONDITION_KINDS if cond else BLOCKER_KINDS + if b.get("kind") not in kinds: problems.append(f"{ref}: blocker kind {b.get('kind')!r} is not a real one") if not (b.get("why") or "").strip(): - problems.append(f"{ref}: blocker on {b.get('task')} has no reason") - if b.get("task") not in q.tasks: + problems.append( + f"{ref}: blocker on {cond or b.get('task')} has no reason" + ) + if cond and b.get("task"): + problems.append( + f"{ref}: blocker names both a task and a condition; it is one or the other" + ) + if not cond and b.get("task") not in q.tasks: problems.append(f"{ref}: blocker names {b.get('task')}, which does not exist") if not os.path.exists(t.file("BRIEF.md")): problems.append(f"{ref}: no BRIEF.md") @@ -5945,24 +6125,48 @@ def build_parser() -> argparse.ArgumentParser: a.add_argument("--number", help="two-digit ordinal; the next free one by default") a.set_defaults(func=cmd_add, creates=True) - b = sub.add_parser("block", help="record why one task must wait for another") + b = sub.add_parser( + "block", + help="record why a task must wait — for another task, or for a " + "condition outside the queue", + ) b.add_argument("ref") - b.add_argument("--on", required=True, help="the task this one waits for") - # `choices` lists the four in --help; `type` is what answers a wrong one, - # so the explanation survives instead of argparse's bare "invalid choice". + # EXACTLY ONE OF THE TWO, enforced here rather than in cmd_block, so the + # refusal for "both" and for "neither" is argparse's and reads the same as + # every other missing argument. + what = b.add_mutually_exclusive_group(required=True) + what.add_argument("--on", help="the task this one waits for") + what.add_argument( + "--condition", + help="what outside the queue holds this task — a credential, an " + "approval, a window, a machine. Nothing clears one but `--clear` " + "naming it back", + ) + # `choices` lists them in --help; `type` is what answers a wrong one, so + # the explanation survives instead of argparse's bare "invalid choice". + # The list is the UNION because argparse has not read `--on` or + # `--condition` yet; `cmd_block` refuses one from the wrong set. b.add_argument( "--kind", type=blocker_kind, - choices=sorted(BLOCKER_KINDS), - help="the kind of condition that makes this task wait. Overlapping " - "files are not one of them — record those with `add --touches`", + choices=sorted(set(BLOCKER_KINDS) | set(CONDITION_KINDS)), + # `choices` is the union and cannot say which form each belongs to, so + # the help text does. A lead reading nine values with no split would + # have to guess the same way the one flat list of four used to make + # them guess. + help="the kind of condition that makes this task wait. With --on: " + + ", ".join(BLOCKER_KINDS) + + ". With --condition: " + + ", ".join(CONDITION_KINDS) + + ". Overlapping files are on neither list — record those with " + "`add --touches`", ) b.add_argument("--why", help="the concrete reason, in your own words") b.add_argument( "--clear", action="store_true", - help="remove the blocker naming --on. A task can carry more than one, " - "so clearing still has to say which", + help="remove the blocker naming --on, or the one naming --condition. " + "A task can carry more than one, so clearing still has to say which", ) b.set_defaults(func=cmd_block) diff --git a/scripts/pane-selftest.sh b/scripts/pane-selftest.sh index 03eb583..14b74cf 100755 --- a/scripts/pane-selftest.sh +++ b/scripts/pane-selftest.sh @@ -24,6 +24,10 @@ # 4. THE TWO TALLIES AGREE. The counter row counts TASKS by state; a section # heading covers TOPICS. They were both drawn as bare numbers, and # `4 running` above `RUNNING 5` is the pane contradicting itself. +# 5b. THE TWO KINDS OF WAIT DO NOT LOOK ALIKE. A blocker naming a task ends +# when that task lands; a blocker naming a CONDITION outside the queue +# ends only when somebody runs `block --clear`. Drawn identically, the +# second reads as "wait", and the operator who is the only actor waits. # 5. THE PUBLISH ROW SAYS THE NEXT MOVE, AT BOTH WIDTHS. A state word is a # fact about the pull request; what the operator does about it is a # second thing, and the row's `note` is where it lives. The note was the @@ -118,6 +122,15 @@ refute "no outcome the state already says" "shipped" "$WIDE" # --- what still has to be there --------------------------------------------- expect "a blocker that still holds is loud" "↳ 02-shepherd-records-publish" "$WIDE" + +# THE CONDITION FORM, which is a different wait and has to look like one. `↳` +# is a wait with an end — the named task lands and this moves. `⊘` has no such +# event: only `queue.sh block --clear` releases it, so the operator reading the +# row is the actor. A pane that drew the two the same would be telling them to +# wait for nobody. +expect "a condition outside the queue is drawn" "⊘ az login for the tenant" "$WIDE" +expect "and its kind says which sort of condition" "credential" "$WIDE" +refute "a condition is not drawn as a wait on a task" "↳ az login" "$WIDE" expect "a missing brief is still said out loud" "no brief" "$WIDE" expect "a result nothing has collected is still said" "uncollected" "$WIDE" expect "the pull request is still named" "#47" "$WIDE" @@ -184,6 +197,7 @@ fi expect "the running work is still named at 30" "Cut the pane back" "$NARROW" expect "the blocker that holds is still there at 30" "↳ 02-shepherd" "$NARROW" +expect "and so is the condition, with its own mark" "⊘ az login" "$NARROW" refute "and still no cleared blocker" "✓ 01-declare" "$NARROW" # The width the pane routinely gets, which is the whole reason the note moved # up the ladder: a next move drawn only at 44 is a next move nobody reads. diff --git a/scripts/queue-selftest.sh b/scripts/queue-selftest.sh index 3e02969..f61dbea 100755 --- a/scripts/queue-selftest.sh +++ b/scripts/queue-selftest.sh @@ -91,6 +91,16 @@ # one; the loop's own commands refresh the facts inside a fenced block # and rewrite it rather than appending to it; and prose the lead wrote # outside that block survives every later pass. +# 20. A TASK HELD BY SOMETHING OUTSIDE THE QUEUE HAS A STATE IT CAN BE +# RECORDED IN. A blocker may name a CONDITION instead of a task — a +# credential, an approval, a window, a machine somebody has to fix — with +# its own closed set of kinds and the same required reason. It puts the +# task in `waiting` and never in `ready`, it reaches `list`, `show` and +# `fleet-status.sh`, and the line the reconciler types into the lead's +# terminal does not count it. Nothing clears one but `block --clear` +# naming it back: not `collect`, not `reap`, not another task landing. +# The task-to-task form keeps its `landed` gate and its `UNCLEARABLE`, +# asserted in that same queue because the two share the code. # # Test 4 is also the wake proof. The event source is `thurbox-cli watch`, which # this script replaces with a recorded stream through `FLEET_QUEUE_WATCH_CMD` — @@ -5063,6 +5073,238 @@ git -C "$glrepo" worktree remove --force \ git -C "$frepo" worktree remove --force \ "$tmp/queue-fake/.worktrees/${ftopic}__02-conflicting" 2>/dev/null +# --- 20. a wait on a CONDITION: recordable, visible, and cleared only by hand - +# +# The defect this proves gone, measured. On 2026-09-11 +# `vending-machine-egress-resume/01-vm-identity-reconciliation` was ready by +# every record fleet keeps and unrunnable in fact: its brief's first +# instruction reads Azure and `az` was not authenticated. `block` took only +# `--on ` — "the task this one waits for" — so there was nothing to write +# down. `plan` listed the task as ready and `notify_lead.py` correctly typed +# the loop's one line into the lead's terminal telling it to dispatch. The only +# honest answer left was to refuse in conversation and leave the record saying +# nothing. +# +# Four claims, and the last one is the incident: +# +# A condition can be RECORDED, and it still needs a kind from a closed set +# and a reason, so "these edit the same file" gains no new spelling. +# It shows up EVERYWHERE work in flight is reported — `plan`'s waiting block, +# `list`, `show`, `fleet-status.sh` — and never in `ready`. +# NOTHING CLEARS IT BUT A HAND. Not a timer, not `collect`, not `reap`. +# `notify_lead.py` DOES NOT COUNT IT toward the ready set it wakes the lead +# about, which is the line that was typed on 2026-09-11. +# +# And the form that already existed is asserted here too rather than assumed, +# because it shares every line of code this section changed: the `landed` gate +# and `UNCLEARABLE` both have to still be true in the same queue. + +export FLEET_QUEUE_DIR="$tmp/queue-conditions" +mkdir -p "$tmp/repo-conditions" + +xtopic="$($QUEUE topic add vending-machine-egress \ + --title 'Resume the vending machine egress' \ + --prompt 'reconcile the VM identities and resume egress')" + +for spec in \ + "01:vm-identity:Reconcile the VM identities against Azure" \ + "02:document-the-tables:Write the identity tables down" \ + "03:terraform-the-vms:Terraform the three VMs" \ + "04:after-a-dead-end:A task waiting on one that never lands" \ + "05:the-dead-end:The task that gets abandoned"; do + IFS=: read -r n slug title <<<"$spec" + if ! out="$($QUEUE add "$xtopic" "$slug" --title "$title" \ + --repo "$tmp/repo-conditions" --branch "fix/$slug" --number "$n" 2>&1)"; then + fail "add $slug" "$out" + fi +done + +# --- the refusals come first: a new form is a new way to spell the old lie --- + +if out="$($QUEUE block "$xtopic/01-vm-identity" \ + --condition 'az is authenticated for the mazet tenant' 2>&1)"; then + fail "a condition with no kind and no reason is refused" "$out" +else + expect "a condition with no kind and no reason is refused" "--kind" "$out" + expect "and the refusal lists the condition kinds, not the task ones" \ + "missing-credential" "$out" + expect "and says what the only release is" "--clear" "$out" +fi + +if out="$($QUEUE block "$xtopic/01-vm-identity" --condition 'az is authenticated' \ + --kind file-overlap --why 'both edit main.tf' 2>&1)"; then + fail "file overlap is not a condition kind either" "$out" +else + expect "file overlap is not a condition kind either" "--kind" "$out" + expect "and the refusal still points at --touches" "--touches" "$out" +fi + +# The two closed sets stay two. A kind that describes a relationship BETWEEN +# TASKS says nothing about a credential, and taking it would have made the set +# decorative. +if out="$($QUEUE block "$xtopic/01-vm-identity" --condition 'az is authenticated' \ + --kind semantic-dependency --why 'reads Azure' 2>&1)"; then + fail "a task kind is refused on a condition" "$out" +else + expect "a task kind is refused on a condition" "missing-credential" "$out" +fi + +if out="$($QUEUE block "$xtopic/03-terraform-the-vms" --on "$xtopic/01-vm-identity" \ + --kind missing-credential --why 'az is not authenticated' 2>&1)"; then + fail "and a condition kind is refused on --on" "$out" +else + expect "and a condition kind is refused on --on" "semantic-dependency" "$out" +fi + +if out="$($QUEUE block "$xtopic/01-vm-identity" --on "$xtopic/02-document-the-tables" \ + --condition 'az is authenticated' --kind other --why 'both' 2>&1)"; then + fail "a blocker names a task or a condition, never both" "$out" +else + expect "a blocker names a task or a condition, never both" \ + "not allowed with argument" "$out" +fi + +# --- recording one, and where it then shows up ------------------------------- + +AZ='az is authenticated for the mazet tenant' +if ! out="$($QUEUE block "$xtopic/01-vm-identity" --condition "$AZ" \ + --kind missing-credential \ + --why "the brief's first instruction reads Azure and az account show fails" 2>&1)"; then + fail "record a condition" "$out" +fi +expect "recording one says who releases it, since nothing else will" \ + "--clear" "$out" + +# The other form, in the same queue, so the `landed` gate below is proved +# against the same code path this section changed. +$QUEUE block "$xtopic/03-terraform-the-vms" --on "$xtopic/02-document-the-tables" \ + --kind semantic-dependency --why 'terraforms the identities 02 writes down' >/dev/null +$QUEUE block "$xtopic/04-after-a-dead-end" --on "$xtopic/05-the-dead-end" \ + --kind semantic-dependency --why 'consumes what 05 was going to add' >/dev/null +set_field "$xtopic/05-the-dead-end" state abandoned + +plan="$($QUEUE plan 2>&1)" +expect "the condition-held task waits" "01-vm-identity" "$plan" +expect "and it is in the waiting block, not the ready one" "waiting: 3" "$plan" +expect "the ready set is what is left" "ready: 1" "$plan" +expect "the plan names the condition itself" "$AZ" "$plan" +expect "and the reason somebody recorded for it" "az account show fails" "$plan" +expect "and says the release is a hand, not an event" "only \`block --clear\`" "$plan" +refute "a condition is never called a wait on a task" "on $AZ" "$plan" + +ready="$($QUEUE plan --json 2>&1 | + python3 -c 'import json,sys; print(",".join(json.load(sys.stdin)["ready"]))')" +if [ "$ready" = "$xtopic/02-document-the-tables" ]; then + pass "plan --json agrees: the condition-held task is not ready" +else + fail "plan --json ready set" "got $ready" +fi + +out="$($QUEUE list 2>&1)" +expect "list shows the task as waiting" "01-vm-identity waiting" "$out" +expect "and carries the condition under it" "$AZ" "$out" + +out="$($QUEUE show "$xtopic/01-vm-identity" 2>&1)" +expect "show carries it too" "$AZ" "$out" +expect "and names its kind" "missing-credential" "$out" + +out="$(./scripts/fleet-status.sh 2>&1)" +expect "fleet-status reports it as well" "$AZ" "$out" + +view="$(./scripts/fleet-status.sh --json 2>&1)" +expect "and the machine-readable reading calls it a wait on something outside" \ + '"status": "outside"' "$view" +expect "which is never cleared" '"cleared": false' "$view" + +out="$($QUEUE check 2>&1)" +expect "a recorded condition is a valid record" "ok" "$out" + +# --- the existing form, unchanged, in the same queue ------------------------- + +expect "a blocker on a task that can never land still says so" "UNCLEARABLE" "$plan" +expect "and still carries that upstream's state" "which is abandoned" "$plan" + +# --- and nothing else clears the condition ----------------------------------- +# +# `collect` and `reap` are the two commands that move tasks without being told +# which, and they run over this queue with the condition standing. A condition +# that expired because some other task landed would put back exactly the +# silence this section is about. + +session_is 44444444-4444-4444-4444-444444444444 idle +$QUEUE attach "$xtopic/02-document-the-tables" \ + 44444444-4444-4444-4444-444444444444 >/dev/null +pipeline_pr 777 fix/document-the-tables +cat >"$FLEET_QUEUE_DIR/$xtopic/02-document-the-tables/result.md" <<'EOF' +--- +outcome: shipped +artifact: https://github.com/Thurbeen/fleet/pull/777 +--- +Wrote the identity tables down. +EOF + +out="$($QUEUE collect 2>&1)" +expect "collect closes the task that finished" "shipped" "$out" +echo MERGED >"$states/777.state" +out="$($QUEUE reap 2>&1)" +expect "and reap lands it once the forge says merged" "landed" "$out" + +plan="$($QUEUE plan 2>&1)" +expect "the task blocker cleared on the LAND, exactly as before" \ + "03-terraform-the-vms" "$plan" +expect "and the condition did not: it is still holding 01" "$AZ" "$plan" +expect "so the ready set grew by one and not by two" "ready: 1" "$plan" +refute "01 is still out of the ready set after a collect and a reap" \ + " $xtopic/01-vm-identity " "$plan" + +# --- 20a. the line the reconciler types, which is where this went wrong ------ +# +# THE ASSERTION THAT REPRESENTS THE INCIDENT, written as the case it came from: +# one task ready, one held by a condition, and the loop says ONE. `notify_lead` +# reads `plan --json`'s ready set and nothing else, which is why the fix lives +# in `is_ready` — but "the loop no longer names this task" is the claim that +# was false on 2026-09-11, so it is asserted here rather than inferred from the +# reading it is built on. + +notify="$tmp/notify-conditions" +mkdir -p "$notify" +printf '{"id":"lead-1","name":"Gate Control","state":"idle"}\n' \ + >"$sessions/lead-1.json" +: >"$sends" + +log="$($QUEUE plan --json | FLEET_LEAD_SESSION="Gate Control" \ + python3 scripts/lib/notify_lead.py --state-dir "$notify" 2>&1)" +woke="$(cat "$sends")" +expect "the loop woke the lead about the ready work" "1 task(s) ready" "$log" +expect "and the line it typed names the task nothing is holding" \ + "03-terraform-the-vms" "$woke" +expect "it says ONE task is ready, not two" \ + "1 task(s) ready and nothing will dispatch" "$woke" +refute "the condition-held task is not in the line the lead was sent" \ + "01-vm-identity" "$woke" + +rm -f "$sessions/lead-1.json" +: >"$sends" + +# --- 20b. and a hand is what releases it ------------------------------------- + +if out="$($QUEUE block "$xtopic/01-vm-identity" --clear \ + --condition 'some other condition' 2>&1)"; then + fail "clearing a condition this task does not hold is refused" "$out" +else + expect "clearing a condition this task does not hold is refused" \ + "records no condition" "$out" + expect "and the refusal says what it does hold" "$AZ" "$out" +fi + +out="$($QUEUE block "$xtopic/01-vm-identity" --clear --condition "$AZ" 2>&1)" +expect "clearing it by name removes it" "1 blocker(s) cleared" "$out" + +plan="$($QUEUE plan 2>&1)" +expect "and the task returns to the ready set" "01-vm-identity" "$plan" +expect "which is now two" "ready: 2" "$plan" +refute "with nothing left holding it" "$AZ" "$plan" + echo if [ "$failed" -eq 0 ]; then printf '\033[32mqueue-selftest: every claim holds\033[0m\n' diff --git a/scripts/queue.sh b/scripts/queue.sh index 0086a22..265fd73 100755 --- a/scripts/queue.sh +++ b/scripts/queue.sh @@ -27,6 +27,15 @@ # a kind and a reason, so it survives the next planning pass instead of # being re-derived. `block` refuses one that does not. # +# A BLOCKER NAMES A TASK OR A CONDITION. `--on ` is the wait with an +# end: it clears when that task LANDS. `--condition ''` is the wait on +# something the queue cannot observe — a credential, an approval, a window, +# a machine somebody has to fix, a decision nobody has made. Nothing clears +# one but `block --clear` naming it back: no timer, no `collect`, no `reap`, +# and no inference from a later dispatch working. Before it existed, a task +# held by an unauthenticated `az` had nowhere to be written down, so `plan` +# called it ready and the reconciler woke the lead to dispatch it. +# # A queue that runs one task at a time is slower than no queue at all. # # `dispatch` takes refs for the one case that is neither ready nor blocked — @@ -163,6 +172,11 @@ # scripts/queue.sh block --on --kind KIND --why 'reason' # or --clear, # which names the blocker to remove, since a task can # carry several; `block --help` lists the valid kinds +# scripts/queue.sh block --condition 'what holds it' --kind KIND --why 'reason' +# # the second form: a wait on something OUTSIDE the +# # queue, which clears only when you run +# # `block --clear --condition 'what holds it'`. +# # Its kinds are their own closed set, also in --help # scripts/queue.sh plan [--json] # what goes out now, what waits, and why # scripts/queue.sh dispatch [...] [--dry-run] # the whole ready set at # once with no ref, which is the norm; refs launch From 193021c756481e0236c8df0b5b714fc8b8aad5a7 Mon Sep 17 00:00:00 2001 From: Arthur Cesare-Herriau Date: Fri, 11 Sep 2026 10:26:34 +0200 Subject: [PATCH 2/3] chore: no-mistakes review - refuse a blank --condition and unquote it in the pane --- interface/fleet_queue.lua | 7 ++++++- scripts/lib/pane_harness.lua | 7 ++++++- scripts/lib/queue.py | 13 ++++++++++--- scripts/pane-selftest.sh | 6 +++++- scripts/queue-selftest.sh | 20 ++++++++++++++++++++ 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/interface/fleet_queue.lua b/interface/fleet_queue.lua index 3b5b625..9a29726 100644 --- a/interface/fleet_queue.lua +++ b/interface/fleet_queue.lua @@ -482,6 +482,11 @@ end --- 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 @@ -489,7 +494,7 @@ local function edges(field) ref = ref or pair local condition = ref:sub(1, 1) == "!" out[#out + 1] = { - ref = condition and ref:sub(2) or ref, + ref = condition and scalar(ref:sub(2)) or ref, kind = kind or "", condition = condition, } diff --git a/scripts/lib/pane_harness.lua b/scripts/lib/pane_harness.lua index eb33ff3..566d647 100644 --- a/scripts/lib/pane_harness.lua +++ b/scripts/lib/pane_harness.lua @@ -281,8 +281,13 @@ local TOPICS = { -- 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", + blockers = "!'az login: for the tenant'|missing-credential", brief = 0, events = 0, moved = ago(48), }, { diff --git a/scripts/lib/queue.py b/scripts/lib/queue.py index 082b820..d5617da 100644 --- a/scripts/lib/queue.py +++ b/scripts/lib/queue.py @@ -1761,10 +1761,9 @@ def cmd_block(args) -> int: """ q = Queue(queue_root()) task = q.get(args.ref) - condition = (args.condition or "").strip() - if condition: - return block_on_condition(q, task, condition, args) + if args.condition is not None: + return block_on_condition(q, task, args.condition.strip(), args) target = q.get(args.on) @@ -1806,6 +1805,14 @@ def block_on_condition(q: Queue, task: Task, condition: str, args) -> int: `task:` entries and this adds none. And no landing to wait for: the record stands until `--clear` names the same condition back. """ + if not condition: + raise QueueError( + "--condition is the wait itself, in words: --condition 'az is\n" + "authenticated for the mazet tenant'. Nothing removes one but\n" + "`block --clear --condition` naming it back, so a blank one is a\n" + "wait nobody could name and nobody could release." + ) + if args.clear: before = len(task.blockers) task.doc["blocked_by"] = [ diff --git a/scripts/pane-selftest.sh b/scripts/pane-selftest.sh index 14b74cf..08f2293 100755 --- a/scripts/pane-selftest.sh +++ b/scripts/pane-selftest.sh @@ -128,9 +128,13 @@ expect "a blocker that still holds is loud" "↳ 02-shepherd-records-publish" "$ # event: only `queue.sh block --clear` releases it, so the operator reading the # row is the actor. A pane that drew the two the same would be telling them to # wait for nobody. -expect "a condition outside the queue is drawn" "⊘ az login for the tenant" "$WIDE" +expect "a condition outside the queue is drawn" "⊘ az login: for the tenant" "$WIDE" expect "and its kind says which sort of condition" "credential" "$WIDE" refute "a condition is not drawn as a wait on a task" "↳ az login" "$WIDE" +# The condition arrives as the raw YAML scalar, and free prose is routinely +# quoted on disk. Those quotes belong to the writer; drawing them puts +# punctuation the operator never typed into the one row they have to act on. +refute "the writer's YAML quoting is not drawn" "⊘ 'az login" "$WIDE" expect "a missing brief is still said out loud" "no brief" "$WIDE" expect "a result nothing has collected is still said" "uncollected" "$WIDE" expect "the pull request is still named" "#47" "$WIDE" diff --git a/scripts/queue-selftest.sh b/scripts/queue-selftest.sh index f61dbea..34d6355 100755 --- a/scripts/queue-selftest.sh +++ b/scripts/queue-selftest.sh @@ -5131,6 +5131,26 @@ else expect "and says what the only release is" "--clear" "$out" fi +# An unset shell variable is the ordinary way this arrives — `--condition +# "$COND"` with nothing in `$COND`. argparse is satisfied, because the flag was +# given; a blank condition is still a wait nobody named, and `--clear` could +# never name it back. +for blank in '' ' '; do + if out="$($QUEUE block "$xtopic/01-vm-identity" --condition "$blank" \ + --kind missing-credential --why 'az is not authenticated' 2>&1)"; then + fail "a blank condition is refused" "$out" + else + expect "a blank condition is refused" "--condition" "$out" + refute "and refused, not crashed" "Traceback" "$out" + fi + if out="$($QUEUE block "$xtopic/01-vm-identity" --clear \ + --condition "$blank" 2>&1)"; then + fail "and clearing a blank condition is refused too" "$out" + else + refute "and clearing a blank condition is refused too" "Traceback" "$out" + fi +done + if out="$($QUEUE block "$xtopic/01-vm-identity" --condition 'az is authenticated' \ --kind file-overlap --why 'both edit main.tf' 2>&1)"; then fail "file overlap is not a condition kind either" "$out" From 1c99fc003687954131e2c026285148ed726eabed Mon Sep 17 00:00:00 2001 From: Arthur Cesare-Herriau Date: Fri, 11 Sep 2026 13:42:50 +0200 Subject: [PATCH 3/3] chore: no-mistakes document - document the condition form of a blocker in the queue README --- orchestration/queue/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/orchestration/queue/README.md b/orchestration/queue/README.md index 8da2f94..157e051 100644 --- a/orchestration/queue/README.md +++ b/orchestration/queue/README.md @@ -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