Skip to content

XERK-263: spool the migration relay's transcript bundle to disk - #437

Merged
xerhab merged 6 commits into
mainfrom
XERK-263-1
Aug 12, 2026
Merged

XERK-263: spool the migration relay's transcript bundle to disk#437
xerhab merged 6 commits into
mainfrom
XERK-263-1

Conversation

@xerhab

@xerhab xerhab commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes XERK-263.

The problem

POST /api/agents/<host>/migrations/<id>/blob read a moved session's whole gzipped transcript bundle into the heap (readRawBody, capped at 65 MiB) and then retained it on the migration record for the entire importing phase, until the target agent pulled it back down. The hub runs at mem_limit: 256m, and the operator can start two moves with two clicks of the Sessions page's Move control.

Measured A/B on a real hub, one 64 MiB relay:

RSS before during upload after the target's GET
main (be96f6b) 40.1 MiB 171.1 MiB, and it stays there
this branch 40.6 MiB 55.9 MiB 63.3 MiB, nothing retained

Seven concurrent 64 MiB bundles now sit at ~74 MiB RSS and fall back to 62 MiB idle. The remaining ~16–23 MiB is allocator high-water that varies run to run with no code difference — the 65 MiB retention is gone, which is the thing the ticket is about.

The change

The relay streams instead of buffering:

  • spoolRawBody(req, cap, filePath) writes the body straight to a file, reading with backpressure so a fast uploader can't just queue it all in the write buffer. It keeps readRawBody's over-cap drain rule, and removes the partial file before its rejection lands.
  • The bundle spools to MIGRATE_SPOOL_DIR (/data/migrations, on the volume /data already needs — no compose change), keyed on the hub-minted migration id, never the URL segment.
  • The record keeps blobPath/blobSize, not bytes. The target's GET streams the file back.
  • dropMigrationBlob() unlinks on every terminal path — handoff, timeout, source session gone, MIGRATIONS_MAX eviction, retirement. Safe mid-download: the unlink drops the name, the reader's fd keeps the bytes.
  • Boot sweeps the spool dir. The records are in memory, so a restart abandons every in-flight move and anything left belongs to no one. It deletes only names matching the shape it writes (see below for why that matters).

The wire contract is untouched — octet-stream in, octet-stream out. No agent change; older agents are unaffected. The tar-member ../absolute guards stay on the target, where the ticket wanted them.

Merged with XERK-266, which hardened this same route on main

XERK-266 landed while this was in QA: it scopes the POST to the migration's own source host and the GET to its target host, and makes every POST refusal answer the same 404, so no response a non-source can't also get names the source to anyone holding the migration id. Both sides are kept, and where they met XERK-266's doctrine wins on the shape of every reply — the bundle already uploading guard, the empty-bundle refusal and source session gone all answer that uniform 404 rather than the 409/400 they had here.

A spool write failure does too, where this branch answered 500. It is deliberately not a second enumerated exception beside the 413: a distinct status would name the source the moment the hub's disk misbehaved. Nothing is lost — the agent only logs the reply, the record still carries transcript bundle spool failed for the operator, and the detail goes to the hub log where it's actionable. That also subsumes the path-leak fix from earlier in this branch, since the body is now a fixed string. Verified on a real hub: a wrong host, an unknown id and an unwritable spool are byte-identical on the wire.

One addition beyond the ticket's primary direction

Spooling moves the pressure off the heap and onto /data, which the archive shares. So MIGRATE_INFLIGHT_MAX (4) bounds how many moves can hold a bundle at once — the ticket's "secondary option", taken as a complement rather than an alternative. It is enforced where a move starts, not on the relay upload: hub-agent.py's _migration_upload is best-effort with no retry, so refusing an upload strands the migration, while refusing the operator's click is a message the web Move control already shows.

What the ticket asked for that isn't here

Dropping XERK-258's refusable:false carve-out. XERK-258 is not on main and has no branch (it's being worked in parallel), so there is nothing here to remove — that cleanup belongs to whoever lands it.

QA

Three adversarial passes (~/.claude/agents/qa.md). The first returned FAIL and was right to.

Defects it found in my first attempt, all fixed here:

  1. Silent truncation, and a false success. A spool write error surfacing during the final flush was swallowed — out.end(() => resolve(len)) discards the callback's error, and settled was already set so the error handler was a no-op too. The hub answered 200 {"ok":true} for a truncated bundle, advanced to importing, queued importSession, and logged nothing; the move then died at the 5-minute timeout with no diagnostic. Repro on a spool FS with 12 KiB free: 40960 bytes -> 200, 28672 on disk. Now: 500, no file, phase=failed, no import queued. spoolRawBody settles inside the end callback, and resolves on close — the descriptor is still open in the end callback, so a close(2) failure would land on the same swallowed side.
  2. A 200 whose Content-Length didn't describe it. The GET read blobPath at handler time but blobSize inside the async open callback; a concurrent settle zeroes both, and since the unlink leaves the fd valid the hub sent the whole body under Content-Length: 0 — which the agent's urllib reads as an empty bundle. 296/300 in QA's race harness. Both fields are now snapshotted before the first async hop: 300/300 correct, and 40/40 through the real Python client.
  3. The boot sweep deleted every file it found. MIGRATE_SPOOL_DIR is deployment config; pointed at /data by a one-word compose slip, boot deleted state.json and devices.json (verified). It now matches /^[0-9a-f]{16}\.bin$/.
  4. The spool-failure 500 leaked the hub's absolute filesystem path; it goes to the log now.
  5. The bundle already uploading 409 didn't drain the body, so the losing uploader got a socket error instead of the refusal.

The second pass returned PARTIAL — not for the product code, which held under fresh attack, but because my regression test for #2 caught a reintroduced bug only 1 run in 5. It timed the settle from outside with a 1 ms sleep, and took a 404 early-out when the drop won. It now lands the settle in the window by construction, on the read stream's open event, and asserts that it landed: 12/12 caught.

The third pass returned PASS. Its remaining nit is taken inline: requestRaw had no timeout, so an unanswerable route hung the suite until the CI job died. The guard moved into the shared helper.

Verified in that final pass, on real hubs over real sockets: the ENOSPC sweep, EISDIR, the over-cap boundary (413 within RAW_BODY_DRAIN_SLACK, socket cut past it — inherited from readRawBody; a conforming agent can't reach it, since it caps its own bundle 1 MiB below the hub's), aborted uploads, empty bodies, concurrent 32 MiB uploads, source session gone, seven successive 64 MiB relays, the in-flight cap with a real handoff freeing its slot, and — closing the biggest gap from pass 1 — the real hub-agent.py legs: _pack_transcript_migration_upload_migration_download_unpack_transcript byte-identical, torn tail dropped, and a malicious tar refused with unsafe tar member '../../…' and nothing written.

Not verified, stated plainly: no move has ever been completed by a live agent pair — the command loop, tmux launch, worktree creation and claude --resume were never driven. That is a standing gap in the migration feature, not in this diff. close(2) failures are structurally closed but not exercised (local volume). RSS was measured unconstrained, not under a real 256 MiB cgroup. Android/glasses/veiller/the browser UI were read, not run.

CI

Semgrep flagged path.join(MIGRATE_SPOOL_DIR, ...) as a possible traversal. It wasn't — the only caller passes the hub-minted m.id — but the function documented that as a fact about its caller rather than enforcing it, which is the kind of comment that goes stale when a second caller appears. migrationSpoolPath now validates the id against the shape startMigration mints and throws otherwise, so the nosemgrep sits behind a real check (the same idiom archive.js uses for the same rule). Verified with CI's exact invocation locally: 0 findings.

Tests

New cases in turma/tests/server.test.js: spool-on-disk on the happy path, the empty body, the over-cap 413 (sized cap + 4096, well inside the drain slack so it can't sit one byte from flaking), the concurrent-upload guard (which no test caught — removing it was a clean mutation escape), an unwritable spool, the download/settle race, the sweep's selectivity, the boot sweep, the in-flight cap freeing its slot, and the spool-path guard. 1095 JS + 1288 Python green.

One test-hygiene fix the merge exposed: the in-flight-cap case left the fleet at MIGRATE_INFLIGHT_MAX, so every later test that started a move got a 503. It settles its own moves on the way out now.

Findings routed elsewhere

  • XERK-271 — filed, then closed as already fixed. I raised it from a code read of the old base: Android's FleetViewModel.run() rendered every non-2xx as "✗ hub unreachable", so the new 503 would have lost its wording on the phone. XERK-264 had already landed the same fix on main (hubErrorMessage reads the {error} off an HttpException or a typed Response), so the merge closed it for free. The ticket is closed with that explanation and the PARITY.md gap line I'd added is removed — the entry now records that Move's refusals reach the operator in the hub's own words like every other command.
  • Accepted limit, not fixed: a second hub sharing one MIGRATE_SPOOL_DIR sweeps the first's in-flight bundles at boot. One hub per volume is already assumed by state.json, devices.json and the archive index; if that changes, the spool isn't where to start.
  • Kept but honestly described: ordering the empty-body unlink before the response closes a real ordering hazard, but QA could not win the race in 40/40 attempts against either build. It's hardening, not a demonstrated bug fix.

xerhab added 6 commits August 12, 2026 13:03
The hub buffered a moved session's whole gzipped transcript bundle in the
heap — readRawBody at a 65 MiB cap — and then RETAINED it on the migration
record for the entire importing phase, until the target agent pulled it back
down. Two concurrent moves, which the Sessions page's Move control lets an
operator start with two clicks, held 130 MiB in a hub that runs at
mem_limit 256m.

The relay now streams instead of buffering:

- POST .../migrations/<id>/blob writes the body straight into a file under
  MIGRATE_SPOOL_DIR (/data/migrations, on the volume /data already needs),
  via a new spoolRawBody() that reads with backpressure and keeps
  readRawBody's over-cap drain rule so an oversize bundle still gets a 413
  on the same connection rather than a socket hang-up.
- The record keeps blobPath/blobSize, not bytes. The target's GET streams
  the file back with the recorded length.
- dropMigrationBlob() unlinks on every terminal path — handoff, timeout,
  "source session gone", record eviction and retirement — and the unlink is
  safe mid-download, since the reader's open fd outlives the name.
- Boot sweeps the spool dir: the records are in memory, so a restart
  abandons every in-flight move and anything left there belongs to no one.

The wire contract is untouched (octet-stream in, octet-stream out), so no
agent change is needed and older agents are unaffected. The tar-member
../absolute guards stay on the target, where they were.

Spooling moves the pressure from the heap onto /data, which the archive
shares, so MIGRATE_INFLIGHT_MAX (4) bounds how many moves can hold a
bundle at once. It is enforced where a move STARTS, not on the relay
upload: hub-agent.py's _migration_upload is best-effort with no retry, so
refusing an upload would strand the migration, while refusing the
operator's click is a message the Move control already shows.

The ticket also suggested dropping XERK-258's refusable:false carve-out for
this route. XERK-258 is not on main and has no open branch, so there is
nothing here to drop; that cleanup belongs to whoever lands it.
An adversarial QA pass on the previous commit returned FAIL with two
reproducible defects, one of them a silent data-corruption path. Both are
fixed here, with a repro for each.

1. A spool write error that first surfaced during the FINAL flush was
   swallowed: `out.end(() => resolve(len))` discards the end callback's
   error, and `settled` was already set, so `out.on("error", fail)` was a
   no-op too. The hub answered 200 {"ok":true} for a truncated bundle,
   advanced the migration to `importing`, queued importSession at the
   target, and logged nothing — the move then died at the 5-minute timeout
   with no diagnostic. spoolRawBody now settles INSIDE the end callback,
   fails on its error, and rejects on a short write (bytesWritten != bytes
   received) rather than resolving with what came off the socket.

   Repro (spool on a 1 MiB tmpfs with 12 KiB free, real hub):
     before: 40960 bytes -> 200, 28672 on disk, phase importing, 0 log lines
     after:  40960 bytes -> 500, no file, phase failed, no import queued
   Every size from 16 KiB to 1 MiB now refuses; none leaves a partial file.

2. The bundle GET read `m.blobPath` at handler time but `m.blobSize` inside
   the async `open` callback. A concurrent settle (heartbeat handoff,
   timeout sweep, eviction) calls dropMigrationBlob, which zeroes both —
   and since the unlink leaves the read's fd valid, the hub sent the whole
   body under `Content-Length: 0`. The agent's urllib read it as an empty
   bundle. Both are now snapshotted before the first async hop.

   Repro (GET raced against a settling heartbeat, 300 iterations):
     before: 296/300 served 524288 bytes under Content-Length: 0
     after:  300/300 correctly framed; 40/40 via the real urllib client

Also from the same pass:

- sweepMigrationSpool deleted EVERY file it found in MIGRATE_SPOOL_DIR.
  That dir is deployment config, and pointing it at /data — a one-word
  compose slip — had boot delete state.json and devices.json (verified).
  It now deletes only names matching the shape it writes.
- The spool-failure 500 handed the agent the hub's absolute filesystem
  path in the error body; the detail goes to the log instead.
- The `bundle already uploading` 409 didn't drain the request body, so the
  losing uploader saw a socket error instead of the refusal.
- The over-cap test sat on the exact RAW_BODY_DRAIN_SLACK boundary, one
  byte from flaking; moved well inside it. The 413 holds only within the
  slack (inherited from readRawBody) — stated plainly rather than claimed
  unconditionally.

New tests cover each: the concurrent-upload guard (which no test caught —
removing it entirely was a clean mutation escape), an unwritable spool, the
download/settle race, and the sweep's selectivity.

QA also found that Android renders every non-2xx as "hub unreachable", so
the new 503 (and the pre-existing /migrate 409s) lose their wording on the
phone. Pre-existing and not Move-specific — it is FleetViewModel.run() for
every action — so it is filed as XERK-271 and logged in android/PARITY.md
rather than grown into this PR.
The re-QA pass returned PARTIAL: the product fixes all held under fresh
attack, but the regression test guarding the subtlest of them — the GET
that served a full body under Content-Length: 0 — caught a reintroduced
bug only 1 run in 5, proven by reverting the fix and running the suite
five times. It timed the settle from outside with a 1 ms sleep, which
almost always lands after the response header is written; and when the
drop won outright the test took its 404 early-out and passed just as
silently. Both escape hatches let it pass in either direction.

It now lands the settle in the window by construction, by patching the
one call that opens it and dropping on the read stream's `open` — after
the descriptor exists, so the unlink can't stop the read, and before the
route's own open handler writes the header, because a listener registered
inside the patch runs first. That is the production race exactly. It also
asserts the settle landed, so the test can't quietly prove nothing.

Same mutation, five runs: 5/5 caught (was 1/5).

Two more from the same pass:

- QA noted the D1 class survives for an error that only materialises at
  close(2): out.end(cb) fires before the descriptor closes (end-cb ->
  finish -> close), so a close failure would land on `error` after the
  resolve and be swallowed exactly as the flush error was. spoolRawBody
  now resolves on `close`. Unreachable on a local volume, but it is the
  same shape as the bug that failed the first QA pass, and closing it is
  two lines. Reverting the D1 fix now fails three tests in 13 ms rather
  than hanging the suite until the CI job times out.
- The empty-bundle 400 is the one path that leaves a migration retriable,
  and it unlinked fire-and-forget — a retry that re-created the same path
  first would have had its good bundle deleted underneath it. The unlink
  now happens before the response.

Also gave the "doesn't hang" test a 2s timeout of its own, so a route that
stops answering fails there instead of by exhausting the job.

Re-ran QA's own harnesses against the changed resolve path: the ENOSPC
sweep still refuses every body that doesn't fit (500, no file, phase
failed, no import queued), the 300-iteration settle race is 300/300
correctly framed, and a real 64 MiB relay is byte-identical.

N3 in the report — a second hub sharing one MIGRATE_SPOOL_DIR sweeps the
first's in-flight bundles at boot — is left as is: one hub per deployment
is already assumed by state.json, devices.json and the archive index on
the same volume, so the spool is not the thing to fix if that changes.
QA's final pass returned PASS with one nit worth taking: a route that
stops answering hangs whichever test reaches it, and the suite then dies
by exhausting the CI job's budget with no failing assertion to read. I
had guarded exactly one test with a Promise.race; this moves the guard
into the shared helper, so it covers every caller and every future one.

Not reachable today — a path-based createReadStream always emits `open` or
`error` — but it is the failure mode this change hit twice while it was
being written, so the helper is the right place for it. Verified with the
shape QA used to demonstrate it (an fd-based read stream, which never
emits `open`): the suite now reports three assertion failures in 15s where
it previously ran to the job timeout.
Conflicts in the migration blob relay, which XERK-266 hardened on main
while this branch was moving it off the heap. Both sides' intent is kept.

From main (XERK-266): the POST is scoped to the migration's own source
host and the GET to its target host, and every POST refusal answers the
same 404, so no response a non-source can't also get names the source to
anyone holding the migration id.

From this branch (XERK-263): the bundle spools to disk instead of riding
in the record.

Where they met, XERK-266's doctrine wins on the shape of every reply:

- The `bundle already uploading` guard, the empty-bundle refusal and the
  `source session gone` branch all answer the uniform 404 rather than the
  409/400 they had here.
- A spool write failure does too, where this branch answered 500. It is
  NOT a second enumerated exception beside the 413: a distinct status
  would name the source the moment the hub's disk misbehaved. Nothing is
  lost — the agent only logs the reply, the record still carries
  "transcript bundle spool failed" for the operator, and the detail goes
  to the hub log where it is actionable. That also subsumes the path-leak
  fix from earlier in this branch: the body is now a fixed string.
- The route's comment enumerates the two refusals this branch added, as
  that comment demands of anything reaching this route.

The GET keeps main's `m.targetHost !== host` check in front of this
branch's snapshot-then-stream.

main's two new tests keep their assertions and move to the spool shape
(`blobPath`, and reading the file rather than `m.blob`). This branch's
three tests move to the uniform 404.

One test-hygiene fix the merge exposed: the in-flight-cap case left the
fleet at MIGRATE_INFLIGHT_MAX, so every later test that started a move
got a 503. It now settles its own moves on the way out.

Verified on a real hub after the merge: a 64 MiB relay is byte-identical
with no retention (40.6 -> 63.3 MiB RSS), and a wrong host, an unknown
id and an unwritable spool are indistinguishable on the wire while the
record and the log keep the true reason. 1094 JS + 1288 Python green.

Also closed XERK-271 and dropped the PARITY.md gap line it tracked:
XERK-264 landed the same fix on main before I filed it, so Android
already words hub refusals from the body via hubErrorMessage.
CI's Semgrep flagged the `path.join(MIGRATE_SPOOL_DIR, `${id}.bin`)` as a
possible path traversal. It isn't — the only caller passes `m.id`, minted
by crypto.randomBytes and never the agent's path segment — but the
function was documenting that as a fact about its caller rather than
enforcing it, which is exactly the kind of comment that goes stale when a
second caller appears.

It now validates the id against the shape startMigration mints and throws
otherwise, so the guarantee holds no matter who calls it, and the
`nosemgrep` sits behind a real check rather than an assertion of good
behaviour — the same idiom archive.js uses for the same rule. Tested
directly, since nothing should be able to reach it through a route.

Verified with the exact CI invocation locally: 0 findings, 541 rules,
301 files. 1094 JS + 1288 Python green.
@xerhab
xerhab merged commit 98081c6 into main Aug 12, 2026
6 checks passed
@xerhab
xerhab deleted the XERK-263-1 branch August 12, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant