diff --git a/.agents/skills/fleet-queue/SKILL.md b/.agents/skills/fleet-queue/SKILL.md index 2ec4d2d..a72bee9 100644 --- a/.agents/skills/fleet-queue/SKILL.md +++ b/.agents/skills/fleet-queue/SKILL.md @@ -81,6 +81,14 @@ validate on its own. The decomposition is yours. `--touches` is the paths you expect the task to change. It is a **risk signal that gets reported**, never a reason to hold anything back — see §3. +**`--branch` must not exist yet.** thurbox's `--worktree-branch` only ever +CREATES a branch, so a name already in the repo — `main` itself, a branch left +behind by an earlier run — cannot have a worktree cut for it. `add` refuses it +here, naming it: the alternative was a spawn that died at `dispatch` with +thurbox's own exit status, a task left `queued`, and a hand-edited `task.yaml`. +A repo this machine cannot read is not asked, so a `--host` task still finds +out at dispatch. + ### `--host` — running a task on another machine `add --host ` takes a name from thurbox's `hosts.toml` and moves the @@ -136,14 +144,18 @@ Each arrives as the same placeholder: ``` Replace every one of them. **`dispatch` refuses a task that still carries one**, -so a half-written brief is stopped as firmly as a blank one. +naming the sections, so a half-written brief is stopped as firmly as a blank +one. The check compares each section against what the scaffold wrote there, so +a brief that QUOTES the placeholder while talking about it is a written brief +and goes out. `add --brief-file ` fills them from a file instead. It reads the file's own `## ` headings and fills the section each one names; a heading that is not one of the four is kept where it is, as content, and a body with no headings at -all goes into `What to do`. So write the file with the four headings and the -brief comes out complete — the sections you leave out keep their placeholder -and `dispatch` still refuses them. +all goes into `What to do`. **Write the file with all four headings.** Handing +in a file is a claim to have written the brief, so `add` refuses one that +leaves a section unwritten, names which, and creates nothing — fix the file and +run the same command again. `None.` is a complete section. Write it as if the reader knows nothing, because it does: workers share no context with you and none with each other. State the goal, the constraints, and diff --git a/scripts/fleet-status-selftest.sh b/scripts/fleet-status-selftest.sh index fd63a52..3836e66 100755 --- a/scripts/fleet-status-selftest.sh +++ b/scripts/fleet-status-selftest.sh @@ -101,7 +101,23 @@ stubbed="$(sandbox "$tmp/bin-stubbed" "${BASE_TOOLS[@]}")" # --- a queue with something in it ------------------------------------------- "$QUEUE" topic add selftest --title "Selftest topic" --prompt 'the prompt, verbatim' >/dev/null 2>&1 -printf 'Do the thing.\n' >"$tmp/brief.md" +cat >"$tmp/brief.md" <<'MD' +## What to do + +Do the thing. + +## Hard constraints + +None. + +## Coordination + +None. + +## Done means + +It is done. +MD "$QUEUE" add selftest dispatched-task --title "A dispatched task" --repo "$tmp/repo" \ --branch t/dispatched --touches FLEET.md --brief-file "$tmp/brief.md" >/dev/null "$QUEUE" add selftest ready-task --title "A ready task" --repo "$tmp/repo" \ diff --git a/scripts/lib/queue.py b/scripts/lib/queue.py index 765f957..36afd93 100644 --- a/scripts/lib/queue.py +++ b/scripts/lib/queue.py @@ -210,6 +210,75 @@ def split_brief_body(body: str) -> dict: return {h: v for h, v in filled.items() if v} +def brief_sections(text: str) -> dict: + """A RENDERED brief's four sections, mapped to the body written under each. + + `split_brief_body` above reads what the lead HANDS IN; this reads what the + file on disk ended up saying, which is a different question with a + different rule. Here a `## ` heading of any kind ENDS the section it + follows -- the scaffold puts `## Reporting back` after the last one, and a + body that carried its own headings keeps them in place -- while only one of + BRIEF_SECTIONS opens a new one. Text before the first is the preamble the + scaffold wrote and belongs to nobody. + + A `## ` inside a fence is quoted markdown, exactly as it is on the way in. + """ + out: dict[str, list] = {} + current = None + fence = None + for line in text.splitlines(): + stripped = line.strip() + if fence is not None: + if stripped.startswith(fence): + fence = None + elif stripped.startswith("```") or stripped.startswith("~~~"): + fence = stripped[:3] + elif line.startswith("## "): + heading = line[3:].strip() + current = heading if heading in BRIEF_SECTIONS else None + if current: + out.setdefault(current, []) + continue + if current is not None: + out.setdefault(current, []).append(line) + return {h: "\n".join(v).strip() for h, v in out.items()} + + +def unfilled_sections(text: str) -> list: + """Which sections still hold the scaffold's own text, and nothing else. + + The check this replaces was a substring grep for BRIEF_PLACEHOLDER over the + whole file, which answers a different question: does this brief MENTION the + placeholder. A brief about the scaffold mentions it, so the queue could not + carry a task about its own scaffold -- the brief for the task that fixed + this had the quotation cut out of it to get dispatched. + + So compare each section against what the scaffold wrote there. A section + saying anything else is written, including one that quotes the placeholder + while describing it. This weakens nothing: the placeholder standing alone + is still exactly what it always was, and every section is still checked. + + A section whose heading is gone is not reported. The scaffold writes all + four, so a missing one is a lead who restructured the file deliberately, + and this is a check on the scaffold's text and not on the lead's shape. + """ + body = brief_sections(text) + return [h for h in BRIEF_SECTIONS if body.get(h, "").strip() == BRIEF_PLACEHOLDER] + + +def brief_shortfall(path: str) -> str: + """Why this BRIEF.md is not something to send a worker, or "" if it is.""" + try: + with open(path) as fh: + text = fh.read() + except OSError: + return "never written" + missing = unfilled_sections(text) + if missing: + return "still the scaffold's own text under " + ", ".join(missing) + return "" + + # HOW A TASK PUBLISHES, as three words about the ARTIFACT it leaves behind — # and the ONE place each is written down. `render_brief` writes `brief` into # the worker's instructions, `publish_verdict` goes and looks for `artifact`, @@ -1227,6 +1296,48 @@ def next_number(tpath: str) -> str: return f"{max(used) + 1 if used else 1:02d}" +def branch_refusal(repo: str, branch: str, base: str, host: str | None) -> str: + """Why `session create` would fail on this branch, asked at `add` time. + + `--worktree-branch X` only ever CREATES X (see `branch_checkout` below, + which exists for the same reason), so a branch that is already there fails + the spawn with thurbox's own non-zero exit. The task then stays `queued` + and the operator hand-edits task.yaml and dispatches again -- the whole + cost of learning at dispatch what `add` was told. + + branch == base is the instance the operator hit, and it is answered from + the arguments alone: a base branch exists by definition, so no worktree can + ever be cut for a task whose branch IS it. Every other existing branch -- + a re-used name, one left behind by an earlier run, one carrying commits + base has not got -- is the same failure and needs the repo to see. A repo + this machine cannot read, which is every `--host` task's, is left to + dispatch exactly as before. + """ + if branch == base: + return ( + f"--branch and --base are both {branch!r}, and no worktree can be " + "cut there:\n" + "thurbox's --worktree-branch only ever CREATES a branch, and the " + "base of a\n" + "task exists by definition. The spawn fails and the task stays " + "queued.\n" + "Name the branch the work goes ON, off the branch it starts from." + ) + if host or not os.path.isdir(repo): + return "" + if not git_out(repo, ["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"]): + return "" + return ( + f"--branch {branch} already exists in {repo}, so no worktree can be " + "cut for it:\n" + "thurbox's --worktree-branch only ever CREATES a branch. The spawn " + "fails with\n" + f"`a branch named '{branch}' already exists` and the task stays " + "queued.\n" + "Name a branch that is not there yet, or delete that one first." + ) + + def cmd_add(args) -> int: root = queue_root() tpath = os.path.join(root, args.topic) @@ -1261,6 +1372,14 @@ def cmd_add(args) -> int: "a path on THAT machine." ) + # Same argument, one layer down: a branch no worktree can be made on is a + # spawn failure `add` can see coming, and refusing it here costs the + # operator one re-run instead of a dead dispatch and a hand-edited + # task.yaml. + refusal = branch_refusal(args.repo, args.branch, args.base, args.host) + if refusal: + raise QueueError(refusal) + # Resolution, first hit wins and per FIELD. A stated method with no stated # tool drops the operator's global one rather than inheriting it: "run # `/no-mistakes --yes`" is the wrong sentence to hand a `push` task. A @@ -1272,8 +1391,6 @@ def cmd_add(args) -> int: elif args.how: how = args.how - os.makedirs(path) - doc = { "id": tid, "topic": args.topic, @@ -1305,11 +1422,37 @@ def cmd_add(args) -> int: "concluded_at": None, } task = Task(args.topic, tid, path, doc) - task.save() + # Rendered BEFORE anything exists on disk, so that the one thing `add` can + # be wrong about costs nothing to be wrong about. `--brief-file` is a claim + # to have written the brief; a file that names three of the four sections + # leaves the fourth holding the scaffold's placeholder, which `dispatch` + # then refuses as "unwritten" -- about a brief the lead did write, without + # saying which heading it means. That round-trip ran three times in one + # session before the operator started patching the rendered file by hand. + # + # `add` with no --brief-file is untouched. That is the deliberate "scaffold + # it, I will write it" path, and dispatch stays its backstop. brief = open(args.brief_file).read() if args.brief_file else None + text = render_brief(task, read_yaml(os.path.join(tpath, "topic.yaml")), brief) + if args.brief_file: + missing = unfilled_sections(text) + if missing: + raise QueueError( + f"{args.brief_file} leaves {len(missing)} of the brief's " + "sections unwritten:\n" + + "\n".join(f" {h}" for h in missing) + + "\nA worker gets all four whatever the file says, so one the " + "file does not\nname stays the scaffold's placeholder and " + "`dispatch` refuses it. Add the\nheading — `None.` is a " + "complete answer — and run this again. Nothing was created." + ) + + os.makedirs(path) + task.save() with open(task.file("BRIEF.md"), "w") as fh: - fh.write(render_brief(task, read_yaml(os.path.join(tpath, "topic.yaml")), brief)) + fh.write(text) + print(task.ref) return 0 @@ -1330,9 +1473,9 @@ def render_brief(task: Task, topic: dict, body: str | None) -> str: Between the two comes BRIEF_SECTIONS, unwritten: the lead supplies content, not structure. `--brief-file` fills whichever of those four sections its own - `## ` headings name (`split_brief_body`) and leaves the rest for the lead, - so a body handed in on the command line still gets the same skeleton and the - same refusal. + `## ` headings name (`split_brief_body`), so a body handed in on the command + line gets the same skeleton -- and `cmd_add` refuses it on the spot if any + section came out of this still holding the placeholder. The operator's own standing instructions ride the same pointer when there are any, and NOTHING when there are not — a fresh clone has no such file, @@ -1977,7 +2120,12 @@ def pull_remote_result(task: Task) -> str: def read_text(path: str) -> str: - """A brief that is missing reads as unwritten, which stops the dispatch.""" + """A file's content, best effort. A missing one reads as the placeholder. + + What the REMOTE push makes of that: a brief that is not there lands on the + host as a scaffold, and the worker has nothing to do. `brief_shortfall` + above is what stops that reaching a host at all, at dispatch. + """ try: with open(path) as fh: return fh.read() @@ -2099,12 +2247,21 @@ def cmd_dispatch(args) -> int: q = Queue(queue_root()) ready = select_for_dispatch(q, args.ref) - unfilled = [t for t in ready if BRIEF_PLACEHOLDER in read_text(t.file("BRIEF.md"))] + # The backstop for `add`'s own check, and it says the same thing: WHICH + # sections, so the answer is in the refusal and not in a file the lead has + # to go and grep. It fires on a task scaffolded and never written, and on + # one hand-edited back into a placeholder after `add` accepted it. + unfilled = [(t, why) for t in ready if (why := brief_shortfall(t.file("BRIEF.md")))] if unfilled: raise QueueError( - "these tasks still carry an unwritten BRIEF.md, and a worker sent one\n" - "would have nothing to do:\n" - + "\n".join(f" {t.file('BRIEF.md')}" for t in unfilled) + "these tasks carry a BRIEF.md a worker would have nothing to do " + "with:\n" + + "\n".join( + f" {t.ref}: {why}\n {t.file('BRIEF.md')}" + for t, why in unfilled + ) + + "\nWrite those sections, or hand the whole brief to `add " + "--brief-file`." ) if not ready: print("dispatch: nothing ready") diff --git a/scripts/queue-selftest.sh b/scripts/queue-selftest.sh index 0bcb174..69d9300 100755 --- a/scripts/queue-selftest.sh +++ b/scripts/queue-selftest.sh @@ -67,11 +67,16 @@ # through the same batch, whether the task was dispatched while the watch # was already streaming, whether nothing was watching at the time, and # whether the run that read them died part-way. Exactly once each. -# 16. The tool leaves the lead no reason to work around it. A `--brief-file` -# that carries the four standard headings fills the four standard -# sections and one with none behaves as it always did; `dispatch` takes -# refs, so holding a task back needs no invented blocker, and a bare -# `dispatch` still sends everything; and `block --kind` lists its four +# 16. The tool leaves the lead no reason to work around it, and it refuses at +# `add` what only `dispatch` used to discover. A `--brief-file` that +# carries the four standard headings fills the four standard sections, +# and one that leaves any of them unwritten is refused THERE, naming +# them, with nothing created; the check is structural, so a brief quoting +# the scaffold's placeholder to talk about it still dispatches; a branch +# no worktree could be cut for is refused before a task exists; +# `dispatch` takes refs, so holding a task back needs no invented +# blocker, and a bare `dispatch` still sends everything and still names +# the sections of a brief nobody wrote; and `block --kind` lists its four # values in `--help` instead of only in the refusal. # 17. ONE setting puts fleet's mark on the lead and on every worker, and takes # it back off both — rendered into the name thurbox is actually asked to @@ -1146,7 +1151,8 @@ $QUEUE add "$ptopic" pr-task --title 'Publish as a plain pull request' \ --repo /tmp/repo-a --branch fix/pr-task --number 02 \ --publish pr --how 'run the operator xyz skill' >/dev/null $QUEUE add "$ptopic" push-task --title 'Publish straight onto the base branch' \ - --repo "$pwork" --branch main --base main --number 03 --publish push >/dev/null + --repo "$pwork" --branch fix/push-task --base main --number 03 \ + --publish push >/dev/null # (a) The declaration reaches the worker, rendered from the one dict in # queue.py — with the operator's words for the tool when there are any, and @@ -1234,7 +1240,8 @@ The pipeline ran, and then I pushed one more commit. EOF $QUEUE add "$ptopic" push-astray --title 'Push a commit that never landed' \ - --repo "$pwork" --branch main --base main --number 06 --publish push >/dev/null + --repo "$pwork" --branch fix/push-astray --base main --number 06 \ + --publish push >/dev/null cat >"$FLEET_QUEUE_DIR/$ptopic/06-push-astray/result.md" </dev/null cat >"$FLEET_QUEUE_DIR/$ptopic/07-push-elsewhere/result.md" <<'EOF' --- @@ -1276,7 +1284,8 @@ Pushed it on devbox. EOF $QUEUE add "$ptopic" push-unreadable --title 'Push into a repo this machine has not got' \ - --repo /tmp/not-a-checkout --branch main --base main --number 08 \ + --repo /tmp/not-a-checkout --branch fix/push-unreadable --base main \ + --number 08 \ --publish push >/dev/null cat >"$FLEET_QUEUE_DIR/$ptopic/08-push-unreadable/result.md" <<'EOF' --- @@ -1982,9 +1991,6 @@ srepo="$shep/repo" mkdir -p "$srepo" git -C "$srepo" init -q -b main git -C "$srepo" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base -for br in conflicting green skipped elsewhere busy unrun gone second prose-only; do - git -C "$srepo" branch "fix/$br" -done # Push access, which is what "opened by the repository owner" means once the # owner is an organisation and the author is a person inside it. `stranger` has @@ -2010,6 +2016,15 @@ artifact: https://github.com/$owner/pull/$pr Shipped it. EOF done + +# The branches appear only NOW, which is the order the real thing happens in: +# `add` records a branch that does not exist yet and refuses one that does, and +# the worker's own spawn is what creates it. Every branch with a pull request +# on it is therefore already there by the time the shepherd looks. +for br in conflicting green skipped elsewhere busy unrun gone second prose-only; do + git -C "$srepo" branch "fix/$br" +done + env PATH="$shep/bin:$base_path" $QUEUE collect >/dev/null python3 - "$shep/gh" <<'PY' @@ -3369,6 +3384,14 @@ Do the thing. The reason it matters. +## Hard constraints + +None. + +## Coordination + +None. + ## Done means It is done. @@ -3384,26 +3407,23 @@ expect "and a recognised heading after it still fills its own section" \ "## Done means It is done." "$odd" refute "so that section is no longer unwritten" \ "## Done means " "$odd" -expect "while the sections it said nothing about stay unwritten" \ - "## Coordination " "$odd" -# (c) A body with no headings at all behaves exactly as it did before: the -# whole file into `What to do`, the other three left for the lead. +# (c) A body with no headings at all is still all `What to do` -- and that is +# now something you can READ, because a file filling only that section is +# refused at `add` naming the other three (16d). The mapping is the same +# one it always was; where you find out about it changed. printf 'Just do it, there is nothing else to say.\n' >"$tmp/flat-brief.md" -$QUEUE add "$etopic" headingless-body --title 'Headingless body' \ +if out="$($QUEUE add "$etopic" headingless-body --title 'Headingless body' \ --repo /tmp/repo-a --branch fix/headingless-body --number 03 \ - --brief-file "$tmp/flat-brief.md" >/dev/null -flatraw="$(cat "$FLEET_QUEUE_DIR/$etopic/03-headingless-body/BRIEF.md")" -expect "a headingless body still fills What to do" \ - "## What to do Just do it, there is nothing else to say." \ - "$(brief_text "$FLEET_QUEUE_DIR/$etopic/03-headingless-body/BRIEF.md")" -left="$(printf '%s\n' "$flatraw" | grep -c 'WRITE THE INSTRUCTIONS HERE')" -if [ "$left" = 3 ]; then - pass "and leaves the other three for the lead, as it always did" + --brief-file "$tmp/flat-brief.md" 2>&1)"; then + fail "a headingless body fills only What to do, and is refused for the rest" "$out" else - fail "and leaves the other three for the lead, as it always did" \ - "counted $left placeholder(s)${nl}$flatraw" + pass "a headingless body fills only What to do, and is refused for the rest" + for heading in "Hard constraints" "Coordination" "Done means"; do + expect "and the refusal names \`$heading\`" "$heading" "$out" + done + refute "and not the one the body landed in" "What to do" "$out" fi # A `## ` inside a fenced block is example text a brief is quoting, not a @@ -3417,6 +3437,14 @@ Copy this shape: not a real heading ``` +## Hard constraints + +None. + +## Coordination + +None. + ## Done means The real one. @@ -3480,16 +3508,16 @@ else fi # The default is unchanged, and stays the norm: no ref sends the whole ready -# set, and still refuses this queue's half-written briefs before it sends any. +# set, and still refuses a brief nobody has written before it sends any. +$QUEUE add "$etopic" written-later --title 'Written later' --repo /tmp/repo-a \ + --branch fix/written-later --number 13 >/dev/null if out="$($QUEUE dispatch --dry-run 2>&1)"; then fail "a bare dispatch still refuses the queue's unwritten briefs" "$out" else expect "a bare dispatch still refuses the queue's unwritten briefs" \ - "BRIEF.md" "$out" + "13-written-later" "$out" fi -for t in 02-keep-odd-headings 03-headingless-body 04-fenced-body; do - printf 'Written now.\n' >"$FLEET_QUEUE_DIR/$etopic/$t/BRIEF.md" -done +printf 'Written now.\n' >"$FLEET_QUEUE_DIR/$etopic/13-written-later/BRIEF.md" out="$($QUEUE dispatch --dry-run 2>&1)" spawns="$(printf '%s\n' "$out" | grep -c 'session create')" if [ "$spawns" = 6 ]; then @@ -3523,6 +3551,184 @@ else expect "and still says where file overlap belongs instead" "--touches" "$out" fi +# --- 16d. the intake path refuses at `add`, where the repair is one edit ----- +# +# Three more of the same family, all hit repeatedly on 2026-09-09. Each one is +# something `add` already knew and `dispatch` was left to discover, which costs +# the lead a whole round-trip per task: dispatch, read the refusal, go and look +# at the file or the record, repair it, dispatch again. +# +# (d) `--brief-file` is a CLAIM to have written the brief. A file that leaves +# a scaffolded section unwritten is refused at `add`, naming the sections +# — not accepted, then refused by `dispatch` as "unwritten", about a +# brief the lead did write. +# (e) The scaffold check is STRUCTURAL. It compares each section against what +# the scaffold wrote, so a brief that quotes the placeholder to talk +# about it still dispatches. A substring grep meant the queue could not +# carry a task about its own scaffold — this very task's brief hit it. +# (f) `--branch` equal to `--base` cannot be spawned: `--worktree-branch` +# only ever CREATES a branch, and a base exists by definition. `add` has +# both values, so `add` refuses — and asks the repo about every other +# branch that is already there, which is the same failure. +# +# `add` with NO --brief-file is untouched. That is the deliberate "scaffold it, +# I will write it" path, and `dispatch` stays its backstop — with a refusal +# that now names the sections too. + +cat >"$tmp/half-brief.md" <<'MD' +## What to do + +Rewrite the state machine so `idle` means the agent said so. +MD + +if out="$($QUEUE add "$etopic" half-written --title 'Half written' \ + --repo /tmp/repo-a --branch fix/half-written --number 20 \ + --brief-file "$tmp/half-brief.md" 2>&1)"; then + fail "a --brief-file that leaves a section unwritten is refused at add" "$out" +else + pass "a --brief-file that leaves a section unwritten is refused at add" + for heading in "Hard constraints" "Coordination" "Done means"; do + expect "and the refusal names \`$heading\` as one of them" "$heading" "$out" + done + refute "and does not name the one the file did fill" "What to do" "$out" +fi + +if [ -e "$FLEET_QUEUE_DIR/$etopic/20-half-written" ]; then + fail "and leaves nothing behind, so the repair is one edit and one re-run" \ + "$(ls "$FLEET_QUEUE_DIR/$etopic/20-half-written")" +else + pass "and leaves nothing behind, so the repair is one edit and one re-run" +fi + +# The one-step property: the same `add`, once the file is whole, produces a +# task that dispatches. No patching of the rendered brief in between. +cat >>"$tmp/half-brief.md" <<'MD' + +## Hard constraints + +None. + +## Coordination + +None. + +## Done means + +`cargo test` passes. +MD + +if out="$($QUEUE add "$etopic" half-written --title 'Half written' \ + --repo /tmp/repo-a --branch fix/half-written --number 20 \ + --brief-file "$tmp/half-brief.md" 2>&1)"; then + pass "a --brief-file that fills every section is accepted" + if out="$($QUEUE dispatch "$etopic/20-half-written" --dry-run 2>&1)"; then + pass "and dispatches in one step, with nothing hand-repaired between" + else + fail "and dispatches in one step, with nothing hand-repaired between" "$out" + fi +else + fail "a --brief-file that fills every section is accepted" "$out" +fi + +# The scaffold path is untouched, and its backstop now says WHICH sections. +$QUEUE add "$etopic" scaffold-me --title 'Scaffold me' --repo /tmp/repo-a \ + --branch fix/scaffold-me --number 21 >/dev/null +if out="$($QUEUE dispatch "$etopic/21-scaffold-me" --dry-run 2>&1)"; then + fail "add with no --brief-file still scaffolds, and dispatch still refuses it" "$out" +else + pass "add with no --brief-file still scaffolds, and dispatch still refuses it" + for heading in "What to do" "Hard constraints" "Coordination" "Done means"; do + expect "and the backstop names \`$heading\`, not just the path" "$heading" "$out" + done +fi + +# (e) A brief that QUOTES the placeholder is a written brief. The check that +# could not tell the two apart is why this task's own brief had to have the +# quotation cut out of it before it could be dispatched. + +cat >"$tmp/quoting-brief.md" <<'MD' +## What to do + +Every section of the scaffold starts as `` +and the dispatch precondition used to grep the whole file for that string, so +a brief describing it refused to go out. Compare each section instead. + +## Hard constraints + +Do not weaken the check. A worker sent a scaffold has nothing to do. + +## Coordination + +None. + +## Done means + +This brief, which quotes ``, dispatches. +MD + +if out="$($QUEUE add "$etopic" quotes-the-scaffold --title 'Quotes the scaffold' \ + --repo /tmp/repo-a --branch fix/quotes-the-scaffold --number 22 \ + --brief-file "$tmp/quoting-brief.md" 2>&1)"; then + pass "a brief that quotes the placeholder is accepted at add" +else + fail "a brief that quotes the placeholder is accepted at add" "$out" +fi +if out="$($QUEUE dispatch "$etopic/22-quotes-the-scaffold" --dry-run 2>&1)"; then + pass "and dispatches, so the queue can carry a task about its own scaffold" +else + fail "and dispatches, so the queue can carry a task about its own scaffold" "$out" +fi + +# (f) The branch. `--branch main --base main` was accepted and then died at +# spawn with thurbox's own non-zero exit, leaving the task queued and the +# operator editing task.yaml by hand. + +if out="$($QUEUE add "$etopic" branch-is-base --title 'Branch is base' \ + --repo /tmp/repo-a --branch main --base main --number 23 2>&1)"; then + fail "--branch equal to --base is refused at add" "$out" +else + pass "--branch equal to --base is refused at add" + expect "and the refusal names the branch" "main" "$out" + expect "and says why it could never be spawned" "worktree" "$out" +fi + +# The same precondition, generally. `--worktree-branch` only ever CREATES the +# branch, so ANY branch already in the repo fails the spawn — base is merely +# the one that exists by definition. A repo this machine can read gets asked. + +brepo="$tmp/branch-repo" +git init -q -b main "$brepo" +git -C "$brepo" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base +git -C "$brepo" branch fix/left-behind + +if out="$($QUEUE add "$etopic" branch-exists --title 'Branch exists' \ + --repo "$brepo" --branch fix/left-behind --base main --number 24 2>&1)"; then + fail "a branch already in that repo is refused at add" "$out" +else + pass "a branch already in that repo is refused at add" + expect "and the refusal names it" "fix/left-behind" "$out" + expect "and quotes the failure the spawn would have died with" \ + "already exists" "$out" +fi + +# A branch that is not there is the ordinary case, and nothing about this is +# allowed to make it slower or louder. +if out="$($QUEUE add "$etopic" branch-is-new --title 'Branch is new' \ + --repo "$brepo" --branch fix/is-new --base main --number 25 2>&1)"; then + pass "a branch that is not there yet is created as it always was" +else + fail "a branch that is not there yet is created as it always was" "$out" +fi + +# A repo this machine has not got is not a repo to ask, so the check is silent +# and dispatch stays the backstop it was — which is every `--host` task. +if out="$($QUEUE add "$etopic" repo-not-here --title 'Repo not here' \ + --repo /tmp/repo-a --branch fix/repo-not-here --base main --number 26 2>&1)"; then + pass "a repo this machine cannot read is left to dispatch, as before" +else + fail "a repo this machine cannot read is left to dispatch, as before" "$out" +fi + # --- 17. one setting puts a mark on every session, and takes it back --------- # # The lead's mark and the workers' are ONE setting, because the reason to turn @@ -3650,10 +3856,6 @@ liverepo="$tmp/live-repo" mkdir -p "$liverepo" git -C "$liverepo" init -q -b main git -C "$liverepo" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base -git -C "$liverepo" branch feat/moved -git -C "$liverepo" branch feat/quiet -git -C "$liverepo" branch feat/never -git -C "$liverepo" branch feat/remote ltopic="$($QUEUE topic add course-correct --title 'Message a worker mid-flight' \ --prompt 'tell a parked worker about new scope')" @@ -3667,6 +3869,12 @@ messaged moved 01 cccccccc-0000-0000-0000-000000000001 messaged quiet 02 cccccccc-0000-0000-0000-000000000002 messaged never 03 cccccccc-0000-0000-0000-000000000003 +# The branches exist from here on, because the worker's own spawn is what +# creates one — `add` above recorded a branch that was not there yet. +git -C "$liverepo" branch feat/moved +git -C "$liverepo" branch feat/quiet +git -C "$liverepo" branch feat/never + out="$($QUEUE send "$ltopic/01-moved" 'Also update the changelog.' 2>&1)" expect "the queue sends the message itself, so the lead stops reaching past it" \ "delivered" "$out" @@ -3745,6 +3953,7 @@ refute "and its row is the row it always was" "messaged" "$row" # A branch this machine cannot read degrades to `not checked`, and a task that # runs on a host is the case that matters: its git is over there. messaged remote 04 cccccccc-0000-0000-0000-000000000004 +git -C "$liverepo" branch feat/remote python3 - "$FLEET_QUEUE_DIR/$ltopic/04-remote/task.yaml" <<'PY' import sys diff --git a/scripts/queue.sh b/scripts/queue.sh index 185bea9..fefb3c7 100755 --- a/scripts/queue.sh +++ b/scripts/queue.sh @@ -152,7 +152,10 @@ # [--publish no-mistakes|pr|push] [--how 'run `/publish`'] # # --brief-file fills whichever of the brief's four # # sections its own `## ` headings name; a body with no -# # headings all goes into `What to do` +# # headings all goes into `What to do`. A file that +# # leaves any section unwritten is refused HERE, naming +# # them, and nothing is created — as is a --branch no +# # worktree could be cut for, which includes --base # 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