Repository navigation
spike: viewport hydrate — latest-page F5 (#924), slim local, scrollbar (A5 rebase) #553
Description
Parent
#548 (agent session architecture) — A5 viewport
Type
Spike — design + maybe a throwaway prototype. Not a ship-or-fail
feature until a follow-up create-plan locks the winner. Do not convert
this issue into a plan.
Rebase (2026-09-06)
This spike was filed before durable async workflow inference. The original
problem (one JSON messages[] Blob, host Stop persist of the whole snapshot,
“do we HTTP Range or page?”) does not match main today.
Hard requirement: the winner of this spike must close
#924 (F5 / reload
fast-forward-replays the entire durable stream). A viewport design that leaves
that bug standing is not done.
Hard requirement: the follow-up plan’s DoD must include green
end-to-end integration tests on the durable-turn int project
(npm run test:int = vitest run --project int) that prove the #924
bullets (and any leftover that ships in the same cut) on a real Wasm
ring. Unit tests of coldAttachFromSnapshot / turnApply alone are
not sufficient. See End-to-end integration tests below.
A4 compaction (#552 / plan #947) is the model seed, not the canvas. This
spike is the paint window. Do not re-plan inference fold.
Leftover (this is the spike)
Three holes remain. #924 is the user-visible one and is non-negotiable.
Closing it (and any leftover that ships with it) requires e2e int rows,
not a unit-only gate.
| # | Leftover | Status |
|---|---|---|
| 1 | Live F5 / cold open replays the Workflows user-stream from index 0, including every reasoning_delta |
#924 — must close |
| 2 | localStorage still JSON.stringifys the whole SessionSnapshot |
leftover of original #553 |
| 3 | Load-earlier is an in-memory slice, not a Blob page fetch; no intelligent scrollbar | leftover of original #553 |
Idle / completed F5 already lands on the latest ring page. Do not re-solve that.
Verified F5 path (why #924 still happens)
Same-tab F5 of a live (running / cancelling) turn on current main:
store.load() // full localStorage JSON
hydrateRingWindow(latest ≤2048) // Wasm paints immediately (looks right)
bootCloudSession // envelope + full Blob GET (no Range)
adoptCloudSession // re-hydrate latest ring; heapAppliedRef = null
kickColdAttach
coldAttachFromSnapshot → { startIndex: 0, dedup: true }
// envelope turnStreamCursor is persisted, then ignored
prefixThroughLastUser // strip this-run tool_run / assistant
// ring = prior turns + this prompt ← operator sees the starting prompt
GET /api/turns/:runId/stream?startIndex=0
apply every SSE frame
reasoning_delta → growThinking // NEVER skipped
tools / text skip only against the (now-empty) this-run window
The lock that causes the replay is intentional in today’s code:
decideAttachClass/coldAttachFromSnapshot: new heap (F5 / login / new
tab / switch) → always{ kind: 'cold', startIndex: 0 }even when
envelopeCis large. Comment: “Heap has not applied this turnRunId.”adoptCloudSession/kickColdAttachsetheapAppliedRef = null, so F5
never takes the hot path (startIndex = heap.count).prefixThroughLastUser(adversarial feat(harness): E19 attach handshake — Blob hydrate + stream attach #857): Blob this-run suffix has no
thinking; leaving it on the ring would appendreasoning_deltaafter the
answer. Cold attach therefore wipes tools/text off the ring and rebuilds
them from the stream — which is why the operator sees prompt → sped-up
thinking → tools → text, not “thinking on top of an already-painted tail.”lib/turnApply.ts: “Thinking is never in Blob — never skip, even on cold
attach.”
Hot resume at heap C already works same-tab stream drop. F5 is a new
heap, so it never uses it.
'cancelling' stays attachable so F5 after an accepted cancel restores
Busy + Stop. That constraint stays. The replay does not.
Idle / completed F5: coldAttachFromSnapshot → null. Latest 2048 from
local then Blob. No stream, no thinking (thinking is not in SessionStore).
Expected #924 bullets 1–2 are already true for idle sessions.
Already shipped (do not re-solve)
| Claim | Where | Notes |
|---|---|---|
| Workflow run owns the turn; viewport attaches | docs/feature-divide.md, POST /api/turns |
Detach closes a reader; it does not stop the run. Worker is the Blob writer. |
| Attach cursor on the wire | meta.turnStreamCursor, GET /api/turns/:runId/stream?startIndex=C |
#813 / #797. Hot resume (same-heap) starts at heap-applied C. F5 refuses C. |
| Dedup on cold attach | lib/turnApply.ts shouldSkipToolStart / textDeltaDedup |
Tools + assistant text can skip — but prefixThroughLastUser empties the this-run window first, so they replay too. |
| Thinking not in SessionStore / Blob | docs/session-model.md |
Correct for paint storage. Replay still streams thinking from the durable run. |
| Transcript chunks + flatten | transcriptPointer + prev / depth (max 256); terminal persist writes a flatten root |
Original Q1 (one JSON array + HTTP Range) is obsolete. Blob PUT is whole-object. prev is mid-turn worker durability, not a viewport pager; GET reconstructs into one snapshot. |
| Ring window + “Load earlier” button | lib/sessionWindow.ts, protocol v6, native/harness/src/ui.zig |
Latest 2048; button steps −512 through already-in-memory session.messages. Not overscroll, not document-length scrollbar, not a Blob fetch. |
| Inference seed ≠ paint | meta.modelMessagesPointer, A3 trim, A4 compaction (#947 p1–p4 on main) |
Model memory is a different pipe. Compaction does not shrink localStorage, Blob GET, or F5 replay. |
| Completed-session boot does not attach | decideAttachClass → none when not live |
Idle F5 is hydrate cost, not stream replay. |
There is no “hydrate tail page from Blob Range” API and no “skip
reasoning on attach” flag. #924 is a policy change on
kickColdAttach / prefixThroughLastUser / reasoning_delta, not a new
transport.
#924 requirement (non-negotiable)
The spike winner, then the follow-up plan, must deliver all three:
- Refresh / cold open of a large session lands on the latest viewport
(tail) — not a chronological replay from the starting prompt. - Thinking rows are not streamed or replayed on hydrate (collapsed /
omitted until the operator explicitly expands or a new live thinking
segment starts). - Live attach (turn still
running/cancelling) resumes from a cursor
/ tail without replaying the whole history at UI speed. Busy + Stop must
still restore.
Closing this spike’s follow-up plan closes #924. #924 stays open until
that ships and the e2e int rows below are green.
End-to-end integration tests (non-negotiable)
The follow-up create-plan DoD must include durable-turn int coverage.
This is a close gate, same class as “must close #924”: a merge that
flips attach policy without these rows is not done.
Where: int/ (npm run test:int). Load a real protocol-current
harness.wasm via int/loadBridge.ts. Fail closed if Wasm is missing
or stale (throw, never it.skip). Helpers already in tree:
bootFromMemory, hydrateRing, ringTexts, int/stores.ts.
Required rows (map 1:1 onto the three #924 bullets):
| # | Prove | Fail if |
|---|---|---|
| 1 | Live F5 / cold open of a running turn hydrates the tail (Wasm ring is not this-prompt-only) |
Ring is the starting prompt; chronological replay |
| 2 | Historical reasoning_delta is not applied on hydrate; a new live thinking segment after attach still paints |
Fast-forward thinking on refresh, or live thinking after attach is dropped |
| 3 | Stream attach uses resume C (envelope / heap tail), not startIndex=0 when C is known; 'cancelling' still attachable so Busy + Stop restore |
Cold attach ignores turnStreamCursor; cancel F5 loses Busy + Stop |
Keep an idle / completed F5 regression: no stream, latest page (already true
on main; do not drop it).
If leftover 2 (slim local) or 3 (scrollbar / load-earlier) ships in
the same cut, those need int rows too: persist/hydrate of a large transcript
must not require the full snapshot in localStorage; load-earlier prepend
must come from a Blob page, not “host already holds all rows.”
Existing int/f5-attach.int.test.ts currently locks the #859
startIndex: 0 cold-attach contract. That expect is #924. The
follow-up must rewrite those rows to the new contract (or wrap only the
pre-flip expects in it.fails until the phase that changes them). Do not
leave the old startIndex: 0 assert green as “coverage.”
Do not substitute lib/harnessChat.wasm-int.test.ts (protocol supply) or
app/api/turns/[runId]/stream/route.test.ts (HTTP startIndex parsing)
for this gate. Those stay; they do not prove F5 hydrate.
Likely multi-phase follow-up (not required)
Leftovers 1 / 2 / 3 are separable cuts. #924 (live F5 policy) can ship
without slim-local and without a document scrollbar.
The spike write-up decides whether they are one cut. If they are not,
the follow-up should be a parent create-plan with phase issues (same
shape as A4 #947 → #948–#952) so #924 is not blocked on scrollbar work.
If they are one cut, a single plan is fine.
Multi-phase is the likely shape, not a mandate. Do not invent phases
before the spike names the cut.
Reminder — one GitHub issue per phase / per PR. Invincible ships one
PR per GitHub issue. If this is multi-phase, each phase MUST be its own
issue (own DRAFT → HANDOFF-READY → IMPLEMENTED lifecycle, own PR). A
single issue that lists several phases shipping in separate PRs is
forbidden — when Phase 1 is marked IMPLEMENTED the issue closes and
Phase 2 has no home. N PRs → N phase issues plus a parent that is
tracking-only (never implemented). Do not implement this spike, the
parent, or “phase 1+2 on one issue.”
Spike questions (answer these against main)
- F5 of a live turn (bug: F5 / reload fast-forward replays entire durable stream (need latest-page hydrate, no thinking replay) #924-1, bug: F5 / reload fast-forward replays entire durable stream (need latest-page hydrate, no thinking replay) #924-3) — Hydrate the Blob/checkpoint
tail into Wasm (do notprefixThroughLastUserwipe), then
GET .../stream?startIndex=Cfor new frames only. Can Busy + Stop
restore without applying historicalreasoning_delta? What isCwhen
localturnStreamCursoris stale vs envelopeCvs heap 0? - Thinking on hydrate (bug: F5 / reload fast-forward replays entire durable stream (need latest-page hydrate, no thinking replay) #924-2) — Skip
reasoning_deltaon cold attach,
or do not subscribe the historical stream at all? Collapsed “thought”
placeholder vs omit. Must not lose live thinking that starts after
attach. Today’s lock (“never skip, thinking is not in Blob”) is the bug,
not a feature to preserve. - Slim local (leftover spike: viewport hydrate — latest-page F5 (#924), slim local, scrollbar (A5 rebase) #553) — Persist only the latest ring window +
envelope carriers (transcriptPointer, turn ids/status/cursor). Full
history stays on Blob. First paint reads the slim tail; 409 adopt still
the latest window. Does Stop persist still need a full snapshot locally?
Independent of Range; can ship with the F5 fix if the spike says they are
the same cut. - Load-earlier / scrollbar (leftover spike: viewport hydrate — latest-page F5 (#924), slim local, scrollbar (A5 rebase) #553) — Keep stepping
session.messages(requires the host to already hold older rows — fights
slim-local), or host-GET the previous Blob chunk viaprevand prepend
a page, Wasm paints the window only, jump-to-latest is O(1) (re-hydrate
tail). Preference: feels like one document; almost never scroll to the
beginning. Do not treat the existing “Load earlier” button as this
question already answered. - Completed long session — No stream. Blob GET is still the full
flatten-root JSON (up to 8 MiB) then the host slices 2048. Is reconstruct
still stuffing the full chain intosnapshot.messagesbefore the ring
slice? Slim-local + chunk walk on demand is the leftover of original
spike: viewport hydrate — latest-page F5 (#924), slim local, scrollbar (A5 rebase) #553 Q2/Q3.
Dropped as a primary question: HTTP Range over one messages[] object
(original Q1). Whole-object PUT + existing prev/depth already answered it.
The three original Blob options are stale as a “does this exist?” question:
main already picked a hybrid of rewrite-OK flatten (option 2) + mid-turn
prev chunks (a bit of option 3). Remaining work is viewport policy
(#924) + slim local + scrollbar, not a new object layout.
Preference (still)
- One logical transcript (flatten root + mid-turn chunks), not thousands of
part/*.json/ SQLite-in-browser. - Do not want 8 MiB
localStoragefor first paint. - Almost never scroll to the beginning; if A1–A4 work, ask the agent instead.
- Feature-divide: host fetches, Wasm paints the window only. No dual
DOM transcript.
Out of spike scope
- Changing the inference fold (A1–A4). Compaction docs are Phase 5 / plan: A4 compaction — phase 5 — living docs (source #552) #952.
- Implementing OpenCode SQLite /
part/*.json - Raising Function body caps / 1-hour turn wall (harness: hard-cap durable turn wall clock at 1 hour (Bjorn authorized) #923)
- Cancel/Busy unstick (bug: harness stays Busy (Waiting for model…) after Workflow run is cancelled #915)
- Occupancy meter (status bar: show current context occupancy in addition to summed turn usage #556)
- Changing existing caps (
HARNESS_RING_MAX2048,HISTORY_PAGE512,
Blob 8 MiB, envelope 1 MiB,prevdepth 256) — any change is a BLOCK +
human decision on the follow-up plan.
Suggested next
Spike write-up on this issue (decision table + recommended option) that
explicitly maps to the three #924 expected bullets and names the e2e int
rows above. Then a separate create-plan for the winner; that plan’s DoD
Closes #924 (and this spike) and requires the int rows green.
Slim-local tail can ship with the F5 fix if the spike says they are the
same cut; otherwise expect a parent + phases (likely, not required) —
one GitHub issue per phase, because one GitHub issue per PR.
Peer harness notes
OpenCode before / after / limit + RFC 8288 is a useful host API
shape if we page Blob chunks — not a reason to shard parts or run SQLite
in the browser.
Pi stream offset via SessionManager is closer to resume at startIndex=C
(already on the wire) than to byte-Range on one object.
References
- Must close: bug: F5 / reload fast-forward replays entire durable stream (need latest-page hydrate, no thinking replay) #924 — F5 / reload stream replay (gated on e2e int rows)
- Must ship: durable-turn int coverage (
int/,npm run test:int) for the bug: F5 / reload fast-forward replays entire durable stream (need latest-page hydrate, no thinking replay) #924 bullets - Parent umbrella: agent session architecture — model-context memory, compaction, and viewport #548 (A5)
- Existing int that currently encodes the bug:
int/f5-attach.int.test.ts(startIndex: 0) - Attach shipped: backend-agents E19: attach handshake — Blob hydrate + stream attach #813 / backend-agents F: viewport attach — GET stream + Blob hydrate + live thinking/tools/status #769 / backend-agents A3: reserved meta.turnStreamCursor carrier #797 (
turnStreamCursor) - Ring window shipped: plan: harness reveal earlier transcript messages (parent) #238 / plan: harness reveal earlier messages — phase 1 — in-ring reveal + docs #239 / plan: harness reveal earlier messages — phase 2 — session window beyond ring #240 (idle latest-page only; not the live F5 fix)
- Thinking collapse (paint cost, not skip-on-refresh): plan: harness — auto-collapse completed-turn thinking (expandable, dvui cost) #428
- Session model: https://github.com/btipling/invincible/blob/main/docs/session-model.md
- Feature-divide: https://github.com/btipling/invincible/blob/main/docs/feature-divide.md
Activity
btipling commentedon Sep 1, 2026
User-visible failure mode of slim/first-paint viewport: F5 fast-forward-replays the entire durable stream (prompt → thinking → tools → text) on long sessions.
Filed as #924 — latest-page hydrate, do not replay thinking on refresh. This spike remains the architecture parent.
[-]spike: Blob viewport — turn-seam offsets, intelligent scrollbar, slim first-paint local[/-][+]spike: viewport hydrate — latest-page F5 (#924), slim local, scrollbar (A5 rebase)[/+]btipling commentedon Sep 7, 2026
Rebase lock (2026-09-06, architecture-verified): this spike is scoped to what is still leftover on post-workflow
main, and must close #924.Shipped (do not re-solve): durable
POST /api/turns, attach cursor on the wire, ring window / idle latest-page, mid-turnprevchunks + terminal flatten, A1–A4 model seed. Original Q1 (HTTP Range over onemessages[]Blob) is obsolete.Leftover:
startIndex=0,prefixThroughLastUserwipes this-run suffix,reasoning_deltanever skips → prompt → sped-up thinking → tools → text. That is bug: F5 / reload fast-forward replays entire durable stream (need latest-page hydrate, no thinking replay) #924. Idle F5 already lands on the latest ring.localStoragestringify.#924 stays open until a follow-up
create-planships the three expected bullets. This issue stays a spike.btipling commentedon Sep 7, 2026
Lock (2026-09-07): follow-up plan requires end-to-end integration tests.
Hard requirements on this spike, same class as “must close #924”:
C; Busy + Stop still restore).npm run test:int/vitest run --project int) on a real Wasm ring that prove those three bullets. Unit tests ofcoldAttachFromSnapshot/turnApplyare not enough. Existingint/f5-attach.int.test.tscurrently locksstartIndex: 0— that expect is the bug and must be rewritten (orit.failsuntil the phase that flips it).Likely (not required) multi-phase parent: leftovers 1 (#924), 2 (slim local), and 3 (scrollbar) are separable. If the spike says they are not one cut, follow A4’s shape (parent
create-plan+ phase issues) so #924 is not blocked on scrollbar work. If they are one cut, a single plan is fine. Do not invent phases before the spike names the cut.This issue stays a spike. The e2e int gate lives on the follow-up plan’s DoD.
btipling commentedon Sep 7, 2026
Reminder: if the follow-up is multi-phase, one GitHub issue per phase. Invincible is one issue per PR. N PRs → N phase issues plus a tracking-only parent (never implemented). A single issue that ships several phases in separate PRs is forbidden — Phase 1 IMPLEMENTED would close the issue and leave Phase 2 homeless. Same shape as A4 #947 → #948–#952.
btipling commentedon Sep 7, 2026
A5 spike decision — verified against main (2026-09-07)
Baseline:
b31ca512e7ddee162fa8313cc52f549822e2d23a. Read-only investigation; no prototype, application edits, builds, or tests performed. This issue remains a spike, not an implementation plan.Findings that change the implementation, not the product requirements
int/f5-attach.int.test.tsstill asserts the broken cold-at-zero policy.turnStreamCursoris not proof that the downloaded transcript contains that prefix.turnApplyincrements it on events, while assistant text is largely bridge-only until finalization; workerpersistStepbuilds its own checkpoint andturnPersistSeamdoes not stamp an associated stream position into that object.overlayEnvelopeMetacan put a different tab's cursor onto the downloaded body. Takingmax(local C, envelope C)would exchange replay for missing text.workflow/@workflow/core4.8.4. Its publicrun.getReadable().getTailIndex()returns the last stored chunk index (or-1for empty). Verified in the installed matching package'sdist/runtime/run.d.ts,run.js, and packagedworkflow-api/get-run.mdx;package-lock.jsonon main pins the same version. No SDK upgrade, guessed API, or new stream transport is needed. Use the actual durable stream position, not an attempted-write counter:turnSseWritedeliberately drops writes after an ambiguous failure.prevlinks are overlapping this-run checkpoints, not disjoint history pages. Terminal heads omitprev/depth. Walkingprevand blindly prepending rows would duplicate history and would not page a completed flatten root. Preserve the logical transcript and reuse its reconstruction/merge semantics on the server; expose logical row windows over it. This reduces browser transfer/heap, not necessarily server Blob-read cost. Physical indexing/Range is not being smuggled into this plan.Decision table
prevprepend; logical windows backed by BlobRecommended cut — four phases, four PRs
These are not one cut. #924 must not wait for cache or scrollbar work.
Published follow-up DRAFT tracking-only parent: #958, with one issue per phase: #959 → #960 → #961 → #962. Neither this spike nor the parent is implemented. Each phase owns its tests and living-doc changes in its own PR; no final docs-only phase to defer correctness. The final phase closes this spike after all three leftovers are delivered; phase 2 may close #924 earlier with its evidence. Planning does not close either issue.
Locked constraints for the follow-up
int-durableCI runsnpm run test:inton a real protocol-current Wasm artifact, failing closed when absent/stale. Unit tests alone cannot close bug: F5 / reload fast-forward replays entire durable stream (need latest-page hydrate, no thinking replay) #924 or this spike.