feat(harness): E19 attach handshake — Blob hydrate + stream attach - #857
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
|
Implement-plan for #813 (E19). Not merged.
Suggested next: |
|
Implement-plan for #813 is up on this PR (not merged).
H24 (#818) should document: cold/F5 = Suggested next: adversarial-review on this PR, then an explicit merge request (merge commit, no squash). |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 1c2959b · 7 files · E19 attach handshake (Blob hydrate + GET stream)
Lenses run: L1, L3, L5, L6, L8, L9 (skip: L2 no new server trust boundary / secrets / workflows; L4 no CI / wasm / deploy; L7 no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — client GET reuses C16, no route/secret change)
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L9 | Attach HTTP/network failure uses the give-up fail path (pushTurnEnd + Lifecycle.Error + Continue-at-head) and the detach persist (turnStatus: 'running'). Those contracts contradict. lib/harnessChat.ts attach fail fold (~1970) after pushTurnEnd (~1907) and setFailLifecycle (~2026). Tests 6 / 6b lock the contradiction (Lifecycle.Error and turnStatus: 'running'). Host progressed anti-spin (HarnessHost.tsx ~545) then refuses in-session GET retry because applied count == startIndex. |
1. Live durable turn. POST SSE drops after onTurnStarted → D18 detach, Ready, keep running (correct). 2. Host hot-resumes GET /api/turns/:id/stream. 3. GET 503 / network error before a 200 SSE body (sawDurableStart stays false). 4. Canvas paints Turn ended · error · … (EMBER Error row). Lifecycle Error. Stop gone (not Busy). Queue Continue inserted if non-empty. shouldSetHostTurnNote('running') is false — no host note. 5. Persist keeps turnRunId + running. 6. progressed is false (0 new frames) → no reconnect. 7. Operator Sends → C15 envelope still live and getRun exists → 409 (app/api/turns/route.ts ~273). Only F5 retries attach. The turn did not end; the workflow is still writing. 404 run-gone is the same paint/persist mix; C15 exists:false lets a later POST through, so it is recoverable — 503/network is not. |
Defender: plan test 6 wants in-canvas EMBER and no /api/agent; comment says “could not subscribe ≠ turn died”; F5 is the retry. Fail: test 6 requires EMBER + no transport downgrade, not Turn-ended + Error lifecycle while running. Existing contract (docs/agent-stream.md End of turn + D18 / test 8): keep-running ⇒ Ready, no Turn ended. Give-up ⇒ Turn ended + Error + clear running. Mixing them leaves Stop gone, Send 409, Continue queued, and a lie on the canvas. Attach retries are already permanent (attaching in classify), so 503 is one-shot. D18 detach is the keep-running pattern this PR already ships for EOF. |
high |
| Minor | L6 | Host glue for the two attach classes is untested. kickColdAttach, boot/adopt/switch-back queueMicrotask, hot-resume progressed anti-spin, and detachTurn clearing inflightRef so a switch can cold-attach live only in HarnessHost.tsx. lib/turnAttach.test.ts covers decideAttachClass; tests 2–9 drive runHarnessTurn, not the host. |
Delete / never call kickColdAttach from boot (or keep inflightRef true across detachTurn on switch) — unit suite stays green; F5 / login / switch-back never GET-attaches a live run. Same for a progressed predicate that reconnects empty-EOF (spin) or never reconnects a POST drop (attachStart === undefined is the only auto-resume). |
Defender: plan table rows 1–9 are turnApi / harnessChat, and those exist. Fail: E19’s user-visible path is the host kick. Repo already source-locks host contracts (lib/detachTurn.test.ts shouldSetHostTurnNote / run contains). This PR adds the F5 attach kick with no such lock. |
medium |
Residual risk
Empty-EOF GET (applied == startIndex) is plan-locked not to spin: a long in-flight tool with an idle reader timeout leaves Ready + running and no reconnect until F5. That is D18, not a new complete path. Same-heap always hot at heap.count (envelope C ignored) is the right anti-gap vs another tab’s LWW write; the plan issue’s leftover “C > applied → cold” sentence is stale relative to this diff. Completed-run replay (test 5) is runHarnessTurn-only — host kickColdAttach no-ops unless running, which is correct because Blob hydrate already has the finished transcript. Attach sendAgent-injected tests can take the JSON success fold (streamAgent=false when only sendAgent is set); production attach streams, and test 5’s skip-sets-assistantStarted path is what prevents a duplicate, not the JSON branch.
Merge guidance
CONCERNS: do not merge until the Major is fixed (or an operator explicitly accepts it).
Suggested split (not implementing here):
- 503 / network / 401 attach (could not subscribe, run may still be live): D18-shaped — Ready, keep
running, noTurn ended ·line. In-canvas EMBER as a non-terminal error row is compatible with plan test 6. Retry GET (backoff) or leave F5 as the only retry, but do not paint a finished turn. - 404 run-gone (
exists:false/ envelope miss): give-up is honest — Error + Turn ended + clearrunning/turnRunId. Keeping a dead id only works because C15 already special-casesexists:false; the envelope should not stay live.
Test 6 / 6b must stop asserting Lifecycle.Error and turnStatus: 'running' together.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, Wasm protocol (unchanged), C16 route body (untouched), G22 server cancel, E20 applyTurnEvent.
Adversarial #857 Major: attach HTTP failure no longer mixes give-up (Turn ended + Error lifecycle) with detach persist (keep running). 404 run-gone paints Turn ended, lands Error, and clears running so a later Send is not C15-409'd. 503/401/network keep running, land Ready, and paint a non-terminal EMBER row (no Turn ended). Host hot-resume goes through decideHotResume; source-lock covers kickColdAttach.
Follow-up (adversarial #857 CONCERNS)Split attach HTTP failure into two contracts (
Host hot-resume now goes through Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 11c1a6b · 7 files · E19 attach handshake (Blob hydrate + GET stream), including follow-up 11c1a6b (split 404 vs 503)
Lenses run: L1, L3, L5, L6, L8, L9 (skip: L2 no new server trust boundary / secrets / workflows — client GET reuses C16; L4 no CI / wasm / deploy; L7 no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned)
Round-1 Major (attach HTTP fail mixed give-up + keep-running) is fixed in 11c1a6b: tests 6 / 6b / 6c / 6d now split 404 (Turn ended + Error + clear running) from 503/401/network (non-terminal EMBER + Ready + keep running). Host source-lock for kickColdAttach / decideHotResume / detachTurn inflight-clear is present.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L6 | Cold attach appends never-skipped reasoning_delta (and any other live-only row) onto a ring that already holds this-run suffix from Blob. Dedup only no-ops text_delta / tool_* / skill_attached; it never re-inserts thinking before that suffix, and skipped tool_start does not re-arm lastRingRowIsToolRun. lib/harnessChat.ts growThinking (~1328) pushMessages Thinking at the tail; attach init (~1171) restores grow-in-place from the current last ring row; test 7 (lib/harnessChat.test.ts ~4863) replays tools before thinking and asserts assistants via .join(''). |
1. Live durable turn: user → thinking → tool_start (card still running) and/or streamed assistant text. 2. D18 persist or mid-turn onSessionPatch writes Blob without thinking (growThinking is ring-only) with tool_run / assistant. 3. F5 / new tab / switch-back: kickColdAttach hydrates that suffix, GET startIndex=0 + dedup. 4. Replay reasoning_delta first (production order). Canvas becomes user · tool/assistant · thinking — thinking after the answer. 5. If the hydrated tool ordinal is still running, skipped tool_start leaves lastRingRowIsToolRun=false after thinking; the later tool_result (shouldSkipToolResult is false for running) handleToolEvents a second kind-6 card. Same for text_delta grow-suffix: after thinking, assistantSegmentOpen is false and lastUiKind !== 'assistant', so growAssistant pushes a new bubble instead of extending the hydrated one. Plan DoD row 2 / test 2b (“full run once”, thinking appears) is the user-visible path; 2b only hydrates the user line. |
Defender: test 2b/3 show thinking; test 7 has one tool card and join('') === 'Hello world'; F5 during inflight may lose the persist race and hydrate user-only (the green path). Fail: user-only hydrate is not the D18 / other-tab path E19 exists for (“Tab reloads mid-tool”, two tabs, envelope persist of tool_run). Test 7’s event order is the inverse of live (tool_* then thinking) and join('') hides a split assistant. growThinking cannot splice before a hydrated suffix; skipped tool_start never rebinds the open card. No test hydrates this-run tools/assistant then replays reasoning_delta first. |
high |
| Minor | L1 | After 503/401/network subscribe-fail, lifecycle is Ready and inflightRef is false, so Wasm Send is live while envelope stays running. Send hits C15 409 (app/api/turns/route.ts ~273), which is permanent give-up: pushTurnEnd + Lifecycle.Error without clearing running (409 body has no turnRunId, fail fold’s clear branch does not fire). Re-creates the mixed contract this PR just split, on the follow-on Send rather than the attach fail itself. |
1. Cold/hot attach GET 503 (store blip) or network error. 2. Canvas: non-terminal EMBER, Ready, keep running (test 6b/6c — correct). Host decideHotResume progressed is false → no GET retry. 3. Operator types (Ready) and Sends. 4. POST 409 “already in progress”. 5. Turn ended · error on canvas, Error lifecycle, persist still running. Queue Continue (if any) 409-loops. Only F5 retries attach. |
Defender: previous review accepted F5 as the 503 retry; C15 409 is doing its job (do not start a second workflow); D18 Ready+running is already a product state. Fail: D18 POST-drop immediately hot-resumes (attachStart undefined → inflight true, Send dropped). 503 is the one Ready+not-inflight+running state this PR adds. Composer Send is then a trap, not a no-op. Bounded: F5 still recovers. |
high |
Residual risk
Empty-EOF GET (applied == startIndex) is still plan-locked not to spin: a long in-flight tool with an idle reader timeout leaves Ready + running and no reconnect until F5. Envelope C is ignored on same-heap hot resume (right anti-gap vs other-tab LWW; plan leftover “C > applied → cold” is still stale). Completed-run replay (test 5) is runHarnessTurn-only — host kickColdAttach no-ops unless running. Attach sendAgent-injected tests can take the JSON success fold; production attach streams. Stop during attach is a raw abort (takePendingCancel) → 'stop' clear-running without G22 server cancel — same as POST Stop after headers, not new. Mid-turn usage onSessionPatch can persist tool_run without thinking, which is the Blob shape that triggers the Major on the next cold attach.
Merge guidance
CONCERNS: do not merge until the Major is fixed (or an operator explicitly accepts it). The Minor can wait if F5-as-503-retry stays the lock, but Send-after-503 should not paint Turn ended + Error while running.
Suggested Major fix (not implementing here): cold replay of reasoning_delta / never-skipped live-only events must land inside the this-run window in stream order, not append after a Blob suffix. Either (a) hydrate only through the last user row and let the stream rebuild this-run (dedup then mostly unused), or (b) keep suffix hydrate but splice thinking (and re-bind skipped tool_start to the hydrated ordinal / reopen the hydrated assistant for grow-suffix) instead of pushMessage at the tail. Add a test: Blob user + tool_run(running) + assistant + stream reasoning_delta then tool_* then text_delta — thinking before tools/assistant, one tool card, one assistant row.
Round-1 404/503 split and host source-lock can stay.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, Wasm protocol (unchanged), C16 route body (untouched), G22 server cancel, E20 applyTurnEvent.
…ob suffix Adversarial #857 Major: cold attach no longer leaves Blob tool_run/assistant on the ring while reasoning_delta appends after them. Dedup hydrates through the last user, replays the stream in order, and restores the suffix on 503/404 before events so persist is not a user-only clobber. Send while a durable run is live cold-reattaches instead of POSTing (C15 409 would mix Turn ended + Error with keep-running).
Follow-up (adversarial #857 CONCERNS, round 2)Landed in Major — cold attach rebuilds this-run from the stream Blob this-run suffix ( Minor — Send while running re-attaches Operator Send when Tests: Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 1df0acc · 7 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups 11c1a6b (404 vs 503) and 1df0acc (cold rebuild + Send-while-running)
Lenses run: L1, L3, L5, L6, L9 (skip: L2 no new server trust boundary / secrets / workflows — client GET reuses C16; L4 no CI / wasm / deploy; L7 no config-seam or host hardcode; L8 living docs deferred to H24 #818 by plan)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change)
Round-1 Major (attach HTTP fail mixed give-up + keep-running) is fixed in 11c1a6b. Round-2 Major (thinking appended after Blob this-run suffix) is fixed in 1df0acc (strip to last user, rebuild from stream; test 2c). This pass attacks the round-2 Minor “fix”: Send-while-running remapped to cold attach.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1 | runPrompt remaps every non-opts.attach Send to cold attach at startIndex=0 + dedup whenever turnStatus==='running' (HarnessHost.tsx · runPrompt ~438–442). It does not call decideAttachClass(heapAppliedRef), and it does not undo the user row Wasm already painted before the poll (takePendingSubmit / queueSubmitFromUi; pushUser:false comment ~508). runHarnessTurn then skips appendMessage(..., 'user', prompt) (harnessChat.ts ~1012–1013). Cold strip/hydrate runs only when SessionStore already has a this-run suffix (prefixThroughLastUser length check ~1127–1140). The 503 / empty-EOF-idle path that this remap exists for typically has no suffix, so the stray user row stays. |
1. Durable run live. Attach GET 503 or empty-EOF with 0 new frames (tests 6b/6c/6d / D18 idle). Lifecycle Ready, inflightRef false, envelope still running. decideHotResume progressed is false → no auto GET. 2. Operator types a follow-up and Sends. Wasm paints that user line and exposes pending submit. 3. Host runPrompt(pending) sees running → { startIndex: 0, dedup: true }, ignoring heapAppliedRef (which may already be { runId, count: C }). 4. No Blob suffix (the common 503 / 0-frame idle case): no hydrateMessages(prefix). Ring is user(old) · [thinking?] · user(NEW). Replay from 0 pushMessages thinking/tools/assistant after NEW. Old run is attributed to the follow-up; composer text is consumed and never POSTed. 5. Suffix present (POST already patched tool_run / assistant, then hot-resume GET 503): strip hydrates through the session last user → wipes NEW from the canvas, rebuilds the old run. Follow-up is lost. 6. After a hot-resume GET 503 with heap C>0, this path also replays from 0 instead of GET ?startIndex=C. |
Defender: round-2 Minor said Send-after-503 hits C15 409 and mixes Turn-ended + keep-running; remapping Send to attach avoids POST; losing the prompt is fine because POST would 409 anyway; strip-hydrate wipes the stray line when a suffix exists. Fail: the remap was added for 503 / empty-EOF idle — the no-suffix case — where strip does not run. Plan cursor-exactness is “full run once” in stream order under the originating user row, not under a follow-up that was never a turn. Wasm paints the user line before host classification (pushUser:false); attach never appends it to SessionStore, so thisRunWindow still keys off the old prompt. Always-cold-at-0 also violates the heap-class lock (hot resume = same JS heap already applied [0,C)). Source-lock lib/turnAttach.test.ts “Send while running cold-reattaches” only asserts the comment string — no ring/session fixture with a pending user row. |
high |
| Minor | L6 | Host Send-while-running glue is still comment-source-locked, not behavior-tested. lib/turnAttach.test.ts ~187–192 greps HarnessHost.tsx for the adversarial comment + startIndex: 0, dedup: true. No test drives runPrompt / a pending submit while running against a mock bridge that already has the extra user row. |
Change the remap to hot-at-C, or stop hijacking and POST, or hydrate prefix on every hijack — the 27 turnAttach tests stay green. The Major above is invisible to CI. |
Defender: plan table rows 1–9 are turnApi / runHarnessTurn; those exist (2c, 6b–6e). Fail: the new user-visible retry path is the host remap. Round 2 already called out vacuous host locks; this PR answered with another grep. |
high |
Residual risk
Empty-EOF GET (applied == startIndex) still does not spin: a long in-flight tool with an idle reader timeout leaves Ready + running until Send or F5. That is D18, not new. Envelope C is ignored on same-heap hot resume (right anti-gap vs other-tab LWW). Completed-run replay (test 5) is runHarnessTurn-only — host kickColdAttach no-ops unless running. Cold rebuild makes this-run skip lists empty after strip (dedup helpers are now backup for a no-suffix hydrate); test 7 still passes via rebuild, not skip. Stop during attach is a raw abort (takePendingCancel) → 'stop' clear-running without G22 server cancel — same as POST Stop after headers.
Merge guidance
CONCERNS: do not merge until the Major is fixed (or an operator explicitly accepts it). The Minor is the missing proof of that fix.
Suggested fix (not implementing here):
- Classify Send-while-running with
decideAttachClass(heapAppliedRef), not a hard-coded cold{ startIndex: 0, dedup: true }. Heap already atC→ hot resume; F5/idle heap → cold at 0. - If the poll consumed a non-empty pending submit, always
hydrateMessages/ ring-window through the session lastuser(even when there is no Blob suffix) before attach, so a follow-up row cannot become a false prompt boundary. Do notappendMessagethat follow-up. Optional: in-canvas non-terminal note that the live run was re-attached (composer text was not a new turn). - Add a
runHarnessTurn(or host-level) fixture: sessionuser + running, bridge already has an extrauser('follow-up'), attach 0 + dedup, streamreasoning_deltathentext_delta— thinking/assistant must sit under the old user row; follow-up must not remain as the last user.
Round-1 404/503 split, round-2 this-run rebuild, decideHotResume anti-spin, and test 2c can stay.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, Wasm protocol (unchanged), C16 route body (untouched), G22 server cancel, E20 applyTurnEvent.
…w-up Adversarial #857 CONCERNS: Send on a live run always cold-attached at 0 and left the Wasm-painted follow-up user as a false prompt boundary. decideSendAttach: count>0 → hot at C; else cold + dedup. Strip the trailing follow-up before attach; always hydrate the cold prefix.
Follow-up (adversarial #857 CONCERNS, round 3)Landed in Major — Send-while-running no longer paints a false prompt
Minor — behavior tests, not a comment grep
Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 02df4fb · 7 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups 11c1a6b (404 vs 503), 1df0acc (cold rebuild), 02df4fb (Send-while-running class + follow-up strip)
Lenses run: L1, L3, L5, L6, L9 (skip: L2 no new server trust boundary / secrets / workflows — client GET reuses C16; L4 no CI / wasm / deploy; L7 no config-seam or host hardcode; L8 living docs deferred to H24 #818 by plan)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change)
Round-1 Major (attach HTTP fail mixed give-up + keep-running) is fixed in 11c1a6b. Round-2 Major (thinking appended after Blob this-run suffix) is fixed in 1df0acc. Round-3 Major (Send-while-running always-cold + false prompt) is fixed in 02df4fb (decideSendAttach + withoutTrailingFollowUpUser; tests 2d/2e). This pass attacks the cold-rebuild backup vs the designed hot-resume reconnect.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1 | Cold-attach coldBackup restore keys off thisRunWindow(failedSession.messages).length === 0 (lib/harnessChat.ts · fail fold ~1978). Thinking is never written to SessionStore (growThinking ~1399). A thinking-only incomplete GET therefore looks like “no this-run rebuilt,” so the fold puts the Blob suffix back on the session while leaving thinking on the ring (!streamPainted is the only ring-restore gate). Host then auto hot-resumes (HarnessHost.tsx · decideHotResume ~562) because heapC > attachStart. Second runHarnessTurn is dedup: false with session = old tool_run/assistant and ring = user + thinking. |
1. POST has already session-mirrored a this-run tool_run (live paint) and D18-persisted it — operator F5s mid-tool, or POST reader drops then F5. Envelope running + Blob suffix user · tool_run · assistant?. 2. kickColdAttach cold-strips to last user, GET startIndex=0. 3. Replay starts with reasoning_delta (required; thinking is not in Blob). 4. GET EOFs without done/error (idle reader / proxy / Function timeout — the same incomplete-GET that decideHotResume is built to reconnect). durableIncomplete → detach, keep running. thisRunWindow is still empty → failedSession.messages = coldBackup. Ring stays user · thinking. 5. Host sets heapApplied.count = C ≥ 1, attachStart = 0, progressed true → microtask GET ?startIndex=C dedup: false. 6. tool_start sees last ring = thinking, not a tool-run → new card. Session still has the restored suffix card. Persist: user · old tool_run · old assistant · new tool_run. Next F5 hydrates the duplicate. Tests 2c / 6e / 8 do not cover this: 2c completes in one GET; 6e is 503 before events (streamPainted false, ring also restored); 8 has no Blob suffix so coldBackup is null. |
Defender: restore exists so a 503/404 before events does not LWW a user-only transcript (test 6e); thinking-only detach should keep the thinking row (comment at ~1980); hot resume after progressed EOF is D18/E19 by design. Fail: 6e is !streamPainted — that already distinguishes “no events” from “thinking painted.” Session restore uses a different predicate (thisRunWindow empty) that thinking never satisfies. The reconnect the host will fire is exactly what re-applies tools onto a session that just got the old suffix back. Cursor-exactness is “full run once”; this persist is twice. |
high |
Residual risk
Empty-EOF GET with applied == startIndex still does not spin: a long in-flight tool with an idle reader timeout and zero frames leaves Ready + running until Send or F5 (D18; Send now classifies via decideSendAttach). Envelope C is ignored on same-heap hot resume (right anti-gap vs other-tab LWW). Completed-run replay (test 5) is runHarnessTurn-only — host kickColdAttach no-ops unless running. Send-while-running still consumes the pending composer text (never POSTed; follow-up row stripped) with no in-canvas note — bounded now that it is not a false prompt boundary. Cold hydrateMessages(prefix) does not sliceMessagesForRing / reset image-math caches; Wasm MAX_MSG drops oldest so the last user still lands, but a 2k+ history attach does N extra pushes. Stop during attach is still a raw abort (takePendingCancel) → 'stop' clear-running without G22 server cancel — same as POST Stop after headers, not new.
Merge guidance
CONCERNS: do not merge until the Major is fixed (or an operator explicitly accepts it). Round-1 404/503 split, round-2 this-run rebuild, round-3 decideSendAttach / follow-up strip, decideHotResume anti-spin, and tests 2c/2d/2e/6e can stay.
Suggested fix (not implementing here):
- Restore
coldBackuponto session only when!streamPainted(same gate as ring restore). Thinking-only incomplete GET must persist the stripped prefix, not the Blob suffix, so the automatic hot resume atCcan append tools/assistant once. - Add a
runHarnessTurnfixture: Blobuser + tool_run + assistant, attach0+dedup,reasoning_deltathen EOF (nodone); then a second attach atCwithtool_start/tool_result/text_delta/done. Assert: one tool card, thinking before tools, one assistantHello world, sessiontool_runcount 1 — not 2.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, Wasm protocol (unchanged), C16 route body (untouched), G22 server cancel, E20 applyTurnEvent.
Adversarial #857 CONCERNS: coldBackup restore keyed off thisRunWindow empty, which thinking never fills. A thinking-only incomplete GET put the Blob suffix back on the session; automatic hot resume then duplicated tools. Restore the suffix only when !streamPainted (same gate as the ring). Test 2f: cold strip + reasoning_delta EOF, then hot resume at C — one tool card, thinking before tools, one assistant. Refs #813 Refs #857
Follow-up (adversarial #857 CONCERNS, round 4)Landed in Major — thinking-only EOF no longer restores the Blob suffix onto the session Cold-attach 503/404 before events still restore the suffix (test 6e). Tests: Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ dda5470 · 7 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups 11c1a6b (404 vs 503), 1df0acc (cold rebuild), 02df4fb (Send-while-running class), dda5470 (thinking-only coldBackup gate)
Lenses run: L1, L3, L5, L6, L8, L9 (skip: L2 no new server trust boundary / secrets / workflows — client GET reuses C16; L4 no CI / wasm / deploy; L7 no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change)
Round-1–4 Majors (mixed 404/503 contract; thinking after Blob suffix; always-cold Send + false prompt; thinking-only coldBackup restore) are fixed in this head. This pass attacks the cold-rebuild hydrate seam.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L9+L5 | Cold attach (and Send-while-running follow-up strip) call bridge.hydrateMessages (lib/harnessChat.ts · cold rebuild ~1165, follow-up strip ~1142) instead of pushSessionToBridge. hydrateMessages → inv_clear_messages (native/harness/src/bridge.zig ~633) which clears Wasm image_cache + math_cache + the in-canvas submit queue. The canonical hydrate (pushSessionToBridge ~466) then resetHarnessImageSession / resetHarnessMathSession, coalesceToolRunMessages, sliceMessagesForRing, foldStatusSlots, and scheduleImagesFromTexts / scheduleMathFromTexts. Cold attach does none of that. Host JS putOk is not reset, so enqueue (lib/harnessImages.ts ~106, lib/harnessMath.ts ~467) skips every URL already put on boot — images/math never re-enter Wasm for the rest of the page load. |
1. Session has a prior-turn user/assistant with markdown images and/or $…$ math, plus a live durable run. 2. F5 / login / switch-back: boot hydrateRingWindow → pushSessionToBridge paints images/math, JS putOk records the URLs. 3. kickColdAttach (HarnessHost.tsx ~371) always { startIndex: 0, dedup: true }. 4. runHarnessTurn always hydrateMessages(prefix) on cold, even with no this-run suffix. inv_clear_messages wipes Wasm caches. Prefix rows re-push; image/math URLs in markdown have empty Wasm caches. 5. JS putOk still has those URLs → scheduleImagesFromMarkdown on later finalizeAssistant cannot revive prior-turn media. Canvas images/math stay blank until the next pushSessionToBridge (Load earlier / another switch / another F5). 6. Send-while-running (Ready + running after 503 / empty-EOF): Wasm may already have queued follow-ups. withoutTrailingFollowUpUser → hydrateMessages(stripped) drops submit_queue and blanks the same caches. 7. Same call also skips #365 coalesceToolRunMessages: SessionStore consecutive tool_run rows (thinking is not persisted, so thinking-separated streaks are consecutive in Blob) split from boot’s scannable groups back into N 1 tool called cards. |
Defender: boot already hydrated via pushSessionToBridge; a second hydrate is needed to drop the this-run suffix / stray follow-up user; tests 2c–2f pass; F5 queue is empty; finalizeAssistant reschedules this-run assistant images. Fail: the second hydrate is a full ring replace with a different contract than pushSessionToBridge. Prior-turn media is the common case (screenshots, formulas) and is never rescheduled. JS/Wasm putOk desync is exactly why the canonical path resets then reschedules — this PR copies clearMessages without those two calls. Queue wipe is a no-op on F5 but is a live loss on the Send-while-running path this PR added. Tests use makeMockExports with no image/math/queue; host source-lock greps kickColdAttach / decideSendAttach, not pushSessionToBridge. |
high |
| Minor | L1+L9 | Operator Send while running still consumes the pending composer text (HarnessHost.tsx · runPrompt → decideSendAttach) and strips the Wasm-painted follow-up (withoutTrailingFollowUpUser) with no in-canvas note. The live run is re-attached; the typed instruction is not POSTed and is not queued. |
1. Attach GET 503 or empty-EOF idle: Ready, inflightRef false, envelope running. 2. Operator types a new instruction and Sends (or queues it). 3. Host remaps to attach. Follow-up row is dropped. Composer buffer is gone. 4. Old run continues under the originating user. Operator has no EMBER/system line saying the follow-up was not a new turn. |
Defender: POST would C15-409 and mix Turn-ended + keep-running (round-2/3); swallowing the prompt is the recovery path; tests 2d/2e lock the strip. Fail: Ready+composer is an honest Send affordance. Remap-without-note looks like a successful new turn that vanished. Bounded (F5 still attaches; no false prompt boundary after 2d/2e). | high |
Residual risk
Empty-EOF GET (applied == startIndex) still does not spin: a long in-flight tool with an idle reader timeout and zero new frames leaves Ready + running until Send or F5 (D18; Send now classifies via decideSendAttach). Envelope C is ignored on same-heap hot resume (right anti-gap vs other-tab LWW; plan leftover “C > applied → cold” is still stale). Completed-run replay (test 5) is runHarnessTurn-only — host kickColdAttach no-ops unless running. Stop during attach is still a raw abort (takePendingCancel) → 'stop' clear-running without G22 server cancel — same as POST Stop after headers, not new. 503/401 subscribe-fail still persists a non-terminal Error row; a retry that 503s again can stack those rows (restore coldBackup includes the previous error, then appends another). Mid-attach patchSession still PUTs coldBackup messages while in-memory next is the stripped prefix — coalesced last-put should win, but a crashed tab in that window can LWW the old suffix.
Merge guidance
CONCERNS: do not merge until the Major is fixed (or an operator explicitly accepts it). Round-1–4 contract splits, this-run rebuild, decideSendAttach / follow-up strip, decideHotResume anti-spin, and tests 2c–2f/6e can stay.
Suggested Major fix (not implementing here):
- Cold rebuild and follow-up strip must go through
pushSessionToBridge(or the same reset → coalesce → slice →hydrateMessages→foldStatusSlots→scheduleImagesFromTexts/scheduleMathFromTextssequence). Never callhydrateMessagesas a surgical ring edit. resetHarnessImageSession/resetHarnessMathSessionbefore the clear, then reschedule from the prefix texts — otherwise JSputOkpermanently shadows the wiped Wasm caches.- Do not use
hydrateMessagesto drop a single trailing user row (that clears the submit queue). Pop/rewrite the last ring row, orhydrateMessagesonly after a fullpushSessionToBridge-shaped rebuild. - Add a
runHarnessTurnfixture: session with a prior-turn assistant that contains an image URL + a running attach; after colddeduphydrate, assertresetHarnessImageSessionran (generation bump) or thatscheduleImagesFromTextsis invoked on the prefix — and thatinv_clear_messagesis not the only ring write.
The Minor can wait if the remap stays the 503/idle recovery, but Ready+Send should not silently eat composer text.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, Wasm protocol (unchanged), C16 route body (untouched), G22 server cancel, E20 applyTurnEvent.
Adversarial #857 CONCERNS: cold rebuild and follow-up strip called hydrateMessages, which clears Wasm image/math caches without resetting JS putOk — prior-turn media stayed blank for the page load. Submit queue was also cleared as a surgical ring edit. Cold attach and 503 restore now go through pushSessionToBridge (reset, coalesce, slice, reschedule). Hot follow-up strip uses rebuildAttachRingFromRows (same cache contract, keeps thinking). Send-while-running sets a host note that the follow-up was not a new turn. Test 2g: image session bump, prior-turn image assistant kept, consecutive tool_run coalesced. Refs #813 Refs #857
Follow-up (adversarial #857 CONCERNS, round 5)Landed in Major — cold attach hydrates via
Minor — Send-while-running host note Operator Send remapped to attach sets Tests: Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 9e0346c · 7 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups 11c1a6b (404 vs 503), 1df0acc (cold rebuild), 02df4fb (Send-while-running class), dda5470 (thinking-only coldBackup gate), 9e0346c (pushSessionToBridge / rebuildAttachRingFromRows)
Lenses run: L1, L3, L5, L6, L8, L9 (skip: L2 no new server trust boundary / secrets / workflows — client GET reuses C16; L4 no CI / wasm / deploy; L7 no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change)
Round-1–5 Majors (mixed 404/503 contract; thinking after Blob suffix; always-cold Send + false prompt; thinking-only coldBackup restore; image/math putOk desync) are fixed in this head. This pass attacks the Send-while-running hydrate seam that round 5 already named and that 9e0346c did not close.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L6 | Send-while-running still clears the in-canvas submit queue. Hot follow-up strip calls rebuildAttachRingFromRows (lib/harnessChat.ts ~1169) → bridge.hydrateMessages (lib/harnessChat.ts ~527) → inv_clear_messages (native/harness/src/bridge.zig ~633) which submit_queue.clears (~641) and re-arms queue_promote_allowed + clears queue_paused. Cold Send (decideSendAttach count 0 / other run) always pushSessionToBridge (lib/harnessChat.ts ~1194), same inv_clear_messages. Round 5 required a surgical last-row drop that does not hydrate; 9e0346c kept hydrate for cache reset. Tests cannot see it: makeMockExports inv_clear_messages (lib/harnessChat.test.ts ~133) zeros __messages only — __queue survives, so 2d/2e/2g stay green with a FIFO the real Wasm would wipe. Source-lock (lib/turnAttach.test.ts ~217) requires rebuildAttachRingFromRows. |
1. Live durable turn. Operator queues A, B, C in the Wasm FIFO (protocol v18) while Busy. 2. POST SSE drops / attach GET 503 / empty-EOF: setFailLifecycle('detach') → Ready, promote gate false, keep running, queue intact. 3. Operator Sends a new composer line (or idle ▶ promotes A). decideSendAttach remaps to attach — never POST. 4. Hot (heap.count > 0): withoutTrailingFollowUpUser + rebuildAttachRingFromRows hydrates the stripped ring. Cold (503 before events / count 0): pushSessionToBridge hydrates the prefix even with no suffix. 5. Production Wasm: FIFO [B,C] (or [A,B,C] if composer-Send) is gone. Pause latch cleared. Host note Follow-up not sent — still attached to the live run. says nothing about the queue. 6. Attach then done cannot auto-promote work that was just erased; detach/503 lands Ready with an empty queue. |
Defender: F5/switch hydrate must clear a previous session's queue (inv_clear_messages comment); image/math reset requires hydrateMessages; queue is Wasm-ephemeral; tests 2d/2e pass; host note covers the composer line. Fail: this is same session, same canvas, same live FIFO — not New/Clear/switch. Round 5 already separated cache reset from queue wipe and forbade hydrateMessages as a surgical edit. kickColdAttach on F5 is empty-queue and is not this path. Composer remap is documented; silent FIFO destroy is not. Mock/Zig mismatch is why L6 stays green. |
high |
| Minor | L1+L9 | Attach 503/401/network still appends a persisted EMBER row on every subscribe-fail (lib/harnessChat.ts ~2025). Cold 503-before-events restores coldBackup first (~2014), so a retry's backup already contains the previous error, then another error is appended. |
1. Live run, F5 cold attach, GET 503. Session/ring: user + error. Keep running. 2. F5 or Send-while-running cold again. prefixThroughLastUser drops the error into the suffix; coldBackup is user+error. 3. 503 before events restores backup, then appends a second EMBER line. Blob now has stacked subscribe-fail rows for one still-live turn. |
Defender: each fail is a distinct subscribe error; EMBER is honest; not Turn-ended. Fail: one in-flight run should not grow an unbounded error tail on retry; F5 is the documented retry. Bounded (operator-driven), not a contract mix. | high |
Residual risk
Empty-EOF GET (applied == startIndex) still does not spin: a long in-flight tool with an idle reader timeout and zero new frames leaves Ready + running until Send or F5 (D18; Send classifies via decideSendAttach — and that Send is the Major above). Envelope C is ignored on same-heap hot resume (right anti-gap vs other-tab LWW). Completed-run replay (test 5) is runHarnessTurn-only — host kickColdAttach no-ops unless running. Stop during attach is still a raw abort (takePendingCancel) → 'stop' clear-running without G22 server cancel — same as POST Stop after headers, not new. Mid-attach patchSession still PUTs coldBackup messages while in-memory next is the stripped prefix — coalesced last-put should win, but a crashed tab in that window can LWW the old suffix. After the cold rebuild, this-run textDeltaDedup / tool skip helpers are effectively dead on the production cold path (suffix stripped → empty hydrate window → replay-from-0); test 7 still passes because replay also yields one tool card + one assistant.
Merge guidance
CONCERNS: do not merge until the Major is fixed (or an operator explicitly accepts it). Round-1–5 contract splits, this-run rebuild, decideSendAttach / follow-up strip, decideHotResume anti-spin, pushSessionToBridge image/math reset, and tests 2c–2g/6e can stay.
Suggested Major fix (not implementing here):
- Do not
hydrateMessages/pushSessionToBridgeto drop a trailing follow-up user or to re-put the prefix on an already-mounted live session. Those clears are for F5/New/switch. Send-while-running must keepsubmit_queue+ pause latch. - Hot strip: pop/rewrite the last ring row, or snapshot+restore the FIFO around hydrate. Host today can
queuedCount/queuedInsertFrontbut cannot read item text (noinv_queued_itemexport) — so snapshot-restore needs a Wasm read or a ring pop. A surgicalinv_pop_last/ hydrate-without-queue-clear is the honest seam; E19's "no Wasm change" does not license destroying v18 queue state. - Cold Send-while-running (same heap, queue live): do not take the F5
pushSessionToBridgepath. Prefix rebuild is forkickColdAttach(empty FIFO). If a Wasm follow-up is on the ring, strip it the same surgical way as hot. - Point
makeMockExportsinv_clear_messagesat__queue.length = 0(and promote-gate default / pause) so it matchesbridge.zig. AddrunHarnessTurn2e/2d with__queuepreloaded — expect the FIFO still there after attach remap. - Keep
ATTACH_FOLLOW_UP_NOTEfor the composer line; it is not a substitute for the queue.
The Minor can wait: cap or replace the last subscribe-fail error on retry, or skip persist of attach-503 rows (paint-only).
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, Wasm protocol (unchanged), C16 route body (untouched), G22 server cancel, E20 applyTurnEvent.
Adversarial #857 CONCERNS: hydrateMessages → inv_clear_messages wiped the live FIFO. v21 inv_clear_ring replaces the ring + image/math caches without touching queue/pause/promote. Send-while-running uses preserveQueue; F5/New/switch still clear. Attach 503 retries replace the last subscribe-fail error instead of stacking. Refs #813 Refs #857
Follow-up (adversarial #857 CONCERNS, round 6)Landed in Major — Send-while-running keeps the submit queue
Minor — attach 503 retries do not stack EMBER rows
Tests: Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ d0750e3 · 18 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups through d0750e3 (inv_clear_ring v21 queue preserve)
Lenses run: L1, L2, L3, L4, L5, L6, L8, L9 (skip: L7 — no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md yes — client GET reuses C16, no new route/secret; build-harness still same-repo pull_request + native/harness/**, not pull_request_target)
Round-1–6 Majors (mixed 404/503; thinking after Blob suffix; always-cold Send; thinking-only coldBackup; image/math putOk; Send-while-running inv_clear_messages FIFO wipe) are fixed at this head (inv_clear_ring v21, preserveQueue, tests 2d-queue / 2e-queue / 2h / 2i, Zig keep-queue lock). This pass attacks the attach fail fold that still keys only on HTTP 404.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L6 | Attach producer-terminal failure is folded as subscribe-fail. attachSubscribeFail (lib/harnessChat.ts ~2041) is attaching && fail.kind !== 'stop'/'detach' && !isAttachRunGone(status). Status 404 is the only give-up. An already-open GET that then sees SSE error (or ok:false with 502/5xx after onTurnStarted) keeps running, Ready, no Turn ended. POST sibling (lib/harnessChat.test.ts ~1908) already locks the opposite: onTurnStarted + SSE error → Error + Turn ended + clear running. No attach row does. |
1. Live durable turn. POST SSE drops → D18 detach, Ready, keep running (correct). Host hot-resumes GET ?startIndex=C. 2. Producer writes error (status: 502 or none) and closes. onTurnStarted already fired; sawStreamTerminal is true; durableIncomplete is false. 3. classifyTurnFailure → 'error'. isAttachRunGone is false. attachSubscribeFail is true. 4. Canvas: non-terminal EMBER, Ready, promote gate false. Persist: turnRunId + running. 5. decideHotResume may reconnect once past the error frame, then empty-EOF stops the spin — envelope still running. 6. Operator Send → decideSendAttach remaps to GET, never POST. F5 → kickColdAttach because still running; replay includes the error event; same fold. New/switch is the only escape. Originating POST of the same event already cleared running (test ~1908) — two viewports diverge; last writer can LWW-resurrect a dead run. |
Defender: plan test 6 is 404/503/auth; producer error is “just another non-404”; keep-running until G22. Fail: subscribe-fail is could not open a readable (!sawDurableStart). Once onTurnStarted has fired, the GET is a live consumer of the same stream POST already classifies. SSE error is a producer terminal (docs/agent-stream.md / POST test ~1908), not store-unavailable. Mid-stream TCP drop is already D18 via durableIncomplete (no finding). 404 remains run-gone. Tests 6/6b/6c/6d lock HTTP-before-body only; injecting the POST SSE-error fixture through attach: would fail on this head. |
high |
Residual risk
Empty-EOF GET (applied == startIndex) still does not spin: a long in-flight tool with an idle reader timeout leaves Ready + running until Send or F5 (D18). Envelope C is ignored on same-heap hot resume (right anti-gap vs other-tab LWW). Completed-run replay (test 5) is runHarnessTurn-only — host kickColdAttach no-ops unless running (Blob hydrate is the finished transcript). v21 is fail-closed (host 21 + wasm 20 will not load); build-harness path-filters native/harness/** on same-repo PRs — deploy still depends on wait-for-SHA, now actually required. preserveQueue is gated on non-empty rawPrompt (Send-while-running); empty-prompt kickColdAttach still inv_clear_messages (F5/switch). Stop during attach is still a raw abort (takePendingCancel) → 'stop' clear-running without G22 server cancel — same as POST Stop after headers.
Merge guidance
CONCERNS: do not merge until the Major is fixed (or an operator explicitly accepts it). Round-1–6 contract splits, this-run rebuild, decideSendAttach / follow-up strip, decideHotResume anti-spin, pushSessionToBridge image/math reset, v21 inv_clear_ring / preserveQueue, and tests 2c–2i / 6a–6f can stay.
Suggested Major fix (not implementing here):
- Subscribe-fail iff attach never opened a readable:
attaching && !sawDurableStart && fail.kind !== 'stop' && !isAttachRunGone(status)(401/503/network beforeonTurnStarted). Keep Ready +running+ non-terminal EMBER. No Turn ended. - After
onTurnStarted: reuse the POST fold. SSEerror/ 5xx body → give-up (Error + Turn ended + clearrunning). EOF without terminal stays D18 detach (durableIncompletealready). 404 stays run-gone even if it somehow races. - Add the POST sibling as an attach row:
onTurnStarted+onEvent({type:'error'})+ok:false→Lifecycle.Error, Turn ended,turnStatus !== 'running'. 503 withoutonTurnStartedmust keep the test 6b contract.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, C16 route body (untouched), G22 server cancel, E20 applyTurnEvent. Zig inv_clear_ring was unit-tested in-source, not via a rebuilt harness.wasm in this review workspace.
Adversarial #857 CONCERNS: attachSubscribeFail keyed only on HTTP 404, so a GET that had already opened the stream then saw producer SSE error kept running + Ready. Subscribe-fail is now !sawDurableStart (503/401/ network before onTurnStarted). After onTurnStarted, producer error reuses the POST give-up fold. Test 6g locks the sibling of the POST SSE-error row; 6h locks 5xx-without-start as subscribe-fail. Refs #813 Refs #857
Follow-up (adversarial #857 CONCERNS)Subscribe-fail is now could not open a readable (
Tests: 6g ( Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 276036a · 18 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups through 276036a (subscribe-fail = !sawDurableStart)
Lenses run: L1, L3, L4, L5, L6, L8, L9 (skip: L2 — client GET reuses C16, no new route/secret/runner/pull_request_target; L7 — no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change; build-harness still same-repo pull_request + native/harness/**, not pull_request_target)
Round-1–7 Majors (mixed 404/503; thinking after Blob suffix; always-cold Send; thinking-only coldBackup restore; image/math putOk; Send-while-running inv_clear_messages FIFO wipe; attach SSE error after onTurnStarted kept running) are fixed at this head. This pass attacks the cold-attach persist wrapper that still substitutes coldBackup after the ring has been rebuilt.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L6 | Cold-attach patchSession always LWW-writes coldBackup messages, even after streamPainted. Fail-fold restore already gates on !streamPainted (lib/harnessChat.ts ~2056); the persist wrapper (lib/harnessChat.ts · patchSession ~1240–1247) does not, and coldBackup is never cleared. onTurnStarted (~1760), SSE usage (~1726), and live change_dir / meta_sandbox_switch (~1408 / ~1423) therefore PUT the boot-time Blob suffix for the whole GET. POST usage patches persist live next (tools grown this turn). Attach usage patches replace that with the snapshot from F5. Tests 2c/2f/2g assert final in-memory next / ring order, not onSessionPatch payloads. |
1. Tab A POST: tools 1–4 painted; a usage event persists next with those cards. 2. Tab B F5/new tab: hydrate that suffix, cold-strip to last user, GET ?startIndex=0, replay thinking + tools. 3. Tab B usage (or onTurnStarted after a later cwd patch) calls patchSession({ ...s, messages: coldBackup }) — Blob/envelope messages = Tab B’s boot suffix (tools 1–2, or 1–4 minus anything A wrote after B hydrated). 4. LWW updatedAt on B’s PUT clobbers A’s newer this-run rows. 5. Tab A or B crash before detach/done persist: Blob is the frozen suffix; if the Workflows stream is then gone (404), E19 cannot rebuild the missing cards. Two-tab attach is in the E19 slice. |
Defender: “end persist of next overwrites; F5 always replays the stream.” Fail: the product path is a long-lived GET viewport, not a short replay-then-done. Mid-turn persistTurn is the only client→Blob write until EOF; E19’s two-tab case is exactly this race. Defender: “fail-fold already uses !streamPainted.” Fail: that gate is only the 503/404 restore onto failedSession; patchSession is a separate closure and keeps substituting after paint. Defender: “tools are not patched mid-turn anyway.” Fail: POST usage does persist live next (test 9); attach usage is the one that substitutes coldBackup. |
high |
| Minor | L3+L9 | Send-while-running explanation is DOM-only and painted in EMBER. HarnessHost.tsx ~560 setHostNote(ATTACH_FOLLOW_UP_NOTE) renders host: Follow-up not sent — still attached to the live run. at ~1247–1259 with color: ember.muted. The follow-up user was stripped from the Wasm ring; there is no in-canvas system/error line. Feature-divide: product truth is the canvas; EMBER is danger-only (this is not Turn-ended / subscribe-fail). |
Operator Send while running (503 idle / empty-EOF). Wasm paints the follow-up, host remaps to attach, strip removes the line. The only copy is a DOM host: chip in ember.muted. A user who never looks at host chrome sees the send vanish with no canvas reason. |
Defender: “round 5 added the note so Send is not silent.” True, and it is not a Turn-ended line (correct). Still a dual-chrome / palette miss: TEAL muted on the canvas (or a non-EMBER host mirror of an in-canvas system row) would match L3/L9. Bounded — attach still happens. | high |
Residual risk
v21 inv_clear_ring is now REQUIRED (REQUIRED_FNS + assertRoundTrip). This diff does not touch build-harness path filters or fetch-harness-artifact wait-for-SHA; a Vercel deploy before the new harness-wasm SHA still fail-closes the canvas (existing contract, not a new race). Attach never retries inside withTransientRetry (attaching → permanent); 503 subscribe-fail needs an operator Send to re-GET. Server cancel of a durable run is still G22 (#816) — Stop during attach GET is local running clear only, same as POST Stop. Dedup helpers (textDeltaDedup / shouldSkipTool*) are dead after the cold rebuild (this-run window is always empty); harmless if the strip stays. H24 (#818) still owns attach wire docs.
Merge guidance
- CONCERNS: do not merge until the Major is fixed —
patchSessionmust stop substitutingcoldBackuponcestreamPainted(same gate as the fail fold), or clearcoldBackupafter the first painted this-run row, and lock it with anonSessionPatchassertion (usage afterreasoning_delta/tool_startmust carry livenext.messages, not the boot suffix). - The Minor can land after or with that fix; it does not block on its own.
What was not attacked
Live DO runner, prod Gateway, Vercel Workflows getReadable ordering vs client C, G22 server cancel, E20 applyTurnEvent, H24 docs, full vitest run (merge-pr gate; this review is static + test-read).
Adversarial #857 CONCERNS: patchSession kept substituting coldBackup after streamPainted, so usage/onTurnStarted/cwd PUTs LWW-clobbered newer this-run rows with the boot-time Blob suffix. Same gate as the fail-fold restore: persist next once painted. Send-while-running note is an in-canvas System row; host chrome mirrors in TEAL, not EMBER. Test 2j: usage after reasoning_delta + tool_start does not carry the boot assistant. Refs #813 Refs #857
Follow-up (adversarial #857 CONCERNS)Landed in Major — Same gate as the fail-fold restore ( Minor — Send-while-running note is in-canvas System + TEAL host mirror
Tests: test 2j — cold Blob suffix Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 4264057 · 18 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups through 4264057 (patchSession persist-live-next after streamPainted)
Lenses run: L1, L3, L5, L6, L8, L9 (skip: L2 — client GET reuses C16, no new route/secret/runner/pull_request_target; L4 — no workflow/deploy-script change, no committed wasm, protocol v21 fail-closed on mismatch; L7 — no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change; build-harness still same-repo pull_request + native/harness/**, not pull_request_target)
Round-1–8 Majors (mixed 404/503; thinking after Blob suffix; always-cold Send; thinking-only coldBackup restore; image/math putOk; Send-while-running inv_clear_messages FIFO wipe; attach SSE error after onTurnStarted kept running; patchSession LWW-clobber of live next with boot Blob suffix) are fixed at this head. This pass attacks the Send-while-running note that is painted after runHarnessTurn returns.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L9+L6 | Send-while-running note is painted after attach returns, with no turnStatus === 'running' gate. HarnessHost.tsx runPrompt (~558–567): else if (sendWhileRunning) { setHostNote(ATTACH_FOLLOW_UP_NOTE); bridge.pushMessage(MessageKind.System, ATTACH_FOLLOW_UP_NOTE); }. result.ok success takes that branch (!result.ok && shouldSetHostTurnNote is false). Attach done already ran the success fold (lib/harnessChat.ts ~1867–1998): pushTurnEnd · turnStatus: 'completed' · completeTurn(true). Canvas then reads Turn ended · model then Follow-up not sent — still attached to the live run. Host chrome mirrors the same lie in TEAL. Test 2d / 2e / 2d-queue are this path (done + result.ok === true) but drive runHarnessTurn only. Host source-lock (lib/turnAttach.test.ts ~191–203) requires the ungated pushMessage / setHostNote strings — it does not lock “only while running”. |
1. Live durable turn. POST SSE drops → D18 detach, Ready, keep running. 2. Operator types a follow-up and Sends. Wasm paints the user line; host decideSendAttach remaps to GET attach; strip drops the follow-up; GET rides the rest of the producer. 3. Producer writes done. Success fold paints Turn ended, sets completed, re-arms promote (queued FIFO items become the next POST). 4. Host then paints System + host: chrome: “Follow-up not sent — still attached to the live run.” 5. Operator sees a finished turn that claims it is still live and does not re-Send the follow-up. Same paint is also minutes late on a long attach: the note is not issued at remap, only after the GET returns. |
Defender: the string’s first clause is true (follow-up was not POSTed); TEAL + System (not EMBER / Turn ended) was the round-8 honesty lock; EOF/running still wants the note. Fail: the string’s second clause is false after done + completed + Turn ended. shouldSetHostTurnNote already exists to suppress host chrome on keep-running; this branch does the opposite — it fires on success. Minimum fix: paint at remap (before await runHarnessTurn) or post-await only when folded.turnStatus === 'running'; never after result.ok. Test 2d must not grow a System follow-up row; add a host-level row that done does not paint the note. |
high |
Residual risk
Empty-EOF GET (applied == startIndex) is still plan-locked not to spin: a long in-flight tool with an idle reader timeout leaves Ready + running until F5 or Send. Boot still full-hydrates the Blob this-run suffix then yields on attachTurnStream fetch before the cold strip — one RTT of tools-without-thinking is possible; plan-locked hydrate-then-attach, not a new complete path. Two-tab LWW on envelope C / pre-streamPainted coldBackup PUT remains acceptable per the plan. Protocol v21 is a required additive export (old wasm fails closed); merge still needs a matching harness-wasm artifact. docs/feature-divide.md protocol sentence jumps v19 → v21 and omits v20 (AGENTS.md / native/harness/README.md still list v20).
Merge guidance
CONCERNS: merge only with explicit accept of the Major — or fix the note gate (running-only / paint-at-remap) and add a done non-paint lock before merge.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, C16 route body (untouched), G22 server cancel, E20 applyTurnEvent, live DO runner.
Adversarial #857 CONCERNS: Send-while-running painted "still attached to the live run" after attach returned, including after done (Turn ended + completed). Gate on !ok && running. shouldPaintAttachFollowUpNote is the unit lock; host source-lock drops the ungated else-if. Tests 2d/2e assert runHarnessTurn does not grow the System row on done. Refs #813 Refs #857
Follow-up (adversarial #857 CONCERNS)Send-while-running note is gated to still
Verification: Not merged. |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 57abbb1 · 18 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups through 57abbb1 (shouldPaintAttachFollowUpNote running-only gate)
Lenses run: L1, L3, L5, L6, L8, L9 (skip: L2 — client GET reuses C16, no new route/secret/runner/pull_request_target; L4 — no workflow/deploy-script change, no committed wasm, protocol v21 fail-closed on mismatch; L7 — no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change; build-harness still same-repo pull_request + native/harness/**, not pull_request_target)
Round-1–9 Majors (mixed 404/503; thinking after Blob suffix; always-cold Send; thinking-only coldBackup restore; image/math putOk; Send-while-running inv_clear_messages FIFO wipe; attach SSE error after onTurnStarted kept running; patchSession LWW-clobber of live next; Send-while-running note after done) are fixed at this head. This pass attacks what the running-only note gate left behind, and Stop during a GET subscribe.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L6+L9 | Send-while-running drops the remapped prompt once attach is no longer running. HarnessHost.tsx runPrompt (~429–596): composer Send is consumed (takePendingSubmit) then remapped via decideSendAttach (never POST). runHarnessTurn strips the Wasm follow-up user (withoutTrailingFollowUpUser / cold prefix hydrate). After await, shouldPaintAttachFollowUpNote (lib/turnAttach.ts · shouldPaintAttachFollowUpNote) is true only for !resultOk && turnStatus === 'running'. On done (result.ok + completed) or 404 / post-start SSE error (completed), the host neither paints a System row nor re-invokes runPrompt(prompt) as a normal POST. Tests 2d / 2e lock the missing note on done; there is no row that the stripped text is re-queued or POSTed. FIFO items typed after Busy still completeTurn(true)-promote; the Send that triggered attach does not. |
1. Live durable turn D18-detaches (SSE drop), Ready, keep running. 2. Operator types add tests and Sends. Host remaps to GET attach; follow-up user is stripped. 3a. Producer writes done (run was almost finished). Success fold: Turn ended · model, completed, completeTurn(true). Canvas has no record of add tests. Composer empty. 3b. Same remap, GET 404 run-gone: Turn ended · error, completed. Run is gone so a POST would not C15-409 — the submit is still discarded. 4. If the operator also queued also README while Busy, that FIFO head POSTs; add tests is gone. Operator either thinks the finished turn answered add tests, or must retype. |
Defender: round-9 locked “still attached to the live run” as a lie after done; tests 2d/2e must not grow that System row; EOF/503 still get the note; never POST while running (C15 409). Fail: the first clause of ATTACH_FOLLOW_UP_NOTE is still true after done/404 (follow-up was not POSTed), and those terminals are not running — C15 no longer applies. Minimum fix: if sendWhileRunning && folded.turnStatus !== 'running', runPrompt(originalPrompt) without attach (the Submit the operator already issued); if still running, keep the note. Do not restore the lying “still attached” string after Turn ended. |
high |
| Major | L1+L6 | Stop/Esc during attach uses the POST give-up fold (clear turnRunId/running, paint Turn ended · you stopped). HarnessHost.tsx poll (~827–829): takePendingCancel() → abortRef.abort() (raw, not DETACH_ABORT_REASON). attachTurnStream returns cancelledFailure. classifyTurnFailure → 'stop'. lib/harnessChat.ts (~2047–2154): attachSubscribeFail requires fail.kind !== 'stop', so Stop never takes the keep-running branch; 'stop' && running clears the id. Plan #813 Goal 4: unmount/EOF = D18 detach; Stop/Esc is G22 (#816). This PR’s own attach contract: abort = this reader only, never a server cancel. No attach+Stop test (POST Stop rows at ~1583/#844 do not cover GET). |
1. F5 / login cold-attaches a live run (kickColdAttach, Busy, GET open). 2. Operator hits canvas Stop (does not want to sit on Busy, or thinks Stop ends the watch). 3. GET aborts; fold paints Turn ended · you stopped; persist completed + turnRunId unset; heapAppliedRef = null. 4. Workflow is still live (G22 not in this PR). 5. Next Send is a POST (decideSendAttach → none). C15 409 mixes Turn ended + Error — the landmine this PR spent nine rounds avoiding for Send-while-running. This viewport cannot re-attach without a winning envelope running (this tab’s PUT may LWW-clobber it). |
Defender: Stop on POST already clears running (#844); G22 owns real cancel; operator Stop means “end this turn”; treating attach Stop as detach would immediately decideHotResume and ignore Stop. Fail: attach is a subscribe, not the producer POST. 503-before-start already keeps running because “could not subscribe ≠ turn died”; operator-closed reader is the same class. Minimum fix: attach + 'stop' → D18 detach persist (keep running, Ready, no Turn ended), and do not decideHotResume that tick (Stop must actually disconnect). Leave server cancel to G22. Add runHarnessTurn attach + Request cancelled. after onTurnStarted that asserts turnStatus === 'running' and no you-stopped line. |
high |
| Minor | L8 | docs/feature-divide.md protocol sentence jumps v19 → v21 and omits v20 (inv_queued_insert_front). AGENTS.md / native/harness/README.md still list v20. New-hire protocol archaeology from the feature-divide blurb is missing a shipped export. |
Read feature-divide “currently 21” paragraph after this merge; v20 is absent; inv_queued_insert_front Continue-on-give-up looks like it was never protocol. |
Defender: living attach wire is H24 (#818); README/AGENTS still complete; one sentence. Fail: this PR edited that sentence (19 → 21) and dropped v20 in the same hunk. Cheap restore: keep v20 in the parenthetical. | high |
Residual risk
Empty-EOF GET (applied == startIndex) is still plan-locked not to spin: a long in-flight tool with an idle reader timeout leaves Ready + running until F5 or Send. Boot still full-hydrates the Blob this-run suffix then yields on attachTurnStream fetch before the cold strip — one RTT of tools-without-thinking is possible; plan-locked hydrate-then-attach. Two-tab LWW on envelope C / pre-streamPainted coldBackup PUT remains acceptable per the plan. Protocol v21 is a required additive export (old wasm fails closed); merge still needs a matching harness-wasm artifact. G22 (#816) still owns actual server cancel — until it lands, POST Stop of a live workflow can 409; the Major above is attach Stop pretending that cancel already happened.
Merge guidance
CONCERNS: merge only with explicit accept of the Majors — or (1) re-POST / re-queue the remapped prompt when attach ends not-running, (2) treat attach Stop as D18 detach + no auto-resume that tick, with tests — before merge. The Minor is optional.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, C16 route body (untouched), G22 server cancel implementation, E20 applyTurnEvent, live DO runner.
Follow-up (adversarial #857 CONCERNS)Landed in Major — Send-while-running re-POSTs the remapped prompt when attach is no longer
Major — attach Stop/Esc is D18 detach, not POST give-up
Minor — Verification: |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 5b3c5ab · 18 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups through 5b3c5ab (re-POST remapped prompt; attach Stop is D18)
Lenses run: L1, L3, L5, L6, L8, L9 (skip: L2 — client GET reuses C16, no new route/secret/runner/pull_request_target; L4 — no workflow/deploy-script change, native/harness/** still path-filters build-harness, no committed wasm, protocol v21 fail-closed on mismatch; L7 — no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change)
Round-1–10 Majors (mixed 404/503; thinking after Blob suffix; always-cold Send; thinking-only coldBackup restore; image/math putOk; Send-while-running FIFO wipe; attach SSE error after onTurnStarted kept running; patchSession LWW-clobber of live next; Send-while-running note after done; remapped prompt dropped on terminal; attach Stop used POST give-up fold) are fixed at this head. This pass attacks what the D18 attach-Stop + re-POST split left behind.
Findings
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Major | L1+L6+L9 | Send-while-running Stop paints ATTACH_FOLLOW_UP_NOTE (“Follow-up not sent — still attached to the live run”) after this reader is closed and auto-resume is skipped. shouldPaintAttachFollowUpNote (lib/turnAttach.ts) is sendWhileRunning && !resultOk && turnStatus === 'running' — the same triple Stop leaves (attachOperatorStop keep-running, result.ok false). HarnessHost.tsx runPrompt (~572–597): shouldSetHostTurnNote('running') is false, so the else-if paints the System row + TEAL host mirror. shouldSkipAttachHotResume is true (raw abort, not DETACH_ABORT_REASON), so there is no later GET this page load. Tests 6i/6j use empty rawPrompt (sendWhileRunning false — kickColdAttach). The unit lock in lib/turnAttach.test.ts asserts {sendWhileRunning:true, resultOk:false, turnStatus:'running'} → paint with no operatorStop / aborted bit. |
1. Durable run D18-detaches (empty EOF), Ready, keep running. 2. Operator types add tests and Sends. Host remaps to GET attach; follow-up user is stripped. Busy. 3. Operator hits canvas Stop (does not want to sit on Busy). GET aborts; fold is D18: keep turnRunId/running, Ready, no Turn ended · you stopped. 4. Host skips hot resume this tick and never re-subscribes. Canvas + host chrome: “Follow-up not sent — still attached to the live run.” Lifecycle is Ready (no spinner). 5. Workflow keeps running on the server. This viewport is deaf until F5 or another Send. Operator either waits for an answer that will not paint, or trusts the note and does not Send. |
Defender: Stop means disconnect; 6i/6j lock keep-running + no you-stopped; “Follow-up not sent” is true; running on the envelope is still true; next Send re-attaches via decideSendAttach. Fail: round-10 made Stop actually disconnect (shouldSkipAttachHotResume). The note’s second clause is now a lie, and in-canvas System is product truth (feature-divide). Ready vs “still attached” is a contradictory canvas. 6i/6j never set sendWhileRunning. Minimum fix: pass operator-stop / aborted into shouldPaintAttachFollowUpNote (false on Stop) and paint a distinct line if anything — e.g. “Follow-up not sent — detached from the live run.” — or nothing. Do not restore “you stopped” / clear running (that re-opens the C15 409 landmine). Add runHarnessTurn attach + non-empty prompt + Stop, and a host source-lock that paint is gated on !shouldSkipAttachHotResume. |
high |
Residual risk
Empty-EOF GET (applied == startIndex) is still plan-locked not to spin: a long in-flight tool with an idle reader timeout leaves Ready + running until F5 or Send. Boot still full-hydrates the Blob this-run suffix then yields on attachTurnStream fetch before the cold strip — one RTT of tools-without-thinking is possible. Two-tab LWW on envelope C / pre-streamPainted coldBackup PUT remains acceptable per the plan. Protocol v21 (inv_clear_ring) is a required additive export (old wasm fails closed); build-harness path-filters native/harness/** — merge still needs a matching harness-wasm artifact. Plan #813 DoD “no Wasm/protocol change” was broken for the FIFO-preserve fix; living attach wire remains H24 (#818). G22 (#816) still owns actual server cancel. Send-while-running done + FIFO auto-promote is ordered by microtask re-POST setting Wasm Busy before the next dvui frame — not re-raised; the JS mock __queue still does not promote.
Merge guidance
CONCERNS: merge only with explicit accept of the Major — or gate the follow-up note off attach Stop (and test Send-while-running + Stop) before merge.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, C16 route body (untouched), G22 server cancel implementation, E20 applyTurnEvent, live DO runner, full vitest run (merge-pr gate; this review did not re-execute the suite).
Adversarial #857 CONCERNS: Send-while-running Stop painted "still attached to the live run" after shouldSkipAttachHotResume closed the GET. Gate the still-attached note on !operatorStop; paint a distinct TEAL System line. Keep running, no Turn ended. Tests: shouldPaint* operatorStop rows; host source-lock; 6k Send-while-running + Stop strips follow-up, no still-attached note. Refs #813 Refs #857
Follow-up (adversarial #857 CONCERNS)Landed in Major — Send-while-running Stop no longer paints “still attached”
Tests: predicate rows + host source-lock ( Verification: |
btipling
left a comment
There was a problem hiding this comment.
Adversarial review — PR #857
Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: main @ cfdf3fc ← plan/durable-attach @ 126cdba · 18 files · E19 attach handshake (Blob hydrate + GET stream), including follow-ups through 126cdba (Send-while-running Stop paints detach, not “still attached”)
Lenses run: L1, L3, L5, L6, L8, L9 (skip: L2 — client GET reuses C16, no new route/secret/runner/pull_request_target; L4 — no workflow/deploy-script change, native/harness/** still path-filters build-harness, no committed wasm, protocol v21 fail-closed on mismatch; L7 — no config-seam or host hardcode)
AGENTS.md read: yes (docs/feature-divide.md yes; SECURITY.md skip-reasoned — no route/secret/runner change)
Round-1–11 Majors (mixed 404/503; thinking after Blob suffix; always-cold Send; thinking-only coldBackup restore; image/math putOk; Send-while-running FIFO wipe; attach SSE error after onTurnStarted kept running; patchSession LWW-clobber of live next; Send-while-running note after done; remapped prompt dropped on terminal; attach Stop used POST give-up fold; Send-while-running Stop painted “still attached” after reader close) are fixed at this head. This pass attacked the D18 Stop-note split and remaining attach state machine (re-POST vs note vs skip-resume, cold strip vs skip, C vs envelope, shouldSetHostTurnNote('running') else-if order).
Findings
Attack did not breach with current evidence. No Blocker / Major / Minor survived self-refutation.
| Sev | Lens | Finding | Break scenario | Refutation attempt | Confidence |
|---|---|---|---|---|---|
| Nit | L8 | AGENTS.md bumps the harness-chrome cell to protocol v21 but never names inv_clear_ring (the live-session ring replace that keeps the FIFO). docs/feature-divide.md and native/harness/README.md do. |
A later agent edits attach hydrate from AGENTS only and calls inv_clear_messages on Send-while-running, wiping the live queue (the v21 reason). |
Defender: fail-closed export + harnessBridge comments + tests 2d-queue / 2h / bridge.zig v21 row lock the seam; AGENTS is a 4k-char cell. Survives as docs drift only — not a production break in this diff. |
medium |
Residual risk
Empty-EOF GET (applied == startIndex) is still plan-locked not to spin: a long in-flight tool with an idle reader timeout leaves Ready + running until F5 or Send. Boot still full-hydrates the Blob this-run suffix then yields on attachTurnStream fetch before the cold strip — one RTT of tools-without-thinking is possible. Two-tab LWW on envelope C / pre-streamPainted coldBackup PUT remains acceptable per the plan. Cold-attach dedup skip helpers are effectively a no-op after the suffix strip (thisRunWindow empty) — rebuild-from-stream is the real lock (test 2c); skip unit tests still pass in isolation. Protocol v21 (inv_clear_ring) is a required additive export (old wasm fails closed); build-harness path-filters native/harness/** — merge still needs a matching harness-wasm artifact. Plan #813 DoD “no Wasm/protocol change” was broken for the FIFO-preserve fix; living attach wire remains H24 (#818). G22 (#816) still owns actual server cancel. Send-while-running done + FIFO auto-promote is ordered by microtask re-POST setting Wasm Busy before the next dvui frame.
Merge guidance
PASS WITH NOTES: safe to merge from this attack; nits optional. Do not treat this as GitHub --approve — merge-pr owns the full vitest run gate.
What was not attacked
Live Workflows getReadable / Redis / Vercel production, two-tab LWW against a real envelope store, C16 route body (untouched this PR), G22 server cancel implementation, E20 applyTurnEvent, live DO runner, full vitest run (merge-pr gate; this review did not re-execute the suite).
Summary
Implements backend-agents E19 (#813): attach a viewport to a live durable run.
Two attach classes (heap state, not tab folklore):
CGET /api/turns/:runId/stream?startIndex=0+ this-run-window dedup[0, C)(SSE drop while mounted)GET ?startIndex=C, no re-hydrate, no replay from 0Cis this consumer’s WorkflowsgetReadableoffset (SSE frames received, including skipped-by-dedup). It is not a Blob row index. EnvelopeCis a persist hint for this heap; F5 never uses another tab’sCasstartIndex.What landed
lib/turnApi.ts—attachTurnStream(GET, abort = this reader only, never a server cancel)lib/turnAttach.ts—decideAttachClass+ this-run-window dedup helpers (notapplyTurnEvent; that is E20 backend-agents E20: shared event-apply consumer (applyTurnEvent) #814)lib/harnessChat.ts— inlineonEventadvancesCon POST and GET; cold dedup; EOF withoutdone/erroris D18 detach; attach 404/503 keeprunning(could not subscribe ≠ turn died)app/harness/HarnessHost.tsx— boot/adopt/switch-back kick cold attach; in-heap SSE drop kicks hot resume (empty EOF does not spin)No Wasm / protocol change. No
/api/agentfallback. No docs in this PR — H24 (#818) owns the attach wire (docs/agent-stream.md/docs/session-model.md): cold/F5 =startIndex=0+ dedup; hot resume =C.Verification
npm run typecheck— passnode_modules/vitest/vitest.mjs run --changed— 4 files / 243 passed / failed=0 (includes wasm-int 13)lib/turnAttach.test.ts(17) ·lib/turnApi.test.ts(18) ·lib/harnessChat.test.ts(195)Expected full-suite delta: baseline on
main@cfdf3fcwas 2351 passed. This PR adds 34 tests (17 attach helpers + 7 GET client + 10runHarnessTurnattach rows) → 2385 passed. Fullvitest runis the merge-pr gate, not this implement-plan step.Plan DoD
attachTurnStreamships; abortable; never cancels the run (D18)Cwithout re-hydrate / without duplicatestartIndex=0+ dedup, including thinking not in Blob (test 2b)Cadvances on POST and GET; persist on existingpersistTurn; A3 drop-to-unset;0preservedrunning, no Turn ended/api/agentfallback; no server cancel; noapplyTurnEventextractionFixes #813
Refs #794
Refs #818