Skip to content

fix(spacedock): walk the transcript for boot output instead of one head - #224

Draft
iamcxa wants to merge 2 commits into
mainfrom
fix/spacedock-boot-incremental-scan
Draft

fix(spacedock): walk the transcript for boot output instead of one head#224
iamcxa wants to merge 2 commits into
mainfrom
fix/spacedock-boot-incremental-scan

Conversation

@iamcxa

@iamcxa iamcxa commented Aug 27, 2026

Copy link
Copy Markdown

Closes #221

What was wrong

transcript_boot() read only the first spacedock_boot_scan_bytes (512,000) of a transcript, on the assumption its own docstring stated: boot output "is written once at session start and never rewritten".

That holds for a first officer launched as the agent. It does not hold for a session that adopts the role mid-conversation by loading the skill — boot then runs wherever the conversation had reached. On the transcript this was found from, the envelope sat 3,306,740 bytes into a 6,995,235-byte file, 6.5× past the window, so no strip ever appeared and the project panel read This project declares no workflow.

What changed

The cursor advances instead of the window widening. Each pass reads about one budget's worth of whole lines from a per-path cursor (state.spacedock_boot_scan, advanced under scanner_lock like turns.scan_turns) and keeps what it found. Total cost becomes once per byte instead of once per byte per refresh, and the cliff is gone rather than moved further out.

Two details are load-bearing rather than incidental, and each has a test that fails without it:

  • Whole lines via readline, not a fixed slice. A record split across two passes parses as neither, and a tool result carrying a boot envelope can outgrow the budget on its own — a fixed slice finds no newline in it and stalls the cursor on that line forever.
  • A trailing line without its newline is left unread. Transcripts are read while a harness is writing them; consuming half a record advances the cursor past a line that then never gets parsed.

Reaching spacedock_max_boot_records pins the cursor, and a transcript walked to its end costs one stat — that is what keeps S-5's per-session Pi cost where it was.

Correction to the issue as filed

#221's "Observed in the wild" section overstates what this PR delivers, and I want that on the record rather than discovered in review. It presents a real session as a field case of the strip failure. The transcript facts in it are accurate and this PR does fix them at the function level. But that session carries no agentSetting, and collectors/claude.py returns at setting != SPACEDOCK_FO before transcript_boot is ever called — so that particular board still shows no strip after this change.

The defect #221 names is real and is fixed here. What is not fixed is the launch shape that surfaced it. Making a mid-session Claude FO classifiable at all is a separate decision: it would extend S-5's boot-envelope-as-classifier from Pi to Claude, and S-5's whole point is that an undeclared Claude session opens nothing. That belongs in its own PR with its own argument, not smuggled in here.

Evidence

Deterministic repro (in SpacedockBootCursorTest): two transcripts carrying the same valid envelope, differing only in where it sits. Before, head-only found 0 and whole-file found 1; after, both find it.

The wiring, not just the parser (in SpacedockDeepBootCollectorTest): a declared first officer with a real workflow README, a mid-flight entity state file, and boot output behind four passes of filler, driven through collectors/claude.session_spacedock. The first call returns the role with no workflows; a later one returns the stage strip with its stages and the entity on review. A shallow-boot case asserts the launch shape that already worked still resolves on the first call. Reverting the reader to main's head-only read fails nine tests, two of them these.

The real transcript, through the new reader: envelope found on pass 7, definition_dir resolved, cursor at 3,617,790 bytes.

Mutation-checked, per CONTRIBUTING. Each mutation applied to main's behaviour or a boundary, then the suite re-run:

Mutation Result
fixed slice instead of whole lines 3 failures
cursor never advances 7 failures
EOF check >=> 1 failure
truncation reset removed 1 failure
accumulator returned instead of a copy 1 failure
per-pass record cap instead of a total cap 1 failure
cap-pin >=> 1 failure
partial trailing line consumed 1 failure

The first cap test I wrote passed under the "no cap" mutation — boot_records caps each pass on its own, so a transcript small enough to fit one pass could not distinguish the two. It was rewritten to span passes (one envelope in pass one, three in pass two, cap of three) and now fails as it should. Two mutations that survive and are left surviving: the pass budget's <<=, a one-byte difference in where a pass stops that nothing observable depends on.

Gate, run after the rebase onto 2ddf897, with the pinned toolchain from requirements-dev.txt (ruff 0.16.0, mypy 2.3.0, coverage 7.15.2). The local toolchain on hand was older (ruff 0.15.0, mypy 1.18.2) and reported two S310 findings in quota.py that the pinned ruff does not — worth knowing before trusting an unpinned local run:

ruff check .            All checks passed!
ruff format --check .   141 files already formatted
mypy                    Success: no issues found in 106 source files
lint_embedded.py        Frontend assets clean
validate_plugins.py     Validated 1 skills across 1 plugins
bump_version --current  0.16.0   (no version field touched)
unittest (1842 tests)   OK (skipped=1)
scripts tests (184)     OK
coverage report         90.6% TOTAL   (fail_under = 73)

Three runs failed before this, always the same test, so I chased it instead of calling it a flake — and the first two explanations I formed were wrong. It is test_http_api ... test_api_data_names_the_revision_it_served, failing on TimeoutError in _get_headers, a 5-second HTTP timeout rather than an assertion.

STORE_OVERRIDES is empty by default, so that test's /api/data runs a real collection over the developer's own stores — about 1.1 s cold on this machine — behind that 5-second timeout. It is reproducible by module pair, and it reproduces identically on main:

python3 -m unittest tests.test_spacedock tests.test_http_api     # 4 runs each
  origin/main (2ddf897)   pass=0  fail=4
  this branch             pass=0  fail=4

So it is pre-existing and local, and main's one clean full-suite pass was ordering luck rather than immunity. It does not show up in CI because a runner has no harness stores to collect. Worth someone's attention on its own, but not this PR's to fix.

I also checked the failure was not my own added cost, since the reader now does per-refresh work main did not:

this branch main
cold collect(), real stores 1114 ms 1129 ms
14 successive collects, steady state ~207 ms ~220 ms

Identical within noise. An earlier reading of 362 ms against 303 ms looked like a regression and was noise from too few samples; I am recording it because I nearly wrote it into this section as one.

Cost

Measured on the 6,995,235-byte transcript above, median of 7:

full walk to EOF (this PR) 9.0 ms, spread over 14 passes — about 0.6 ms per refresh
one head read (main) 0.2 ms, and never reaches a deep envelope
a settled call, cursor at EOF 2.5 µs — one stat, as before

So the change buys a one-time 9 ms per large transcript, amortised across refreshes, and then costs nothing.

These numbers come from timing transcript_boot directly, not from scripts/bench_collect.py, which the pre-PR suite points at and which does not run on main: AttributeError: 'Namespace' object has no attribute 'host' at cli.py:200, reproduced on a clean origin/main worktree at d3f9867. That looks like --host landing without the bench's Namespace being updated. It is unrelated to this diff and not fixed here.

Docs

  • SKILL.md — the strip caveat said no strip appears when boot is "outside the scanned head of a long transcript", which is no longer a thing that happens. It now says the strip can take a few refreshes to arrive on a long transcript.
  • docs/design-spacedock.md — new S-6 for the cursor and the two load-bearing details; S-5's cost paragraph updated; and the rejected-alternatives entry corrected. That entry claimed whole-transcript scanning "would have made the collection pass quadratic in transcript size" — true of the brace-balancing parser boot_records replaced, not of either reader today. The other half of that rejection stands untouched: there are still no later status envelopes to find, which is a different question from reaching the boot envelope at all.

without-it unanswered

  • test_an_unreadable_transcript_yields_nothing — the missing-file path was already covered indirectly. It is three lines and pins the defensive-parsing contract explicitly, but nothing has bitten here. Yours to cut.

Rebase note

Rebased onto 2ddf897 (#223), which rewrote the Session title bullet and added The line beneath the title. The conflict was in SKILL.md: #223's three bullets were taken whole and only the strip caveat re-applied on top, so nothing of #223's wording is reverted here.

Not done

sync-docs was not invoked; the docs pass above was done by hand, so it has had no voice-and-tone check.

transcript_boot read only the first spacedock_boot_scan_bytes on the
stated assumption that boot output is written at session start. That
holds for a first officer launched as the agent and not for a session
that adopts the role mid-conversation, where boot lands wherever the
conversation had reached. On a real transcript the envelope sat 3.3 MB
into a 7.0 MB file, so no stage strip ever appeared.

Each pass now reads about one budget of whole lines from a per-path
cursor and keeps what it found, making the cost once per byte rather
than once per byte per refresh. Whole lines through readline, because a
record split across passes parses as neither and a tool result carrying
an envelope can outgrow the budget on its own. A trailing line without
its newline is left for the next pass, since transcripts are read while
a harness writes them.

Closes #221

Signed-off-by: Kent Chen <kentchen@reccehq.com>
@iamcxa
iamcxa force-pushed the fix/spacedock-boot-incremental-scan branch from 79862d8 to 9e042f1 Compare August 27, 2026 07:40
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Coverage

Name                                                                  Stmts   Miss Branch BrPart  Cover
-------------------------------------------------------------------------------------------------------
cargento/skills/cargento/agy_hook.py                                     79     14     28      7  78.5%
cargento/skills/cargento/cargento_runtime/__init__.py                     0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/aggregate.py                  194      1     50      0  99.6%
cargento/skills/cargento/cargento_runtime/asks.py                       110      0     28      0 100.0%
cargento/skills/cargento/cargento_runtime/claude_data.py                305     31    142     14  89.9%
cargento/skills/cargento/cargento_runtime/cli.py                        126     14     26      3  87.5%
cargento/skills/cargento/cargento_runtime/collectors/__init__.py          0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/collectors/antigravity.py     410     42    166     26  86.8%
cargento/skills/cargento/cargento_runtime/collectors/claude.py          209     13     68      7  92.8%
cargento/skills/cargento/cargento_runtime/collectors/codex.py            98      7     38      7  89.7%
cargento/skills/cargento/cargento_runtime/collectors/copilot.py         148      6     50      2  96.0%
cargento/skills/cargento/cargento_runtime/collectors/cursor.py          277     20    106     17  89.8%
cargento/skills/cargento/cargento_runtime/collectors/droid.py            32      3      6      1  89.5%
cargento/skills/cargento/cargento_runtime/collectors/gemini.py           53      7     16      4  84.1%
cargento/skills/cargento/cargento_runtime/collectors/goose.py            89     11     28      4  87.2%
cargento/skills/cargento/cargento_runtime/collectors/opencode.py         78      6     26      2  92.3%
cargento/skills/cargento/cargento_runtime/collectors/pi.py              326     34    152     20  88.7%
cargento/skills/cargento/cargento_runtime/config.py                     187      1     20      1  99.0%
cargento/skills/cargento/cargento_runtime/diagnostics.py                 84      4     26      4  92.7%
cargento/skills/cargento/cargento_runtime/dismissals.py                 113      2     28      2  97.2%
cargento/skills/cargento/cargento_runtime/events.py                     162      0     62      0 100.0%
cargento/skills/cargento/cargento_runtime/http_api.py                   515     31    176      9  94.2%
cargento/skills/cargento/cargento_runtime/io.py                         126      2     28      0  98.7%
cargento/skills/cargento/cargento_runtime/lifecycle.py                  323     15    104      6  95.1%
cargento/skills/cargento/cargento_runtime/notifications.py              174     14     60      4  91.5%
cargento/skills/cargento/cargento_runtime/observation.py                235      2     64      0  99.3%
cargento/skills/cargento/cargento_runtime/observer.py                   217     28    100     13  84.5%
cargento/skills/cargento/cargento_runtime/probe.py                       44      0     18      1  98.4%
cargento/skills/cargento/cargento_runtime/quota.py                      333      2    112      1  99.3%
cargento/skills/cargento/cargento_runtime/records.py                    195      5     94      9  95.2%
cargento/skills/cargento/cargento_runtime/sessions.py                   101      0     44      0 100.0%
cargento/skills/cargento/cargento_runtime/snapshot.py                    36      0      4      0 100.0%
cargento/skills/cargento/cargento_runtime/spacedock.py                  395     42    196     22  88.8%
cargento/skills/cargento/cargento_runtime/state.py                       64      0      2      0 100.0%
cargento/skills/cargento/cargento_runtime/stream.py                      57      0      8      0 100.0%
cargento/skills/cargento/cargento_runtime/transcripts.py                354     16    190     15  94.3%
cargento/skills/cargento/cargento_runtime/turns.py                      197     14    104     12  90.7%
cargento/skills/cargento/cargento_runtime/web/__init__.py                 0      0      0      0 100.0%
cargento/skills/cargento/cargento_runtime/web/page.py                    54      0     14      0 100.0%
cargento/skills/cargento/event_hook.py                                   86      4     28      3  93.9%
cargento/skills/cargento/mcp_server.py                                  377     22    112     14  92.2%
cargento/skills/cargento/notify_hook.py                                  49     15      6      1  67.3%
cargento/skills/cargento/server.py                                        3      0      2      1  80.0%
cargento/skills/cargento/statusline_hook.py                             131     13     46      8  87.0%
scripts/bench_collect.py                                                211      9     54      5  94.7%
scripts/bench_event_latency.py                                           67     67     14      0   0.0%
scripts/bump_version.py                                                  60     12     24      5  77.4%
scripts/capture_hook.py                                                 287     30     86     11  88.5%
scripts/lint_embedded.py                                                 92      3     28      2  95.8%
scripts/validate_plugins.py                                             663    186    386     58  69.3%
-------------------------------------------------------------------------------------------------------
TOTAL                                                                  8526    748   3170    321  89.7%

Threshold: fail_under in pyproject.toml · label coverage-exception to bypass (visible in PR timeline).

…ector

The cursor tests pin transcript_boot on its own. The user-visible claim
is a wiring one -- a declared first officer whose boot output sits past
the first pass ends up with a stage strip -- so it needs the collector,
the workflow read and the entity read exercised together.

Reverting the reader to main's head-only read fails nine tests, two of
them these.

Signed-off-by: Kent Chen <kentchen@reccehq.com>
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.

Spacedock stage strips are missed when boot output lands past the 512 KB head scan

1 participant