mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
chore: keep investigation artifacts out of performance PR
This commit is contained in:
@@ -1,367 +0,0 @@
|
||||
# Orca live lag investigation — September 5, 2026
|
||||
|
||||
## Consolidated performance issue register — compiled 2026-09-11
|
||||
|
||||
Every performance issue found across the four `debug-orca-performance` sessions,
|
||||
the `debug-orca-perf-issue` sessions (worktree since deleted, branch survives as
|
||||
`origin/debug-orca-perf-issue`), and the `improve-cmd-j-dialog-performance`
|
||||
session. Each row keeps the measurement it came from. Status is what the
|
||||
evidence supports today, not what is hoped for.
|
||||
|
||||
Companion documents in this worktree:
|
||||
`orca-persistence-design-assessment.md` (implementation spec for P1–P8),
|
||||
`docs/reference/typing-latency-field-diagnosis-2026-09-10.md` (H1–H5, R1–R8),
|
||||
`docs/reference/renderer-agent-status-performance.md`,
|
||||
`docs/reference/spinner-rendering-performance.md`.
|
||||
|
||||
### Summary
|
||||
|
||||
| Group | Issues | Fixed | Open |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Persistence / main-thread state writes (P) | 11 | 4 | 7 |
|
||||
| Host and machine contention (H) | 5 | 0 (operational) | 5 |
|
||||
| Main process: git and subprocess load (G) | 8 | 0 | 8 |
|
||||
| Renderer and terminal rendering (R) | 8 | 0 | 8 |
|
||||
| Terminal daemon session leak (D) | 9 | 1 | 8 |
|
||||
| Cmd-J palette search (C) | 6 | 0 (spec only) | 6 |
|
||||
|
||||
### P — Persistence and main-thread state writes
|
||||
|
||||
Source: `debug-orca-performance` sessions `3dc15f5c`, `411b72f9`, `484eb712`.
|
||||
Live probe on the packaged app, PID 67676, build `1.4.198-adhoc.20260907185057`,
|
||||
9.0 MB profile under `profiles/local-default/`.
|
||||
|
||||
| # | Issue | Measured | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| P1 | Terminal reattach clones the whole workspace session and forces a whole-app synchronous flush even when the binding already matches | 4 calls in 30 s, 59.15–99.80 ms each; flush 48.37–84.38 ms; overlapping renderer→main IPC 142.6–232.9 ms | **Fixed** — fast lane in `pty-binding-fast-lane.ts`, early return in `pty-binding-persistence.ts` |
|
||||
| P2 | `writeToDiskSync` returns on a hash match without raising `lastDurableWriteGeneration`, while `flushOrThrow` has already bumped `writeGeneration` | Counter parks one generation behind after every no-op flush; the fast path would silently disable itself after its first fall-through | **Fixed** — `primary-state-writes.ts` raises the counter on the unforced hash-match return |
|
||||
| P3 | The unchanged-state hash comparison runs *after* `buildStateToSave()`, so a skipped disk write still pays full serialization, encoding and hashing on main | 12 `buildStateToSave` calls in 30 s, 34.88–43.94 ms each, 468.20 ms of synchronous serialization in the window; ~9.23 MB per call | Open — deferred fix 3 |
|
||||
| P4 | State document bloat: one terminal attach serializes megabytes of unrelated history | 9,372,404 bytes total: `workspaceSession` 4,486,512; `automationRuns` 3,380,645 (661 entries); `worktreeMeta` 637,426 | Open |
|
||||
| P5 | Split tabs ping-pong the tab row's PTY id: a tab row holds one `ptyId` while a tab holds several panes, and main overwrote it with whichever pane bound, contradicting the renderer's stated rule | Real profile: 1,424 tabs, 1,764 panes, 310 split tabs, 674 panes (38%) that could never reach the fast lane | **Fixed** — `terminal-tab-pty-ownership.ts`, shared by the write path and the predicate |
|
||||
| P6 | The durability check compared two *global* counters, so any unrelated dirty state held the fast lane shut | 9 of 17 reattaches missed on `not_durable` alone, 209 ms of the 337 ms spent flushing in that window (62%) | **Fixed** — per-pane durability records in `pty-binding-durability-records.ts` |
|
||||
| P7 | A reattach that cannot find its tab mints a duplicate tab and layout instead | 16 reattaches in one minute all missed on `tab_missing, layout_missing`; 16 new tabs titled "Terminal 1" in one worktree. Dev profile 116 tabs, real profile 1,424 — the likely source of the 9 MB document | **Open, not chased** — highest-leverage remaining lead |
|
||||
| P8 | The debounced autosave serializes the same ~9 MB on main roughly once a second while state is changing | Same 34.88–43.94 ms per serialization as P3 | Open — deferred fix 3, autosave-only scope |
|
||||
| P9 | Deferred fix 2 (small binding transactions) is blocked on four consumers of the single state document | Backup rotation, the orcad snapshot member set, profile transfer, and older builds that read the file directly | Deferred |
|
||||
| P10 | The binding-writer ratchet cannot see aliased writers: `ssh/ssh-target-id-migration.ts` rewrites `tab.ptyId` and `ptyIdsByLeafId` in place through a parameter named `record` | No correctness hole today — it bumps the generation through its caller's `scheduleSave` — but the regex is a tripwire, not a proof | Open by design; recorded in the writer table |
|
||||
| P11 | Five paths mutate durable state in place without bumping `writeGeneration` (`ssh-pty-binding-cleanup`, `ssh-pty-lease-operations`, `ssh-pty-lease-tombstone-retention`, `workspace-session-snapshot-publication`, `loaded-state-adaptation`) | Reviewed and cleared for *this* fix: none sets a binding to a requested value, and the equality check catches a cleared binding. An earlier claim that all five had to be fixed first was withdrawn | Open, low severity |
|
||||
|
||||
Fix-3 constraint worth keeping: secret encryption needs Electron `safeStorage`
|
||||
on main, the worktree-meta projection depends on reference identity a worker
|
||||
boundary destroys, and `flushOrThrow` is a synchronous barrier a worker cannot
|
||||
provide. Fix 3 is scoped to the debounced autosave only.
|
||||
|
||||
### H — Host and machine contention
|
||||
|
||||
Source: `debug-orca-performance` session `3a100df6`, 2026-09-10, packaged
|
||||
`1.4.200-adhoc`, main PID 87050 / renderer 87163, 18-core Apple Silicon, 128 GB.
|
||||
|
||||
| # | Issue | Measured | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| H1 | Host fully saturated — this was the primary cause of the reported lag at that moment | CPU idle 0.27%, load 28–54; 124 of 128 GB RAM used with 66 GB in the compressor; swap 47.0 of 48 GB compressing at 32 MB/s; 2,345 processes | Operational |
|
||||
| H2 | Activity Monitor (PID 67969) leaked to a 99 GB footprint, all dirty malloc, after 7 days, burning 82–101% CPU | Killing it: swap 47.0→16 GB, RAM used 124→95 GB, free 3→31 GB, CPU idle 0.3%→20% | Resolved by SIGKILL |
|
||||
| H3 | A Codex-spawned `rg --hidden` scanning the whole home directory | PID 65656, 130–340% CPU for over 3 minutes, ~16k IOPS | Operational |
|
||||
| H4 | 179 agent CLI processes (93 codex, 65 claude, 11 agy, 10 opencode) plus four dev Orca instances, one renderer at 2.6 GB | 27 GB RSS, ~200% CPU combined | Operational |
|
||||
| H5 | Earlier (2026-09-05/07) skill-discovery scan storm from release agents over the home directory | `rg` at 314%, 351% and 390% CPU; 18 CPUs, 3,075 processes, 33,750 threads, 73.86% system, 4.92% idle, load 17.13. Remediation is targeted skill-directory discovery and dedup across release workers | Open (agent-side) |
|
||||
|
||||
Keystroke path context: every keystroke crosses six process hops (renderer,
|
||||
main, daemon, shell, back, GPU, WindowServer). Under H1 each hop competes with a
|
||||
runqueue of 30–50 threads, and any compressed page it touches must be
|
||||
decompressed first. Ghostty is one native process, which is why it stayed fast.
|
||||
|
||||
### G — Main process: git and subprocess load
|
||||
|
||||
| # | Issue | Measured | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| G1 | Git subprocess storm on the main process event loop | 822 `git.exec` spans in 10 minutes, 97 s of git wall time, 407–614 spawns per 5 minutes for an hour; admission queue wait p95 25 ms, max 568 ms | Open |
|
||||
| G2 | The `orca` repo itself is pathological, making every spawn slow | 630 registered worktrees, 9,266 refs, 394 pack files plus 10 abandoned `tmp_pack_*`, 266,787 loose objects (6.3 GB); `git worktree list` 1.78 s, `git status --porcelain` 0.64 s (1.5 s sys) | Operational — `git worktree prune`, `git gc` |
|
||||
| G3 | `git worktree list` re-run instead of cached | 59 calls / 20 s in one 10-minute window, ~40 calls in 10 minutes at 1.78 s each | Open — follow-up 5 |
|
||||
| G4 | node-pty and `pty:write` live in the main process (`src/main/ipc/pty/ipc/write.ts:23`); every git/gh spawn is initiated synchronously on that same loop (`src/shared/child-process/run-process.ts:51`); git stdout is decoded and parsed in the main-thread data handler (`src/main/git/command-runner/git-stream-stdout.ts:184`). Admission caps limit concurrency, not spawn count (`git-admission-state.ts:4`) | Main-process main thread measured 25% busy, mostly `OnUvRead` of child stdout and PTY socket data | Open — architectural |
|
||||
| G5 | gh PR fanout: two `gh` processes per refresh (branch lookup then `gh pr view`), triggered per visible worktree row and by a 60 s per-card timer | Serialized and budgeted, so a lesser factor | Open — follow-up 6 |
|
||||
| G6 | `could not lock config file ~/.gitconfig` — concurrent agents racing on global git config | Observed in a terminal during the capture | Open |
|
||||
| G7 | Earlier `git show-ref` fan-out: `getPullRequestRemoteRefState` → `listExactRemoteBaseRefs` (`pull-request-remote-ref-probes.ts`) → `probeExactRefs` (`git/exact-ref-probe.ts`) builds a candidate per configured remote, concurrency eight | Bursts of 18 `show-ref` calls, 17 failing, 20–31 ms each; 550 calls in 10 minutes; 191 in a 3-minute window | Open |
|
||||
| G8 | Synchronous `uv_fs_access` on the main thread from a timer callback (2026-09-05, PID 99632) | 3,033 of 8,321 main-thread samples (36.45%); renderer 90.21% idle in the same window. JS caller and pathname never identified | Open, unreproduced since |
|
||||
|
||||
### R — Renderer and terminal rendering
|
||||
|
||||
| # | Issue | Measured | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | Main parses every PTY chunk for agent status and titles and sends a `pty:sideEffect` IPC message to the renderer per chunk (`src/main/runtime/orca-runtime-on-pty-data.ts:36`). Titles are damped to one per 500 ms; agent status and bells are not | 200 live terminals feeding the renderer regardless of visibility; five agent TUIs redrawing heavily, one opencode at 86% CPU | Open — follow-up 1 |
|
||||
| R2 | The agent status reducer builds a new object per update (`agent-status-live-reducer.ts:87`), so every status ping re-runs every Zustand selector in the app | Renderer main thread 28% average, bursts to 32%, one 5 s sample 86% busy; time spent in JIT-compiled JS and IPC deserialization, not GC or rendering | Open — follow-up 2 |
|
||||
| R3 | 27–33 terminal pane managers mounted while one is visible; park-verdict churn pins tabs mounted for 60 s at a time (`terminal-park-verdict-flip-telemetry.ts:144`, issue #15136) | Mounted panes keep xterm buffers and per-pane 3 s pollers, and share one 8 ms drain budget with the visible pane | Open — follow-up 3 |
|
||||
| R4 | The hidden-pane output gate is per tab, so a hidden pane inside the visible tab's split still gets every byte written into xterm (`terminal-pane-pty-deps.ts:55`) | — | Open — follow-up 4 |
|
||||
| R5 | Every tab reveal clears the WebGL glyph atlas for all visible terminals and forces a repaint (`pane-reveal-repaint.ts:63`) | 88 times in the current log | Open — follow-up 7 |
|
||||
| R6 | Keystroke echo through a live Orca terminal | 180–460 ms, bounded rather than measured (the CLI read itself costs ~330 ms) | Open |
|
||||
| R7 | Renderer resource accumulation across a day: heap and private memory climb, then fall back after a restart | 197→347 MB JS heap and 772–1,517 MB private memory before a restart versus 71–144 MB and 207–221 MB after; 11,330 DOM nodes, 1,119 stored terminal layouts, ~26 MB serialized store; later census 727 worktrees, 1,111 terminal tabs / 1,630 unified tabs, ~4,900–5,000 store listeners, 178 stored agent rows | Open |
|
||||
| R8 | Terminal replay wedges: `terminal_replay_guard_wedged_release` breadcrumbs, plus an unhandled "no diff result available" rejection and a `terminal_park_verdict_churn` burst | Six breadcrumbs across two panes (three events per pane, not six failures); replay-guard waits 10 s to probe and another quiet 10 s before declaring a wedge | Open, never correlated with a keystroke |
|
||||
|
||||
Measurement caveat carried forward: the 98.8 ms median / 520.2 ms maximum
|
||||
"parse → render" figure from the 2026-09-07 echo diagnostic is **not** valid as
|
||||
paint latency. xterm invokes the write callback before firing `onWriteParsed`,
|
||||
and Orca's foreground write callback can synchronously refresh the terminal, so
|
||||
the diagnostic attributes the *next* content render to the input. Do not use it
|
||||
to justify changing xterm scheduling, synchronized output, or GPU settings.
|
||||
|
||||
### D — Terminal daemon session leak
|
||||
|
||||
Source: `debug-orca-perf-issue` session `9565dd27` (worktree deleted; branch
|
||||
`origin/debug-orca-perf-issue`). Documented there in
|
||||
`docs/terminal-daemon-session-leak-investigation.md`.
|
||||
|
||||
| # | Issue | Measured | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 | The terminal daemon held ~427 live login shells with ~1,900 descendant processes, and the main process pinned at 100% CPU draining their PTY output | Daemon PID 22100, 5 days old, survived the app restart; a 3-second `sample` showed the main thread almost entirely inside Node stream-read callbacks | Resolved operationally by a later restart (427→73 shells, 100%→0.6% CPU); underlying leak open |
|
||||
| D2 | Sessions leak because kills fail silently | 1,745 sessions created versus 927 exited; 151 `session-kill-failed`, 108 `shell-ready-timeout`. Per-day since Aug 14: 60–200 leaked/day, 3–126 kill failures/day, no step change | Open |
|
||||
| D3 | The kill path swallowed the error, so the log said a kill failed but never why (`daemon-request-router.ts:197-201`) | Payload carried only `{sessionId, immediate, clientId}` | **Fixed** — the payload now carries `errorName` and `error`; test in `daemon-server-kill-attribution.test.ts` |
|
||||
| D4 | Rejected hypothesis: `shell-ready-timeout` caused by the Aug 18 content-addressed wrapper change (#15285) | Overlap is 138 of 1,054 timeout sessions (13%); 1,145 leaked sessions never timed out; the Aug 20 onset is an artifact of that commit *adding the log line*; a 300 ms barrier timeout only releases held bytes and never alters the process tree | Not the cause |
|
||||
| D5 | The real leak shape is `created → killed → kill-failed` with no attach and no exit; the daemon reports the session killed, a later kill fails, and the shell is never seen exiting | Most common sequence in the log. Prime suspect is `SessionNotFoundError` from `getAliveSession`: the session is gone from the map while the shell still runs | Open — D3 is what makes the next occurrence self-diagnosing |
|
||||
| D6 | Stale-daemon adoption: the daemon protocol has not moved past v36, so new app versions keep adopting an old daemon and daemon-side fixes never land | Daemon from `1.4.191` (Aug 28) still serving app `1.4.197`, later `1.4.200` — 9 then 13 days old | Open — design question: replace the daemon on app-version change, not just protocol change |
|
||||
| D7 | No session-count backstop or age-based reap, so a silently failing kill path degrades into hundreds of live shells | — | Open |
|
||||
| D8 | Sidebar flashing is the same chokepoint: per-session status churn drives sidebar re-renders | One tab flipping park state 12 times in 42 s; 29 recent `sidebar_worktree_activate`; renderer heap swinging 200–480 MB per minute | Open (same root as R3) |
|
||||
| D9 | Secondary burst: the readiness-checklist automation ran `xargs -P 64` calling `orca orchestration worker-release` | 3,481 dispatches; it ended without CPU dropping, so it was not the root cause | Operational |
|
||||
|
||||
### C — Cmd-J palette search
|
||||
|
||||
Source: `improve-cmd-j-dialog-performance` session `90ab767d`, verified against
|
||||
`a899f92402`. Spec: `docs/reference/cmd-j-search-performance-design.md` in that
|
||||
worktree. No code changed.
|
||||
|
||||
| # | Issue | Measured | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| C1 | Document representation is the real memory cost: `atoms`, `words` and `components` are one object per token, each holding a sliced string, so retention is ~7× the text-plus-offset estimate | 204 MiB for an 800-document long-comment fixture. Fix is packed `Int32Array` boundary tables, not eviction | Spec |
|
||||
| C2 | The `documentPayloadMb` budget in `palette-match-budget.ts` undercounts retention by that same ~7× factor | Should be replaced with a retained-heap probe | Spec |
|
||||
| C3 | The draft's 32 MiB cache budget holds ~130 of 800 documents, so the motivating fixture re-normalizes ~85% of its corpus on every new query | — | Spec — budget rejected |
|
||||
| C4 | The most frequent live invalidation was missed: `worktree-unread-activity.ts` bumps `lastActivityAt` on terminal output, replacing one `Worktree`, which replaces `allWorktrees`, which rebuilds all 800 documents while the palette is open | — | Spec — reuse rule now handles it |
|
||||
| C5 | `workspace-kanban-search.ts` calls `buildWorktreePaletteDocuments` with `evidencePolicy: 'board'`, so a shared cache must key on policy or the palette and the board thrash each other | — | Spec |
|
||||
| C6 | Text match and ranking cannot be memoized apart today: `toWorktreePaletteSearchResult` and `baseResult` fold `preparePaletteActivity` into the match result, so a `nowMs` change forces a re-match | Steady-state work after a per-entity cache is O(changed entities) plus 30–50 ms of matching, which is why a worker was demoted to a gated option | Spec |
|
||||
|
||||
Rollout note from the same review: CDP paint timings quantize to frame
|
||||
boundaries, so a 2 ms tolerance would block rollout on artifacts. Function-level
|
||||
timings keep the tight bound; paint-level timings get one frame.
|
||||
|
||||
### What the fast-lane work actually bought
|
||||
|
||||
Measured on the dev instance trace (`persistence.pty-binding` spans) and against
|
||||
the real profile. Full numbers are in the PR body.
|
||||
|
||||
| Stage | Reattaches | Fast lane | Flushed | Main-thread ms |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Before any fix (22 calls) | 4 | 0 | 22 | 710 ms, 13–84 ms per call |
|
||||
| After tab-row fix (17 calls) | 13 | 2 (0 ms total) | 15 | 337 ms, of which 209 ms was `not_durable` alone |
|
||||
| After per-pane durability | — | expected to absorb that 209 ms | — | — |
|
||||
|
||||
`Reattaches` counts a subset of all calls by origin; it overlaps the outcome columns.
|
||||
`Fast lane` and `Flushed` are mutually exclusive outcomes and sum to the call count
|
||||
(0 + 22 and 2 + 15 respectively). Do not add `Reattaches` to those outcomes.
|
||||
|
||||
Real-profile eligibility: 1,078 of 1,764 panes (61%) before the tab-row fix, all
|
||||
1,764 after it, subject to the durability check.
|
||||
|
||||
### Still unexplained
|
||||
|
||||
The worst captured keystroke was queued **117.4 ms**; the binding call accounts
|
||||
for about **16 ms** of it. The remaining ~101 ms has never been attributed. No
|
||||
end-to-end typing-lag root cause is established. If lag persists after the
|
||||
persistence work, the next suspects in order are P7 (duplicate tab minting),
|
||||
R1/R2 (per-chunk side effects and the status reducer), and G1/G4 (git spawns on
|
||||
the keystroke loop) — not more persistence work.
|
||||
|
||||
## Pinpointed defect — September 7, 12:31–12:32 Phoenix
|
||||
|
||||
**Terminal reattachment performs full-session cloning and whole-app synchronous persistence even when the terminal binding already matches.** This is now measured at the actual function boundaries, not inferred from CPU sample percentages. It is a confirmed source of main-thread stalls. A delayed real terminal keydown was captured in the same operation window; this does not assign every millisecond of its delay, or every historical lag report, exclusively to persistence.
|
||||
|
||||
The running app changed independently again to PID 67676, build `1.4.198-adhoc.20260907185057`. Verified its registered inspector handler before reopening the localhost debugger. Located the actual live Store through the existing app IPC handler's closure; temporarily wrapped only `persistPtyBinding`, `flushOrThrow`, and the serialization object's `buildStateToSave`. All original property descriptors/methods were restored afterward. No bindings were created or changed by the probe; it observed the user's normal app activity. Renderer IPC probes were read-only `app.getIdentity()` calls at 100 ms intervals, with one request outstanding at most. No CPU profiler ran during this capture.
|
||||
|
||||
Four actual local `persistPtyBinding` calls in 30 seconds:
|
||||
|
||||
| Start (Phoenix) | Entire binding call | Overlapping renderer→main IPC round trip | Existing binding |
|
||||
| --- | ---: | ---: | --- |
|
||||
| 12:31:43.677 | 66.53 ms | 177.3 ms | Tab PTY, leaf PTY and incarnation already match; layout exists |
|
||||
| 12:31:43.981 | 59.15 ms | 168.8 ms | Same |
|
||||
| 12:31:47.259 | 60.46 ms | 142.6 ms | Same |
|
||||
| 12:32:01.681 | 99.80 ms | 232.9 ms | Same |
|
||||
|
||||
The full binding call includes a workspace-session clone, then `flushOrThrow`. The four flushes took 48.37–84.38 ms. Twelve `buildStateToSave` calls (normal saves plus binding flushes) each produced about 9.23 MB and took 34.88–43.94 ms, totaling 468.20 ms of synchronous serialization in the 30-second window. These nested durations must not be summed as independent costs.
|
||||
|
||||
The renderer captured 69 trusted terminal keydowns. One key's event timestamp was approximately 12:31:47.303, inside the third binding call; its capture listener did not run until 12:31:47.420: **117.4 ms queue delay**. A following key queued 31.8 ms. Most other captured terminal keys queued only a few milliseconds. The binding call ended at about 12:31:47.319, so it does **not** account for the additional ~101 ms before the delayed listener ran. IPC round trips likewise include renderer scheduling after main replies. The measurements establish the synchronous blocker and correlated real input jank, not exclusive attribution of the entire delay to that function.
|
||||
|
||||
### Exact implementation chain
|
||||
|
||||
`persistPtyIpcSpawnCommit` → `persistAdmittedStablePaneBinding` → `Store.persistPtyBinding` → `cloneWorkspaceSessionState` → `flushOrThrow` → `writeToDiskSync` → `buildStateToSave` → full-state JSON serialization / UTF-8 encoding / hashing, followed by the durable write when necessary.
|
||||
|
||||
- `src/main/ipc/pty/ipc/spawn-commit-persist.ts`: the stable-owner persistence path runs on attachment, not just a brand-new terminal.
|
||||
- `src/main/persistence/loading-store/pty-binding-persistence.ts`: clones the session before setting the binding; there is no equality short-circuit before the clone/flush. Actual probe arguments had matching tab/leaf PTY IDs and incarnation.
|
||||
- `src/main/persistence/loading-store/primary-state-writes.ts`: the unchanged-state hash comparison occurs **after** `buildStateToSave()`. Even a skipped disk write still pays whole-state serialization/encoding/hashing on main.
|
||||
- `src/main/persistence/loading-store/state-serialization-secret-handling.ts`: performs the full JSON serialization synchronously. The ostensibly async save path calls the same synchronous builder before its first filesystem await.
|
||||
|
||||
The live profile's disk snapshot was 9,372,404 bytes. Top contributors, counted outside Orca without exposing values: `workspaceSession` 4,486,512 bytes; `automationRuns` 3,380,645 bytes (661 entries); `worktreeMeta` 637,426 bytes. Thus attaching one terminal serializes megabytes of unrelated automation history and other workspace state. The older root `orca-data.json` was stale; the live file is under `profiles/local-default/`.
|
||||
|
||||
### Fix target, not yet implemented
|
||||
|
||||
Put a durability-aware unchanged-binding fast path **before** the full-session clone and serialization, and decouple small PTY-binding durability from whole-app snapshot persistence. Do not merely remove `flushOrThrow` or add `await`: genuine binding changes must remain crash-safe, including mixed-version remote/session ownership cases. In-memory equality alone is insufficient if the matching binding has not yet been durably committed. Normal autosaves also need their synchronous whole-state preparation cost reduced or moved off the main event path.
|
||||
|
||||
Evidence: `/var/folders/69/kktjrbf5569d0qvcyxjgmllm0000gn/T/orca-persistence-calls-UD72gC/capture.json`. Earlier primitive-level corroboration: sibling `orca-main-blocking-X0om9Y/capture.json` (PID 16839), including a 92.9 ms IPC request overlapping session cloning and binding-triggered serialization. All diagnostic wrappers were restored; no application fix, settings change, process termination, or app restart was performed by this investigation.
|
||||
|
||||
## Final assessment from the available captures — September 7, 12:02 Phoenix
|
||||
|
||||
**Confirmed performance defects/conditions:** expensive synchronous durable-state persistence on Electron's main thread; intermittent renderer long tasks; and periods of extreme machine-wide kernel contention. **Not established:** a single causal explanation for the user's perceived typing lag. The real typing capture found fast input dispatch and first-output parsing. Its apparent 99–520 ms post-parse delay is invalid as a paint measurement because of a verified event-ordering race in the diagnostic. Do not use it to justify changing xterm scheduling, synchronized output, or GPU settings.
|
||||
|
||||
The final Chromium trace on main PID 16839 / main renderer 16994 ran around 12:00 without CPU profiling or input injection. Across 383 submitted compositor frames, submit-to-presentation latency was median 18.84 ms, p95 19.75 ms, maximum 31.61 ms. Across 171 animation-frame intervals, median was 13.06 ms, p95 14.14 ms, maximum 73.69 ms. The trace had no recorded input-latency events, so it cannot prove keyboard-to-screen latency or retroactively clear the compositor during the earlier typing episode. It does not support a sustained 100–500 ms presentation backlog in this later window.
|
||||
|
||||
Trace artifact: `/var/folders/69/kktjrbf5569d0qvcyxjgmllm0000gn/T/orca-chromium-lag-5NBBAV/trace.json` (about 50 MB; kept locally inside a private temporary directory). The actionable code-level finding is the synchronous persistence path described below. A fix to that path needs separate implementation and before/after validation; no blind product fix or setting change was made during this diagnostic task.
|
||||
|
||||
Cleanup verified: no renderer debugger attached, no input/timing/phase probe globals or retained diagnostic pane references. Chromium recording was stopped. The localhost Node inspector was scheduled to close after disconnect; port closure was checked separately. No user process was terminated, no restart was initiated, and no input text or terminal content was changed. The only active terminal operation was a zero-byte write and repaint for event-order validation.
|
||||
|
||||
## Direct live typing capture — September 7, 11:21–12:00 Phoenix time
|
||||
|
||||
### Verified targets and non-restarting debugger access
|
||||
|
||||
The app changed independently during this investigation. `orca status --json` identified PID 57789 / renderer 57858, build `1.4.198-hourly.202609071712`, launched at 11:21. Later it identified PID 16839, build `1.4.198-adhoc.20260907181350`. Historical PID 44501 was no longer running; captures were switched to the live instance. No app restart was initiated here.
|
||||
|
||||
The earlier debugger-access blocker was overcome: native samples showed Node's `SignalInspector` thread, and a read-only `sysctl(KERN_PROC_PID)` check verified SIGUSR1 was caught rather than ignored/defaulted. After verifying port 9229 was free, sent SIGUSR1 to the exact main PID. Node's localhost inspector opened without restarting. Inspector evaluation confirmed the PID/version and Electron webContents types; the main UI was webContents 1, type `window`, renderer PID 57858—not any of its browser guests. A live DOM check confirmed `document.hasFocus()`, visible document, and focused `.xterm` textarea. Renderer CDP attachment was temporary and detached after each capture.
|
||||
|
||||
### Real keystrokes captured, with an important measurement correction
|
||||
|
||||
At 11:33:16–11:33:46, the existing typing diagnostic observed 31 direct terminal inputs: 28 single-input observations, one ambiguous two-input burst, one unmatched input. Focused pane at the start/end was leaf `fb249133-0bc3-4ac0-906f-f061b81a1c54`, active terminal tab `049d2c43-a715-49fc-bfa6-63fd85836c9a`, alternate buffer, 155 × 65, status agent type Claude. These identities come from the live renderer, not the CLI's cwd-scoped active terminal.
|
||||
|
||||
| Instrumented stage | Median | p95 | Maximum | Interpretation |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Input listener → xterm input dispatch | 0.8 ms | 3.2 ms | 3.6 ms | Excludes OS/Chromium queueing before the listener |
|
||||
| Dispatch → first associated output parse event | 2.8 ms | 7.2 ms | 9.0 ms | Output association is heuristic, not glyph identity |
|
||||
| Parse → subsequent public xterm render event | 98.8 ms | 189.4 ms | 520.2 ms | **Not valid as paint latency; see ordering bug below** |
|
||||
|
||||
The 28 associated output batches were each 58 bytes, one write. Independent DOM timing captured actual terminal keydown dispatch delays generally around 2–20 ms. The initial 91.6 ms delay overlapped profiler startup and must not be presented as an independent product stall. Renderer timer drift: median 0.6 ms, p95 5.5 ms, max 143.3 ms. Six renderer long tasks lasted 54–106 ms (410 ms total). Neither aggregate idle percentage nor these measurements captures hardware-key-to-displayed-pixel latency.
|
||||
|
||||
**Confirmed diagnostic ordering bug:** xterm invokes a write callback before firing `onWriteParsed` (`node_modules/@xterm/xterm/src/common/input/WriteBuffer.ts`). Orca's foreground write callback can synchronously refresh the terminal (`pane-terminal-foreground-render-settle.ts`). The built-in echo diagnostic only queues an observation when `onWriteParsed` arrives, so it can miss the render that has already happened and attribute the *next* content render to this input. Public `terminal.onRender` also excludes redraw-only renders. It cannot be equated with screen presentation.
|
||||
|
||||
A live, zero-byte write/repaint check on PID 16839 confirmed the event order without sending PTY input or changing terminal contents: write callback at +0.1 ms; public render and service render at +0.6 ms; `onWriteParsed` after those at +0.6 ms; another service-only render at +12.4 ms. The pane was neither observer-paused nor synchronized-output-held. This proves the measurement race exists on the real runtime, not that this controlled check reproduces the user's lag. The earlier conversational claim that 99–520 ms establishes held terminal redraw is withdrawn. No diagnostic/product implementation fix was applied.
|
||||
|
||||
### Confirmed main-thread work and machine contention
|
||||
|
||||
The JavaScript profiles identify real main-process blocking work, more precisely than earlier native samples:
|
||||
|
||||
- Repeated durable-state serialization: `buildStateToSave`, reached through normal persistence and `flushOrThrow → persistPtyBinding`. The first capture contains sampled non-idle runs approximately 83–106 ms on the latter path, and a 137 ms run including the ordinary save path. These are sampled intervals, not exact instrumented handler durations or correlated keystrokes.
|
||||
- Native SQLite `run` accounted for about 844 ms sampled self-time over the first 30.8-second profile; synchronous subprocess `spawn` about 241 ms. The second profile had about 276 ms and 113 ms respectively. These totals are not single stalls.
|
||||
- The second renderer profile was approximately 88.8% sampled idle; the main process approximately 90.7%. That rules out continuous JavaScript saturation in those particular captures, not intermittent stalls or compositor/OS delay.
|
||||
- At 11:26, `top` reported 3,245–3,248 processes, about 34,500 threads, 0% idle CPU, 84–85% system CPU, and one-minute load above 230. A separate 100-request probe at 11:28 again measured 0% idle / 73% system CPU; main HTTP response median 0.86 ms, p95 29.31 ms, max 342.64 ms. The probe's own timer drift max was 2.16 ms. This confirms severe machine contention and an intermittent main response stall, without assigning all kernel load to one process.
|
||||
- Two broad `grep -rn` scans from the linear-triage workspace and a recursive project-wide `find ... -exec grep` were still running after roughly 90 minutes. Their existence and CPU usage are confirmed; they alone do not explain 18 cores of kernel load. No searches, agents, or user apps were stopped.
|
||||
|
||||
Live census at the typing capture: 727 worktrees, 1,111 terminal tabs / 1,630 unified tabs, roughly 4,900–5,000 store listeners, 12 mounted panes after retention cleanup, 178 stored agent rows. GPU terminal rendering was configured `off`. These are scale/settings observations, not proof of a leak or GPU-related cause.
|
||||
|
||||
### Evidence and tooling caveats
|
||||
|
||||
- Valid typing/CPU capture: `/var/folders/69/kktjrbf5569d0qvcyxjgmllm0000gn/T/orca-input-lag-3S06gm/` (`input-timing.json`, `terminal-echo.json`, main/renderer `.cpuprofile`).
|
||||
- Earlier CPU capture: sibling `orca-input-lag-6W47Sw/`. It contained only one keydown and no matched terminal inputs. Its timer-drift data is invalid because the initial inspector adapter omitted the timing probe's 100 ms argument; this was corrected before the valid capture. Its profiler-start frame gap is instrumentation overhead.
|
||||
- Native samples: `/tmp/orca-1128-renderer.sample.txt`, `/tmp/orca-1128-main.sample.txt`, `/tmp/orca-1130-main.sample.txt`; filename labels are approximate—the report header contains the actual timestamp/PID/version.
|
||||
- Detailed phase probe: sibling `orca-render-phases-1ItnGv/phases.json` captured zero focused-pane events; it establishes no latency result.
|
||||
- Diagnostic scripts only were added; targeted syntax/lint checks pass. No persistent setting changes, terminal commands, window activation, or diagnostic uploads were performed.
|
||||
|
||||
## Latest conclusion — September 7
|
||||
|
||||
No end-to-end typing-lag cause has been established. The earlier synchronous filesystem stall is a confirmed historical main-thread bottleneck, not a demonstrated explanation of the latest recurrence. The latest backend responsiveness measurements below are mostly fast. They leave keyboard dispatch, renderer work, PTY stream delivery and echo-to-paint timing unmeasured; they do not isolate the renderer as the culprit.
|
||||
|
||||
Rechecked the running instance: main PID 44501 still has only ports 61004 and 6768 listening, with no renderer remote-debugging endpoint. The browser CDP bridge is scoped to managed browser pages; it is not an established diagnostic connection to Orca's main UI. The Electron skill's existing-instance safety constraint means no automatic restart to enable debugging. No restart or focus change was performed.
|
||||
|
||||
Next necessary capture: in the existing Orca window, open View → Toggle Developer Tools → Performance, record about ten seconds while reproducing the typing delay, stop, and save the trace locally. Disable screenshots and avoid sensitive input during capture. This preserves the accumulated runtime state and allows attribution of input delays to JavaScript, layout/paint or scheduling; terminal echo can still require a subsequent PTY-specific measurement if the UI trace is responsive. A clean synthetic app or another aggregate CPU sample is not a substitute.
|
||||
|
||||
The prepared capture script was formatted with the project's `oxfmt`; targeted `oxlint` and `node --check` pass after mechanical brace fixes. No application implementation was changed.
|
||||
|
||||
## Deeper investigation — September 7: direct responsiveness probes
|
||||
|
||||
Sampled the previously omitted terminal daemon (PID 22100). It has survived updates since build `1.4.191-hourly.202608281156`; its protocol is v36. Longevity/version alone is not evidence of a defect. Its ten-second sample had 6,977/7,041 main-thread samples waiting in `kevent`, approximately 99.1% idle. Evidence: `/tmp/orca-sept7-daemon.sample.txt`.
|
||||
|
||||
Measured 120 paired read-only requests over 24.47 seconds, at approximately five requests per second: daemon control-socket `ping` and a lightweight main-process HTTP 404 response on port 61004. The diagnostic connection used a unique client ID, did not attach to terminal streams, and closed afterward. Authentication was kept out of output.
|
||||
|
||||
| Probe | Median | p95 | Maximum | Over 50 ms |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Daemon ping | 0.23 ms | 0.52 ms | 73.52 ms | 1 |
|
||||
| Main HTTP response | 0.45 ms | 14.17 ms | 45.30 ms | 0 |
|
||||
| Probe's own timer drift | 1.02 ms | 1.15 ms | 1.37 ms | 0 |
|
||||
|
||||
These measurements do not reproduce sustained backend unresponsiveness. They do not measure PTY stream queuing, renderer IPC, keyboard handling, or echo-to-paint latency, and do not rule out stalls between requests. A non-interactive `sudo -n fs_usage` attempt also failed because a password is required; no privileged tracing was performed.
|
||||
|
||||
Prepared `config/scripts/capture-live-input-lag.mjs`, reusing the existing renderer timing probe. It records bounded keyboard-event timings, input-surface categories, slow frame gaps, long tasks and a JavaScript CPU profile; it does not record key values, input text or terminal output. It needs a Playwright page attached to the main renderer, leaves app focus/state alone, and cleans up its probes. `profileStartWindow` brackets CPU-profiler start in renderer time to aid correlation. This is diagnostic tooling, not an application fix.
|
||||
|
||||
Validated against an isolated headless Chromium fixture: keyboard events classified as terminal, a deliberate 100 ms timer task detected, CPU samples present, typed sentinel absent from timing output, and listeners/globals cleaned up. The first fixture used CDP evaluation for the deliberate stall, which did not generate a Long Task entry; moving that workload into a real browser timer made the intended validation pass. This fixture is not a reproduction of the user's Orca lag. Syntax check passes. Prettier was not available through `pnpm exec prettier`.
|
||||
|
||||
The installed app still exposes no reachable renderer CDP endpoint. Its View menu has `Toggle Developer Tools` (`src/main/menu/register-app-menu.ts`), so a user-recorded Performance trace while typing can preserve the currently laggy state. Alternatively, an explicitly coordinated relaunch with remote debugging would permit the prepared automated capture, but would clear accumulated state. No restart was performed. The actual user-visible lag root cause remains unproven; the decisive missing evidence is a real input/paint trace, not more aggregate CPU snapshots.
|
||||
|
||||
## Live recurrence — September 7, 10:38 Phoenix time
|
||||
|
||||
Running build `1.4.198-adhoc.20260907061156`, main PID 44501 and renderer PID 44566, approximately ten hours since launch. Fresh ten-second samples: main has 393/8,279 samples (4.75%) in `__posix_spawn`, but only two in `access`; renderer has approximately 85% in its idle Mach wait. The earlier synchronous access bottleneck did not recur in this capture. These percentages are sampled stack occupancy, not measured keystroke delays.
|
||||
|
||||
Recent renderer diagnostics show 38–39 mounted terminal managers, 11 browser guests, JS heap 326–340 MB and private memory 1,082–1,511 MB. Six replay-wedge breadcrumbs at 10:35:40 identify two panes in workspace hash `8a018c17`; three events per pane are not six independent terminal failures. A three-minute trace window contained 191 `show-ref` probes and substantial other Git work. Two VM-stat intervals had zero swap-ins/outs but ongoing decompression; active swapping was not observed.
|
||||
|
||||
Long-running background scans continue: a `grep -rn` across projects and Orca application data had run approximately 46 minutes; a `find` across projects executing content searches had run approximately 44 minutes. Their working directories map to linear-triage and a readiness-checklist workspace. Another `find .. -name AGENTS.md` had run approximately 12 minutes. These were not started by this investigation.
|
||||
|
||||
Evidence: `/tmp/orca-sept7-main.sample.txt`, `/tmp/orca-sept7-renderer.sample.txt`. Current findings establish subprocess-start overhead and a larger renderer population, with recent replay failures, but still do not correlate a keystroke with a specific blocking function. No restart, process termination, or implementation change was performed.
|
||||
|
||||
## Current assessment — updated September 7, 2026
|
||||
|
||||
The strongest captured evidence is synchronous filesystem `access()` work on Orca's main thread, reached from a timer: 36.45% of main-thread samples during the reported lag recurrence. This can delay main-thread event handling and terminal IPC. It is not yet a confirmed end-to-end explanation of typing latency: no keystroke timing was correlated with the blocked calls.
|
||||
|
||||
The exact JavaScript timer/caller, pathname, and reason the filesystem check took so long remain unknown. Overlapping whole-home skill searches were independently observed creating heavy system load and are a plausible amplifier, not a proven cause of this access stall. Replay warnings, Git probe fan-out, and accumulated renderer resources remain separate observations rather than established typing root causes.
|
||||
|
||||
Earlier conversational claims that this fully explained the lag were too strong. The next decisive step is to identify the caller and pathname during recurrence using a JavaScript profile or permitted filesystem tracing, then verify latency before and after a targeted change. No implementation fix has been made. This September 7 update summarizes the September 5 captures; it contains no new live measurements, and recorded PIDs are historical.
|
||||
|
||||
User reports slow typing in Orca while Ghostty typing is fast. Running app:
|
||||
`1.4.198-adhoc.20260906034507`, main PID 87931, renderer PID 87972.
|
||||
|
||||
## Initial measurements
|
||||
|
||||
- Five-second native samples at 21:44 Phoenix time: renderer main thread approximately 31% outside its idle wait; app main thread approximately 94% in its idle wait. These are sampled occupancy estimates, not keystroke latency measurements.
|
||||
- At 21:39:22–24, six `terminal_replay_guard_wedged_release` breadcrumbs identified two terminal panes. This is evidence of failed/stalled replay writes, but has not been correlated with the currently laggy pane.
|
||||
- Recent renderer high-water snapshot: 33 terminal elements, 11,330 DOM nodes, four browser guests, 1,119 stored terminal layouts, approximately 26 MB serialized store, 197 MB JS heap and 1,417 MB private memory. Counts alone do not establish a leak or causal performance problem.
|
||||
- Ten-minute trace window: 550 `git show-ref` calls, plus other Git operations. Some status/diff calls exceeded one second; asynchronous command durations do not prove main-thread blocking.
|
||||
- Native samples: `/tmp/orca-live-main.sample.txt` and `/tmp/orca-live-renderer.sample.txt`.
|
||||
- Logs: `~/Library/Application Support/orca/logs/main.trace.ndjson`.
|
||||
|
||||
## Initial assessment
|
||||
|
||||
Terminal replay/rendering is a lead, not an established root cause. Ghostty's responsiveness makes Orca-specific work worth investigating. The app has no exposed CDP listener, limiting direct JavaScript profiling without another supported diagnostic entry point. No app processes were stopped and no implementation was changed.
|
||||
|
||||
## Deeper checks
|
||||
|
||||
- Mapped the replay breadcrumb workspace hash `db7a2eae` to the main Orca repository workspace (`<repo>`), rather than this debug worktree. The two tab hashes were not found in the persisted terminal-tab inventory. No additional wedge breadcrumbs appeared through approximately 21:52. The initial warnings cannot establish the cause of current typing lag.
|
||||
- Read `replay-guard.ts`: the warning can follow a rejected write (including disposal), or a FIFO probe with no parse progress. The default stalled-write path waits 10 seconds to probe and another quiet 10 seconds before declaring a wedge. It is not a measurement of per-keystroke latency.
|
||||
- A second, 15-second renderer sample contained 12,899 main-thread samples, including 11,280 in the idle wait: approximately 12.5% outside idle. File: `/tmp/orca-live-renderer-long.sample.txt`. This does not support continuous renderer saturation; short stalls remain possible. Native Electron symbols are insufficient to identify the JavaScript functions responsible.
|
||||
- Confirmed recurring Git fan-out: recent bursts frequently contain 18 `show-ref` calls, 17 failing, with individual maximum durations around 20–31 ms. Source path: `getPullRequestRemoteRefState` → `listExactRemoteBaseRefs` in `src/main/text-generation/pull-request-remote-ref-probes.ts` → `probeExactRefs` in `src/main/git/exact-ref-probe.ts`. It builds a candidate for every configured remote and runs separate processes with concurrency eight. This explains the observed probe pattern, but does not prove typing stalls.
|
||||
- Renderer heap fluctuated approximately 194–347 MB in recent minute snapshots, falling back to 194 MB; this is not evidence of steadily growing JS heap. Browser guest count changed from four to five, so native-memory samples do not describe an unchanged workload.
|
||||
- The profile's `DevToolsActivePort` file names port 9333, but that endpoint is not listening. The running main process's port 56976 did not expose a CDP target list. The file alone is stale evidence, not a usable debugger connection.
|
||||
|
||||
## Remaining diagnostic gap
|
||||
|
||||
Need the exact laggy surface (embedded terminal versus chat composer, and workspace/agent) and a capture during its slow typing. Current native samples and historical breadcrumbs do not identify an actual keystroke bottleneck. Do not label replay failures, accumulated state, or Git fan-out as the confirmed typing root cause without that correlation. No restart, window activation, or diagnostic upload was performed.
|
||||
|
||||
## Improvement after an update/restart — 22:17 Phoenix time
|
||||
|
||||
The user reported that typing was less laggy. The original sampled processes no longer exist. Logs record `updater_quit_and_install_started`, native installer invocation, and fresh main/renderer lifecycle events. Current main PID 99632 started at 22:11:35; renderer PID 561 at 22:11:37. The installed version changed from `1.4.198-adhoc.20260906034507` to `1.4.198-adhoc.20260906044334`. This restart was not initiated by this investigation.
|
||||
|
||||
- Initial current CPU snapshot: main 1.5%, renderer 3.9%; a subsequent snapshot was 2.3% and 0.1% respectively.
|
||||
- Post-restart renderer minute snapshots: private memory approximately 207–221 MB versus earlier approximately 772–1,417 MB; JS heap approximately 71–144 MB versus earlier approximately 194–347 MB.
|
||||
- Browser guests were initially zero, then one, versus four to five before restart.
|
||||
- No replay-wedge breadcrumbs in the latest six-minute window. Git activity remains substantial (778 recorded Git spans across the window, including startup).
|
||||
|
||||
The improvement coincides with an actual update/restart and a much lighter renderer workload. This supports restart-cleared accumulated runtime resources and/or a build change as explanations; it does not distinguish them or establish a leak. A same-build, same-workload keystroke trace during recurrence is still needed for a causal diagnosis.
|
||||
|
||||
## Follow-up at approximately 22:56–22:57: confirmed background scan contention
|
||||
|
||||
- Latest terminal rendering diagnostics show 12–13 mounted managers, versus roughly 33–35 earlier. Recent renderer JS heap snapshots fall from 167 MB to 133 MB; private memory is approximately 534–587 MB. The resource population is lower, but not identical to just after restart.
|
||||
- Orca's resource inventory reports 458 managed sessions across 133 workspaces, totaling approximately 65.6 GiB RSS. RSS sums include shared pages and are not unique physical memory. The app's aggregate RSS was approximately 2.55 GiB, including all renderer processes; this is not comparable directly to the main renderer's private-memory breadcrumb.
|
||||
- The `1.4.198-release` workspace accounted for approximately 346% CPU in the inventory. Direct OS sampling then found `rg` processes at 314%, 351%, and 390% CPU, plus several `find` processes around 33–40% each.
|
||||
- Confirmed search commands include `find <home> -path */SKILL.md -type f` and `rg -l --hidden --glob SKILL.md prod-release-scan|release scan|production release <home> /tmp`. Two surviving `rg` processes had cwd `<repo>/1.4.198-release`. These scans were launched by other agents, not this investigation.
|
||||
- System snapshot at 22:56:57: 18 logical CPUs, 3,075 processes, 33,750 threads; CPU 21.21% user, 73.86% system, only 4.92% idle; one-minute load average 17.13. The scan storm is therefore material system contention, not merely a large percentage on one otherwise-idle core.
|
||||
|
||||
This establishes a current source of CPU/filesystem pressure: overlapping whole-home skill-discovery scans from release agents. Similar `find` activity appeared in the original capture, but these later scans do not retrospectively prove the original typing bottleneck. The appropriate remediation for this confirmed waste is targeted skill-directory discovery and deduplication across release workers. No agents were messaged, stopped, or modified, and no user processes were killed.
|
||||
|
||||
## Lag recurrence at 23:00: main-thread synchronous filesystem blockage
|
||||
|
||||
After the user explicitly reported continued lag, concurrent ten-second native samples captured:
|
||||
|
||||
- Main PID 99632: 3,033 of 8,321 main-thread samples (36.45%) on a timer callback stack ending in `uv_fs_access` → `access`. This is synchronous filesystem work executing on the Electron main thread. Sampled occupancy does not establish whether this was one continuous stall or several checks.
|
||||
- Renderer PID 561: 7,705 of 8,541 main-thread samples (90.21%) in its idle Mach wait. The renderer was not continuously CPU-saturated during this capture.
|
||||
- Two one-second VM-stat intervals showed zero swap-ins, swap-outs, compression, and decompression deltas. Existing swap usage alone is not evidence that this particular stall was caused by active swapping.
|
||||
- Background `find` scans were still running, including some approximately four minutes old. They could amplify filesystem latency; correlation does not establish which exact path blocked main.
|
||||
|
||||
This is the strongest direct Orca-specific finding so far: a timer performs a synchronous filesystem access check on main, potentially blocking input/PTY IPC while the renderer waits. Native samples do not expose the JavaScript function name or path argument. `fs_usage -w -f filesys -t 5 99632` was attempted read-only but refused because it requires root; no elevated command was run. Code inspection found multiple possible synchronous existence/access callers, so no individual function has been assigned blame without proof.
|
||||
|
||||
Evidence files: `/tmp/orca-lag-2300-main.sample.txt` and `/tmp/orca-lag-2300-renderer.sample.txt`. Next decisive measurement is a path-level filesystem trace or a JavaScript CPU profile during the same lag, followed by moving the identified periodic filesystem check off main's synchronous path.
|
||||
@@ -1,205 +0,0 @@
|
||||
# Orca persistence: terminal reattach fast path
|
||||
|
||||
Date: September 7, 2026
|
||||
|
||||
Status: Fix 1 is implemented on this branch. This document describes its durability boundary and local measurements. Fixes 2 and 3 remain proposals; no live before/after validation of the implementation has been performed.
|
||||
|
||||
## Review evidence (September 7)
|
||||
|
||||
The review found a false durability acknowledgement after `flushAsync`: late session writes do not advance the generation once shutdown begins. The fast path now excludes `quitFlushStarted`, preserving the existing synchronous-flush error. A regression failed before that guard and passed afterward.
|
||||
|
||||
The binding write now has one flush and rollback boundary. Session mutation does not depend on tracing. Trace sampling uses the existing recorder's decision hook without a per-call dropped flag or a separate flushed flag. Diagnostic scripts reuse one inspector connector and close it when process verification fails.
|
||||
|
||||
- Invariant: `persistPtyBinding` cannot acknowledge an unpersisted late-shutdown binding; unchanged durable bindings avoid cloning and synchronous flushing.
|
||||
- Oracle: disk contents and the thrown shutdown error, plus zero clone/flush counts on repeated local, SSH, and runtime bindings.
|
||||
- Gates: the persistence suite and affected deterministic coverage from `terminal-session.explicit-close-retirement`; its live Electron journeys were not run.
|
||||
- Validation: 931 tests passed and one opt-in metadata benchmark skipped; a subsequent targeted run passed 80 tests, including SSH reattach and terminal-close continuity. Node typecheck, targeted lint, formatting, and whitespace checks passed.
|
||||
- Coverage: host partition behavior is tested for local, SSH, and paired runtimes; folder-workspace and binding-recovery tests are included in the persistence suite. PTY I/O, WSL process execution, platform launch policy, and wire formats are unchanged by the review fixes. No live platform matrix or before/after typing-latency measurement was collected.
|
||||
|
||||
## Problem
|
||||
|
||||
Every terminal pane that mounts or remounts calls `Store.persistPtyBinding`, which clones the workspace session, mutates it, and calls `flushOrThrow`. The flush serializes the whole persisted state (9.2 MB on the measured install) on the Electron main thread, then compares its hash to the last written hash and usually skips the disk write. The serialization is paid whether or not the write happens.
|
||||
|
||||
Live instrumentation on the running app measured four such calls in 30 seconds, each 59 to 100 ms, all with tab PTY, leaf PTY, layout membership, and incarnation already matching the request. Twelve serializations in that window totaled 468 ms of main-thread time. A real terminal keydown queued 117 ms during one of the calls; the call accounts for about 16 ms of that, so this is a confirmed stall, not the whole lag. Details and capture artifacts: [the live investigation notes](orca-live-lag-investigation.md).
|
||||
|
||||
The trigger is the renderer's cold-park policy in `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`: at most 4 workspaces and 6 tabs stay warm, so on a many-worktree install nearly every workspace switch remounts every pane in the revealed workspace, and each pane reattaches. A three-pane workspace is three back-to-back flushes.
|
||||
|
||||
## Change
|
||||
|
||||
The implementation adds an early return to `persistPtyBinding` in `src/main/persistence/loading-store/pty-binding-persistence.ts` that fires when the requested binding is already in memory and already on disk. Nothing is cloned and nothing is serialized on that path.
|
||||
|
||||
### Placement
|
||||
|
||||
The check runs after the four existing refusal checks (`expectedSourceBinding`, `expectedBinding`, `mayReviveRetiredSurface`, `mayCreate`) and before the non-local partition re-point and `cloneWorkspaceSessionState`. Refusal semantics stay exactly as they are: every `return false` today still returns `false` first.
|
||||
|
||||
### No-op predicate
|
||||
|
||||
All of the following must hold. Any miss falls through to the existing code unchanged.
|
||||
|
||||
| Condition | Why |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `args.expectedSourceBinding === undefined` | The split path always changes membership and arms the topology fence. |
|
||||
| `isTerminalLeafId(args.leafId)` | Legacy leaf ids take the early flush branch and never write layout state. |
|
||||
| Tab exists in `session.tabsByWorktree[bindingWorktreeId]` with `tab.id === args.tabId`, and `tab.ptyId` already equals what `tabRowPtyIdAfterLeafBinding` would write | Otherwise the call mints a tab or rewrites the row. See "The tab row" below. |
|
||||
| `session.terminalLayoutsByTabId[args.tabId]` exists, `layout.root` is non-null, and `layoutContainsLeafId(layout.root, args.leafId)` | Otherwise the call mints or splits the layout. |
|
||||
| `layout.ptyIdsByLeafId?.[args.leafId] === args.ptyId` | The load-bearing binding. |
|
||||
| `session.terminalPtyIncarnationsByPaneKey?.[paneKey] === args.incarnationId` | Strict equality: undefined on both sides is a match; undefined on one side is not. |
|
||||
| `args.expectedBinding === undefined \|\| args.expectedBinding.incarnationId === args.incarnationId` | A reconciled incarnation must still bump the topology fence. |
|
||||
| `!session.terminalSurfaceTombstonesByPaneKey?.[paneKey]` | A tombstone is cleared by the write path; it is state the call would change. |
|
||||
| Binding is durable (next section) | In-memory equality alone can match a binding still waiting in the debounced save. |
|
||||
|
||||
When the predicate holds, `return true`. The `true` return matters: `persistAdmittedStablePaneBinding` in `src/main/ipc/pty/pane/stable-owner.ts` throws `terminal_pane_owner_changed` on `false`, and `spawn-commit-persist.ts` uses the `true` result to suppress its second binding write.
|
||||
|
||||
### The tab row
|
||||
|
||||
A tab row names one PTY, but a split tab holds several panes. The renderer keeps the row on the first pane and refuses to let later split-pane spawns steal it, because a remount reattaches the tab to whatever the row says. Until this change the main-process write path overwrote the row with whichever pane was binding, and the renderer's next session publish put the first pane back. On the dev profile that ping-pong was the sole reason all four reattach-shaped calls in the first 22-span capture fell through: they matched on layout, leaf PTY, and incarnation and missed only on `tab_pty`. On the real profile 310 of 1,424 terminal tabs are split, holding 674 of 1,764 panes, so 38% of remounts could never have hit the fast lane.
|
||||
|
||||
`terminal-tab-pty-ownership.ts` holds the rule both sides now follow. The row is rewritten only when it is null or points at the PTY this leaf is replacing. A non-null row absent from the leaf map stays unchanged until the renderer clears or replaces it. A sibling pane's bind leaves it alone. The predicate compares the row against what that rule would write, so a sibling reattach counts as a match. Every main-process reader of the row already falls back to the per-leaf map, so none depends on it naming the most recent pane. The two existing tests that pin a null row being filled stay valid.
|
||||
|
||||
### Durability check
|
||||
|
||||
```ts
|
||||
!runtime.quitFlushStarted &&
|
||||
(runtime.lastDurableWriteGeneration >= runtime.writeGeneration ||
|
||||
isBindingDurable(runtime.durableBindingRecords, paneKey, session, ptyId, incarnationId, ...))
|
||||
```
|
||||
|
||||
`scheduleSave` bumps `writeGeneration` before it arms the timer. `writeToDiskAsync` raises `lastDurableWriteGeneration` after the file is durably renamed or proven byte-identical by the hash comparison. A pending mutation leaves the durable generation behind. A synchronous flush can close that gap while an older async promise is still pending: it first removes the in-flight temporary file, so a parked async rename fails instead of overwriting the newer snapshot; if the rename already completed, the sync write wins. Thus no additional pending-write guard is needed. The existing async-write syscall test covers the parked-rename race.
|
||||
|
||||
Before this change, `writeToDiskSync` raised the counter only after a real rename. On a hash match with `force` unset it returned without touching it, while `flushOrThrow` had already bumped `writeGeneration`. Left alone, every sync flush that nets to unchanged state parks the counter one behind and also clears the debounce timer, so nothing heals it until unrelated state schedules an async write. The fast path would fall through on the next reattach, hit the same hash match, and stay disabled. This change therefore includes a one-line fix in `writeToDiskSync`: on the unforced hash-match return, set `lastDurableWriteGeneration = max(lastDurableWriteGeneration, writeGeneration)`, mirroring the async branch. A matching hash means the file already holds this state, which is exactly what the counter records. The `force` path is excluded on purpose: it exists because an async rename may be racing past the generation check, so the file's contents are not yet proven.
|
||||
|
||||
`PtyBindingPersistenceOperationsRuntime` reads the existing generation counters and quit state for eligibility, plus pending-write and timer state for trace metadata. No new durability tracking state is introduced.
|
||||
|
||||
The generation check covers the entire persisted state, which measurement showed is too coarse to be useful on its own. In a capture of 17 calls during workspace switching, nine missed on `not_durable` and nothing else, accounting for 209 ms of the 337 ms spent flushing. `scheduleSave` bumps `writeGeneration` for any state change, and switching workspaces dirties unrelated state constantly, so a binding untouched for minutes still looked unpersisted.
|
||||
|
||||
`pty-binding-durability-records.ts` closes that gap by remembering, per pane key, the session object identity, the PTY id, the incarnation, and the generation of the flush that put them on disk. A binding whose own last change is already durable qualifies regardless of what else is pending. The map is bounded at 4096 entries and cleared wholesale when it fills, since the entries are cheap to rebuild.
|
||||
|
||||
Retiring a record needs no cooperation from other writers, which is what keeps this safe as the code moves. Every session-replacing writer in the audit below installs a fresh session object, so the identity check retires the record on its own. The two in-place binding writers are covered by the stored values instead: SSH lease cleanup only clears bindings to null or removes keys, and SSH target migration rewrites the PTY id, so neither can leave behind a stale record that still matches a request.
|
||||
|
||||
The fast path must preserve the persistence lifecycle: once the final quit flush has started, a matching binding still reaches the existing refusal to synchronously flush. Durable equality does not authorize a late binding acknowledgement during shutdown.
|
||||
|
||||
### Why the counter is trustworthy
|
||||
|
||||
The counter would lie only if some code set a binding value to the requested value without bumping the generation, leaving memory matching while disk holds an older value. Every writer of binding values under `src/main` was enumerated for this spec, including writers that reach the fields through an alias rather than by property name:
|
||||
|
||||
| Writer | Mutates live session? | Saves? |
|
||||
| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `loading-store/pty-binding-persistence.ts` | Yes | `flushOrThrow` in the same call. |
|
||||
| `loading-store/workspace-session-terminal-binding-replay.ts` | No, the incoming replacement | Called only from `setWorkspaceSession` paths, which `scheduleSave`. |
|
||||
| `leasing-ssh-ptys/ssh-pty-binding-cleanup.ts` | Yes | Calls `scheduleSave` itself when any binding changed. Also only clears bindings to `null` or removes keys, which cannot match a request. |
|
||||
| `leasing-ssh-ptys/ssh-pty-pane-supersession.ts` | Via the cleanup module | `flush()` after. |
|
||||
| `ssh/ssh-target-id-migration.ts` | Yes, in place, through a `record` alias for `ptyIdsByLeafId` and directly on `tab.ptyId` | Sole caller `leasing-ssh-ptys/ssh-target-reassignment.ts` calls `scheduleSave` when anything changed. |
|
||||
| `runtime/runtime-terminal-orphan-session-adoption.ts` | No, a `structuredClone` | Result handed to `setWorkspaceSession`. |
|
||||
| `restoring-sessions/session-owner-removal.ts` | No | New session object handed to `setWorkspaceSession`. |
|
||||
| `tracking-repos/worktree-identity-migration.ts` | Yes, tombstone worktree ids | Caller in `metadata-lineage-operations.ts` calls `scheduleSave` when changed. |
|
||||
| `orca-profiles/profile-project-session-state.ts`, `profile-project-session-transfer.ts` | No, copies for transfer and removal | Results land through `setWorkspaceSession` or a state replace that saves. |
|
||||
| `runtime/mobile-session-layout-projection.ts` | No, a projection for the mobile client | Never persisted. |
|
||||
| `leasing-ssh-ptys/ssh-pty-lease-operations.ts` | Assigns `.ptyId` on a lease row, not a binding | Not a binding writer; listed because the ratchet regex matches it. |
|
||||
| Everything else outside `src/main/persistence` | Spreads into a new object and calls `setWorkspaceSession`; verified for every non-test `getWorkspaceSession` caller. | |
|
||||
|
||||
Other code mutates non-binding live state without a bump (SSH lease shutdown marking, lease tombstone retention, the deferred scrollback snapshot migration, load-time diff-comment relocation). None writes a binding value, so none can defeat this predicate. They are out of scope.
|
||||
|
||||
### Ratchet test
|
||||
|
||||
Keep the table above true without relying on memory. Add `src/main/persistence/loading-store/terminal-binding-writer-boundary.test.ts`, modeled on `src/shared/child-process/child-process-import-boundary.test.ts`:
|
||||
|
||||
- Walk `src/main` for `.ts` files, skipping test files, fixtures, and ignored directories the same way that test does.
|
||||
- Match, on comment-stripped text, assignments to `ptyIdsByLeafId`, `.ptyId`, `.root`, `terminalPtyIncarnationsByPaneKey`, and `terminalSurfaceTombstonesByPaneKey`. Assignment means `=` not followed by `=`, on a property access or index expression. Comparisons and destructuring are not matches.
|
||||
- Hold the allowlist in `__fixtures__/terminal-binding-writer-allowlist.txt`. Seed it from the regex's actual first run, not from the table. On the current tree that is the eleven files below; a reimplementation must recompute the list and pin the count it finds. The allowlist only shrinks. Add the three assertions from the model: no unlisted writer, no stale entry, offender count equals a literal pin.
|
||||
|
||||
```
|
||||
src/main/orca-profiles/profile-project-session-state.ts
|
||||
src/main/orca-profiles/profile-project-session-transfer.ts
|
||||
src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts
|
||||
src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts
|
||||
src/main/persistence/loading-store/pty-binding-persistence.ts
|
||||
src/main/persistence/loading-store/workspace-session-terminal-binding-replay.ts
|
||||
src/main/persistence/restoring-sessions/session-owner-removal.ts
|
||||
src/main/persistence/tracking-repos/worktree-identity-migration.ts
|
||||
src/main/runtime/mobile-session-layout-projection.ts
|
||||
src/main/runtime/runtime-terminal-orphan-session-adoption.ts
|
||||
src/main/ssh/ssh-target-id-migration.ts
|
||||
```
|
||||
|
||||
`ssh-pty-pane-supersession.ts` assigns no binding field itself and is not matched. Pin at the count the first run prints.
|
||||
|
||||
- Failure message: "New writer of a terminal binding value. It must bump the persistence write generation (scheduleSave, flushOrThrow, or setWorkspaceSession) in the same operation, or persistPtyBinding's fast path can skip a flush it needed. See orca-persistence-design-assessment.md."
|
||||
|
||||
The ratchet catches new files that name the fields. It cannot see a writer that reaches a binding record through an alias, as `ssh-target-id-migration.ts` does with its `record` parameter; that file is caught only because it also assigns `tab.ptyId` directly. The regex is therefore a tripwire for the common case, and the table above is the actual audit. A harness-wide serialization invariant or a frozen session view would defend a stronger property than the fast path needs and were considered and dropped.
|
||||
|
||||
## Tests
|
||||
|
||||
Extend `src/main/persistence-flush-and-save-scheduling.test.ts`, which already has the repeated-binding inode test at the "Warm-restart re-bind storm" case. Note that in this harness `Store.prototype.flushOrThrow` is installed from `PrimaryStateWriteOperations.prototype` by the class merge at the bottom of `store.ts`, and the binding code calls it through `runtime.flushOrThrow`, an arrow that dispatches on the instance. `vi.spyOn(store, 'flushOrThrow')` on the instance therefore intercepts it, as `persistence-split-pane-incarnation.test.ts` already relies on.
|
||||
|
||||
1. **Fast path skips all work.** Bind once. Spy on `store.flushOrThrow` and on `globalThis.structuredClone`. Bind again with identical args. Expect zero calls to each, return value `true`, and inode unchanged.
|
||||
2. **Pending save still flushes, and the sync no-op raises the counter.** Bind once. Call `store.setWorkspaceSession({ ...store.getWorkspaceSession() })`, which bumps the generation without changing any binding. Bind again identically. Expect `flushOrThrow` called once and the inode unchanged, because the flush hits the hash match and skips the rename. Then bind a third time and expect zero further flushes: this is the assertion that the `writeToDiskSync` hash-match branch now advances `lastDurableWriteGeneration`. Without that fix the third bind flushes again.
|
||||
3. **Incarnation mismatch falls through.** Bind with `incarnationId: 'a'`, then bind identically with `incarnationId: 'b'`. Expect a flush and the new incarnation on disk.
|
||||
4. **Undefined versus defined incarnation falls through.** Bind with an incarnation, then bind without one. Expect a flush.
|
||||
5. **Tombstone falls through.** A tombstone cannot be created through `setWorkspaceSession`: `sanitizeWorkspaceSessionTerminalRetirements` consumes and clears the tombstone map on every session write, which the "raised topology revision" case in `ssh-reattach-pane-cardinality.test.ts` pins. Seed it the way that file's "older profile" case does: write a data file whose session carries the binding, the incarnation, and a tombstone for the same pane key, then `createStore`. Bind identically with `incarnationId` set and `mayReviveRetiredSurface` unset. Expect a flush and the tombstone cleared.
|
||||
6. **Reconciled incarnation still bumps the fence.** Pass `expectedBinding` with an older incarnation and the same PTY. Expect the topology revision for the repo to advance, per the existing `reconciledIncarnation` logic.
|
||||
7. **Refusals unchanged.** For each of `expectedSourceBinding` tab mismatch, `expectedBinding` PTY mismatch, `mayReviveRetiredSurface: false` with a tombstone, and `mayCreate: false` with a missing layout, assert `false` is still returned and nothing was flushed. The tombstone and `mayCreate` refusals are already covered in `ssh-reattach-pane-cardinality.test.ts`; the fence refusals in `persistence-pty-binding-reconciliation.test.ts` and `persistence-split-pane-incarnation.test.ts`. Confirm rather than duplicate.
|
||||
8. **Non-local partition.** Repeat test 1 with an SSH host id to confirm the fast path resolves the session from `workspaceSessionsByHostId` and does not re-point the partition.
|
||||
9. **Sync hash match raises the durable generation.** Directly: change unrelated state, call `flushOrThrow` twice, and assert the second call performs no rename and leaves `lastDurableWriteGeneration === writeGeneration`. This pins the `writeToDiskSync` fix independently of the binding path.
|
||||
|
||||
The tests that actually execute `persistPtyBinding` are the ones built on a real `Store` through `createStore`: `persistence-flush-and-save-scheduling`, `persistence-host-partitioned-ssh-pty-bindings`, `persistence-split-pane-incarnation`, `persistence-pty-binding-reconciliation`, `persistence-pty-binding-leaf-tab-resolution`, `persistence-host-admitted-terminal-membership`, `persistence-worktree-deletion-fencing`, `persistence-ssh-remote-pty-leases`, `ssh-reattach-pane-cardinality`, `runtime/host-terminal-close-persistence-durability`, and `ipc/pty/ipc/spawn-commit-ssh-lease-cardinality`. All must keep passing. Two of them make `flushOrThrow` throw on a rebind to test rollback; both still fall through under the predicate, one because the tab PTY is `null` beforehand and one because the incarnation is being reconciled. The IPC and runtime suites (`pty-dead-owner-respawn`, `pty-persisted-incarnation-repair`, `mobile-session-tabs-part-05`, `mobile-session-tabs-part-08`, `orca-runtime-terminal-retirement`) inject a mock store with `persistPtyBinding: vi.fn()`, so the fast path never runs in them and their flush counts cannot move.
|
||||
|
||||
## Verification
|
||||
|
||||
Run `pnpm tc:node` and `pnpm test src/main/persistence-flush-and-save-scheduling.test.ts src/main/persistence/loading-store`, then the eleven real-store files above.
|
||||
|
||||
See "Measuring eligibility" below for how eligibility is recorded on the user's machine so the rate can be measured with this build.
|
||||
|
||||
Then measure on the real install after a day of accumulated state, using the probe through the inspector connection in `config/scripts/orca-main-inspector-connection.mjs`:
|
||||
|
||||
- Target: zero `buildStateToSave` calls whose stack includes `persistPtyBinding` during a sequence of workspace switches between parked workspaces, on calls the span recorded as `fast_lane`.
|
||||
- Then a typing capture with `config/scripts/capture-live-input-lag.mjs`. Report the keystroke queue-delay distribution before and after. Do not claim the lag is fixed from the persistence numbers alone; the unattributed remainder of the 117 ms needs its own capture.
|
||||
|
||||
## Measuring eligibility
|
||||
|
||||
Each call emits a `persistence.pty-binding` span through the existing local trace sink. It records `binding.outcome` (`fast_lane`, `flushed`, `refused`, or `threw`), host kind (`local`, `ssh`, or `runtime`), save-pending state, and the generation gap at entry. Calls reaching the predicate also record `binding.eligible` and comma-separated `binding.misses`. Refused calls do not evaluate eligibility. `flushed` means the write path completed; it does not mean bytes changed or a rename was necessary.
|
||||
|
||||
The sink already provides rotation, consent gating, redaction, and diagnostic-bundle collection. No extra persistence file or network sink is introduced. The attributes contain no pane, PTY, worktree, path, or host identifiers.
|
||||
|
||||
Successful fast-path spans have a budget of 200 per 60-second window. Further fast-path spans in that window are dropped; there is no separate sampling flag. Other outcomes are not budgeted. A saturated window therefore cannot supply an exact hit rate: report its count as a lower bound or exclude it from a rate calculation. The budget alone cannot reconstruct dropped calls.
|
||||
|
||||
For an unsaturated window, report outcomes, eligibility among evaluated calls, the miss histogram, calls whose only miss is `not_durable`, and the time range covered. The generation gap is global, so `not_durable` can reflect unrelated dirty state as well as a pending binding write. Include duration totals to assess main-thread cost. Rotated logs and bundle size limits can truncate the available time range.
|
||||
|
||||
### Reattach hit rate
|
||||
|
||||
`persistPtyBinding` cannot tell a fresh spawn from a warm remount: both arrive with a PTY id and an incarnation. Fresh spawns always flush, so a rate over all calls is diluted by however many terminals the user opened. Every caller does know, so each passes an optional `origin` in the args and the span records it as `binding.origin`:
|
||||
|
||||
| Call site | Origin |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `persistAdmittedStablePaneBinding` in `stable-owner.ts` | `result.isReattach === true` gives `reattach`, else `spawn`. |
|
||||
| Unfenced write in `spawn-commit-persist.ts` | Same rule. |
|
||||
| Unfenced write in `runtime/spawn-commit.ts` | `split` when `expectedSourceBinding` is set, else the same rule. |
|
||||
| Reattach bind in `ssh-relay-session.ts` | `relay_reattach`. |
|
||||
| Anything else | `unknown`, the default. |
|
||||
|
||||
`origin` is span metadata only. `persistPtyBinding` does not branch on it, and the predicate and ratchet ignore it.
|
||||
|
||||
The headline is fast-lane spans divided by spans with `origin` in `{reattach, relay_reattach}`, reported with the per-origin split. The second number is the count of reattaches whose only miss is `not_durable`; if it dominates, the fast path is correct but a pending save is what blocks it, and the renderer's switch-time write cadence is the next target. `isReattach` means the provider reused a live PTY, so it does not separate a parking-policy remount from an app-restart reattach. Both count. Isolating the parking case would need the renderer to send a reason with the spawn request, which is out of scope.
|
||||
|
||||
A first read of 22 spans from the running dev instance, before `origin` existed, found zero fast-lane hits. Four calls at one timestamp were durable with a zero generation gap and matched on layout, leaf PTY, and incarnation, but every one missed on `tab_pty` alone: a tab row holds a single `ptyId` while a tab can hold several panes, so sibling panes in a multi-leaf tab overwrite each other's `tab.ptyId` on every remount and can never converge. That install has seven such tabs. Whether the `tab_pty` check should compare against the pane's own binding rather than the tab's last-written PTY is the open question the origin-tagged rate will size.
|
||||
|
||||
The implementation does not record serialized payload size or a `binding.flushed` boolean. The reader script and a separate instrumentation-only release are deferred.
|
||||
|
||||
The inspector probe in `config/scripts/persistence-call-probe.mjs` remains a separate historical investigation tool: it records memory equality and call timings, not generation counters. Its captures alone cannot establish fast-path eligibility.
|
||||
|
||||
## Safety constraints
|
||||
|
||||
- Do not remove or defer `flushOrThrow` for any binding that changes state.
|
||||
- The `writeToDiskSync` counter fix applies only to the unforced hash-match return. Do not raise the counter on the `force` path or on any failed write.
|
||||
- Do not change the return value or exception of `persistPtyBinding` for any input the fast path does not accept. The SSH relay reattach in `ssh-relay-session.ts` expires the lease on `false`.
|
||||
- Do not widen the fast path to the automation, lease, or layout-publish paths; they have their own semantics.
|
||||
- The `persistence.pty-binding` span carries no pane key, PTY id, worktree id, path, or SSH target id. If a future attribute needs an identifier, hash it or drop it.
|
||||
- Preserve host partition behavior: resolve the session for the requested host id and never assume local.
|
||||
- Keep all Windows, WSL, SSH, and relay coverage as it is; the change is host-agnostic and adds no platform branch.
|
||||
|
||||
## Deferred: fixes 2 and 3
|
||||
|
||||
Recorded so the constraints are not rediscovered.
|
||||
|
||||
**Fix 2, small binding transactions.** Persist a binding with only the topology and ownership needed for recovery instead of the whole document. The session clone exists only for `restoreSession` after a failed flush; a narrow rollback must restore the tab row, the layout, the incarnation map, the tombstone map, and the repo topology revision. If bindings leave the single state document, every consumer must follow: backup rotation and `.bak` recovery in `backup-recovery-rotation.ts`, the orcad snapshot member set in `ssh/orcad-state-snapshot.ts` which copies `profiles/` wholesale on remote hosts, profile transfer and move, and downgrade, because an older build reads `orca-data.json` directly and would start with no bindings.
|
||||
|
||||
**Fix 3, off-main serialization.** The debounced autosave still serializes the full document synchronously before its first filesystem await. Three constraints: secret encryption in `buildStateToSave` uses Electron `safeStorage` and must stay on main, so only stringify, sentinel substitution, encoding, and hashing can move; the worktree-meta projection settles rows on reference identity, which a structured-clone boundary destroys; and `flushOrThrow` is a synchronous barrier used by binding, retirement, the Codex credit ledger, and quit, which a worker cannot provide. This fix applies to the autosave path only unless every synchronous barrier caller is converted, which is its own change with its own crash-window analysis.
|
||||
@@ -1,433 +0,0 @@
|
||||
<!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>
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Files that assign a terminal binding value (ptyIdsByLeafId, .ptyId, layout .root,
|
||||
# terminalPtyIncarnationsByPaneKey, terminalSurfaceTombstonesByPaneKey) under src/main.
|
||||
# Each one either bumps the persistence write generation in the same operation or never
|
||||
# touches the live session. Audited in orca-persistence-design-assessment.md. Only shrinks.
|
||||
# touches the live session. Audited in the persistence writer audit. Only shrinks.
|
||||
src/main/orca-profiles/profile-project-session-state.ts
|
||||
src/main/orca-profiles/profile-project-session-transfer.ts
|
||||
src/main/persistence/leasing-ssh-ptys/ssh-pty-binding-cleanup.ts
|
||||
|
||||
@@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest'
|
||||
* keeps the audited list of such writers from growing silently.
|
||||
*
|
||||
* It is a tripwire, not the audit: a writer that reaches a binding record through an alias is
|
||||
* invisible to the regex. The audit table lives in orca-persistence-design-assessment.md.
|
||||
* invisible to the regex. The audit table lives in the persistence writer audit.
|
||||
*/
|
||||
const TERMINAL_BINDING_WRITER_ALLOWLIST: readonly string[] = readFileSync(
|
||||
join(__dirname, '__fixtures__', 'terminal-binding-writer-allowlist.txt'),
|
||||
@@ -97,7 +97,7 @@ describe('terminal binding writer boundary', () => {
|
||||
'New writer of a terminal binding value. It must bump the persistence write generation ' +
|
||||
'(scheduleSave, flushOrThrow, or setWorkspaceSession) in the same operation, or ' +
|
||||
"persistPtyBinding's fast path can skip a flush it needed. " +
|
||||
'See orca-persistence-design-assessment.md.'
|
||||
'See the persistence writer audit.'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user