Skip to content

backend-agents C (plan #789): reserved meta.turnRunId/turnStatus + host detach (unmount ≠ abort run) - #790

Merged
btipling merged 3 commits into
mainfrom
plan/turns-detach
Aug 23, 2026
Merged

backend-agents C (plan #789): reserved meta.turnRunId/turnStatus + host detach (unmount ≠ abort run)#790
btipling merged 3 commits into
mainfrom
plan/turns-detach

Conversation

@btipling

Copy link
Copy Markdown
Owner

Summary

Implements plan #789 (backend-agents slice C, source #766): stop treating
the tab as the turn lifetime.

  • Reserved meta.turnRunId session carrier (Redis-safe opaque via the
    existing isRedisSafeOpaqueId rule, ^[A-Za-z0-9_-]{1,512}$) + optional
    meta.turnStatus
    (idle|running|cancelling, a cached hint only —
    getRun(runId) is the authority when an id is present). Both follow the
    meta.selectedModel drop-to-unset-on-poison, never 400 discipline.
  • Host detach: HarnessHost unmount / session switch / New/Clear now call a
    single detachTurn() seam (lib/detachTurn.ts decideDetach) that keys on
    turnRunId presence:
    • Durable run (id present, post-E): close-reader only — never abort the
      run, never a server cancel. The run identity survives on the envelope.
    • Legacy tab-owned turn (no id, slice-C reality): still aborts the
      /api/agent fetch so a detached busy tab never leaves an unpersisted 1800 s
      Function burning with no writer (the plan-review Major guard).
    • No run id ⇒ idle (today's Ready); Stop/Esc (takePendingCancel) still
      cancels, isolated from the detach path.
  • No Wasm/protocol change, no queue persist, no Workflow port (E/G/H own
    those). This slice ships the carrier + detach seam only.

Files

  • lib/sessionCloudCaps.ts — client-safe sanitizeTurnRunId / sanitizeTurnStatus,
    TurnStatus type, NEW cap TURN_STATUS_MAX_BYTES = 32.
  • lib/sessions/sessionStore.ts — both keys in RESERVED_META_KEYS + dedicated
    drop-to-unset branches in validateMeta (never 400; STRICT unknown-key intact).
  • lib/sessionStore.ts (local snapshot) — fields + sanitize-on-load.
  • lib/sessionRepository.ts — fold into cloudMetaFor, restore on pull/adopt
    (parseCloudSessionSnapshot, overlayEnvelopeMeta); emit-when-set, omit=clear.
  • app/harness/HarnessHost.tsxdetachTurn() seam wired to unmount/switch/
    Clear/New; Stop/Esc (takePendingCancel) unchanged.
  • lib/detachTurn.ts (+ test) — pure detach-vs-abort guard.
  • Docs: docs/session-model.md (reserved-meta table + schema list),
    docs/feature-divide.md (unmount = detach row). AGENTS/README/SECURITY/.env.example N/A (one-line whys in plan).

Verification

  • npm run typecheck → exit 0.
  • Targeted + full suite run directly with vitest (local binary):
  • Cap governance: no existing cap raised/lowered → no human gate. TURN_STATUS_MAX_BYTES=32 is a NEW generous cap (plan Caps table); turnRunId reuses REDIS_SAFE_OPAQUE_ID_MAX=512. Both ride the existing 1 MiB whole-meta budget / 4.5 MB Function ceiling.
  • Cloud ops: N/A — no Production mutate (reserved-meta carriers + host detach only). No workflow_dispatch change.

Closes #789 · Refs #766 (source) · #764 (parent) · #710 (umbrella) · #765 / #767 (registry B/D).

@btipling btipling self-assigned this Aug 23, 2026
@vercel

vercel Bot commented Aug 23, 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 23, 2026 5:04am

Request Review

@btipling

Copy link
Copy Markdown
Owner Author

Implemented plan #789 (backend-agents slice C). Adds reserved meta.turnRunId/meta.turnStatus carriers with drop-to-unset-on-poison, and a host detachTurn() seam (unmount/switch/New-Clear = detach; abort only a legacy no-run-id turn; Stop/Esc still cancels). No Wasm/protocol change.

Verification: typecheck exit 0; full vitest run (direct local binary) 133 files / 2061 tests passed, failed=0, exit 0 (baseline 2046 → +15 new); test:di-gate OK. Cap governance: no existing cap raised/lowered; TURN_STATUS_MAX_BYTES=32 NEW tabled; turnRunId reuses REDIS_SAFE_OPAQUE_ID_MAX=512.

@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 #790

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainplan/turns-detach · 13 files · backend-agents C (reserved meta.turnRunId/turnStatus + host detach seam)
Lenses run: L1, L2, L3, L5, L6, L7, L8 (skip: L4 — no GHA/CI/artifact files; L9 — no palette/UX chrome)
AGENTS.md read: yes (docs/feature-divide.md unmount=detach row; SECURITY.md session envelope trust)

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Minor L1 Plan edge case + docs/feature-divide.md claim session switch / New / Clear mid-turn = detach. onSwitchSession / onNewSession / onClear still return on inflightRef.current before detachTurn() (app/harness/HarnessHost.tsx). The only tear-down that can fire while a turn is in flight is unmount. Start a turn. Click another rail session / New / Clear. Host no-ops; viewport stays Busy. Detach-vs-abort never runs. Tab close is the only live-turn detach. Post-E, a durable run still cannot be abandoned by switch — user is stuck until the Function ends or the tab unmounts. Inflight guards are the #642 leftover-Send lock and pre-exist this PR; step 4 of the plan listed unmount/switch/clear calling detach without saying “lift the guard.” Fails: the plan’s own edge case (“Session switch / New / Clear mid-turn: same detach discipline — abandonment is not cancellation”) and the feature-divide sentence state it as current truth. The new detachTurn() lines on those three paths are dead for a live turn. high
Minor L6 No Host wiring proof that unmount uses detachTurn(). Plan row 7 allowed an extracted helper; lib/detachTurn.test.ts covers decideDetach only. Unmount cleanup in HarnessHost.tsx is untested. This repo already source-locks Host call sites (lib/harnessHostModelPersist.test.ts, lib/sessionSummaryLabel.test.ts). Revert the unmount cleanup from detachTurn() back to abortRef.current?.abort(). decideDetach tests stay green. Default (no turnRunId) still aborts, so C looks the same — the durable-run close-reader path is the untested wiring. Helper extraction was an allowed DoD option; the four call sites are one-liners. Fails: the #710-class behavior lives in the cleanup, not the helper. A source lock that unmount/switch/new/clear call detachTurn() and that the remaining abortRef.abort() sites are only runPrompt (replace controller) and takePendingCancel (Stop/Esc) would pin the seam. high

Residual risk

decideDetach keys on presence of a client-writable meta.turnRunId (envelope PUT accepts any Redis-safe opaque; drop-to-unset, never 400). At C the host never mints an id (/api/agent is still the turn owner), so a session that already carries a valid-looking id will not abort on unmount — the plan-review Major (unpersisted 1800s Function) returns for that session. Default snapshots omit the field → abort (today’s behavior). close-reader is a documented no-op until F. Logout relies on unmount after signOut (LogoutButton does not call detachTurn). getRun is not consulted (hint vs authority is docs-only this slice).

Merge guidance

  • PASS WITH NOTES: safe to merge from this attack; nits optional
  • E/F should lift or justify the inflight guards when switch-away must detach a durable run, and should not trust a client-supplied id without a real Workflow run

What was not attacked

Live Workflow turn owner (E). Viewport attach / reader close (F). Stop→Workflow cancel (H). Prod Gateway. Self-hosted runner. Dual-chat. Wasm protocol (no native/ in diff).

@btipling

btipling commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Plan to address feedback on PR #790 (Status: DONE — all round-1 + round-2 items addressed)

Last updated: 2026-08-23

Feedback items (most complex → least)

# Severity Review item Status
1 Minor L1 — switch/New/Clear mid-turn detach is dead code behind the inflight guard; docs overclaim 🟢 done
2 Minor L6 — no Host wiring proof unmount uses detachTurn(); add a source lock 🟢 done
3 Nit L8 (round-2) — docs/feature-divide.md detach row cites "PR #790 review L1" (PR-review archaeology in a living ownership table); optional trim 🟢 done

#1 — switch/New/Clear detachTurn() is dead for a live turn (Minor, L1) (🟢 DONE)

  • Review item: onSwitchSession / onNewSession / onClear still return on
    inflightRef.current / switchInFlightRef.current before detachTurn()
    (app/harness/HarnessHost.tsx), so the only tear-down that can fire while a turn
    is in flight is unmount. The plan edge case + docs/feature-divide.md row
    claim "session switch / New / Clear mid-turn = detach" as current truth.
  • Grounded problem (verified on head 7a9cb85c): onClear:831 returns on
    inflightRef.current || switchInFlightRef.current before detachTurn() at :834;
    onNewSession:911 returns on the same guard before detachTurn() at :916;
    onSwitchSession:936-937 returns on inflightRef.current /
    switchInFlightRef.current before detachTurn() at :941. The unmount cleanup
    (:739) calls detachTurn() unconditionally — the only live-turn detach site
    at slice C. docs/feature-divide.md:47 overclaimed the switch/New/Clear paths.
  • Severity assessment: Minor. No runtime bug at slice C (those detachTurn()
    calls are reachable only when not in flight, where they degrade to today's
    no-op). The defect is a claim-vs-reality gap in living docs plus a seam that
    E/F must lift the guard to actually use for durable-abandon.
  • Strategies considered:
    • A) Document honestly + defer the guard lift to E/F — fix the
      docs/feature-divide.md row so it states the seam is wired on all four
      tear-down sites but switch/New/Clear are gated by the pre-existing harness: session list in the Wasm transcript rail (protocol v17) #642
      inflight lock, so during a live turn only unmount fires detach; E/F lifts
      or justifies the guards for durable-abandon (reviewer's own merge guidance).
      (→ CHOSEN — matches the review's "E/F should lift OR JUSTIFY"; lifting the
      guard here would change the harness: session list in the Wasm transcript rail (protocol v17) #642 leftover-Send lock in a slice-C carrier/lock
      PR and at C (no durable run) would make a busy-legacy-turn switch abort the
      turn you're actively watching — out of scope.)
    • B) Lift the inflight guard on the three paths so switch/New/Clear detach a
      durable run — rejected: scope creep into harness: session list in the Wasm transcript rail (protocol v17) #642 behavior; unsafe at C where
      /api/agent is still the turn owner (would cancel the busy turn on switch).
    • C) Remove the dead detachTurn() calls — rejected: would remove the E/F seam
      the plan's step 4 intends; L6 wants the sites pinned, not deleted.
  • Open questions: none — resolved by choosing A.
  • Fix (locked, applied in commit 31d7f78): edited docs/feature-divide.md:47
    to state honestly: seam wired on unmount + switch + New/Clear; during an
    in-flight turn the switch/New/Clear paths are held by the pre-existing harness: session list in the Wasm transcript rail (protocol v17) #642
    inflight guard, so unmount is the live-turn detach site at slice C; E/F
    lifts/justifies the guard when switch-away must detach a durable run. No Host
    code change (behavior at C is correct). The plan-issue edge case is superseded
    by this living-doc truth + the E/F follow-up.
  • Tests: none needed for the doc fix (docs-only); the L6 source lock (§2.2 Install and register GitHub Actions self-hosted runner #2)
    independently pins the four detachTurn() call sites.

#2 — Host wiring source lock (Minor, L6) (🟢 DONE)

  • Review item: no proof unmount uses detachTurn(); lib/detachTurn.test.ts
    covers decideDetach only. Revert the unmount cleanup and decideDetach tests
    stay green — the backend-agents: client-independent turns — in-flight + queue survive refresh / tab close #710-class behavior lives in the cleanup, not the helper.
  • Grounded problem (verified on head 7a9cb85c): lib/detachTurn.test.ts
    tested only the pure decideDetach decision. Unmount (HarnessHost.tsx:739),
    switch (:941), New (:916), Clear (:834) call detachTurn(). The remaining
    abortRef.current?.abort() sites are detachTurn legacy path (:384),
    runPrompt controller replace (:410), and the Stop/Esc takePendingCancel
    poll (:662).
  • Severity assessment: Minor. No defect today (unmount IS wired), but the next
    person could revert it and all tests stay green — the seam would silently vanish.
    Matches the repo's existing source-lock precedent
    (lib/harnessHostModelPersist.test.ts "HarnessHost wiring lock").
  • Strategies considered:
    • A) Add a source-lock test (node readFileSync on HarnessHost.tsx, matching
      the existing wiring-lock pattern) asserting the four detachTurn() call sites
      and that the only abortRef.current?.abort() sites are detachTurn,
      runPrompt, and the Stop/Esc takePendingCancel path. (→ CHOSEN)
    • B) Extract + unit test a small host function that wraps the unmount cleanup —
      heavier than warranted; C is pure-cleanup already.
  • Fix (locked, applied in commit 31d7f78): added a describe('HarnessHost wiring lock — tear-down uses detachTurn (PR #790 review L6)') block to
    lib/detachTurn.test.ts that (1) asserts detachTurn(); appears exactly 4
    times, each anchored on its // Plan #789 (source #766): … comment; and (2)
    asserts abortRef.current?.abort(); appears exactly 3 times, with the three
    allowed contexts (if (decision.kind === 'abort') {, the runPrompt controller
    replace, and if (b.takePendingCancel()) {).
  • Tests: new source-lock row in lib/detachTurn.test.ts — now 5 tests
    (was 4). Gate: npm run typecheck exit 0; node scripts/di-gate.mjs OK;
    vitest run on the file + dependents = 112 passed / exit 0; npm run build
    green.

#3 — feature-divide detach row cites "PR #790 review L1" (Nit, L8 round-2) (🟢 DONE)

Progress log (append-only)

Merge decision

Round-1 items (#1, #2) and round-2's only Nit (#3) all addressed. PR #790 is
merge-ready; per flow, do not self-merge — invoke /merge_pr #790 (mandatory full
vitest run) after a confirming re-review on head 4a6f3cf.

…lock

L1 (Minor): feature-divide row now states that during an in-flight turn only unmount fires detach — switch/New/Clear are gated by the pre-existing #642 inflight lock and their detachTurn() runs on the idle-viewport teardown; E/F lifts or justifies the guards for durable-abandon.

L6 (Minor): lib/detachTurn.test.ts gains a HarnessHost wiring source lock asserting the four detachTurn() call sites (unmount/switch/New/Clear) and that the only abortRef.abort() sites are detachTurn, runPrompt, and takePendingCancel.

@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 #790 (round 2)

Verdict: PASS WITH NOTES
Repo: btipling/invincible
Scope: mainplan/turns-detach · 13 files · HEAD 31d7f78 (backend-agents C)
Lenses run: L1, L2, L3, L5, L6, L7, L8 (skip: L4 — no GHA/CI/artifact files; L9 — no palette/UX chrome)
AGENTS.md read: yes (docs/feature-divide.md detach row; SECURITY.md session envelope trust)

Round 1 PASS WITH NOTES: inflight guards make switch/New/Clear detach dead for a live turn (docs overclaim); no Host wiring lock that unmount calls detachTurn().

Prior items

Prior Status on 31d7f78
Minor L1 switch/New/Clear mid-turn detach is dead; feature-divide overclaim Closed (docs). Row now states: seam wired on four sites; during an in-flight turn only unmount fires detach; #642 inflight lock holds the other three; E/F lifts or justifies. Host behavior unchanged (correct at C).
Minor L6 no Host proof unmount uses detachTurn() Closed. lib/detachTurn.test.ts source-locks 4× detachTurn(); with comment anchors + 3× abortRef.current?.abort(); (detachTurn / runPrompt replace / takePendingCancel). Reverting unmount to a direct abort drops the detach count to 3 and bumps abort to 4.

Findings

Sev Lens Finding Break scenario Refutation attempt Confidence
Nit L8 docs/feature-divide.md detach row cites “PR #790 review L1” (issue/PR archaeology in a living ownership table). A clone-and-run reader has to open this PR to decode the sentence. The inflight-lock fact is already in the same cell. Plan #789 living-docs rule was timeless / no phase theater; the repo’s tables routinely cite plan numbers. Fails only as a PR-review citation, not a plan id. Optional trim. high

Residual risk

decideDetach still keys on presence of a client-writable meta.turnRunId (envelope PUT accepts any Redis-safe opaque). At C the host never mints an id, so a session that already carries a valid-looking id will not abort on unmount — the plan-review Major (unpersisted 1800s Function) returns for that session. Default snapshots omit the field → abort (today’s behavior). close-reader is a no-op until F. Logout still relies on unmount after signOut. Source lock is regex-shaped (abortRef.current?.abort();) — an abort() without ?. would not bump the count.

Merge guidance

  • PASS WITH NOTES: safe to merge from this attack; nits optional
  • E/F: lift or justify the inflight guards when switch-away must detach a durable run; do not trust a client-supplied id without a real Workflow run

What was not attacked

Live Workflow turn owner (E). Viewport attach / reader close (F). Stop→Workflow cancel (H). Prod Gateway. Self-hosted runner. Dual-chat. Wasm protocol (no native/ in this diff).

@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 — PASS (VERDICT: PASS)

Target commit: 4a6f3cf (current head). Fresh review after the head was pushed for the round-2 Nit L8 doc fix.

Head delta reviewed (31d7f78..4a6f3cf)

Single commit, docs-only: docs(feature-divide) removes the (PR #790 review L1) archaeology citation from the detach row (docs/feature-divide.md:47). One line changed; the substantive clause E/F lifts or justifies those guards when switch-away must detach a durable run is retained verbatim. No TS/Zig/code surface touched.

Round findings — all closed, re-confirmed on this head

  • L1 (Minor): detach dead-behind-inflight-guard docs overclaim — fixed; doc now states only unmount fires detach during an in-flight turn. ✓
  • L6 (Minor): HarnessHost wiring source lock added in lib/detachTurn.test.ts (4 detach / 3 abort sites). ✓
  • L8 (Nit): PR archaeology dropped from ownership table. ✓

Independent verification on this head

  • npm run typecheck → exit 0
  • Full node_modules/vitest/vitest.mjs run133 files / 2062 tests passed, exit 0 (direct vitest, no wrapper)
  • node scripts/di-gate.mjsdi-gate OK; no new PGlite(/createDbConnection( in any changed test file

Verdict: PASS. No blocking/major/minor findings survive on head 4a6f3cf; the change is a safe, non-behavioral doc clean-up matching the intended round-2 alignment.

@btipling
btipling merged commit 46317df into main Aug 23, 2026
2 checks passed
@btipling
btipling deleted the plan/turns-detach branch August 23, 2026 05:09
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.

plan: backend-agents C — session meta.turnRunId + detach (unmount ≠ abort run)

1 participant