Skip to content

fix(update): fan-out downloads get full bytes; stuck updates explain themselves - #435

Merged
theogravity merged 5 commits into
mainfrom
fix/node-update-fanout-and-refusal-copy
Oct 24, 2026
Merged

theogravity merged 5 commits into
mainfrom
fix/node-update-fanout-and-refusal-copy

Conversation

@theogravity

Copy link
Copy Markdown
Contributor

What

Prompts typed into a freshly launched agent pane sometimes sat in the input box without submitting. Claude Code paints its full UI within half a second of spawn but mounts its stdin reader seconds later; anything typed in that window is consumed as one bulk chunk on mount, which the harness eats as a paste: the text lands, the trailing Enter is stripped, nothing submits. Waiting for a non-blank screen cannot see this: the screen is fully painted while the pane still swallows input.

How

Delivery now holds the Enter until the typed text comes back on the screen, which proves the harness is reading its input. The check counts non-overlapping occurrences of the prompt's normalized head (48 chars) and waits for one MORE than the baseline frame, so a short prompt hiding inside banner text and spinner repaints both hold the gate instead of releasing it early. A pane that never echoes (masked input, say) still gets its Enter when the gate budget (10s, on top of the 15s settle) runs out, which is the old blind behavior, never a worse one.

The policy lives in one shared module, deliverPromptInto in @internal/pane-runtime, now used by both delivery sites (the control plane's LocalLauncher.deliverPrompt and the node's prompt_deliver executor) instead of two mirrors that drift. The prompt_deliver wire frame is unchanged, so an old server with a new node just gets the new gate; no protocol bump owed (worst case 25s, inside the remote RPC's 45s budget).

Tests

  • 14 new prompt-delivery.test.ts cases (fake pane + owned clock: order, swallowed newlines, ANSI, banner collisions, spinner repaints, budget fallbacks, error paths) plus a real-tmux regression: a booting TUI whose mount-time read strips CRs and only submits a later Enter chunk.
  • commands-prompt-tail.test.ts updated for the delegated executor.
  • Full suite green: 4509 tests on the server package, zero failures across 54 tasks; verify-types / lint:check / lint:prose clean.

Changeset: @internal/server patch + @internal/node patch.

…themselves

The lazy-release fetch shared one in-flight promise whose single stream was
already spent for every joiner but the first, so "Update all" handed all but
one node an empty download. fetchArtifact now resolves only once the bytes
are complete and verified on disk, hands out a handle that opens its own
reader per call, and a digest mismatch fails the fetch before any byte is
served.

The not-supervised refusal composes from the runtime report (live or held),
naming whether a definition exists and what a launchd GUI-domain refusal
means. On the node, kickstart exit 125 carries the signed-in-at-the-display
hint, and a refused restart after the swap reports the installed binary, the
manager's reason, and the two ways forward.
…ownload temps, honest docs

A failed fetch no longer drags its joiners down with it: a waiter that
inherits a rejection retries once onto whatever newer attempt is running
(or starts one), so one poisoned digest poisons one caller, not the
fan-out. downloadVerified names its temp per download (pid plus a uuid),
which is what two parallel cache:false fetches of one stale-shelf target
need to stop interleaving writers and cross-deleting each other, and
drainedFile opens its reader on first pull so a materialization that
throws still sweeps the temp instead of leaking it.

security.md still told the pre-fix story ("streamed through and hashed on
the way past") and cited line anchors that had moved; both now describe
the shape that shipped. Two regression tests cover the new behavior: a
joiner's retry refetches, and side-by-side cache:false fetches both drain
full bytes and leave the operator's file and no .download- residue.
…comment anchors, the GUI hint narrows

Round 2 of the fan-out review found no MAJOR: the comments were the only
survivors. downloads.route.ts's cache:false note, node-artifact-serve's
rule 3 and broken-pair note, the shelf-serve test, and one fetchArtifact
note still described bytes streaming through without landing on disk;
they now say what happens, which is a uniquely-named temp copy the
response removes. The response-body docblock moves off fileReader onto
drainedFile, the function it describes, and the joiner comment stops
overstating the pre-fix harm: without the retry, it was the downloads
already coalesced onto the failed attempt that died, not every download
for the TTL.

The launchctl kickstart hint keys on the exact sentence its story is
true of (Domain does not support specified action) instead of the bare
word domain, so a find-service-in-domain failure keeps its status
pointer; a new near-miss test pins the split.
…eam word

One test comment still said the operator's bytes are streamed PAST, and
the air-gapped arm said a refusal beats streaming to a node. The first
names a mechanism the fan-out fix removed (those bytes now serve from a
temp copy); the second never had a stream to name (that arm fetches
nothing). Comments only.
…nchors go, vacuous temp assertions made honest

downloadVerified's temp name is unique per attempt now, which is what
the fan-out needs and what a SIGKILL turns into a new orphan every
crash: no in-process path can clean what the kill interrupted, so the
disk was one crash-loop away from accumulating artifact-sized strays
with no reclaim path. Each download now sweeps matching strays older
than an hour before it writes (the mtime of a live download stays
fresh, so the age bound keeps a concurrent fetch's copy out of reach),
and a new test plants an old stray beside a young one and expects only
the corpse to go.

Two comment anchors still described the retired serve-through shape in
the present tense (the ledger docblock, the uncoalesced arm's label);
they now match what the code does. Four assertions in the downloadVerified
suite hunted the old pid-only temp name, which nothing writes anymore,
and so could no longer fail; they scan for prefix strays instead, which
is the honest form. The digest-failure phrase is a named constant so the
artifact-log branch cannot silently drift from what downloadVerified
throws, and the fan-out test's title counts its own tokens.
@theogravity
theogravity merged commit 1ebe595 into main Oct 24, 2026
31 of 32 checks passed
@theogravity
theogravity deleted the fix/node-update-fanout-and-refusal-copy branch October 24, 2026 03:02
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 24, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant