Skip to content

feat(harness): E19 attach handshake — Blob hydrate + stream attach - #857

Merged
btipling merged 12 commits into
mainfrom
plan/durable-attach
Aug 26, 2026
Merged

feat(harness): E19 attach handshake — Blob hydrate + stream attach#857
btipling merged 12 commits into
mainfrom
plan/durable-attach

Conversation

@btipling

Copy link
Copy Markdown
Owner

Summary

Implements backend-agents E19 (#813): attach a viewport to a live durable run.

Two attach classes (heap state, not tab folklore):

Class When Wire
Cold F5 / login / new tab / adopt / session switch-back / poison C Existing Blob/local hydrate, then GET /api/turns/:runId/stream?startIndex=0 + this-run-window dedup
Hot resume Same JS heap already applied stream frames [0, C) (SSE drop while mounted) GET ?startIndex=C, no re-hydrate, no replay from 0

C is this consumer’s Workflows getReadable offset (SSE frames received, including skipped-by-dedup). It is not a Blob row index. Envelope C is a persist hint for this heap; F5 never uses another tab’s C as startIndex.

What landed

  • lib/turnApi.tsattachTurnStream (GET, abort = this reader only, never a server cancel)
  • lib/turnAttach.tsdecideAttachClass + this-run-window dedup helpers (not applyTurnEvent; that is E20 backend-agents E20: shared event-apply consumer (applyTurnEvent) #814)
  • lib/harnessChat.ts — inline onEvent advances C on POST and GET; cold dedup; EOF without done/error is D18 detach; attach 404/503 keep running (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/agent fallback. 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 — pass
  • node_modules/vitest/vitest.mjs run --changed4 files / 243 passed / failed=0 (includes wasm-int 13)
  • Targeted: lib/turnAttach.test.ts (17) · lib/turnApi.test.ts (18) · lib/harnessChat.test.ts (195)

Expected full-suite delta: baseline on main @ cfdf3fc was 2351 passed. This PR adds 34 tests (17 attach helpers + 7 GET client + 10 runHarnessTurn attach rows) → 2385 passed. Full vitest run is the merge-pr gate, not this implement-plan step.

Plan DoD

  • attachTurnStream ships; abortable; never cancels the run (D18)
  • Hot resume at this-heap C without re-hydrate / without duplicate
  • Cold / F5 hydrates then startIndex=0 + dedup, including thinking not in Blob (test 2b)
  • C advances on POST and GET; persist on existing persistTurn; A3 drop-to-unset; 0 preserved
  • EOF without terminal → detach, keep running, no Turn ended
  • typecheck + changed vitest + wasm-int green
  • No Wasm/protocol change; no /api/agent fallback; no server cancel; no applyTurnEvent extraction
  • Cloud ops N/A. Living docs deferred to H23/H24/H25 (PR pointer above)

Fixes #813
Refs #794
Refs #818

Open/login/F5 on a running durable turn hydrates Blob then GET
/api/turns/:runId/stream at startIndex=0 with this-run-window dedup.
Hot resume (same JS heap already applied [0,C)) reconnects at C.
C advances on POST and GET via the inline onEvent and rides persistTurn.

Fixes #813
Refs #794
@btipling btipling self-assigned this Aug 26, 2026
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
invincible Ignored Ignored Aug 26, 2026 11:01pm

Request Review

Copy link
Copy Markdown
Owner Author

Implement-plan for #813 (E19). Not merged.

  • Cold attach (F5/login/new tab): Blob hydrate then GET ?startIndex=0 + this-run-window dedup.
  • Hot resume (same JS heap): GET ?startIndex=C.
  • C advances on POST and GET; EOF without done/error is detach; 404/503 keep running.
  • Verification: npm run typecheck pass; vitest run --changed 4 files / 243 passed (wasm-int 13 included). Expected full-suite 2351 → 2385 (+34). Full suite is the merge-pr gate.

Suggested next: adversarial-review on this PR.

Copy link
Copy Markdown
Owner Author

Implement-plan for #813 is up on this PR (not merged).

  • npm run typecheck green
  • vitest run --changed: 4 files / 243 passed / failed=0 (wasm-int 13 included)
  • Full suite is the merge-pr gate (~2351 → ~2385 with the 34 new tests)

H24 (#818) should document: cold/F5 = startIndex=0 + dedup; hot resume = this-heap C; envelope C is not an F5 offset.

Suggested next: adversarial-review on this PR, then an explicit merge request (merge commit, no squash).

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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, no Turn 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 + clear running / turnRunId. Keeping a dead id only works because C15 already special-cases exists: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.

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS)

Split attach HTTP failure into two contracts (11c1a6b):

Status Canvas Lifecycle Persist
404 run-gone Turn ended · error (EMBER) Error clear running
503 / 401 / network non-terminal EMBER, no Turn ended Ready (D18-shaped) keep running

Host hot-resume now goes through decideHotResume (empty-EOF still does not spin). Source-lock tests cover kickColdAttach (boot/adopt/activate), decideHotResume, and detachTurn clearing inflightRef.

Verification: npm run typecheck pass. vitest run --changed3 files / 235 passed / failed=0 (harnessChat 197, turnAttach 25, wasm-int 13). Full suite remains the merge-pr gate.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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).

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS, round 2)

Landed in 1df0acc:

Major — cold attach rebuilds this-run from the stream

Blob this-run suffix (tool_run / assistant) is not thinking. Leaving it on the ring made reasoning_delta append after the answer. Cold attach now hydrates through the last user, replays GET startIndex=0 in order, and keeps a persist backup so a 503/404 before events does not LWW a user-only transcript.

Minor — Send while running re-attaches

Operator Send when turnStatus=running cold-reattaches (startIndex=0 + dedup). Never POST (C15 409 would mix Turn ended + Error with keep-running).

Tests: test 2c (thinking before tools, one tool card, one assistant), test 6e (503 restores suffix), prefixThroughLastUser, host source-lock for Send-while-running.

Verification: npm run typecheck pass. Targeted vitest run lib/harnessChat.test.ts lib/turnAttach.test.ts lib/turnApi.test.ts3 files / 244 passed / failed=0 (harnessChat 199, turnAttach 27, turnApi 18). Full suite remains the merge-pr gate.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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 at C → hot resume; F5/idle heap → cold at 0.
  • If the poll consumed a non-empty pending submit, always hydrateMessages / ring-window through the session last user (even when there is no Blob suffix) before attach, so a follow-up row cannot become a false prompt boundary. Do not appendMessage that 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: session user + running, bridge already has an extra user('follow-up'), attach 0 + dedup, stream reasoning_delta then text_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.

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS, round 3)

Landed in 02df4fb:

Major — Send-while-running no longer paints a false prompt

  • decideSendAttach(heapApplied): applied count > 0 → hot resume at this-heap C; count 0 / other run / idle → cold at 0 + dedup. Never POST (C15 409).
  • Non-empty pending submit drops the Wasm-painted follow-up user before attach (withoutTrailingFollowUpUser).
  • Cold attach always hydrates through the last session user (even with no Blob suffix).

Minor — behavior tests, not a comment grep

  • test 2d: extra user('follow-up') + cold attach → thinking/assistant under hello, follow-up gone.
  • test 2e: extra follow-up + hot resume at C=7 → live assistant grows, follow-up gone.
  • decideSendAttach / withoutTrailingFollowUpUser unit rows; host source-lock now requires decideSendAttach(.

Verification: npm run typecheck pass. Targeted vitest run lib/harnessChat.test.ts lib/turnAttach.test.ts lib/turnApi.test.ts3 files / 254 passed / failed=0 (harnessChat 201, turnAttach 35, turnApi 18). Full suite remains the merge-pr gate.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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 coldBackup onto 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 at C can append tools/assistant once.
  • Add a runHarnessTurn fixture: Blob user + tool_run + assistant, attach 0 + dedup, reasoning_delta then EOF (no done); then a second attach at C with tool_start / tool_result / text_delta / done. Assert: one tool card, thinking before tools, one assistant Hello world, session tool_run count 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

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS, round 4)

Landed in dda5470:

Major — thinking-only EOF no longer restores the Blob suffix onto the session

Cold-attach coldBackup restore now uses !streamPainted (same gate as ring restore), not thisRunWindow empty. Thinking is never in SessionStore, so a thinking-only incomplete GET used to put tool_run / assistant back on the session while the ring kept thinking; the host then auto hot-resumed at C and duplicated tools.

503/404 before events still restore the suffix (test 6e).

Tests: test 2f — Blob user + tool_run + assistant, attach 0 + dedup, reasoning_delta then EOF; second attach at C with tools + text_delta / done → one tool card, thinking before tools, one assistant, session tool_run count 1.

Verification: npm run typecheck pass. Targeted vitest run lib/harnessChat.test.ts lib/turnAttach.test.ts lib/turnApi.test.ts3 files / 255 passed / failed=0 (harnessChat 202, turnAttach 35, turnApi 18). Full suite remains the merge-pr gate.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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. hydrateMessagesinv_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 hydrateRingWindowpushSessionToBridge 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. withoutTrailingFollowUpUserhydrateMessages(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 · runPromptdecideSendAttach) 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 → hydrateMessagesfoldStatusSlotsscheduleImagesFromTexts / scheduleMathFromTexts sequence). Never call hydrateMessages as a surgical ring edit.
  • resetHarnessImageSession / resetHarnessMathSession before the clear, then reschedule from the prefix texts — otherwise JS putOk permanently shadows the wiped Wasm caches.
  • Do not use hydrateMessages to drop a single trailing user row (that clears the submit queue). Pop/rewrite the last ring row, or hydrateMessages only after a full pushSessionToBridge-shaped rebuild.
  • Add a runHarnessTurn fixture: session with a prior-turn assistant that contains an image URL + a running attach; after cold dedup hydrate, assert resetHarnessImageSession ran (generation bump) or that scheduleImagesFromTexts is invoked on the prefix — and that inv_clear_messages is 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

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS, round 5)

Landed in 9e0346c:

Major — cold attach hydrates via pushSessionToBridge

hydrateMessagesinv_clear_messages wiped Wasm image/math caches without resetting JS putOk, so prior-turn media stayed blank after F5/switch-back. Cold rebuild and 503-before-events restore now go through pushSessionToBridge (reset putOk, coalesce consecutive tool_run, slice, reschedule). Hot-resume follow-up strip uses rebuildAttachRingFromRows (same cache contract; keeps live thinking, which has no SessionStore role).

Minor — Send-while-running host note

Operator Send remapped to attach sets ATTACH_FOLLOW_UP_NOTE on the host chrome (Follow-up not sent — still attached to the live run.). Not a Turn-ended line; not EMBER; does not sit on the live ring (would break hot grow-in-place).

Tests: test 2g — prior-turn image assistant kept, consecutive tool_run coalesced to one card, image session generation bumps. Source-lock: pushSessionToBridge on cold/503 restore; rebuildAttachRingFromRows on hot strip; host setHostNote(ATTACH_FOLLOW_UP_NOTE).

Verification: npm run typecheck pass. Targeted vitest run lib/harnessChat.test.ts lib/turnAttach.test.ts lib/turnApi.test.ts3 files / 258 passed / failed=0 (harnessChat 203, turnAttach 37, turnApi 18). Full suite remains the merge-pr gate.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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 / pushSessionToBridge to 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 keep submit_queue + pause latch.
  • Hot strip: pop/rewrite the last ring row, or snapshot+restore the FIFO around hydrate. Host today can queuedCount / queuedInsertFront but cannot read item text (no inv_queued_item export) — so snapshot-restore needs a Wasm read or a ring pop. A surgical inv_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 pushSessionToBridge path. Prefix rebuild is for kickColdAttach (empty FIFO). If a Wasm follow-up is on the ring, strip it the same surgical way as hot.
  • Point makeMockExports inv_clear_messages at __queue.length = 0 (and promote-gate default / pause) so it matches bridge.zig. Add runHarnessTurn 2e/2d with __queue preloaded — expect the FIFO still there after attach remap.
  • Keep ATTACH_FOLLOW_UP_NOTE for 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

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS, round 6)

Landed in d0750e3:

Major — Send-while-running keeps the submit queue

hydrateMessagesinv_clear_messages wiped the live Wasm FIFO (and re-armed promote / cleared pause). Protocol v21 adds inv_clear_ring: ring + image/math caches only.

Path Clear Queue
F5 / New / switch (kickColdAttach, empty prompt) inv_clear_messages cleared (previous session)
Send-while-running hot strip rebuildAttachRingFromRowspreserveQueue kept
Send-while-running cold rebuild / 503 restore pushSessionToBridge({ preserveQueue: true }) kept

makeMockExports inv_clear_messages now matches Zig (zeros __queue, re-arms promote).

Minor — attach 503 retries do not stack EMBER rows

paintSubscribeFail replaces the last non-terminal error instead of appending.

Tests: 2d-queue / 2e-queue / 2i (FIFO survives Send remap, including 503) · 2h (F5 still clears) · 6f (second 503 replaces) · Zig inv_clear_ring keeps queue/pause/promote · host hydrateMessages({preserveQueue}) + REQUIRED_FNS.

Verification: npm run typecheck pass. Targeted vitest run lib/harnessChat.test.ts lib/harnessBridge.test.ts lib/turnAttach.test.ts lib/harnessHostModelPersist.test.ts4 files / 317 passed / failed=0 (harnessChat 208, turnAttach 37, harnessBridge 57, harnessHostModelPersist 15). Full suite remains the merge-pr gate. Wasm rebuild is CI build-harness (protocol v21); this workspace cannot compile Zig.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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 before onTurnStarted). Keep Ready + running + non-terminal EMBER. No Turn ended.
  • After onTurnStarted: reuse the POST fold. SSE error / 5xx body → give-up (Error + Turn ended + clear running). EOF without terminal stays D18 detach (durableIncomplete already). 404 stays run-gone even if it somehow races.
  • Add the POST sibling as an attach row: onTurnStarted + onEvent({type:'error'}) + ok:falseLifecycle.Error, Turn ended, turnStatus !== 'running'. 503 without onTurnStarted must 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

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS)

Subscribe-fail is now could not open a readable (!sawDurableStart), not every non-404.

Path Canvas Lifecycle Persist
503 / 401 / network before onTurnStarted non-terminal EMBER, no Turn ended Ready (D18-shaped) keep running
404 run-gone Turn ended · error (EMBER) Error clear running
After onTurnStarted + SSE error / 5xx Turn ended · error (EMBER) Error (POST give-up) clear running
EOF without terminal no Turn ended Ready keep running (D18)

Tests: 6g (onTurnStarted + SSE error → give-up, never /api/agent) · 6h (502 without start stays subscribe-fail). 6/6b/6c/6d unchanged.

Verification: npm run typecheck pass. vitest run lib/harnessChat.test.ts lib/turnAttach.test.ts lib/turnApi.test.ts3 files / 265 passed / failed=0 (harnessChat 210). Full suite remains the merge-pr gate.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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 — patchSession must stop substituting coldBackup once streamPainted (same gate as the fail fold), or clear coldBackup after the first painted this-run row, and lock it with an onSessionPatch assertion (usage after reasoning_delta / tool_start must carry live next.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

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS)

Landed in 4264057:

Major — patchSession no longer LWW-writes coldBackup after streamPainted

Same gate as the fail-fold restore (coldBackup && !streamPainted). onTurnStarted before paint still persists the boot suffix (do not PUT a truncated prefix). Usage / cwd / sandbox patches after thinking or tools persist live next.

Minor — Send-while-running note is in-canvas System + TEAL host mirror

MessageKind.System on the ring (not EMBER, not Turn-ended). Host chrome uses teal.muted when the note is ATTACH_FOLLOW_UP_NOTE.

Tests: test 2j — cold Blob suffix user + tool_run + assistant, attach reasoning_delta + tool_start + usage; usage patch has no boot assistant. Source-lock for the persist gate and TEAL/System wiring.

Verification: npm run typecheck pass. vitest run on lib/harnessChat.test.ts + lib/turnAttach.test.ts + lib/harnessHostModelPersist.test.ts3 files / 264 passed / failed=0. Full suite remains the merge-pr gate.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS)

Send-while-running note is gated to still running (57abbb1):

After attach Canvas Host chrome
EOF / 503 (!ok + running) System follow-up note TEAL host: mirror
done (ok + completed) Turn ended only — no “still attached” lie none
404 give-up Turn ended · error error note

shouldPaintAttachFollowUpNote is the unit lock. Host source-lock drops the ungated else if (sendWhileRunning). Tests 2d/2e assert runHarnessTurn does not grow the System row on done.

Verification: npm run typecheck pass. vitest run4 files / 328 passed / failed=0 (harnessChat 211, turnAttach 42, harnessBridge 57, turnApi 18). Full suite remains the merge-pr gate.

Not merged.

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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 (decideSendAttachnone). 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.

Adversarial #857 CONCERNS: Send-while-running that ends not-running
(done/404/give-up) re-POSTs the remapped prompt (pushUser) instead of
dropping it. Attach Stop/Esc keeps running, no Turn ended · you stopped,
and skips auto hot-resume that tick. feature-divide restores v20.

Refs #813
Refs #857

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS)

Landed in 5b3c5ab:

Major — Send-while-running re-POSTs the remapped prompt when attach is no longer running

shouldRepostAttachFollowUp: done / 404 / post-start SSE error → queueMicrotask(runPrompt(prompt, { pushUser: true })). C15 409 no longer applies. EOF / 503 still get the System note (still running). Tests 2d/2e still lock no “still attached” lie on done.

Major — attach Stop/Esc is D18 detach, not POST give-up

attachOperatorStop: keep turnRunId + running, no Turn ended · you stopped, Ready. shouldSkipAttachHotResume skips auto-reconnect that tick (raw abort, not DETACH_ABORT_REASON). POST Stop still clears (#844). Tests 6i (after onTurnStarted) and 6j (before start, no subscribe-fail EMBER).

Minor — docs/feature-divide.md protocol blurb restores v20 (inv_queued_insert_front) between v19 and v21.

Verification: npm run typecheck pass. Targeted vitest — 4 files / 337 passed / failed=0 (harnessChat 213, turnAttach 49, harnessBridge 57, turnApi 18). Full suite remains the merge-pr gate.

Refs #813
Refs #857

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: CONCERNS
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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

Copy link
Copy Markdown
Owner Author

Follow-up (adversarial #857 CONCERNS)

Landed in 126cdba:

Major — Send-while-running Stop no longer paints “still attached”

shouldPaintAttachFollowUpNote takes operatorStop (shouldSkipAttachHotResume). Stop → ATTACH_FOLLOW_UP_DETACH_NOTE (“Follow-up not sent — detached from the live run.”), TEAL System + host chrome. Keep running, no Turn ended · you stopped, no auto-resume this tick.

Tests: predicate rows + host source-lock (operatorStop / isAttachFollowUpHostNote). 6k — Send-while-running + Stop after onTurnStarted: follow-up stripped, keep running, no still-attached / detach note from runHarnessTurn (host paints the detach line).

Verification: npm run typecheck pass. Targeted vitest — 4 files / 343 passed / failed=0 (harnessChat 214, turnAttach 54, harnessBridge 57, turnApi 18). Full suite remains the merge-pr gate.

Refs #813
Refs #857

@btipling btipling left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #857

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: main @ cfdf3fcplan/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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

backend-agents E19: attach handshake — Blob hydrate + stream attach

1 participant