# Busy race (#32) — measure first, then decide

> JIT plan (AGENTS.md §Plans). Successor to `RECHARGE-MILESTONE-PLAN.md`, which shipped as v0.39.0
> with #32 deliberately unbuilt. Gate for every task, as always: `sh ci/run-gates.sh` PASS **and**
> `traceable-reqs check` exit 0.
>
> Mirror-excluded by the root JIT-plan predicate (`:(glob)*-PLAN.md`) on its name alone — no
> `ci/publish/mirror-public.sh` edit is needed or wanted.

## The state this inherits

**The Request's stated root cause is REFUTED.** `UserPromptSubmit` *does* mark busy —
`report_busy_with_input` (`hook.rs`, the UPS handler), and a live trace on this endpoint carried a
real `busy=36ms` stage. "UPS doesn't mark busy" is not the defect, and no fix may be built on it.

**Nothing has replaced it yet.** Two candidates, and the distinction between what is *reachable* and
what *fires* is the whole reason this is a measurement task rather than a build task:

- **(a) A race, not an absence.** The busy mark lands one binary spawn plus ~36 ms after Enter.
  Anything core pushes inside that window is PTY-injected into a turn that is already running —
  which fits the report's wording ("directly after a UserPromptSubmit") exactly. The ~36 ms is
  measured; the *consequence* is not.
- **(b) A silent hole.** The busy/poll block is guarded by `if !id.is_empty()`. An identity
  resolution that returns empty marks **nothing**, and from outside reads exactly like a healthy
  turn. **DEMONSTRATED REACHABLE** by driving the binary directly from a scratch cwd with
  `env -u SPT_ENDPOINT_ID`: it logged `TRACE UserPromptSubmit id=- …` with no `busy=` stage at all.
  **NOT demonstrated: that it ever fires in the field.** Those are different claims and only the
  second one justifies a fix.

## Why this waits on a release rather than starting now

v0.39.0 ships #34, which is the instrument this needs: before it, `hook-trace.log` filled its 512KB
cap in 101 minutes (measured 87 B/s, 95% of bytes stale-frame replay) and the roll *emptied the file
in place*, performed by whichever endpoint's hook crossed the cap. The `TRACE UserPromptSubmit` line
this measurement depends on was written and destroyed roughly two minutes later — that is what filed
#34 in the first place. The fix is in the binary, not on the node, until the release is installed.

**So step 0 is: install v0.39.0 on this node and confirm the instrument.** Do not skip this and do
not trust a zero hit before it — the last "the TRACE lines are missing" turned out to be a wiped log
and a perfectly healthy emitter. Confirm by reading a rolled generation (`hook-trace.log.1` exists,
the live file is fresh) and by seeing one summary refusal line per poll rather than one per frame.

## Step 0 — DONE (measured 2026-09-09, this node, adapter 0.39.0)

The instrument is confirmed on both halves the step asked for, plus one gap it did not ask for.

**Summarisation holds.** The node cut over from the old binary to 0.39.0 cleanly between
`01:57:31Z` (last per-frame stale line) and `01:58:11Z` (first summary line), with **zero** per-frame
`COMMUNE frame for <id> at seq N is older…` lines after the cutover. One poll that refused 458 stale
frames wrote **one** line, not 458.

**Volume, measured across that same cutover in one file:** `2047.6 B/s` before, `34.7 B/s` after —
a 59× cut, and 95%-stale-replay is no longer what fills the log. What remains, by bytes: the
`commune-frame poll` line (~1 KB each, it prints the whole matched seq list), then the
`authored by echo-commune` refusal — **which is still one line per frame**, not summarised like its
sibling. Both are candidates if the log ever needs shrinking again; neither is urgent.

**The gap: retention is hours, not days.** 512 KB at 34.7 B/s is ~4.2 h per generation, ~8.4 h for
both — and step 3 asks for a count over a *multi-day* window. #34 bought enough room to survive an
investigation, which is what it was filed for; it cannot accumulate the population #32 needs.
So `ci/measure/trace-harvest.py` carries every generation forward into a durable, de-duplicated
archive outside the repo (`%LOCALAPPDATA%\spt-claude-code\evidence\`), and reports the two numbers
below. **Run it every session** — a generation lost between runs is evidence that does not come back.

**The remaining volume is NOT worth a release of its own.** The two candidates above (the ~1 KB
poll line and the un-summarised echo-commune refusal) are together ~58% of post-cutover bytes, so
trimming them would roughly double a generation, 4.2h to ~10h. That matters only for time when
nobody harvests — peers keep writing the log while this endpoint is away, so a gap longer than the
~8.4h both generations cover loses evidence. But the trim lives in the binary, and a binary change
costs the full runbook (three triples, tag, release, mirror) for a retention gain the harvester
already covers whenever sessions are regular. **Bundle it with whatever release #32's fix
eventually ships, rather than minting one for it.** If a multi-day gap does cost real evidence,
that changes the arithmetic and the trim earns its own release.

An hourly scheduled task running the harvester would close the away-time gap without a release,
but it is a persistent job on the operator's machine rather than a change in a repo I am DRI for —
**operator's call, not mine.** Worth offering if the gap ever bites.

**The roll is now FIELD-VERIFIED (2026-09-09T04:04Z).** The live file crossed the cap at
`02:48:29Z` and rolled to `hook-trace.log.1` instead of emptying in place — the exact behaviour #34
was filed for, previously covered by a unit test only.

Measured at the harvester, comparing the two generations line by line:

- `.1` holds **1808** lines with **zero** overlap against the live file — a clean roll, not a
  truncation, and not a duplicate copy.
- The seam is contiguous to the millisecond: `.1` ends `02:48:29.439Z`, the live file begins
  `02:48:29.440Z`. Nothing was dropped at the boundary.
- All 1808 rolled lines are present in the durable archive, **0 missing** — so the harvester does
  recover a rolled generation, which is what the bar above asked for.

This retires the "do not claim it" hold. The retention arithmetic is unchanged: ~4.2 h per
generation, ~8.4 h across both, so **the harvester still must run every session.** The roll
preserves a generation; it does not extend the window.

## The measurement

1. **Size (a)'s window.** Stamp the wall-clock gap from UPS hook entry to the `state busy` call
   returning, on a real endpoint, across a spread of turns — not one sample. `busy=36ms` is one
   observation on one endpoint; the number that matters is the distribution, including a cold
   binary spawn.
2. **Show whether anything lands in it.** Replay the io funnel across a turn where a message
   arrived near a prompt submit and read whether the inbound was PUSHED or SPOOLED. A window that
   nothing ever lands in is a window, not a defect.
3. **Count (b) in the field.** Grep the retained trace for UPS lines carrying no `busy=` stage.
   Zero over a real multi-day window means (b) is reachable-but-unfired, and the honest outcome is
   to record that and close it — not to harden it anyway.

### First readings (2026-09-09, n is small — these are a baseline, not a verdict)

From a 14-minute post-install window, 18 `TRACE UserPromptSubmit` lines across four endpoints:

- **(a) the window is wider than the report's premise, and it is not a constant.**
  `min 34ms · p50 121ms · p90 1083ms · max 1184ms`. The `busy=36ms` this plan inherited is near the
  *floor*, not the typical case — a third of the samples are an order of magnitude above it. Whatever
  the consequence turns out to be, it must be argued against the tail, not against 36ms.
- **(b) 0 UPS lines with no `busy=` stage.** This is 14 minutes. It is **not** the multi-day window
  the plan asks for and must not be read as one — it is the first entry in an accumulating count.
- **step 2: 0 of 83 inbound arrivals landed inside a busy window** (2 windows for this endpoint so
  far). Same caveat: the sample is 2 windows wide.

Read the trace with python, never grep: it is UTF-8-lossy, and it lives at
`%LOCALAPPDATA%\spt-core\adapters\_github\SaberMage-claude-spt\hook-trace.log` (the pointer-mode
`source_dir`, **not** `adapters/claude-spt/`).

## Only then, the fix

If the measurement shows the window is real and things land in it, the direction is to move the busy
mark to the earliest point in the hook that has an id, ahead of every other stage. **That is a
direction, not a decision** — this plan has not earned it. If (b) is what fires, the fix is a
different one (an identity that resolves empty should be loud, not silently unmarked). Mint the
requirement **with the fix**, once the measurement names which defect is being fixed; the id and its
title should state what was measured, not what was suspected.

## v0.39.0's other two fixes, field-checked alongside step 0

**#31 trust auto-seed — VERIFIED on the shipped binary (2026-09-09).** Exercised at its real
pre-spawn point without launching a harness: the seeder runs *before* the CLI is resolved, so
`claude-spt launch --cli <nonexistent> --account alt` from an unknown cwd seeds and then exits 2
having spawned nothing. It granted all three approvals under an exact forward-slash key, wrote the
**alt account root** rather than the ambient one, left the sibling entries and every top-level key
untouched, and left no `.sptc-trust-tmp` behind. What this does NOT prove is an end-to-end bringup
coming up bound — that needs a real `spt endpoint run`.

The route there matters: **`launch` is the seeder's only caller.** `claude-spt psyche` and
`claude-spt echo-commune` are separate spawn kinds and do not seed — which matches #31's stated
scope (the F-027 bringup wedge), so it is scope, not a hole. It is also why a commune is useless as
a probe here: a commune's ingest never touches `launch`.

Two dead ends worth not repeating: the node's trust stores hold **zero** fully-approved projects,
which reads like #31 failing and is not — it is what "no bringup has run since the install" looks
like. And the spt daemon's stderr log cannot answer whether a launch ran: it contains zero
occurrences of `launch` or `psyche` across 53 MB, so a zero hit there means nothing.

**#34 retention over hours — still open**, by construction: see the unseen roll, above.

**#24 recharge rename — three of four surfaces confirmed.** The keyword hint and the start-of-session
brief both fire with the new wording on this node. The skill frontmatter/body is **delivered but not
yet live**: cplugs 0.1.19 is installed with the correct `--recharge` text (cache *and* marketplace
copy, and the alt profile's junction sees it), yet every running session still serves 0.1.18's
`--across` wording — every `claude` process on this node started 2026-09-07 01:03, two days before
the 01:57:56Z install, and CC loads plugin skills at process start. That is a restart away, not a
defect, and it is worth stating precisely because it looks exactly like the #17 class (a fix that
reached nobody) and is not one. It does mean **the `--recharge` flag cannot be field-verified from a
session older than the install** — invoking it here would exercise 0.1.18's body and prove nothing.
Verify it from the first session started after a restart.

## Also outstanding

- **`#1 Spill-to-file tax`** — SHELVED by operator ruling, not closed. Still ours, still untyped
  (Type is set once and never changed, so it is filled deliberately or not at all). Its own finding
  ("cap tuning recovers only 6%") implies a redesign rather than a tweak, so it wants a bag of its
  own rather than a corner of this one.
- **Retiring `--across` / "commune across"** — stage 2 of the v0.39.0 rename, a later release, once
  peers' durable context has rotated. Before retiring it, grep `ci/` and `tests/` for the token, not
  just `src/`: the wake rename shipped its retirement forty releases late for exactly that reason.
  `tests/recharge-vocabulary.sh` is where the retirement's guard belongs when it comes.
