Skip to content

v0.38.0

Choose a tag to compare

@github-actions github-actions released this 27 Aug 12:35
· 50 commits to main since this release

[0.38.0] - 2026-08-27

Changed

  • The "delegated worker went quiet" report is now a turn the orchestrator answers, not a line in its scrollback (PROTOCOL_VERSION unchanged)
    When a delegated worker takes its task pointer and then emits no agent event at all, the deck reports that to the orchestrator. Until now the report was written into the orchestrator's pane and left there unsubmitted, so nothing happened until a human pressed Enter. In a dispatched unit there is no human, and dispatch has no return edge — so the failure the report exists to surface reached nobody, and the delegation simply stalled. The report now arrives as a submitted turn, through the same guarded write-and-submit path the "worker has not responded with work-done" report has always used, and its wording asks the orchestrator to check the worker's pane and choose: keep waiting, re-delegate, reassign, or notify the user.
    This is classified as a compatibility break even though nothing on the wire moved — no field added, removed or retyped, no new message, no handler signature change — because what changed is the meaning of an existing delivery. Text that was inert daemon output is now model input that consumes a turn. Per this project's own rule (docs/develop/versioning.md) a semantic break behind a stable wire is versioned as breaking, which is why this drives a minor bump rather than a patch.
    What to expect if you upgrade. An orchestrator that previously sat idle through a worker's silence will now visibly wake up and act on it — that is the point of the change, but it is a behavioural difference in an automated loop, and an orchestrator whose own instructions do not anticipate the report will improvise a response to it. The report is explicitly framed as a daemon report rather than a message from a person or an agent, and it still names no role and no task text, so an orchestrator that decides to do nothing can say so and stop. Two related notices are deliberately left as they were: the one for a worker whose process exited, and the one for a clear = true respawn that never produced a live worker. Both report a failure that is already final, so there is nothing for the orchestrator to remedy and nothing to be gained from spending its turn. Note also when the switch takes effect: the behaviour changes when the daemon starts on the new build, not when the binary on disk is replaced, so if you upgrade while agents are running you keep the old daemon — and with it the old, unsubmitted report — until it next restarts.
    One consequence worth knowing before you upgrade. Every automatic submission the deck makes shares a limitation: if the target pane is holding an unsent draft you typed and have not sent, the deck's text is appended to that draft and both are submitted together. That has always been true of the idle-worker report; it is now true of this one as well. The deck refuses to repeat bytes it already delivered into a pane you have typed into since, but it cannot tell a fresh report apart from your draft once both are in the same input box.

Added

  • Tell the deck which agent a launcher command starts
    A role or mode whose command runs its agent through something else — devbox run -- codex, mise exec -- codex, nix develop -c codex, a make target, a bespoke run-codex.sh — rendered as No agent, with no badge and no status. The deck identifies an agent by the first word of the command, and a launcher's first word is the launcher. Nothing about devbox says what it is about to start, so the deck declined to guess and you got nothing.
    Roles and modes now take an optional agent key naming what the command actually launches:
    [[orchestrations.roles]]
    name = "reviewer"
    command = "devbox run -- codex --sandbox workspace-write"
    agent = "codex"
    It accepts claude, opencode, pi, codex or devin — the same names dot-agent-deck wrap --agent takes, resolved by the same code, so the two cannot drift. On [[modes]] it names the agent for the mode's agent pane, whose command you type into the new-pane form rather than the config. Leave the key out and nothing changes: the command is read exactly as it always was, so no existing config needs an edit.
    For Codex the key does more than fix a label. Identifying the agent is also what puts the deck's output monitor around it, and Codex says nothing about itself until its first turn begins — so a launcher-started Codex pane sat blank from launch until you delegated the first task to it, then silently started working. That is the window this closes: the badge and status are there at spawn. Claude never showed the symptom, because it announces itself as soon as it starts, which is why the same devbox run wrapper looked fine for one role and broken for the next.
    Two behaviours worth knowing. A name no agent claims resolves to no agent rather than to a guess from the command, so a typo looks like the problem you were trying to fix — dot-agent-deck validate now warns about an unknown name and lists the accepted ones. And the declaration is re-read from .dot-agent-deck.toml on every delegation, exactly like command is, so editing either takes effect on the next clear = true delegation without recreating the pane.
    See Configuration, Orchestration and Workspace Modes.

Fixed

  • A config that will not parse can no longer repaint the terminal dot-agent-deck validate prints into
    .dot-agent-deck.toml travels with a repository — a clone, a contributor branch, a PR checkout — so validate reads a file you may not have written. When that file fails to parse, the error validate prints quotes the offending source line verbatim, which is exactly what makes a toml error worth reading and also what carried raw escape bytes straight to your terminal. A line reading bogus = <ESC>[31mPWNED<ESC>[0m printed as coloured text; a longer sequence could clear the screen, move the cursor over lines the deck had already written, or reorder them with a bidi override so the diagnostic said something other than what it meant. A 100 KB "line" printed 100 KB.
    Everything a terminal acts on rather than shows is now rendered as visible text — \u{1b} instead of a real ESC, and likewise for the C1 range, NUL, DEL and the bidi overrides — so the evidence that something odd is in the config survives without the terminal obeying it. The diagnostic as a whole is bounded, with the true length reported in place of the tail, so a pathological file cannot flood a terminal or a CI log: that 100 KB case now prints just over 4 KB and says how long it really was.
    A genuine syntax error is untouched, which was the constraint the fix had to meet. The gutter frame — the 3 | bogus line under its rail with a caret beneath — is the whole value of a toml error, and its line structure is the deck's to trust, so those newlines are preserved while each line's content is made safe. For a config with no control characters in it the output is byte-identical to before.
    This closes the last unescaped seam in validate. The same release seals the issue-by-issue output, which needed a config that parsed; this one needs no valid config at all.
  • A mode that fails to activate no longer leaves a card behind for a pane that is gone
    Activating a workspace mode spawns its agent pane first and then opens the tab around it. When that second step failed, the pane was closed but everything registered for it was not: the dashboard kept rendering a card for a pane that no longer existed, and the pane's name, its saved launch metadata and its routing identity stayed in memory. Retrying the same broken mode added another set each time, for as long as the deck stayed open.
    All of it is now unwound together, through one shared step that the mode-restore path uses as well — so the two cannot drift apart, and a future per-pane record cannot be cleaned up on one path and forgotten on the other. The unwind only runs when the pane is genuinely gone: if closing it fails, the pane is still live, and its card and metadata stay visible so you can see it and retry rather than having them purged out from under a running agent.
  • --task-file refuses an oversized or non-regular task file instead of hanging or reading it without limit
    dot-agent-deck delegate, work-done, and dispatch read the task text with --task-file <path>, or from stdin with --task-file -. Neither read had a size bound, and neither checked what the path actually pointed at. A path naming a FIFO with no writer blocked the command forever — the block happened inside open, before anything got a chance to look at it — and one naming an endless character device such as /dev/zero, or a file being appended to, was read into memory until the process ran out of it. Both reads are now capped at 1 MiB and refused, never truncated, past that; a path that is not a regular file is refused on its type before a single byte is read.
    The delegation protocol the deck writes into every orchestrator's context tells it to pass task text as a file rather than inline, precisely because --task lets the caller's shell mangle backticks, quotes and $VAR first. That makes --task-file the primary input for every delegation, so it is the one that needed the bound.
    1 MiB is far above anything a task can legitimately be: this project's largest PRD is ~117 KiB, and 1 MiB of prose is roughly 250k tokens — past the context window of the agent the text is being written for. Both refusals name what was wrong and what to do about it, rather than failing anonymously.
    One behaviour changes for existing callers: --task-file <(some-command) — bash process substitution — passes a /dev/fd/N pipe, not a regular file, so it is now refused. Pipe into stdin instead: some-command | dot-agent-deck delegate --to coder --task-file -, which is bounded but has no file-type requirement. Symlinks still work and are still followed; the type check applies to the resolved target, so a symlink pointing at a FIFO or a device is refused just the same.
  • Spawning an agent in a very small terminal no longer builds a broken pane parser
    Every embedded pane keeps a local terminal parser sized to the pane's viewport. The dimensions come from the layout helpers in the deck's renderer, and each of them subtracts a border allowance from the space available — so a terminal short enough, narrow enough, or split between enough panes yields zero rows or zero columns. On the resize path that is handled: the per-frame sweep skips a pane whose target area has collapsed. On the spawn path it was not, and the zero went straight into the parser constructor.
    A parser cannot be zero-sized. Its grid derives the bottom of its scroll region as rows - 1, so a zero-row parser is inconsistent from the moment it exists — in a development build that subtraction overflows and takes the deck down at the point of construction, and in a released build, where overflow checks are off, it wraps instead and leaves a grid with no rows claiming a scroll region that ends at row 65535.
    The deck already had the right answer for this and had had it for some time: one function that decides what a usable parser geometry is, clamping to the same bounds the daemon enforces on a PTY resize and falling back to 24x80 otherwise. The rehydration path used it, and so did the render seams. The spawn path — the one an agent is actually started through — did not. Every parser the deck builds is now built through a single guarded constructor, so there is one definition of a valid geometry and one place it is applied, and a spawn into a collapsed pane starts at the fallback size and picks up its real dimensions on the next frame.
    Two things had been absorbing this in practice, both by accident rather than design: the next frame's resize sweep usually corrected the geometry before any output arrived, and the deck's parser-feed guard contains crashes coming out of the terminal parser. That guard wraps feeds, not construction, so it never covered this case at all.
  • worktree reclaim can now recognise the worktrees the deck itself created
    dot-agent-deck worktree list|reclaim removes a merged, clean worktree without asking only when it can prove the deck created it, and it proves that from a marker file in the worktree's git metadata dir. Nothing ever wrote that marker. Every worktree the deck created therefore reported OWNED=no and could only ever reach the ask verdict, so the unattended remove tier — the point of the feature — was unreachable in normal use and --yes became the habitual invocation. A confirmation that is always required stops being a confirmation.
    The marker is now written at creation time, by the one function that runs git worktree add, so both creation paths get it: dispatch <name> (including the orchestration spawn, whose roles share one worktree) and the issue-dispatch fire flow. It records which of those created the worktree and what for, rather than a bare "the deck". It lives in the worktree's own git metadata dir — never in the working tree, where it would make every marked worktree permanently dirty and therefore permanently unreclaimable — and git worktree remove deletes it along with the worktree. Writing it is best-effort: if it fails, the dispatch still runs and the worktree simply asks for a confirmation later.
    Worktrees that already exist are deliberately not adopted, including the ones on your machine right now. They keep reporting OWNED=no and keep asking. The marker is an ownership claim consumed by a path that deletes directories, and on disk the deck cannot tell a worktree it created last week from one you or an orchestrator created — so anything that claimed the first would claim the second. The cost of not adopting is one --yes on a named path; the cost of adopting wrongly is an unattended removal of somebody else's work. For the same reason, a worktree that another process created in the moment before ours ran is left unmarked rather than claimed.
    The reasoning, and the rule it follows, are recorded in docs/develop/worktree-ownership.md.
  • A dispatched team's cards keep their role names once the team starts working
    dot-agent-deck dispatch <unit> --orchestration <name> opens a tab with one card per role — ClaudeCode · orchestrator, ClaudeCode · coder, ClaudeCode · reviewer — so you can tell which agent is which. The first delegation to a role configured with clear = true silently renamed that role's card to the replacement agent's session UUID (ClaudeCode · c70493f1-13…), and it stayed that way. On a three-role team that meant the deck stopped being able to say who was who at exactly the moment the team began doing work.
    The name is a property of the pane — its role, or a scheduled task's title — not of the conversation running in it. A clear = true delegate ends that conversation and starts a fresh one in the same pane, and the ending session took the name with it: the placeholder card restored in its place was born nameless, so the replacement had nothing left to inherit. A pane's friendly name now survives a session ending, which also means a card keeps its name across an agent's own /clear restart.
    Only a dispatched orchestration showed the symptom. Every other way of opening one — the Ctrl+N form, a restored session, a reconnect — separately records role names dashboard-side and repainted the card from there; the tab a dispatch builds does not, so it was the one place where the loss was visible.
  • A dispatched prompt destroyed while the agent was still booting is now recovered
    A prompt the deck writes at spawn time — a dot-agent-deck dispatch, a scheduled fire, an issue dispatch — goes into the pane before anyone can know whether an agent is there to read it. Two writes carry the prompt itself; every attempt after that only asks the pane to submit what it is already holding. When something destroys both — a launcher that reads the bytes itself before starting the real agent, or the agent's own terminal setup flushing the input buffer as it comes up — there is nothing left in the box for those later attempts to submit, and the deck spends the rest of its sixty-second window asking an empty input box to send an empty line. The pane comes up healthy, idle, and never told what to do.
    The daemon now recovers that case. A delivery whose payload writes were both made into a pane with no conversation yet, on a pane the deck could vouch for before it wrote — one that declared a launcher handoff, or that the deck itself started as a known agent — may write the prompt one more time when the real agent announces itself afterwards, and only if the agent that announces itself is the same kind the deck already believed was there. An announcement that disagrees with what the deck knew about the pane is refused rather than believed: what an agent says about itself can take this permission away, never hand it out. The agent starting after our bytes is the evidence: whatever consumed them, they are almost certainly not sitting in the input box, so writing them again is recovery rather than a second copy. That is the whole of the new permission — one extra write, once, per prompt, and only with that evidence.
    Everything that made two writes the limit still holds. A third blind rewrite is refused exactly as before, which is what keeps a prompt from being appended to itself and submitted as one garbled turn. The extra write waits half a second after the agent's start before it may carry the prompt, for the same reason the first write waits: a session existing is not the same as a terminal that is ready to accept a newline. It is refused outright on panes running Codex or OpenCode, whose session-start signal fires only once a prompt has been accepted — there, an agent starting after our write means our prompt arrived, not that it was lost — and no announcement claiming to be some other agent can lift that refusal. It is refused on a pane that has an unsent draft in it, on a pane whose conversation changed, and on a pane the deck knows nothing about. And a delivery whose agent was already up when the prompt was written — the ordinary, healthy case — cannot earn it at all.
    What the evidence cannot do is prove the order it implies. The deck knows the agent's announcement reached it after the prompt went out; it cannot know the announcement was not sent a fraction of a second beforehand and merely delayed in transit. When that happens the agent really was there, our prompt really was in its input box, and the extra write appends to it — so the agent submits the task two or three times over in a single turn. It is the same rare misordering that could already cost a delivery a wasted retry; what changes is that it now costs a duplicated turn instead. A turn that comes back as the prompt repeated always ends the delivery, so this stays one confused turn rather than an escalating loop — but only the short-prompt, run-together shape is reported as a problem. Every other shape is indistinguishable from a clean delivery, and a long prompt is reported by the agent only as its first two hundred characters, which look the same however many copies follow. Making that distinguishable needs the delivery fingerprint tracked separately, and the two-write ceiling that used to be the guarantee is still the ceiling for every delivery that earns nothing.
    This covers prompts the daemon owns: dispatch, the scheduler, and issue dispatch. Prompts the dashboard delivers itself — a mode's seed prompt, an orchestration role's opening prompt — keep today's behaviour for now, because on that path a launcher-wrapped pane has a separate problem that has to be fixed first: the launcher's own startup signal is taken as the pane's conversation, so the real agent announcing itself reads as the conversation having changed and the prompt is dropped. That is tracked on its own, as #684.
  • A wrapped agent no longer survives its own wrapper as an unkillable process
    dot-agent-deck wrap opened the inner pseudo-terminal for the agent it wraps and then let the agent inherit the terminal's master descriptor alongside the slave it actually needs. Because the agent itself held that master, the terminal's reference count could never fall to zero: when everything above the agent died the slave never hung up, the agent's read never returned, and it kept running with a working directory that had already been deleted. Nothing could recover it — the agent is setsid'd into its own session at spawn, so no process group kill from the deck, the daemon or a shell can reach it. A census of one development machine found 221 such processes, the oldest alive 9.4 days, each one still holding a working directory the tooling had already tried to delete — an unkillable process spinning against a tempdir that no longer has a name, polluting every subsequent diagnosis, and keeping the deleted files' disk blocks pinned for as long as it runs.
    Both ends of that terminal are now marked close-on-exec the moment they are created, so the wrapped agent gets exactly the three descriptors the wrapper routed onto its standard streams and nothing else — the same hygiene portable_pty already applies to every unwrapped pane, which is why the leak was wrapper-shaped. Marking the master is what fixes the defect; marking the slave removes a spare terminal descriptor the agent was never handed deliberately and which survived it closing or redirecting its own standard streams. The visible change is that an agent whose wrapper is ended uncatchably (the deck's own SIGKILL escalation past the terminate grace, an OOM kill, a crashed wrapper) now exits with its terminal within a moment instead of surviving it. Nothing about a normally running wrapper changes: the agent keeps its terminal, its controlling terminal, and its line discipline, and wrap still reaps it on every ordinary exit path.
    This is the second half of the same defect as the previous release's wrapped-child lifetime backstop. That backstop bounds an agent no hangup can reach — one that ignores SIGHUP and reads nothing — and it stays; it arms only when DOT_AGENT_DECK_TEST_MAX_LIFETIME_SECS is present in the environment, which the test harness sets and nothing else does. Note the qualifier is on the variable, not on the build: a developer who exports that variable and then starts an ordinary daemon or wrapper gets the same bound, and their own agents will be terminated at that deadline. The harness now also clamps the value it inherits from the surrounding shell to 300 seconds, so an over-large one exported in a developer's environment cannot quietly outlive the assumptions the temp-directory reaper makes about it. A test that pins the variable for itself is deliberately unaffected and still gets the value it asks for. This fix needs no such gate, so it holds on a Ctrl-C'd run, a panic, or a timeout just as well.
  • The state dir and lock root are no longer created through a planted symlink
    On Unix, the routine that guarantees the deck's security-sensitive directories exist at owner-only 0700 — the state dir, the daemon lock root — did not check whether anything was already sitting at that path. Both steps it takes follow symlinks: mkdir(2) returns EEXIST for a symlink, the standard library's recursive fallback swallows that because its "is it already a directory?" test resolves the link, and the chmod that follows then lands on the link's target. So a local attacker running as the same user, who plants a symlink at the state-dir path before the daemon first starts, redirects the whole thing onto a directory of their choosing. Measured on the code as it shipped: the call reported success, and an unrelated 0755 directory outside the deck's tree came back 0700.
    The path is now checked with lstat first and refused outright if it is a symlink, so the attacker's target is left untouched and the error names both the path and the reason. Anything else that cannot be stat'd is refused too, rather than assumed safe. Note that this narrows the window rather than closing it: a race remains between the check and the mkdir/chmod, and a fully race-free version (opening the directory O_NOFOLLOW and changing the mode through the descriptor) is deliberately not used here because it would refuse to repair a pre-existing 0000 directory that the current code fixes.
    If you deliberately keep ~/.local/state/dot-agent-deck (or the lock directory) as a symlink pointing somewhere else, the deck will now refuse to start and tell you so. Point DOT_AGENT_DECK_STATE_DIR / DOT_AGENT_DECK_LOCK_DIR at the real directory instead. The check applies only to the last path component, so a symlinked parent~/.local/state itself — is unaffected. There is no way to keep the symlink and the guard: the threat model is an attacker running as you, so nothing about a link or its target tells a planted one apart from a deliberate one.
    The sibling routine used for config directories is unaffected — it never changes the mode of a directory it did not create, so there is nothing for a symlink to redirect.
  • A card title from the hook socket can no longer repaint or reorder the dashboard
    Any process that can reach the hook socket — which on a deck means any agent running on it — can name a session card by putting a display_name in an event's metadata. That value was stored behind nothing but a check that it was not empty, and drawn straight into the card's title. An ESC sequence in it therefore reached a rendered cell, where flushing the frame writes it to the terminal and it repaints whatever it likes; a U+202E right-to-left override reordered the text around it, which on a deck of stacked cards means the neighbouring agents' titles; and an unbounded string was carried and stored in full. The only thing standing in the way was whatever the terminal renderer happened to do with those characters, which is not a property this project owns or tests.
    Names arriving that way are now repaired as they are ingested: control characters (including ESC, NUL, DEL and the C1 range) and the Unicode bidirectional formatting and override codepoints are dropped, surrounding whitespace is trimmed, and the result is clamped to the same 128-byte ceiling the daemon already enforces on a name typed into a rename — cut on a character boundary and marked with a trailing , exactly as an over-long session id already is. A name that turns out to be nothing but hostile characters is skipped rather than stored, so the card keeps the title it had instead of losing it to a malformed event. The printable payload of an escape sequence survives as ordinary text, which is deliberate: dropping it would mean parsing ANSI, and would eat legitimate names containing brackets.
    The repair happens at ingest rather than at the point of drawing, because the name is stored on the session, copied onto a session that supersedes it, and read back from several places — scrubbing it at one of them would leave the stored value hostile for all the others. The filter itself is now shared with the daemon-restart prompt, which had been carrying its own copy.
  • A quiet delegated worker is reported by what its pane is showing, not by a guess
    When a delegated worker went quiet, the deck used to write ⚠ delegate possibly not delivered … It may never have received the prompt into the orchestrator's pane. That is a cause the daemon had never checked, and it is usually the wrong one: some agents emit no hook event at all until their first prompt arrives, so a booted, healthy worker sitting at its own input looks — from the event stream alone — exactly like one that never received anything. The notice sent people hunting for a delivery bug that did not exist.
    The notice now reports what that pane is actually rendering. The deck already holds every pane's scrollback, so at the moment the notice is built it replays those bytes through the same terminal parser the TUI renders with and quotes the last few non-blank lines of the screen. Ask the agent to do anything settles the question at a glance; an authentication prompt, an update notice, a model picker or a first-run confirmation all report themselves just as well. A pane that has drawn nothing at all is reported as blank, which is what makes "the agent may never have started" a reasonable reading rather than an assertion.
    This keys on the symptom — a pane that emitted nothing — and never on which agent is running, so it works behind a devbox run …, mise or npm run launcher the deck cannot see through. The pane's text arrives wrapped in an [UNTRUSTED-PANE-TEXT: … ] frame and introduced as untrusted, because whatever an agent drew on its screen may include text it read from a repository you cloned; it is trimmed to the last few rows and capped, with a trailing when a screen was cut short.
    See Idle workers and notifications for the full wording and the knobs that control the window.
  • A silence report no longer accuses a worker generation that was already replaced
    A clear = true delegate ends the worker's current session and starts a fresh one in the same pane. Until now the deck kept watching the replaced generation while its replacement booted, so if the old generation's window expired during that boot the orchestrator was told its delegation had gone undelivered — while the new worker was starting up perfectly normally. In one captured daemon log the gap was about thirty seconds wide and the stale report won by 56 milliseconds.
    The cost of that was not noise. The report is what tells an orchestrator its delegation failed, so a false one invites a second delegate to a worker that is already working — which discards a live agent's session and produces another replaced generation, and another chance to do it again.
    The deck now records the changing of the guard at the moment the replacement takes the pane, rather than thirty seconds later when its task pointer is finally written. The replaced generation's watch is retired there and then, so it cannot report; the replacement's own watch is armed from that same moment and still fires normally if the replacement really does go quiet. The bookkeeping that lets a late work-done from the replaced delegation be credited to it, instead of silently disarming the newer watch, is carried across the handover intact — and every path that abandons a dispatch without delivering a pointer now retires the watch it reserved, so no delegate leaves a watcher behind.
    Reported alongside this, and fixed in the same release: #702 — the silence report is now submitted as a turn the orchestrator can act on, instead of sitting unsubmitted in its pane waiting for a human to press Enter. See the Breaking Changes entry for what that means for an existing setup.
    The remaining half of the original symptom is tracked separately as #243: some agents emit no event at all until their first prompt arrives, so a worker that is up and healthy can still be reported as silent. What the report says about that case already improved in the previous release — it quotes what the worker's pane is actually rendering rather than guessing — and the underlying readiness gate is a separate fix.
    See Idle workers and notifications for the report's wording and the knobs that control its window.

Miscellaneous

  • The toolchain versions CI installs and the ones devbox installs can no longer drift apart
    cargo test-fast and cargo test-e2e are cargo nextest aliases, and the point of pinning nextest and the Rust toolchain in both devbox.json and .github/workflows/ is that "it passed locally" and "it passed in CI" are the same claim. Since 2026-08-11 they were not: an automerged Renovate PR moved devbox.json to cargo-nextest 0.9.143 while ci.yml sat on 0.9.140, and for eleven days every local run and every CI run used a different build of the test runner. Nothing could see it — renovate.json's own rule said the lockstep "is the whole point of pinning them, and nothing enforces it automatically", and this is what that sentence predicted.
    CI moves forward to 0.9.143, the version the devbox shell has been installing all along. That direction was checked rather than assumed: nextest 0.9.141, 0.9.142 and 0.9.143 were published within 45 minutes of each other and carry identical release notes, so the whole 0.9.140→0.9.143 delta is one changeset — a junit.report-skipped setting that defaults off, a progress bar while listing, dynamic-library search-path corrections, and archive-filterset fixes. None of it touches scheduling, retries, process-per-test or timeouts, so nothing in it can perturb the known e2e flake classes in either direction; and the dylib and archive halves are inert here, since this workspace builds no dylib and archives nothing. The fast tier is green under it.
    The drift was structural rather than a missed PR, so the lane that produced it changed too. The same tool was bumped down two Renovate lanes with opposite automerge policies: devbox.json self-merged, the workflow pin waited for a human. renovate.json now holds the five toolchain-class devbox packages (cargo-nextest, rustc, cargo, clippy, rustfmt) for the same human who holds the workflow pins, so both halves arrive labelled manual-review and land as one change. Grouping the two lanes into a single PR was the other option and is recorded there as rejected: a shared group bundles two PRs when both lanes have an update, but it cannot make them offer the same version — nixpkgs never carried nextest 0.9.141 or 0.9.142 and has no Rust 1.98.0 at all — so the usual case would be a grouped PR carrying one half, which is the same drift wearing a title that says lockstep. The cost of the option taken is stated too: one Renovate PR per toolchain release that used to merge itself now needs a click.
    scripts/check-pin-lockstep.sh is the part with teeth. It reads both files, hardcodes no version, and fails when the two sides disagree, when one side pins nothing (so a rename cannot make it pass vacuously), when devbox.json's four Rust components disagree with each other, or when a workflow pin is written in a form Renovate's regex cannot read — the silent-rot case where a pin quietly stops being tracked with nothing going red. It runs from cargo test-fast, which puts it in a contributor's per-task gate and in the three required CI build jobs, and again in CI's devbox job, which is the only job no changes gate can skip — the one place a devbox-only bump like the original could be seen. It refuses a devbox.json that pins only three of the four Rust components, rather than passing on the agreement of the survivors. It also refuses a pin that is readable but untrackedtoolchain: "1.97.1" or tool: "cargo-nextest@0.9.143" mean the same thing to YAML, and match neither of Renovate's regexes, so quoting a pin would otherwise end its tracking with nothing going red. Fifteen tests drive it, fourteen against synthetic drifted repositories and one against the real checkout; it was confirmed to fail on the actual 0.9.140/0.9.143 split, and on the live 1.97.1/1.98.0 toolchain split, before either pin was corrected.
    The Rust toolchain is brought back into lockstep the other way. devbox.json pins rustc/cargo/clippy/rustfmt at 1.97.1, and the seven workflow toolchain: sites had moved to 1.98.0 — the same drift, in the class where it matters more, since the toolchain decides what compiles and where new deny-by-default lints land. It arrived exactly as the nextest one did: the workflow half of a two-lane pin was merged on its own while the devbox half could not follow. So the workflow sites go back to 1.97.1, which is the newest version available on both sides: nixpkgs packages no Rust newer than 1.97.1 today — not in nixos-unstable, not on master, with no packaging PR open — so 1.98.0 is unreachable for a devbox shell however much one might want it. Rust 1.98.0 itself is fine for this code and that was measured rather than assumed: cargo fmt --check and cargo clippy --workspace --all-targets --features e2e -- -D warnings both pass clean under it, including its new deny-by-default invalid_runtime_symbol_definitions lint. When nixpkgs ships it, both halves move together in one PR and this check is what will insist on that.
  • A three-agent prompt-delivery test no longer spends half its delivery budget on a wait it cannot win
    scheduler/dispatch/015 boots three real interactive Claude panes at once and requires each of them to genuinely submit the seed prompt it was dispatched with. It passed in isolation and failed in a full cargo test-e2e run — 32.2s green alone, 174.1s red in the tier, measured back to back on the same machine.
    Each pane races a deadline the test does not own: a spawn-time prompt gets 60 seconds, absolute, captured before the readiness wait, and on expiry the daemon abandons the delivery, marks the card Error and stops retrying permanently. So the test's own 150-second wait — already two and a half times that window — could never have been the fix, and neither could making it longer.
    Capturing the deck's delivery log with the assertion showed where those 60 seconds went. The readiness gate deliberately ignores the bootstrap launcher's own SessionStart and holds out for the real agent's native one, but in this scenario the launcher only starts Claude after the write it is blocked reading — so the gate cannot be satisfied before the write, and it spent 30.1 of the 60 seconds waiting for an event that could not arrive, leaving 29.9 for the agent to boot and confirm. Pinning the gate short, exactly as the test's deterministic sibling already did, returns that half to the retry window the real agent gets — which is what production spends it on, since a native SessionStart normally releases the gate in milliseconds. The test now finishes in under six seconds instead of thirty-two, with every assertion unchanged.
    It also reserves the whole test-thread pool while it runs, scheduled first so the reservation costs a run-start slot rather than draining the tier's several-minute tail, and gets a single retry for load that is not the tier's to control. The three seeds still go out concurrently — that is the field report the test reproduces and the property it exists to pin.
    Its failures say more than they used to. The delivery log prints with the assertion, so the abandonment line and its attempt count name the cause directly instead of leaving it indistinguishable from a regression in the retry path. That is how the remaining sensitivity was identified and separated out: only two attempts of any delivery carry the prompt itself, and on this path both land inside roughly one and a half seconds of the agent starting, so an agent that boots slower than that loses the prompt outright. That is a limitation in delivery rather than in the test, and it is tracked separately.
  • Dispatching PRD work has a skill of its own, and it composes the task for the shape
    This repo already had a project-local skill for dispatching issues (/issue-queue) and one for dispatching PRs (/pr-review-queue), but nothing for PRDs — so every PRD dispatch composed its task from scratch, and the decision that composition has to make was made from memory or not at all. /prd-queue is the third sibling: it builds the queue of open PRDs that are genuinely available, claims them, dispatches one isolated unit each, and reports where the work went.
    The decision it exists to encode is that a PRD dispatch is not one task but two, and which one is right depends on the unit's shape. /prd-full is a single-agent skill — "run a PRD end-to-end autonomously" — with no notion of roles or delegation. For a --single dispatch that is exactly right and complete. For an --orchestration dispatch it is the wrong instruction: pointing an orchestrator at a single-agent skill invites it to run the lifecycle itself instead of delegating, which defeats the reason a team was dispatched. A team is already governed by this repo's orchestrator role template, which is the orchestration-aware expansion of the same lifecycle and covers what /prd-full does not — the notifications, the demo reel, the recorded e2e gate, the test-plan gate and the tester→coder TDD chain. So the skill asks the shape per PRD, not once per batch, and writes a different task document for each answer.
    Two mechanics of what a dispatched orchestrator actually receives are handled rather than assumed. The role template, the available agents, the delegation protocol and the dispatched task all land in one file with the task appended last, and nothing arbitrates between them but the model — so the team task states the precedence explicitly instead of leaving it to ordering. Their headings interleave too: a task written with ## sections lands with them as peers of ## Delegation protocol rather than as children of ## Your task, which would put the task's own untrusted-data fence at document level and invert what it labels. Task files therefore never emit an ## heading.
    The --single shape gets the notification instruction a team already has from its role template. That shape is the one most in need of it: dispatch is fire-and-forget with no return edge, so a unit finishing with "PR open, checks settled, stop" otherwise tells nobody at all.
    Selection carries the sibling skills' hard-won safety rules unchanged — task files rather than inline task strings, slugs never derived from issue text, PRD-derived text fenced as untrusted data, claims verified against origin/main, the three-way in-flight check, and never deleting a branch to free a name — plus two checks that are specific to PRDs. It selects on the PRD label where /issue-queue excludes it, so the two queues are complements and every open issue belongs to exactly one. And it requires an actual prds/<n>-*.md document on the remote before dispatching, because the whole lifecycle reads one and a label without a document is a unit that stalls at its first step or invents a PRD of its own.