Files
orca/docs/reference/orca-persistence-design-visual.html
T

433 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Orca Persistence — Terminal Reattach Fast Path · Visual Explainer</title>
<style>
:root{
--bg:#0b0e14; --bg2:#11151f; --card:#151b29; --card2:#1a2233;
--line:#263049; --txt:#e8ecf4; --mut:#9aa6bf; --dim:#6b7690;
--acc:#5cc8ff; --grn:#3ddc84; --amb:#ffb454; --red:#ff6b6b; --pur:#b388ff;
--mono:'SF Mono','JetBrains Mono',Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:var(--sans);line-height:1.55}
a{color:var(--acc)}
.wrap{max-width:1080px;margin:0 auto;padding:0 28px 120px}
/* HERO */
.hero{padding:64px 0 28px;text-align:left}
.kicker{display:inline-flex;gap:8px;align-items:center;font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--acc);
border:1px solid #234052;background:#0e1a24;padding:6px 12px;border-radius:999px;margin-bottom:20px}
.hero h1{font-size:clamp(30px,4.6vw,52px);line-height:1.08;letter-spacing:-.02em;font-weight:750}
.hero h1 .hl{color:var(--grn)}
.hero p.sub{color:var(--mut);font-size:17px;margin-top:14px;max-width:760px}
.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px;font-size:13px;color:var(--dim)}
.meta span{border:1px solid var(--line);background:var(--bg2);padding:5px 12px;border-radius:999px}
/* KPI cards */
.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:28px 0 8px}
@media(max-width:820px){.kpis{grid-template-columns:repeat(2,1fr)}}
.kpi{background:linear-gradient(180deg,var(--card2),var(--card));border:1px solid var(--line);border-radius:14px;padding:16px 16px 14px}
.kpi .n{font-size:30px;font-weight:800;letter-spacing:-.02em}
.kpi .l{font-size:12.5px;color:var(--mut);margin-top:2px}
.kpi .s{font-size:11.5px;color:var(--dim);margin-top:6px}
/* NAV */
.toc{position:sticky;top:0;z-index:20;background:rgba(11,14,20,.9);backdrop-filter:blur(10px);
border-bottom:1px solid var(--line);margin:26px -28px 0;padding:10px 28px;display:flex;gap:8px;overflow-x:auto}
.toc a{white-space:nowrap;font-size:12.5px;color:var(--mut);text-decoration:none;border:1px solid var(--line);
padding:6px 12px;border-radius:999px;background:var(--bg2)}
.toc a:hover{color:var(--txt);border-color:var(--acc)}
section.block{margin-top:44px}
.sec-head{display:flex;align-items:baseline;gap:14px;margin-bottom:6px}
.sec-num{font-family:var(--mono);font-size:13px;color:var(--bg);background:var(--acc);font-weight:700;
min-width:30px;height:30px;border-radius:9px;display:inline-flex;align-items:center;justify-content:center}
.sec-head h2{font-size:23px;letter-spacing:-.01em}
.lede{color:var(--mut);max-width:820px;margin:6px 0 18px;font-size:15.5px}
.card{background:linear-gradient(180deg,var(--card2),var(--card));border:1px solid var(--line);border-radius:16px;padding:22px}
.card h3{font-size:15px;margin-bottom:10px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:820px){.grid2{grid-template-columns:1fr}}
.mono{font-family:var(--mono);font-size:12.8px}
code.inline{font-family:var(--mono);font-size:.86em;background:#0d1320;border:1px solid var(--line);
padding:1px 7px;border-radius:6px;color:var(--acc);white-space:nowrap}
pre{background:#0a0e16;border:1px solid var(--line);border-radius:12px;padding:16px 18px;overflow-x:auto;
font-family:var(--mono);font-size:12.6px;line-height:1.6;color:#cfe3ff}
.tag{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
padding:3px 9px;border-radius:6px;margin-bottom:8px}
.t-red{background:rgba(255,107,107,.14);color:var(--red);border:1px solid rgba(255,107,107,.35)}
.t-grn{background:rgba(61,220,132,.12);color:var(--grn);border:1px solid rgba(61,220,132,.35)}
.t-amb{background:rgba(255,180,84,.12);color:var(--amb);border:1px solid rgba(255,180,84,.35)}
.t-blu{background:rgba(92,200,255,.12);color:var(--acc);border:1px solid rgba(92,200,255,.35)}
.t-pur{background:rgba(179,136,255,.12);color:var(--pur);border:1px solid rgba(179,136,255,.35)}
/* waterfall */
.wf-row{display:grid;grid-template-columns:150px 1fr 86px;gap:10px;align-items:center;margin:9px 0;font-size:13px}
.wf-label{font-family:var(--mono);font-size:12px;color:var(--mut);text-align:right}
.wf-track{background:#0a0e16;border:1px solid var(--line);border-radius:8px;height:30px;position:relative;overflow:hidden}
.wf-bar{position:absolute;top:0;bottom:0;border-radius:6px;display:flex;align-items:center;padding-left:10px;
font-family:var(--mono);font-size:11px;font-weight:700;color:#08131c}
.wf-ms{font-family:var(--mono);font-size:12.5px;color:var(--txt)}
.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--mut);margin-top:12px}
.sw{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px;vertical-align:-1px}
/* chain */
.chain{display:flex;align-items:stretch;gap:0;margin:18px 0;flex-wrap:wrap}
.node{flex:1;min-width:150px;background:#0d1422;border:1px solid var(--line);border-radius:12px;padding:14px;text-align:center}
.node b{display:block;font-size:13.5px;margin-bottom:4px}
.node small{color:var(--mut);font-size:12px}
.node .ico{font-size:22px;display:block;margin-bottom:6px}
.arrow{align-self:center;padding:0 8px;color:var(--acc);font-size:20px;font-weight:800}
/* flowchart */
.flow{background:#0a0e16;border:1px solid var(--line);border-radius:14px;padding:22px;display:flex;flex-direction:column;align-items:center;gap:0}
.fbox{border:2px solid var(--acc);border-radius:12px;padding:10px 22px;font-family:var(--mono);font-size:13px;
background:#0e1a28;text-align:center;max-width:640px}
.fdiamond{border:2px solid var(--amb);background:#1c1508;border-radius:12px;padding:10px 22px;font-family:var(--mono);
font-size:13px;text-align:center;max-width:640px;transform:skewX(0)}
.farrow{color:var(--dim);font-size:18px;line-height:1.1;padding:3px 0}
.farrow small{display:block;font-family:var(--mono);font-size:11px;color:var(--grn)}
.fret{border:2px solid var(--grn);background:#0a2117;border-radius:12px;padding:10px 22px;font-family:var(--mono);font-size:13px;text-align:center}
.fslow{border:2px solid var(--red);background:#230f12;border-radius:12px;padding:10px 22px;font-family:var(--mono);font-size:13px;text-align:center}
.split{display:flex;gap:14px;width:100%;justify-content:center;flex-wrap:wrap}
.split>div{flex:1;min-width:240px}
/* predicate checklist */
.pred{border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin:9px 0;background:#0d1422;
display:grid;grid-template-columns:34px 1fr;gap:12px;align-items:start;cursor:pointer;transition:border-color .15s}
.pred:hover{border-color:var(--acc)}
.pred.open{border-color:var(--acc)}
.pred .tick{width:26px;height:26px;border-radius:8px;border:1.5px solid var(--dim);display:flex;align-items:center;
justify-content:center;font-size:14px;color:transparent;flex-shrink:0;margin-top:2px}
.pred.done .tick{background:rgba(61,220,132,.2);border-color:var(--grn);color:var(--grn)}
.pred code{font-family:var(--mono);font-size:12.8px;color:var(--acc)}
.pred .why{font-size:13px;color:var(--mut);margin-top:4px}
.pred .detail{display:none;font-size:13px;color:var(--txt);background:#0a0f1a;border-top:1px dashed var(--line);
margin:10px -16px -14px;padding:12px 16px 14px;border-radius:0 0 12px 12px;grid-column:1/-1}
.pred.open .detail{display:block}
/* gen sim */
.sim{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:820px){.sim{grid-template-columns:1fr}}
.gauge{background:#0a0e16;border:1px solid var(--line);border-radius:12px;padding:16px}
.gbar{height:26px;background:#060a12;border-radius:8px;position:relative;margin:8px 0;border:1px solid var(--line);overflow:hidden}
.gfill{position:absolute;top:0;bottom:0;transition:width .35s ease}
button{font-family:var(--sans);font-weight:700;font-size:13px;border:1px solid var(--line);background:#16203280;
color:var(--txt);padding:9px 14px;border-radius:10px;cursor:pointer;margin:4px 6px 4px 0}
button:hover{border-color:var(--acc)}
button.primary{background:var(--grn);color:#06130c;border-color:var(--grn)}
button.warn{background:transparent;border-color:var(--amb);color:var(--amb)}
.pill{font-family:var(--mono);font-size:12px;border:1px solid var(--line);border-radius:7px;padding:2px 9px;background:#0a0f1a}
table{width:100%;border-collapse:collapse;font-size:13px;margin-top:10px}
th{text-align:left;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);padding:8px 10px;border-bottom:1px solid var(--line)}
td{padding:9px 10px;border-bottom:1px solid #1a2236;vertical-align:top}
tr:last-child td{border-bottom:none}
td.mono{font-family:var(--mono);font-size:12px;color:#cfe3ff}
.yes{color:var(--grn);font-weight:700}.no{color:var(--dim)}
.callout{border-left:3px solid var(--amb);background:#181206;border:1px solid #3a2c12;border-left-width:3px;
border-radius:0 12px 12px 0;padding:14px 18px;font-size:13.5px;color:#f0d9ae;margin:14px 0}
.callout.grn{border-color:var(--grn);background:#0a1f14;border-top-color:#1c4130;border-right-color:#1c4130;border-bottom-color:#1c4130;color:#b9ecd2}
.callout.blu{border-color:var(--acc);background:#0c1a26;border-top-color:#1d3a52;border-right-color:#1d3a52;border-bottom-color:#1d3a52;color:#c4e5fb}
.tests{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}
@media(max-width:820px){.tests{grid-template-columns:1fr}}
.test{background:#0d1422;border:1px solid var(--line);border-radius:12px;padding:13px 14px;font-size:12.8px}
.test b{font-size:13px}
.test p{color:var(--mut);margin-top:4px;font-size:12.3px}
.tnum{font-family:var(--mono);font-size:11px;color:var(--bg);background:var(--pur);border-radius:6px;
padding:1px 7px;font-weight:800;margin-right:6px}
footer{margin-top:60px;color:var(--dim);font-size:12.5px;border-top:1px solid var(--line);padding-top:18px}
.big-analogy{font-size:15px;background:linear-gradient(135deg,#10202e,#131a2b);border:1px solid #274a63;border-radius:14px;padding:18px 20px;margin:14px 0}
progress{width:100%;height:8px}
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<div class="kicker">⚡ Orca persistence · implementation spec · fix 1 of 3</div>
<h1>Terminal reattach <span class="hl">fast&nbsp;path</span></h1>
<p class="sub">Every time you switch workspaces, each terminal pane re-saves the <em>entire 9.2&nbsp;MB</em> app state — even when nothing changed. This design adds a cheap “already saved?” check that skips all that work. No product code changed yet; this page is the whole spec, visualized.</p>
<div class="meta"><span>📅 Sept 7, 2026</span><span>📄 orca-persistence-design-assessment.md</span><span>🔧 1 file changed + 1-line counter fix</span><span>⏸ Fixes 2 &amp; 3 deferred</span></div>
<div class="kpis">
<div class="kpi"><div class="n" style="color:var(--red)">9.2 MB</div><div class="l">serialized per reattach call</div><div class="s">whole persisted state, on the main thread</div></div>
<div class="kpi"><div class="n" style="color:var(--amb)">468 ms</div><div class="l">main-thread time in 30 s window</div><div class="s">12 serializations · 59–100 ms each</div></div>
<div class="kpi"><div class="n" style="color:var(--acc)">117 ms</div><div class="l">real keystroke queued behind one call</div><div class="s">~16 ms attributable — a confirmed stall</div></div>
<div class="kpi"><div class="n" style="color:var(--grn)">~0 ms</div><div class="l">target cost of a no-op reattach</div><div class="s">early return: no clone, no serialize</div></div>
</div>
</div>
<nav class="toc">
<a href="#problem">1 · Problem</a><a href="#trigger">2 · Trigger chain</a><a href="#change">3 · The change</a>
<a href="#predicate">4 · No-op predicate</a><a href="#durability">5 · Durability check</a><a href="#counterbug">6 · Counter bug</a>
<a href="#audit">7 · Writer audit</a><a href="#tests">8 · Tests</a><a href="#verify">9 · Verification</a><a href="#deferred">10 · Deferred</a>
</nav>
<!-- 1 PROBLEM -->
<section class="block" id="problem">
<div class="sec-head"><span class="sec-num">1</span><h2>The problem: paying for a save that saves nothing</h2></div>
<p class="lede">Mounting or remounting a terminal pane calls <code class="inline">Store.persistPtyBinding</code>. Today that path <b>clones the session → mutates → <code class="inline">flushOrThrow</code> → serializes everything → hashes → usually skips the disk write</b>. The disk write is skipped, but the expensive serialization already happened.</p>
<div class="card">
<span class="tag t-red">◉ Live instrumentation · running app · 30-second window</span>
<h3>Waterfall — 4 reattach calls, all no-ops (tab, leaf, layout, incarnation already matched)</h3>
<div class="wf-row"><div class="wf-label">reattach #1</div><div class="wf-track"><div class="wf-bar" style="left:2%;width:31%;background:linear-gradient(90deg,#ff9d6b,#ff6b6b)">59 ms · hash match → skip write</div></div><div class="wf-ms">59 ms</div></div>
<div class="wf-row"><div class="wf-label">reattach #2</div><div class="wf-track"><div class="wf-bar" style="left:36%;width:40%;background:linear-gradient(90deg,#ff9d6b,#ff6b6b)">100 ms · hash match → skip write</div></div><div class="wf-ms">100 ms</div></div>
<div class="wf-row"><div class="wf-label">reattach #3</div><div class="wf-track"><div class="wf-bar" style="left:20%;width:33%;background:linear-gradient(90deg,#ff9d6b,#ff6b6b)">⌨ keydown queued 117 ms here</div></div><div class="wf-ms">~80 ms</div></div>
<div class="wf-row"><div class="wf-label">reattach #4</div><div class="wf-track"><div class="wf-bar" style="left:55%;width:30%;background:linear-gradient(90deg,#ff9d6b,#ff6b6b)">~70 ms · skip write</div></div><div class="wf-ms">~70 ms</div></div>
<div class="legend"><span><i class="sw" style="background:#ff6b6b"></i>serialize + hash on main thread (paid)</span><span><i class="sw" style="background:#2a3547"></i>disk write (skipped — hash matched)</span><span>12 serializations total in window · 468 ms blocked</span></div>
<div class="callout">The key mental image: <b>the main thread is a single checkout lane</b>. Serialization is a customer with a full cart who, at the till, is told “you already paid” — but everyone behind them (your keystroke) still waited. The fix is to check the receipt <i>before</i> unloading the cart.</div>
</div>
</section>
<!-- 2 TRIGGER -->
<section class="block" id="trigger">
<div class="sec-head"><span class="sec-num">2</span><h2>Why it fires constantly: the cold-park policy</h2></div>
<p class="lede">The renderer keeps at most <b>4 workspaces</b> and <b>6 tabs</b> warm (<code class="inline">terminal-hidden-view-parking.ts</code>). On a many-worktree install, nearly every workspace switch remounts every pane — and each pane reattaches.</p>
<div class="card">
<div class="chain">
<div class="node"><span class="ico">🗂️</span><b>Workspace switch</b><small>user reveals a parked workspace</small></div><div class="arrow">→</div>
<div class="node"><span class="ico">❄️</span><b>Cold-park eviction</b><small>only 4 workspaces · 6 tabs stay warm</small></div><div class="arrow">→</div>
<div class="node"><span class="ico">🔌</span><b>Every pane remounts</b><small>3-pane workspace = 3 reattaches</small></div><div class="arrow">→</div>
<div class="node" style="border-color:var(--red)"><span class="ico">💾</span><b>3 back-to-back flushes</b><small>~3 × 9.2 MB serialize, back-to-back</small></div>
</div>
<div class="big-analogy">� parking-garage analogy — Only 4 cars fit in the heated garage; the rest park outside in the cold. Every time you want a cold car, you <b>rebuild the entire garage inventory from scratch</b> just to confirm your car is still where you left it. The fast path is glancing at the parking ticket instead.</div>
</div>
</section>
<!-- 3 CHANGE -->
<section class="block" id="change">
<div class="sec-head"><span class="sec-num">3</span><h2>The change: one early return, in exactly the right slot</h2></div>
<p class="lede">Add an early return to <code class="inline">persistPtyBinding</code> (<code class="inline">loading-store/pty-binding-persistence.ts</code>) that fires when the binding is <b>already in memory and already on disk</b>. Nothing is cloned, nothing serialized on that path. It returns <code class="inline">true</code> — which matters (see below).</p>
<div class="grid2">
<div class="card">
<span class="tag t-blu">Placement — refusal semantics untouched</span>
<div class="flow">
<div class="fbox">①–④ existing refusal checks<br><small style="color:var(--mut)">expectedSourceBinding · expectedBinding · mayReviveRetiredSurface · mayCreate → return false</small></div>
<div class="farrow">↓ <small>every false today still returns false first</small></div>
<div class="fret">✦ NEW: no-op predicate holds? → return true <small style="color:var(--grn)">no clone · no serialize · no flush</small></div>
<div class="farrow">↓ miss → fall through unchanged</div>
<div class="fbox">non-local partition re-point</div>
<div class="farrow">↓</div>
<div class="fslow">cloneWorkspaceSessionState → mutate → flushOrThrow</div>
</div>
</div>
<div class="card">
<span class="tag t-grn">Why “return true” (not false, not void)</span>
<h3>Two callers depend on the value</h3>
<table>
<tr><th>Caller</th><th>What true/false does</th></tr>
<tr><td class="mono">persistAdmittedStablePaneBinding<br><span style="color:var(--dim)">stable-owner.ts</span></td><td>throws <span class="pill">terminal_pane_owner_changed</span> on <b>false</b> → must return <span class="yes">true</span> for a no-op</td></tr>
<tr><td class="mono">spawn-commit-persist.ts</td><td>uses <span class="yes">true</span> to <b>suppress its second binding write</b></td></tr>
</table>
<div class="callout grn" style="margin-top:14px">Returning <code class="inline">true</code> says: “the binding you asked for is durably in place — no further work needed.” Callers stand down instead of retrying or tearing down.</div>
<h3 style="margin-top:16px">Cost comparison per no-op call</h3>
<table>
<tr><th>Path</th><th>Clone</th><th>Serialize 9.2 MB</th><th>Hash</th><th>Disk write</th></tr>
<tr><td><b>Today</b></td><td class="no">✚ yes</td><td style="color:var(--red);font-weight:700">✚ ~60–100 ms</td><td class="no">✚ yes</td><td class="no">skipped</td></tr>
<tr><td><b>Fast path</b></td><td class="yes">— none</td><td class="yes">— none</td><td class="yes">— none</td><td class="yes">— none needed</td></tr>
</table>
</div>
</div>
</section>
<!-- 4 PREDICATE -->
<section class="block" id="predicate">
<div class="sec-head"><span class="sec-num">4</span><h2>The no-op predicate: 9 locks, one key</h2></div>
<p class="lede"><b>All nine must hold.</b> Any miss falls through to the existing code unchanged — the fast path only ever <i>skips work it can prove is redundant</i>. Click each row for the reasoning. Progress: <span class="pill" id="predProgress">0 / 9 verified</span> <button id="predAll" style="padding:4px 12px;font-size:12px">check all</button> <button id="predReset" style="padding:4px 12px;font-size:12px">reset</button></p>
<div id="predList">
<div class="pred" data-i="0"><div class="tick">✓</div><div><code>args.expectedSourceBinding === undefined</code><div class="why">Split path always changes membership + arms the topology fence.</div><div class="detail">🔀 If a split is in flight, layout membership is <i>about to change</i> by definition. Skipping the flush would skip the fence bump that invalidates stale readers. So: any split intent → fall through, always.</div></div></div>
<div class="pred" data-i="1"><div class="tick">✓</div><div><code>isTerminalLeafId(args.leafId)</code><div class="why">Legacy leaf ids take a different early-flush branch that never writes layout state.</div><div class="detail">🧬 Legacy ids have their own semantics. The fast path only understands the modern terminal-leaf shape; anything else → existing code.</div></div></div>
<div class="pred" data-i="2"><div class="tick">✓</div><div><code>tab exists, tab.id === args.tabId, tab.ptyId === args.ptyId</code><div class="why">Otherwise the call would <b>mint a new tab</b> — real state change.</div><div class="detail">➕ The normal path creates the tab row if missing or repoints it. If we skipped that, the tab would silently never exist. Existence + exact PTY match required.</div></div></div>
<div class="pred" data-i="3"><div class="tick">✓</div><div><code>layout exists, layout.root ≠ null, layoutContainsLeafId(root, leafId)</code><div class="why">Otherwise the call would <b>mint or split the layout</b>.</div><div class="detail">🌳 The pane must already be a member of the tab's layout tree. A missing layout or a leaf that isn't in the tree means the write path would restructure — not a no-op.</div></div></div>
<div class="pred" data-i="4"><div class="tick">✓</div><div><code>layout.ptyIdsByLeafId[leafId] === args.ptyId</code> ⭐ <i style="color:var(--amb)">the load-bearing binding</i><div class="why">The actual “this pane shows this PTY” fact. Everything else is scaffolding around this one equality.</div><div class="detail">🔑 This is the core claim: <i>the requested binding already is the stored binding</i>. All other conditions exist to make sure “stored” also means “complete, live, and durable”.</div></div></div>
<div class="pred" data-i="5"><div class="tick">✓</div><div><code>incarnations[paneKey] === args.incarnationId</code> (strict)<div class="why"><code>undefined === undefined</code> matches; defined-vs-undefined does <b>not</b>.</div><div class="detail">🔄 The incarnation distinguishes “same PTY, fresh spawn” from “same PTY, same process”. Strict equality closes the hole where a respawned pane could masquerade as the old one. Tests 3–4 pin both directions.</div></div></div>
<div class="pred" data-i="6"><div class="tick">✓</div><div><code>expectedBinding === undefined || expectedBinding.incarnationId === incarnationId</code><div class="why">A reconciled incarnation must still bump the topology fence.</div><div class="detail">🚧 Even when the PTY matches, a reconcile event carries ordering information (the fence) that downstream readers use. Skipping it would freeze the revision — so reconcile → fall through and bump. Test 6 pins this.</div></div></div>
<div class="pred" data-i="7"><div class="tick">✓</div><div><code>!tombstones[paneKey]</code><div class="why">The write path <b>clears tombstones</b> — that clearing is itself a state change.</div><div class="detail">🪦 A tombstone means “this surface was retired”. The normal write consumes it. Skipping would leave a stale retirement marker behind. Test 5 seeds a tombstone from a data file to pin this.</div></div></div>
<div class="pred" data-i="8"><div class="tick">✓</div><div><code>Binding is durable</code> — <span class="mono" style="color:var(--grn)">lastDurableWriteGeneration ≥ writeGeneration</span><div class="why">In-memory equality can match a binding still waiting in the debounced save. Memory ≠ disk.</div><div class="detail">💾 The generation counter (next section) proves the bytes are actually on disk — or proven byte-identical by hash. Without this, a crash window opens: we'd report “saved” for state only in RAM.</div></div></div>
</div>
</section>
<!-- 5 DURABILITY -->
<section class="block" id="durability">
<div class="sec-head"><span class="sec-num">5</span><h2>Durability check: two counters, one inequality</h2></div>
<p class="lede">Two generation counters replace any need for timer or in-flight checks. The rule is one line: <code class="inline">runtime.lastDurableWriteGeneration &gt;= runtime.writeGeneration</code>.</p>
<div class="card">
<div class="sim">
<div class="gauge">
<span class="tag t-blu">Interactive — drive the counters</span>
<h3><span class="mono">writeGeneration</span> <small style="color:var(--dim)">(dirty — work requested)</small></h3>
<div class="gbar"><div class="gfill" id="fillW" style="left:0;width:40%;background:var(--amb)"></div></div>
<h3><span class="mono">lastDurableWriteGeneration</span> <small style="color:var(--dim)">(safe — bytes on disk / hash-proven)</small></h3>
<div class="gbar"><div class="gfill" id="fillD" style="left:0;width:40%;background:var(--grn)"></div></div>
<p style="font-size:13px;margin-top:8px">Fast path allowed? <b id="simVerdict" style="color:var(--grn)">YES — durable ✓</b></p>
<div style="margin-top:8px">
<button id="bSched">① scheduleSave → bump W</button>
<button id="bAsync">② async rename → raise D</button>
<button id="bSyncNoop">③ sync flush, hash match</button>
<button class="warn" id="bFail">✕ failed write (D stays)</button>
</div>
<p class="mono" id="simLog" style="font-size:11.5px;color:var(--mut);margin-top:10px;min-height:40px">W=4 D=4 — in sync.</p>
</div>
<div>
<h3>Lifecycle rules (why no timer check is needed)</h3>
<table>
<tr><th>Event</th><th>W</th><th>D</th><th>Effect</th></tr>
<tr><td class="mono">scheduleSave</td><td style="color:var(--amb)">+1 first</td><td class="no">—</td><td>any scheduled write opens a gap D &lt; W by construction</td></tr>
<tr><td class="mono">writeToDiskAsync</td><td class="no">—</td><td style="color:var(--grn)">raise after durable rename / hash-proof</td><td>gap closes only when bytes are proven</td></tr>
<tr><td class="mono">in-flight / failed write</td><td class="no">—</td><td class="no">untouched</td><td>D stays behind → fast path correctly refuses</td></tr>
</table>
<div class="callout grn">The invariant does the work: <b>a scheduled, in-flight, or failed write always leaves D behind W</b>, so the single inequality detects all three. No separate “is a save pending?” flag can drift.</div>
<div class="callout blu">Scope note: the check asks only <i>“is <b>this binding</b> on disk?”</i> Unrelated dirty state keeps its own save and its own crash window — skipping this flush never widens it.</div>
</div>
</div>
</div>
</section>
<!-- 6 COUNTER BUG -->
<section class="block" id="counterbug">
<div class="sec-head"><span class="sec-num">6</span><h2>The one-line companion fix: the parked counter</h2></div>
<p class="lede"><code class="inline">writeToDiskSync</code> raises <code class="inline">D</code> only after a real rename. On a hash match (state unchanged, write skipped) it returns <i>without touching D</i> — while <code class="inline">flushOrThrow</code> already bumped <code class="inline">W</code>. The counter parks one behind forever, and the fast path would stay disabled.</p>
<div class="grid2">
<div class="card">
<span class="tag t-red">Without the fix — fast path disables itself</span>
<pre>W=5 D=4 ← sync flush, hash match, no rename
D untouched → gap never heals
(debounce timer also cleared → nothing reschedules)
next reattach: D(4) &lt; W(5) → fall through
→ flush again → hash match again → W=6 D=4
→ …disabled forever ♾️</pre>
</div>
<div class="card">
<span class="tag t-grn">With the fix — one line</span>
<pre style="border-color:rgba(61,220,132,.4)">on unforced hash-match return:
lastDurableWriteGeneration =
max(lastDurableWriteGeneration, writeGeneration)</pre>
<p style="font-size:13.5px;color:var(--mut);margin-top:10px">A matching hash <i>proves</i> the file already holds this state — exactly what D records. Mirrors the async branch. The <code class="inline">force</code> path is deliberately excluded: it exists because an async rename may be racing past the generation check, so contents are not yet proven. Failed writes never raise D.</p>
<p style="font-size:13px;margin-top:8px"><span class="pill">PtyBindingPersistenceOperationsRuntime</span> widens from <span class="mono" style="font-size:12px">Pick&lt;…,'flushOrThrow'|'state'&gt;</span> to also include <span class="mono" style="font-size:12px">writeGeneration, lastDurableWriteGeneration</span>. No new tracking state.</p>
</div>
</div>
<div class="callout">⚠️ Named behavior delta for the PR: a no-op reattach <i>after the quit flush has started</i> currently throws from <code class="inline">flushOrThrow</code> (and <code class="inline">spawn-commit-persist.ts</code> tears down the fresh spawn). With the fast path it returns <code class="inline">true</code> — correct, because the binding is durable.</div>
</section>
<!-- 7 AUDIT -->
<section class="block" id="audit">
<div class="sec-head"><span class="sec-num">7</span><h2>Why the counter is trustworthy: the writer audit</h2></div>
<p class="lede">The counter lies only if some code sets a binding value <i>without bumping the generation</i> — memory matches, disk is stale. So the spec enumerates <b>every writer of binding values under <code class="inline">src/main</code></b>, including alias writes (e.g. a <code class="inline">record</code> parameter that <i>is</i> <code class="inline">ptyIdsByLeafId</code>).</p>
<div class="card">
<span class="tag t-pur">Audit result — every writer bumps (or can't produce a match)</span>
<table>
<tr><th>Writer</th><th>Mutates live?</th><th>How generation is bumped</th></tr>
<tr><td class="mono">pty-binding-persistence.ts</td><td class="yes">yes</td><td><span class="pill">flushOrThrow</span> same call</td></tr>
<tr><td class="mono">workspace-session-terminal-binding-replay.ts</td><td class="no">replacement object</td><td>via <span class="pill">setWorkspaceSession</span> → scheduleSave</td></tr>
<tr><td class="mono">ssh-pty-binding-cleanup.ts</td><td class="yes">yes</td><td><span class="pill">scheduleSave</span> when changed; also only clears to null / removes keys (can't match)</td></tr>
<tr><td class="mono">ssh-pty-pane-supersession.ts</td><td class="yes">via cleanup</td><td><span class="pill">flush()</span> after</td></tr>
<tr><td class="mono">ssh-target-id-migration.ts</td><td class="yes">in place, via <span class="pill">record</span> alias + tab.ptyId</td><td>sole caller <span class="pill">ssh-target-reassignment</span> → scheduleSave when changed</td></tr>
<tr><td class="mono">runtime-terminal-orphan-session-adoption.ts</td><td class="no">structuredClone</td><td>handed to <span class="pill">setWorkspaceSession</span></td></tr>
<tr><td class="mono">session-owner-removal.ts</td><td class="no">new object</td><td>handed to <span class="pill">setWorkspaceSession</span></td></tr>
<tr><td class="mono">worktree-identity-migration.ts</td><td class="yes">tombstone worktree ids</td><td>caller in metadata-lineage-operations → scheduleSave</td></tr>
<tr><td class="mono">profile-project-session-state / transfer</td><td class="no">copies</td><td>land via setWorkspaceSession / saving replace</td></tr>
<tr><td class="mono">mobile-session-layout-projection.ts</td><td class="no">projection only</td><td>never persisted</td></tr>
<tr><td class="mono">ssh-pty-lease-operations.ts</td><td class="no">lease row .ptyId ≠ binding</td><td>n/a — listed only because the ratchet regex matches it</td></tr>
</table>
<div class="grid2" style="margin-top:14px">
<div class="callout blu" style="margin:0">Out of scope, verified safe: SSH lease shutdown marking, lease tombstone retention, deferred scrollback migration, load-time diff-comment relocation — none writes a binding value.</div>
<div class="callout grn" style="margin:0"><b>Ratchet test</b> <span class="mono" style="font-size:12px">terminal-binding-writer-boundary.test.ts</span> — walks <span class="mono" style="font-size:12px">src/main</span>, matches assignments to the 5 binding fields on comment-stripped text, pins the 11-file allowlist (+ literal count). The allowlist only shrinks. Honest caveat: alias writes slip past the regex — the table above is the real audit; the regex is a tripwire.</div>
</div>
</div>
</section>
<!-- 8 TESTS -->
<section class="block" id="tests">
<div class="sec-head"><span class="sec-num">8</span><h2>Tests: 9 cases, each pinning one predicate arm</h2></div>
<p class="lede">Extended in <code class="inline">persistence-flush-and-save-scheduling.test.ts</code> (real <code class="inline">Store</code> via <code class="inline">createStore</code>; instance-level <code class="inline">vi.spyOn(store,'flushOrThrow')</code> intercepts the merged-prototype arrow). All 11 real-store suites must stay green; mock-store suites can't move (fast path never runs under <code class="inline">persistPtyBinding: vi.fn()</code>).</p>
<div class="tests">
<div class="test"><span class="tnum">1</span><b>Fast path skips all work</b><p>Bind ×2 identical. Expect zero <span class="mono">flushOrThrow</span>, zero <span class="mono">structuredClone</span>, returns true, inode unchanged.</p></div>
<div class="test"><span class="tnum">2</span><b>Pending save still flushes (+ counter fix)</b><p><span class="mono">setWorkspaceSession</span> bumps W → 2nd bind flushes once (hash match, no rename) → 3rd bind flushes zero times. Without the one-line fix, the 3rd bind flushes again.</p></div>
<div class="test"><span class="tnum">3</span><b>Incarnation mismatch falls through</b><p>'a' → 'b': flush + new incarnation on disk.</p></div>
<div class="test"><span class="tnum">4</span><b>undefined vs defined falls through</b><p>Strict equality: one-sided undefined ≠ match → flush.</p></div>
<div class="test"><span class="tnum">5</span><b>Tombstone falls through</b><p>Seed via data file (sanitizer eats tombstones on <span class="mono">setWorkspaceSession</span>) → flush + tombstone cleared.</p></div>
<div class="test"><span class="tnum">6</span><b>Reconcile bumps the fence</b><p><span class="mono">expectedBinding</span> older incarnation, same PTY → topology revision advances.</p></div>
<div class="test"><span class="tnum">7</span><b>Refusals unchanged</b><p>All four <span class="mono">return false</span> paths still false, nothing flushed. Mostly confirm-existing-coverage.</p></div>
<div class="test"><span class="tnum">8</span><b>Non-local partition</b><p>Repeat #1 with SSH host id: resolves via <span class="mono">workspaceSessionsByHostId</span>, no re-point.</p></div>
<div class="test"><span class="tnum">9</span><b>Sync hash-match raises D</b><p>Directly: dirty state → flushOrThrow ×2 → 2nd does no rename and D === W.</p></div>
</div>
<div class="callout blu">Rollback suites stay green by construction: the two tests that make <span class="mono">flushOrThrow</span> throw on rebind both fall through the predicate (one has tab PTY <span class="mono">null</span> beforehand, one is reconciling an incarnation).</div>
</section>
<!-- 9 VERIFY -->
<section class="block" id="verify">
<div class="sec-head"><span class="sec-num">9</span><h2>Verification: reproduce first, claim later</h2></div>
<div class="grid2">
<div class="card"><span class="tag t-amb">⚠ Before implementing — re-run the probe</span>
<p style="font-size:13.5px;color:var(--mut)">Live capture proved memory matched, but <b>not whether a save was pending</b>. A workspace switch sends a renderer session patch → <span class="mono">scheduleSave</span> <i>before</i> panes remount — so the first reattach per burst may legitimately flush the patch; only later panes hit the fast path.</p>
<p style="font-size:13.5px;margin-top:8px">Probe now records per call: <span class="pill">savePending</span> <span class="pill">writeGeneration</span> <span class="pill">lastDurableWriteGeneration</span> (<span class="mono" style="font-size:12px">config/scripts/persistence-call-probe.mjs</span>).</p>
<div class="callout">If every captured call shows a pending save, <b>fix 1 alone does not move the number</b> — the renderer's switch-time patch cadence becomes the next target.</div>
</div>
<div class="card"><span class="tag t-grn">✔ After — measure on the real install</span>
<pre>pnpm tc:node
pnpm test src/main/persistence-flush-and-save-scheduling.test.ts \
src/main/persistence/loading-store
# + the eleven real-store files</pre>
<p style="font-size:13.5px;margin-top:10px;color:var(--mut)"><b style="color:var(--txt)">Target:</b> zero <span class="mono">buildStateToSave</span> calls with <span class="mono">persistPtyBinding</span> on the stack during parked-workspace switches, where <span class="mono">savePending</span> was false. Then a typing capture (<span class="mono">capture-live-input-lag.mjs</span>) reporting queue-delay distribution before/after.</p>
<div class="callout grn">Do not claim the lag is fixed from persistence numbers alone — the unattributed remainder of the 117 ms needs its own capture.</div>
</div>
</div>
<div class="card" style="margin-top:14px"><span class="tag t-red">Safety constraints (do not …)</span>
<table><tr><th>Constraint</th><th>Rationale</th></tr>
<tr><td>Remove/defer flush for any binding that <b>changes</b> state</td><td>crash window — durability first</td></tr>
<tr><td>Raise D on the <span class="mono">force</span> path or failed writes</td><td>contents unproven (async race)</td></tr>
<tr><td>Change return/exception for inputs the fast path rejects</td><td>SSH relay reattach expires the lease on <span class="mono">false</span></td></tr>
<tr><td>Widen to automation / lease / layout-publish paths</td><td>own semantics, own fences</td></tr>
<tr><td>Assume local partition</td><td>resolve per requested host id; host-agnostic, no platform branch</td></tr></table>
</div>
</section>
<!-- 10 DEFERRED -->
<section class="block" id="deferred">
<div class="sec-head"><span class="sec-num">10</span><h2>Deferred: fixes 2 &amp; 3 (constraints recorded, not lost)</h2></div>
<div class="grid2">
<div class="card"><span class="tag t-amb">Fix 2 · Small binding transactions</span>
<h3>Persist only recovery-critical binding state, not the whole document</h3>
<p style="font-size:13.5px;color:var(--mut)">The session clone exists only for <span class="mono">restoreSession</span> after failed flush — a narrow rollback must restore tab row + layout + incarnation + tombstones + topology revision. If bindings leave the single document, <b>every consumer must follow</b>:</p>
<table><tr><th>Consumer</th><th>What breaks</th></tr>
<tr><td class="mono">backup-recovery-rotation.ts</td><td>backup rotation + .bak recovery</td></tr>
<tr><td class="mono">ssh/orcad-state-snapshot.ts</td><td>copies profiles/ wholesale on remote hosts</td></tr>
<tr><td>profile transfer / move, downgrade</td><td>older builds read orca-data.json directly → boot with no bindings</td></tr></table>
</div>
<div class="card"><span class="tag t-amb">Fix 3 · Off-main serialization</span>
<h3>Move stringify off the main thread (autosave path only)</h3>
<p style="font-size:13.5px;color:var(--mut)">The debounced autosave still serializes synchronously before its first fs await. Three hard constraints:</p>
<table><tr><th>#</th><th>Constraint</th></tr>
<tr><td>🔐</td><td>Secret encryption uses Electron <span class="mono">safeStorage</span> → <b>must stay on main</b>. Only stringify / sentinel substitution / encoding / hashing can move.</td></tr>
<tr><td>🪞</td><td>Worktree-meta projection settles rows on <b>reference identity</b> — a structured-clone boundary destroys it.</td></tr>
<tr><td>🚧</td><td><span class="mono">flushOrThrow</span> is a <b>synchronous barrier</b> (binding, retirement, credit ledger, quit) — a worker can't provide it. Converting every caller is its own change + crash-window analysis.</td></tr></table>
</div>
</div>
<div class="big-analogy">🧭 The big picture — <b>Fix 1</b> (this spec): stop doing redundant work · <b>Fix 2</b>: make necessary work smaller · <b>Fix 3</b>: move remaining work off the main thread. In that order, because each later fix has a wider blast radius.</div>
</section>
<footer>Visual explainer for <span class="mono">orca-persistence-design-assessment.md</span> (Sept 7, 2026) · Fix 1 spec, revised after review · Fixes 2–3 deferred with constraints · No product code changed on the branch.</footer>
</div>
<script>
// predicate checklist
const preds=[...document.querySelectorAll('.pred')];
const prog=document.getElementById('predProgress');
function refresh(){const n=preds.filter(p=>p.classList.contains('done')).length;prog.textContent=n+' / 9 verified';}
preds.forEach(p=>p.addEventListener('click',()=>{p.classList.toggle('done');p.classList.toggle('open');refresh();}));
document.getElementById('predAll').onclick=e=>{e.stopPropagation();preds.forEach(p=>{p.classList.add('done');p.classList.add('open')});refresh();};
document.getElementById('predReset').onclick=e=>{e.stopPropagation();preds.forEach(p=>p.classList.remove('done','open'));refresh();};
// generation simulator
let W=4,D=4;const MAX=8;
const fillW=document.getElementById('fillW'),fillD=document.getElementById('fillD'),
verdict=document.getElementById('simVerdict'),log=document.getElementById('simLog');
function draw(msg){
fillW.style.width=(W/MAX*100)+'%';fillD.style.width=(D/MAX*100)+'%';
const ok=D>=W;
verdict.textContent=ok?'YES — durable ✓':'NO — fall through, must flush';
verdict.style.color=ok?'var(--grn)':'var(--amb)';
log.textContent='W='+W+' D='+D+(msg?' — '+msg:'');
}
document.getElementById('bSched').onclick=()=>{W++;if(W>MAX){W=MAX}draw('scheduleSave bumped W: gap opened, fast path refuses');};
document.getElementById('bAsync').onclick=()=>{D=W;draw('async rename durable (or hash-proven): D caught up');};
document.getElementById('bSyncNoop').onclick=()=>{D=Math.max(D,W);draw('WITH one-line fix: hash match proves bytes → D=max(D,W). Without it, D stays behind ♾️');};
document.getElementById('bFail').onclick=()=>{draw('write failed: D untouched, gap remains — refuses correctly');};
draw();
</script>
</body>
</html>