mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 00:02:37 +00:00
433 lines
43 KiB
HTML
433 lines
43 KiB
HTML
<!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 path</span></h1>
|
||
<p class="sub">Every time you switch workspaces, each terminal pane re-saves the <em>entire 9.2 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 & 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 >= 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 < 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) < 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<…,'flushOrThrow'|'state'></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 & 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> |