Skip to content

fix(terminal): every explicit terminal close commits through one main transaction - #22929

Merged
brennanb2025 merged 17 commits into
mainfrom
brennanb2025/terminal-close-intents
Sep 27, 2026
Merged

brennanb2025 merged 17 commits into
mainfrom
brennanb2025/terminal-close-intents

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​571 $\color{#cf222e}{\Huge{\mathbf{−}}}$​20 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​551
Prod 21 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​239 $\color{#cf222e}{\Huge{\mathbf{−}}}$​69 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​170

ELI5

When you close a terminal tab, or one pane of a split, Orca now tells its main process "close this tab" or "close this pane" directly, and main saves that right away. Before, main only learned a terminal was closed when its process exited, so a close whose process didn't exit cleanly could come back after a restart. Several close paths could also turn "close this pane" into "close the whole tab", taking the live pane beside it with it.

What Changed

Before

  • Main keeps its own saved list of each repo's terminal tabs and panes, and a renderer save can no longer remove entries from it once main owns them. So a close only left disk when main saw the closed process exit.
  • A closed tab or pane came back after a reload or restart when that exit never arrived: an SSH shutdown that threw, an unregistered SSH provider, or a stop that was confirmed but never reported an exit.
  • "Tab or pane?" was not carried anywhere. Each close path worked it out again from its own copy of the layout, and every fallback meant "the whole tab". Concretely:
    • orca terminal close on one pane of a split closed the whole tab when that pane's stop couldn't be confirmed;
    • a phone closing one pane of a split, on a host with no desktop window, killed and closed the whole tab;
    • a phone closing a pane with no live process sent the desktop a whole-tab close.

After

  • Every explicit close states its target: { kind: 'tab' } or { kind: 'pane', leafId } (src/shared/terminal-surface-close-target.ts). No close path has an optional pane id that silently means "the whole tab".
  • One transaction in main, closeTerminalSurface, commits the removal in the session partition of the host that owns the worktree. It runs over the profile store's durable write (runDurableMutation), bumps the repo's topology revision so a stale renderer save can't restore the surface, and returns what it did.
  • One function, resolveTerminalCloseTarget (src/main/runtime/terminal-surface-close.ts), is the only place a pane close can become a tab close. It does so only when the layout owner's copy names that exact pane as the tab's only pane, and then it goes through the normal tab close, so the pinned-tab guard still runs. A pane that no copy knows about, or an empty copy, is a no-op: a pane close never widens on missing information. Closing the only pane of an unsplit tab still closes the tab.
  • The layout owner's copy is the desktop's published layout when the desktop lists the tab, otherwise main's own saved layout. Until main becomes the single writer of the layout, main's saved layout can lag a split whose new pane hasn't bound yet, so reading it would close the tab and lose that pane.
  • The desktop renderer sends its closes to main over session:close-terminal-surface (tab close and pane close). It still removes the surface and stops its own processes immediately, exactly as before. It never waits for main's write; the IPC result is only used to log a failure.
  • orca terminal close, orchestration closes, phone closes (relayed through the desktop and headless) and the headless mobile close all use the same transaction. The per-path sibling counts are gone.
  • For a pane close main starts (CLI or phone), main tells the desktop to drop that pane by its pane id (closeTerminalPane). Tab closes use a separate, tab-only notice. The old notice by numeric pane id is gone.
  • A phone closing one pane of a split now stops and closes only that pane, and a failed kill no longer blocks the close.
  • A failed save never undoes a user's close. The removal stays in memory, the save stays marked unsaved so the next write persists it, and the kill still runs. Only retirements main starts on its own (a process exit) keep the store's rollback.
  • orca terminal close still exits 1 when it can't confirm the process stopped, because a script must not read that as "stopped". The message now says what happened: Closed terminal <handle>. The PTY was not confirmed stopped: <reason>. It adds "The kill retries when the host reconnects." only when the host actually recorded that kill for replay. The host now records it before the follow-up kill rather than after it, and reports it in a new optional receipt field, pendingKillRecorded.

Unchanged: exit handling. Exits still retire tabs and panes through the existing exit path, and the renderer's exit behaviour is untouched. No durable close records are added.

Why

A user's close depended on a process exit, and whether a close meant a tab or a pane was re-derived at every layer with "the whole tab" as the default. Each lost exit and each fallback was its own bug, and review kept finding new ones one site at a time. Making the close itself the commit, making the target a type, and putting the one tab-or-pane decision in one function removes both classes instead of guarding each site.

Alternatives considered:

  • Have the execution host confirm each close, and reject it when the host can't be reached. Rejected: bookkeeping must never block a user's action, and an unreachable SSH host would make the tab impossible to close.
  • Decide "last pane closes the tab" from main's saved layout alone. Rejected for now: main's saved layout can lag a split whose new pane hasn't bound yet, so it would close the tab and lose that pane. It becomes the rule once main is the single layout writer.
  • Have main also do the kill for the desktop's own closes. Deferred. The renderer's teardown handles hibernation, runtime-environment terminals and shared process ownership; moving it belongs with the single-writer work.
  • Make orca terminal close exit 0 when only the stop is unconfirmed. Rejected: every close branch exits 1 in that case so a chained script can't read "closed" as "stopped", and changing that contract is separate from this PR.

Differences from the common pattern

  • Matches: the membership removal is committed first and the kill is best-effort afterwards; every close enters through one owner-side transaction; a close aimed at one pane never escalates to its tab, and a close for an unknown pane does nothing.
  • Differs: the desktop still does its own kill for the closes it starts; main commits only the membership for those.
  • Differs: the close is not acknowledged by the execution host, on purpose (see above).
  • Differs: there are two owners of a tab's layout until the single-writer work lands, so the tab-or-pane decision reads whichever owns that tab.

Linked Issue

No public issue. This is the first slice of the terminal tab membership work: every close is an explicit, committed intent.

Visual Proof

Live Electron QA on a separate Mac (hidden window, Playwright over CDP), at the ported head 67345847d6 with a before arm on the main commit it merged.

Step Before close After close
Close a tab with its close button (DROP)
Close one pane of a split from the UI (RIGHT)
orca terminal close on one pane of a split

After a full app restart on the same profile, only the survivors come back (git worktree, then folder workspace):

The last pane still goes through the pinned-tab dialog, and a pane whose shell ignores hangup and terminate signals closes alone with its sibling kept:

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Live QA (hidden Electron windows, Playwright over CDP; closed state read from the renderer store and from the profile's persisted session):

  • Desktop, macOS (separate Mac and this laptop): tab close (git worktree and folder workspace), UI pane close, orca terminal close on a typed and on a never-typed pane, a pane that ignores signals, the pinned-tab dialog, and exit. Each closed surface stayed closed across a reload and a full restart on the SQLite profile store, and the surviving tab or pane came back.
  • SSH (throwaway Linux container): closing a tab and a split pane while the host was paused (docker pause); both stayed closed across unpause, reload and restart, and only the addressed pane closed.
  • Mobile (iOS Simulator paired to a dev desktop): a phone close of a pinned tab is refused exactly as on main; an unpinned phone close stays closed after a desktop restart.

New tests (each proven by breaking the fix and watching it fail):

  • orca-runtime-terminal-close-no-widening.test.ts: every close entry point (desktop tab and pane, CLI by handle and by PTY, phone relayed and headless) across normal, unconfirmed stop, throwing stop, pane already retired, a sibling the desktop has but main hasn't saved yet, pinned, no saved layout, and nothing published yet. It asserts the sibling survives, no tab close is sent for a pane close, and a last-pane close goes through the tab close.
  • terminal-close-async-durability.test.ts: closes against the real SQLite store with delayed writes. A close reports success only once the write is on disk; a failed write keeps the removal and still kills; the desktop close never waits on the write; a split binding landing mid-write doesn't refuse the desktop's own tab close. Going back to a synchronous flush fails it.
  • The target parser, the resolver, the renderer intents (tab and pane), save-then-reload of tab and pane closes, the leaf notice (mounted and parked panes), pendingKillRecorded on both sides (field present and absent), and the CLI message and exit code.

Commands: pnpm tc, pnpm run audit:anti-slop, check-changed-code-quality.mjs, full pnpm exec oxlint, vitest over src/main/runtime, src/main/persistence/loading-store, the session IPC, CLI, renderer terminal and store tests, and both cross-version wire suites (cross-version-terminal-wire, retirement proof).

AI Disclosure

Written with Claude.

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Mixed versions: the renderer-to-main close channel and the pane notice are between the renderer and main of the same build. The paired-client wire (session.tabs.close, terminal.close, terminal.closeTab) is unchanged; the pane-vs-tab intent was already carried by the parent::leaf vs parent id shape and by the separate close methods. The only wire addition is the optional pendingKillRecorded field on the terminal.close result: an older CLI ignores it, and a newer CLI talking to an older host sees no field and makes no retry promise.
  • SSH: the close is committed in the partition of the host that owns the worktree, whatever the kill does. An unconfirmed stop records the kill for replay on reconnect, and the replay is fenced by process incarnation.
  • Folder workspaces use the same path as git worktrees.
  • Performance: each close that changes something does one durable write; closes that change nothing skip it. "Close others" on N tabs does N writes.
  • Known, not changed here (owned by the next slices):
    • A pane closed in the few milliseconds between a split and its new process binding, whose exit is then never confirmed (in practice a failed SSH kill), can reappear after a restart. Fixing it needs the in-flight closed-surface set that lands with the change that stops exits from retiring surfaces. The same window exists on main.
    • A desktop pane close is still fenced by that pane's process incarnation: if the same pane re-binds in the milliseconds before the write lands, the close is refused and the pane stays saved until its exit retires it.
    • A single-pane terminal that no layout copy records at all becomes a stop plus a pane notice on the desktop instead of a tab close. No path was found that gives such a tab a live process.
    • A sole, brand-new terminal whose shell is exited by orca terminal send (no keyboard input) stays on the tab bar until a reload, although main has already dropped it. Typing exit closes it live. Identical on main: the renderer keeps a never-typed newborn pane while main retires it, and that split is what the next slice removes.

Author X handle: @BrennanKB5

Checklist

  • This PR is small and focused (focused, but not small: the typed close target touches every close path)
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

… transaction

A renderer save cannot shrink terminal membership once main owns a repo's
topology, so desktop tab and pane closes, CLI split-pane closes and mobile
split-pane closes only became durable when the killed process's exit retired
the surface. A close whose kill failed or threw, or whose exit was never
certified, came back after a reload.

Every close now reaches closeTerminalSurface: the renderer sends an explicit
intent for user and cleanup closes, the CLI and mobile split-pane closes commit
the pane after their stop, and the headless and relayed mobile closes reuse the
same commit. A failed flush keeps the in-memory removal and no longer cancels
the kill. Exit retirement is unchanged.
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 7fd09323-5b3a-46cb-b1f0-6dadfbd0391a

📥 Commits

Reviewing files that changed from the base of the PR and between 28613ff and e17f7a6.

📒 Files selected for processing (2)
  • src/shared/terminal-surface-close-target.test.ts
  • src/shared/terminal-surface-close-target.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The runtime now distinguishes terminal tab closes from split-pane closes. Shared typed targets flow through IPC, preload APIs, renderer intents, and runtime close paths. The runtime resolves pane targets against available layout state, removes only the addressed pane when appropriate, and sends pane-specific notifications. Unconfirmed SSH PTY stops can record a kill order for host reconnection; close results and CLI output indicate whether that order was recorded. Tests cover close routing, pane and tab behavior, persistence, and stop reporting.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to e17f7

Split-pane closes can alter another worktree’s layout, hide a pane whose process may still be running, or leave a watcher subscribed. Resolve these close-path issues before merging.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to e17f7

A pane close can affect a terminal layout outside the requested workspace, and a failed process stop can leave a terminal running after its pane disappears. The impact is bounded by the host session and the caller’s existing terminal access; broader exposure is not established.

Retained concerns

  • Medium · security · inferred: A pane target whose tab belongs to another worktree in the same host session can retire that tab’s layout leaf: the pane path checks the host-wide tab-keyed layout but not membership in the requested worktree.
  • Medium · reliability · inferred: A graph-backed split-pane close removes membership even when its PTY stop is unconfirmed, potentially hiding a still-running process. An unconfirmed SSH kill may be recorded for retry, but that does not establish a recovery guarantee for every controller.
Security review details

Security Blast Radius

  • inferred — The demonstrated pane-target ownership gap reaches other worktrees sharing a host session; the evidence does not establish cross-host reachability or a remotely unprivileged attacker.

Security Findings and Attack Paths

  • inferred — A caller able to invoke the renderer session channel can supply a worktree ID and another tab’s pane coordinates. Shape validation permits the request, while main looks up the layout by tab ID and can persist a change to that other tab’s pane.

Trust Boundaries and Controls

  • observed — The IPC parser rejects malformed targets, and pane retirement checks a supplied PTY or incarnation when one is provided. The explicit pane close instead derives the PTY from the current layout and supplies no incarnation, so those checks do not prove the intent refers to the original pane incarnation.

Resilience and Maintainability Implications

  • inferred — Topology revisions protect against stale session saves, but their presence does not establish that a delayed close intent is safe if tab and leaf coordinates can be reused. A never-reuse guarantee was not established.

Hardening Proposals

  • proposed — Before retiring a pane, require its tab to belong to the requested worktree and consider carrying an expected PTY incarnation or another non-reusable surface identity across the close boundary.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.18% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 44 functions across 49 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: routing every explicit terminal close through one main-process transaction.
Description check ✅ Passed The description is comprehensive and follows the template. It explains the user impact, implementation, rationale, alternatives, visual proof, testing, compatibility notes, known limitations, and AI d…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 4ebc1f1c-7c09-423e-9ccc-9d1fec6fff22

📥 Commits

Reviewing files that changed from the base of the PR and between acf8e67 and d285a67.

📒 Files selected for processing (18)
  • src/main/ipc/register-core-handlers/register-core-handlers.test.ts
  • src/main/ipc/register-core-handlers/register-core-handlers.ts
  • src/main/ipc/session.ts
  • src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
  • src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
  • src/main/runtime/orca-runtime-close-mobile-session-tab.ts
  • src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
  • src/main/runtime/orca-runtime-terminal-close-continuity.test.ts
  • src/main/runtime/orca-runtime-terminal-surface-close.test.ts
  • src/main/runtime/terminal-surface-close.ts
  • src/preload/api/session-bridge.ts
  • src/preload/api/workspace-session-api.ts
  • src/renderer/src/components/terminal-pane/pending-split-close.test.ts
  • src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
  • src/renderer/src/store/terminals/terminal-surface-close-intent.ts
  • src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts
  • src/renderer/src/store/terminals/terminal-tab-close.ts
  • src/renderer/src/web/preload-api/web-workspace-session-api.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Comment on lines +271 to +272
// Why: the pane's removal is this close's own commit, not a side effect of its exit.
this.closeTerminalLeaf(worktreeId, tab.parentTabId, tab.leafId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

set -eu
printf '%s\n' '--- changed file outline ---'
ast-grep outline src/main/runtime/orca-runtime-close-mobile-session-tab.ts
printf '%s\n' '--- target file around branch ---'
sed -n '220,300p' src/main/runtime/orca-runtime-close-mobile-session-tab.ts
printf '%s\n' '--- direct bindings and call sites ---'
rg -n -C 4 'closeTerminalLeaf|listMobileSessionTabs|mobileSessionTabsByWorktree|setMobileSession|publish.*Mobile|MobileSessionTabs' src/main
printf '%s\n' '--- diff for target file against supplied base ---'
git diff --unified=35 8fb13edd9ff98f3670bc09a1270cd69fb07ccc58 d285a67c0a3ce1007b87764b81c8a4356a99f34b -- src/main/runtime/orca-runtime-close-mobile-session-tab.ts

Repository: stablyai/orca

Length of output: 45665


🏁 Script executed:

set -eu
printf '%s\n' '--- closeTerminalLeaf declarations and calls ---'
rg -n -C 12 'closeTerminalLeaf\s*\(' src/main/runtime --glob '*.ts' --glob '!*.test.ts'
printf '%s\n' '--- finishCommittedClose declaration and calls ---'
rg -n -C 16 'finishCommittedClose\s*=|function finishCommittedClose|finishCommittedClose\s*\(' src/main/runtime/orca-runtime-close-mobile-session-tab.ts
printf '%s\n' '--- snapshot mutation and emission helpers in runtime sources ---'
rg -n -C 10 'storeMobileSessionSnapshot|emitMobileSessionTabsSnapshot|notifyMobileSessionTabsChanged' src/main/runtime/orca-runtime-close-mobile-session-tab.ts src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts

Repository: stablyai/orca

Length of output: 34912


🏁 Script executed:

set -eu
printf '%s\n' '--- closeTerminalSurface declarations ---'
rg -n -C 18 'closeTerminalSurface\s*\(' src/main/runtime --glob '*.ts' --glob '!*.test.ts'
printf '%s\n' '--- closeTerminalLeaf file context ---'
sed -n '90,145p' src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
printf '%s\n' '--- relevant snapshot/listing definitions ---'
rg -n -C 14 'getMobileSessionTabsForWorktree|listMobileSessionTabs|mobileSessionTabsByWorktree' src/main/runtime/orca-runtime-service.ts src/main/runtime --glob '*.ts' --glob '!*.test.ts' | head -n 500

Repository: stablyai/orca

Length of output: 41450


🏁 Script executed:

set -eu
printf '%s\n' '--- listMobileSessionTabs implementation ---'
rg -n -C 24 '(?:async\s+)?listMobileSessionTabs\s*\(' src/main/runtime --glob '*.ts' --glob '!*.test.ts'
printf '%s\n' '--- headless close implementation ---'
sed -n '1,125p' src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
printf '%s\n' '--- snapshot emission and leaf-removal candidates ---'
rg -n -C 12 'leafId|parentLayout|emitMobileSessionTabsSnapshot|storeMobileSessionSnapshot' src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts --glob '*.ts'

Repository: stablyai/orca

Length of output: 45659


🏁 Script executed:

set -eu
printf '%s\n' '--- exact non-test listing declarations ---'
rg -n 'listMobileSessionTabs' src/main/runtime --glob '*.ts' --glob '!*.test.ts' --glob '!*.spec.ts'
printf '%s\n' '--- candidate method contexts ---'
while IFS=: read -r file line rest; do
  [ -n "$file" ] || continue
  start=$((line-20)); [ "$start" -lt 1 ] && start=1
  end=$((line+45))
  printf '\n--- %s:%s ---\n' "$file" "$line"
  sed -n "${start},${end}p" "$file"
done < <(rg -n 'listMobileSessionTabs' src/main/runtime --glob '*.ts' --glob '!*.test.ts' --glob '!*.spec.ts' | cut -d: -f1-2)

Repository: stablyai/orca

Length of output: 1824


🏁 Script executed:

set -eu
sed -n '1,125p' src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts

Repository: stablyai/orca

Length of output: 5598


🏁 Script executed:

set -eu
rg -n -C 25 'hydrateHeadlessMobileSessionTabsFromWorkspaceSession\s*\(' src/main/runtime --glob '*.ts' --glob '!*.test.ts' --glob '!*.spec.ts'

Repository: stablyai/orca

Length of output: 42310


🏁 Script executed:

set -eu
sed -n '31,240p' src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts

Repository: stablyai/orca

Length of output: 10097


🏁 Script executed:

set -eu
printf '%s\n' '--- hydration tail ---'
sed -n '180,270p' src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts
printf '%s\n' '--- storeMobileSessionSnapshot definition ---'
rg -n -C 18 'storeMobileSessionSnapshot\s*\(' src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts src/main/runtime --glob '*.ts' --glob '!*.test.ts' --glob '!*.spec.ts' | head -n 220

Repository: stablyai/orca

Length of output: 28146


🏁 Script executed:

set -eu
printf '%s\n' '--- exact snapshot store helper ---'
rg -n -C 10 '((protected|private|public)\s+)?storeMobileSessionSnapshot\s*\(' src/main/runtime --glob '*.ts' --glob '!*.test.ts' --glob '!*.spec.ts' | grep -E '^[^:]+:[0-9]+:' | head -n 120
printf '%s\n' '--- terminal snapshot retirement helpers ---'
rg -n -C 12 'retireTerminalSurfacesFromSnapshot|retire.*Terminal.*Snapshot|exactSurfaces' src/main/runtime --glob '*.ts' --glob '!*.test.ts' --glob '!*.spec.ts'

Repository: stablyai/orca

Length of output: 37166


🏁 Script executed:

set -eu
printf '%s\n' '--- store helper ---'
sed -n '80,125p' src/main/runtime/orca-runtime-runtime-id.ts
printf '%s\n' '--- touch helper ---'
sed -n '1,155p' src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts
printf '%s\n' '--- retirement helper output shape ---'
sed -n '193,285p' src/main/runtime/mobile-session-terminal-retirement.ts

Repository: stablyai/orca

Length of output: 10569


🏁 Script executed:

set -eu
sed -n '1,35p' src/main/runtime/orca-runtime-close-mobile-session-tab.ts
sed -n '255,278p' src/main/runtime/orca-runtime-close-mobile-session-tab.ts

Repository: stablyai/orca

Length of output: 3026


Publish the leaf removal after a successful PTY kill.

This branch persists the workspace session removal but does not update or notify the mobile snapshot. A later listing is not sufficient in all cases. Without a PTY exit event, subscribed mobile clients can retain the closed leaf.

Suggested fix
 import { rendererPublicationThrottle } from '../window/renderer-publication-throttle'
+import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-retirement'
 
 export class OrcaRuntimeWithCloseMobileSessionTab extends OrcaRuntimeWithRefuseUnattributedMobileSessionTabClose {
...
           // Why: the pane's removal is this close's own commit, not a side effect of its exit.
           this.closeTerminalLeaf(worktreeId, tab.parentTabId, tab.leafId)
+          const retired = retireTerminalSurfacesFromSnapshot({
+            snapshot,
+            ptyId: pty.ptyId,
+            exactSurfaces: [{ parentTabId: tab.parentTabId, leafId: tab.leafId }],
+            exactOnly: true
+          })
+          if (retired) {
+            this.storeMobileSessionSnapshot(worktreeId, retired.snapshot)
+            this.notifyMobileSessionTabsChanged(worktreeId)
+          }
           return finishCommittedClose()

return { session, ptyIdsToKill: [], closed: false, pinned: false }
}
// The leaf's own binding is passed so the retirement's stale-binding fence always admits it.
const retired = retireTerminalSurfaceFromPersistence(session, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Verify the tab belongs to worktreeId before retiring a leaf.

terminalLayoutsByTabId[tabId] is selected without a worktree check. If a close request supplies a tab ID from another worktree in the same session, retireTerminalSurfaceFromPersistence can change that tab's layout while looking for its tab row in the requested worktree. The other worktree then retains a tab with the wrong layout. Check tab membership before this call.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

One timing-dependent over-removal: a leaf-addressed close can delete the whole parent tab's membership — sibling pane included — when main's persisted layout lags the live split. Details inline.

Reviewed changes

  • One close transaction in main — new closeTerminalSurfaceInWorkspaceSession (terminal-surface-close.ts) removes a tab or a single split leaf and advances the repo topology revision so a stale renderer save cannot restore it; closeTerminalSurface/closeTerminalSurfaceFromRenderer/closeTerminalLeaf replace the old commitHeadlessTerminalTabRetirement.
  • Renderer close intents — new session:close-terminal-surface IPC plus commitTerminalSurfaceClose, sent from closeTab (tab) and the pane-close action (leaf); pinned/running-process guards and the kill stay in the renderer, and paired-web keeps its no-op.
  • CLI / mobile / headless closes — closeTerminal of one pane in a split, the phone split-leaf close, and the relayed mobile close all commit the pane through the same transaction after the stop.
  • Flush failure no longer cancels a close — the headless close keeps the in-memory removal and still kills; rollback now applies only to host-started retirements.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…osed

A CLI or mobile close of one pane in a split commits the pane in main, but the
desktop kept showing it until reload when no exit arrived to remove it. The
close now sends a leaf-addressed notice: a mounted pane closes by leaf id, and
a parked tab collapses its stored layout. Addressing by leaf makes the notice
and the renderer's exit handling no-ops after each other, which replaces the
numeric pane-id notice that could close the whole tab when the exit won.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e20e010d-70f5-4cc4-910a-073b797ef21a

📥 Commits

Reviewing files that changed from the base of the PR and between d285a67 and 7012c3e.

📒 Files selected for processing (13)
  • src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
  • src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
  • src/main/runtime/orca-runtime-terminal-surface-close.test.ts
  • src/main/runtime/orca-runtime-test-scenario-builders.spec.ts
  • src/main/runtime/runtime-notifier-contract.ts
  • src/main/window/runtime-window-lifecycle.ts
  • src/preload/api/ui-bridge-clipboard-and-window-controls.ts
  • src/preload/api/ui-command-event-api.ts
  • src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice.test.ts
  • src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice.ts
  • src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts
  • src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts
  • src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.

Comment on lines +198 to +199
if (siblingCount > 1) {
this.closeTerminalLeaf(leaf.worktreeId, leaf.tabId, leaf.leafId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not retire a split pane while its PTY stop is unverified.

If stopAndWait fails or times out, stopExplicitlyClosedTabPtys can return false after a follow-up kill attempt. This branch still removes the graph-backed pane and notifies the renderer. The PTY may remain running without a visible pane. Keep the surface until termination is verified, or define and enforce a separate recovery path for an unverified stop.

}

/** Removes one leaf from a parked tab's stored layout; a no-op once the leaf is gone. */
export function collapseParkedTerminalLeaf(tabId: string, leafId: string, ptyId?: string): void {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Remove the closed leaf’s parked watcher when a notice closes it.

If main closes a parked split pane without a reported PTY exit, applyClosedTerminalLeafNotice calls this function directly. The layout loses the leaf, but its entry in disposersByPtyId remains subscribed. Unlike the exit path, no later step removes that watcher. Dispose and remove the closed leaf’s watcher on the notice path while preserving the surviving pane’s watcher.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in this delta. The new commit makes the pane-close notice leaf-addressed, which fixes the case where a notice arriving after the pane's exit fell through to a whole-tab close. The stale-persisted-layout over-removal flagged in the previous review is unchanged and still open.

Reviewed changes

  • Leaf-addressed close notice — RuntimeNotifier.closeTerminal now takes leafId?: string instead of a numeric paneRuntimeId, and closeTerminalLeaf tells the desktop renderer to drop the leaf it just committed. The ui:closeTerminal payload, preload types, and the renderer bridge all move to leafId.
  • New applyClosedTerminalLeafNotice — a parked tab collapses its stored leaf, a mounted pane gets CLOSE_TERMINAL_PANE_EVENT with { tabId, leafId }, so the notice and the pane's own exit handling are mutually no-ops.
  • collapseParkedTerminalLeaf extracted from collapseParkedExitedLeaf so both the exit and the notice collapse one stored leaf and clear the launch agent from the leaf's PTY binding.
  • CLI/orchestration and mobile split closes — stopExplicitlyClosedTabPtys and the mobile session-tab close commit the leaf and notify by leaf, with a parsePaneKey fallback for the leaf id when no HUB surface is available.
  • Tests — new closed-terminal-leaf-notice.test.ts, a leaf-intent assertion in pending-split-close.test.ts, and leaf-notice assertions in the surface-close and continuity suites.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

A leaf-addressed close fell through to the whole-tab close whenever main's layout no longer
held that leaf as one of several. Main's exit handling retires an exited split pane from the
saved layout, so closing that pane afterwards (the exited-pane overlay's Close, or a CLI close
whose stop delivers the exit first) removed the whole tab, live sibling included, and the
next renderer save could not restore it. A pane close is now a no-op unless its leaf is in a
multi-pane layout.

Also updates two mobile split-close assertions to expect the leaf-addressed notice, and adds a
test that a relayed mobile close of a renderer-listed tab still reaches the renderer's pin guard.
The existing CLI split test resolves its handle through the renderer graph, so the branch
that closes a runtime-owned pane by its PTY handle had no test failing without its commit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important

The no-op guard removes the sibling over-removal, but the close is still not durable when main's persisted layout lags the live split: closing the leaf main already persisted is rebased back on the renderer's save, restoring the closed pane and dropping the live sibling. Reproduction inline.

Reviewed changes

  • Leaf close no longer widens into a tab close — closeTerminalSurfaceInWorkspaceSession now takes the leaf branch for any leafId and returns a no-op when the persisted layout is absent, single-leaf, or does not contain the leaf, instead of falling through to closeTerminalTabInWorkspaceSession.
  • Tests — added never widens a pane close into a tab close (exit already retired the leaf, pinned tab, no saved layout), keeps the sibling when the stop delivers the exit before the pane commit, and mobile close of a tab the desktop renderer lists; tightened the mobile split-leaf and headless split tests to assert the leaf-addressed notice.
  • JSDoc — closeTerminalSurfaceInWorkspaceSession now states that a leaf close never removes its tab and callers close the last pane with a tab close.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +25 to +30
if (
!layout ||
countTerminalLayoutLeaves(layout.root) <= 1 ||
!layoutContainsLeafId(layout.root, options.leafId)
) {
return { session, ptyIdsToKill: [], closed: false, pinned: false }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When main's persisted layout lags the live split and the user closes the leaf main already persisted, this returns closed: false, so the intent never advances the topology revision. The renderer's post-close save then carries the sibling only, and rebaseLayout sees differing membership and keeps the host's prior layout — so a reload restores the closed leaf and drops the live sibling. Reproduced against a real Store (details below).

Technical details
# No-op leaf close is rebased back when the persisted layout lags

## Reproduction (verified with a throwaway test against a real `Store`)
- Persisted session: one terminal tab `TAB_ID`, layout `{ root: leaf LEAF_ID, ptyIdsByLeafId: { LEAF_ID: PTY_ID } }`, topology revision advanced (the debounce-lag state after a split).
- `runtime.closeTerminalSurfaceFromRenderer({ worktreeId, tabId, leafId: LEAF_ID })` → guard hits `countTerminalLayoutLeaves(...) <= 1` → `closed: false`, no revision bump.
- Renderer's post-close save (host revision stripped): layout `{ root: leaf SIBLING_LEAF_ID, ptyIdsByLeafId: { SIBLING_LEAF_ID: SIBLING_PTY_ID } }`.
- Reload → persisted layout root is `LEAF_ID` again (the closed pane), and `SIBLING_LEAF_ID` is gone.
- Closing the NON-persisted sibling instead is durable: candidate and prior membership match, so `rebaseLayout` keeps the candidate.

## Affected sites
- `src/main/runtime/terminal-surface-close.ts:25-30` — the no-op guard; the intent must also make the removal survive the renderer's next save.
- `src/main/runtime/workspace-session-terminal-membership-authority.ts:44` — `rebaseLayout` returns `current` (host prior) whenever candidate leaf membership differs, so the renderer's shrink is discarded.
- `src/main/persistence/loading-store/workspace-session-snapshot-publication.ts:41` / `session-host-partitions.ts:184` — where `sanitizeWorkspaceSessionTerminalRetirements` runs on every renderer save.

## Required outcome
- Closing the persisted leaf with a lagging layout must not resurrect it after a reload. The intent's commit and the rebase must agree on the post-close layout, or the guard needs live sibling evidence before deciding to no-op.

## Open questions for the human
- Should the no-op path record a tombstone / advance the revision so the renderer's save is treated as authoritative for the dropped leaf, rather than leaving the host prior layout to win the rebase?
- Is the lag window (renderer layout write debounced ~150 ms) considered reachable enough to cover, given the PR explicitly targets "a stop was confirmed but no exit was ever reported"?

…that pane

`orca terminal close <handle>` on one pane of a split used to close the
whole tab, live sibling included, whenever that pane's stop could not be
confirmed (for example an unreachable SSH host). An unconfirmed stop is
unverifiable, not a reason to drop siblings: the close now commits only
that leaf, tells the renderer to drop that leaf, and leaves the owed kill
to the controller's existing SSH pending-kill path.

On a host where no renderer lists the tab, main now also removes the
closed pane from the paired-client snapshot (with its retirement proof),
since no exit may arrive to do it.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in this delta. The unconfirmed-stop fix is correct and well covered; the previously flagged lag concern on terminal-surface-close.ts (thread 4109091881) is unchanged by these commits and remains open.

Reviewed changes

  • CLI pane close no longer widens on an unconfirmed stop — orca-runtime-stop-explicitly-closed-tab-ptys.ts now branches on siblingCount: a multi-pane close always commits only the addressed leaf through closeTerminalLeaf, and the whole-tab path is reserved for siblingCount <= 1. An unverifiable stop still issues the owed kill through the controller's pending-kill path.
  • Headless paired-client snapshot drops the pane — new retireClosedTerminalLeafFromMobileSnapshot removes the leaf (with its retirement proof) from mobileSessionTabsByWorktree when no renderer lists the parent tab, so paired clients lose the pane even though no exit may arrive. A renderer that lists the tab republishes instead, so the method returns early.
  • Tests — two it.each cases cover the no-exit and SSH-throw stop paths (asserting the sibling survives a stale save and the kill goes only to the closed pane), plus a no-renderer paired-client snapshot test. All 40 tests across the two files pass and oxlint is clean.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Reject a non-string leafId at the IPC boundary. · session.ts:32-46

src/main/ipc/session.ts:32-46
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject a non-string leafId at the IPC boundary.

The API defines leafId as an optional string, but the handler silently drops any other present value. The runtime then takes the no-leafId branch and can close the entire tab instead of one pane.

Suggested fix
-      if (typeof args?.worktreeId !== 'string' || typeof args.tabId !== 'string') {
+      if (
+        typeof args?.worktreeId !== 'string' ||
+        typeof args.tabId !== 'string' ||
+        (args.leafId !== undefined && typeof args.leafId !== 'string')
+      ) {
         throw new Error('invalid_terminal_surface')
       }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: bca7fd6c-091e-4f4a-b6a1-218ddb028bbc

📥 Commits

Reviewing files that changed from the base of the PR and between 7012c3e and bc8bbd7.

📒 Files selected for processing (6)
  • src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
  • src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
  • src/main/runtime/orca-runtime-terminal-surface-close.test.ts
  • src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts
  • src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts
  • src/main/runtime/terminal-surface-close.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.

… a tab close

Every explicit close now states its target as `{kind:'tab'}` or `{kind:'pane', leafId}`; no
optional leaf id silently means the whole tab. Main resolves a close it started in exactly one
place, reading the copy of the tab's panes its layout owner holds (the renderer-published layout
for tabs the desktop renderer lists, main's session layout otherwise). Only `last-pane` escalates,
through the existing tab path so the renderer's pin guard still runs; an unknown pane never widens.

- The CLI and phone paths drop their per-site sibling counts for the resolver.
- The notifier splits into a tab-only close and a leaf-addressed pane close.
- The headless tab closer takes a parent tab id, so a pane row cannot reach it.
- A phone close of one pane on a host with no desktop window now stops and closes only that pane.
- A phone close of one pane with no live process record closes that pane, not its tab.
`orca terminal close` still exits 1 when the process stop cannot be verified, but its message now
says the terminal was closed and names the host's reason, instead of "close failed". It promises
that the kill retries on reconnect only when the SSH relay itself never answered the stop, the one
case a recorded kill order backs.
A paired client's close of one pane threw `terminal_close_failed` before committing anything when
the controller reported the kill failed, so the pane stayed. The kill is now best-effort, as it is
for a whole-tab close: the pane's removal always commits and the failure stays on the PTY's
liveness verdict.
…ast pane

The close resolver read an owner copy that records no panes as "the tab has
one pane", so a CLI close of one pane of a split, addressed while the
renderer listed the tab before publishing its panes, closed the whole tab.

Every copy now counts only if it records at least one pane, read in the
owner's order with the published rows as the last fallback, and a pane
close widens only when a copy lists that pane as the tab's only one. An
unsplit tab whose saved layout predates its pane still closes: its
published row names the pane.
The close receipt inferred "the kill retries when the host reconnects" from
the stop reason's text, which a new transport message or a reworded error
would silently break.

An explicit close now records the replayable kill order when its stop goes
unconfirmed, before sending the follow-up kill (whose own failure is
recorded only once its RPC settles), and reports that on the receipt as an
optional `pendingKillRecorded`. The CLI promises the retry only from that
field, so an older host, which never sends it, gets no promise.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 5c974116-10e9-4b33-89db-cbcb1c813c02

📥 Commits

Reviewing files that changed from the base of the PR and between bc8bbd7 and 28613ff.

📒 Files selected for processing (41)
  • src/cli/handlers/terminal-close.ts
  • src/cli/handlers/terminal.test.ts
  • src/cli/terminal-format.test.ts
  • src/cli/terminal-format.ts
  • src/main/ipc/pty-ssh-undelivered-kill.test.ts
  • src/main/ipc/pty/runtime/controller.ts
  • src/main/ipc/pty/runtime/undelivered-ssh-kill.ts
  • src/main/ipc/session.ts
  • src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-fixtures.ts
  • src/main/runtime/__fixtures__/orca-runtime-terminal-close-continuity-state-fixture.ts
  • src/main/runtime/headless-close-keeps-publication-epoch.test.ts
  • src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
  • src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
  • src/main/runtime/orca-runtime-close-mobile-session-tab.ts
  • src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
  • src/main/runtime/orca-runtime-terminal-close-continuity.test.ts
  • src/main/runtime/orca-runtime-terminal-close-no-widening.test.ts
  • src/main/runtime/orca-runtime-terminal-surface-close.test.ts
  • src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-10.spec.ts
  • src/main/runtime/orca-runtime-tests/mobile-session-tabs.spec.ts
  • src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts
  • src/main/runtime/runtime-notifier-contract.ts
  • src/main/runtime/runtime-pty-controller-contract.ts
  • src/main/runtime/terminal-close-observed-exit-test-fixture.ts
  • src/main/runtime/terminal-surface-close.test.ts
  • src/main/runtime/terminal-surface-close.ts
  • src/main/window/runtime-window-lifecycle.ts
  • src/preload/api/ui-bridge-clipboard-and-window-controls.ts
  • src/preload/api/ui-command-event-api.ts
  • src/preload/api/workspace-session-api.ts
  • src/renderer/src/components/terminal-pane/pending-split-close.test.ts
  • src/renderer/src/components/terminal-pane/use-terminal-pane-close-actions.ts
  • src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts
  • src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts
  • src/renderer/src/hooks/useIpcEvents-close-routing-session-tabs.test.ts
  • src/renderer/src/store/terminals/terminal-surface-close-intent.ts
  • src/renderer/src/store/terminals/terminal-tab-close-intent.test.ts
  • src/renderer/src/store/terminals/terminal-tab-close.ts
  • src/shared/pty-liveness-verdict.ts
  • src/shared/runtime-terminal-contracts.ts
  • src/shared/terminal-surface-close-target.ts
💤 Files with no reviewable changes (1)
  • src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Comment on lines +62 to +67
recordUnconfirmedStop: (ptyId) =>
recordUnconfirmedExplicitSshStop({
store: deps.store,
ptyId,
reversible: deps.reversibleStopOwnersByPtyId.has(ptyId)
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
rg -n -C 8 'recordSshRemotePtyKillIntent\s*\(' src/main
rg -n -C 10 'recordUnconfirmedStop\?\.\(' src/main/runtime

Repository: stablyai/orca

Length of output: 34962


🏁 Script executed:

#!/bin/bash
sed -n '1,110p' src/main/ipc/pty/runtime/undelivered-ssh-kill.ts
sed -n '100,205p' src/main/persistence/leasing-ssh-ptys/ssh-pty-kill-intent-operations.ts
sed -n '150,190p' src/main/persistence/loading-store/ssh-lease-recovery-operations.ts
sed -n '1,110p' src/main/ipc/pty/runtime/controller.ts
sed -n '1,85p' src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts

Repository: stablyai/orca

Length of output: 16507


🏁 Script executed:

#!/bin/bash
rg -n -C 8 'type SshPtyLeaseOperations|interface SshPtyLeaseOperations|flush\s*[:(]' src/main/persistence
rg -n -C 10 'getSshPtyLeaseOperations|flush\s*\(\)' src/main/persistence/loading-store src/main/persistence/leasing-ssh-ptys

Repository: stablyai/orca

Length of output: 42281


🏁 Script executed:

#!/bin/bash
rg -n -C 18 'class WriteFlushBarrierOperations|WriteFlushBarrierOperations|flush\(\): void|flushOrThrow' src/main/persistence
rg -n -C 18 'function getSshPtyLeaseOperations|const getSshPtyLeaseOperations|getSshPtyLeaseOperations\s*=' src/main/persistence

Repository: stablyai/orca

Length of output: 42218


🏁 Script executed:

#!/bin/bash
sed -n '1,95p' src/main/persistence/loading-store/write-flush-barriers.ts
sed -n '217,255p' src/main/persistence/loading-store/ssh-lease-recovery-operations.ts
rg -n -C 12 'getRelayPtyIdForSshLeaseStorage' src/main/persistence

Repository: stablyai/orca

Length of output: 9897


🏁 Script executed:

#!/bin/bash
rg -n -C 18 'function toRelaySshPtyId|const toRelaySshPtyId|toRelaySshPtyId\s*=|function getRelayPtyId|const getRelayPtyId|getRelayPtyId\s*=' src/main/providers src/main/ipc/pty

Repository: stablyai/orca

Length of output: 3056


🏁 Script executed:

#!/bin/bash
rg -n -C 15 'toRelaySshPtyId' .

Repository: stablyai/orca

Length of output: 41913


Keep the follow-up kill independent of stop recording.

recordUnconfirmedStop runs before kill. The store adapter normalizes the PTY ID with toRelaySshPtyId, which throws when the PTY belongs to a different SSH connection. A connection mismatch between ptyOwnership and the encoded PTY ID can therefore skip kill. Catch recording errors, leave pendingKillRecorded false, and continue with kill.

Suggested fix
-            pendingKillRecorded = this.ptyController.recordUnconfirmedStop?.(ptyId) === true
+            try {
+              pendingKillRecorded = this.ptyController.recordUnconfirmedStop?.(ptyId) === true
+            } catch {
+              pendingKillRecorded = false
+            }
             this.ptyController.kill(ptyId)

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in this delta. The target-kind refactor, the pane-close resolver, and the unconfirmed-stop receipt are well covered, and the targeted suites pass. The previously flagged persistence-lag concern (thread 4109091881 on terminal-surface-close.ts) is unchanged by these commits and remains open — the renderer intent path still resolves the pane branch against session.terminalLayoutsByTabId alone rather than through resolveTerminalCloseTarget, so this delta does not close it.

Reviewed changes

  • Discriminated close target — closeTerminalSurface and the session:close-terminal-surface IPC now take TerminalSurfaceCloseTarget ({ kind: 'tab' | 'pane' }), validated at the process boundary by parseTerminalSurfaceCloseTarget; the notifier splits into whole-tab closeTerminal(tabId) and optional closeTerminalPane(tabId, leafId), and ui:closeTerminal carries the target object through preload and the renderer bridge.
  • One resolver decides widening — new resolveTerminalCloseTarget/resolvePaneClose read the layout owner's copies (published rows' parentLayout, renderer graph leaves, session layout) so only a copy that positively shows the pane as the tab's sole pane widens a pane close; CLI and mobile closes route through it, and closeTerminalPane no longer passes force.
  • Unconfirmed-stop receipts — stopExplicitlyClosedTabPtys returns { stopped, pendingKillRecorded }, a new controller recordUnconfirmedStop writes the SSH replay order before the follow-up kill (recordUndeliveredSshPtyKill now boolean), and RuntimeTerminalClose.pendingKillRecorded drives the CLI/format wording so the reconnect retry is promised only when a replay order exists.
  • Tests — new resolveTerminalCloseTarget unit suite, a 23-row never widens matrix across renderer/CLI/phone entry points and lag/stop/kill-failure conditions, plus CLI-receipt, formatter, and PTY-kill-store coverage. All 96 tests across the five touched suites pass under config/vitest.config.ts.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

The low-evidence lint gate rejects Reflect.get and broad object parameters,
which failed static analysis. Narrow with 'in' checks instead and cover the
boundary parser's accept and reject cases.
… durable writes

Main moved terminal-tab retirement onto awaited background writes
(runDurableMutation) with an owner-identity fence. The close transaction now
commits through that path instead of a synchronous flush, which the live
background writer refuses:

- closeTerminalSurface is async over runDurableMutation, keeps main's tab
  owner fence, and fences a pane close by the pane's own incarnation.
- A failed durable write never undoes a user's close or skips its kill: the
  mutation carries no rollback, the removal stays dirty for the next write.
- Every main-side caller awaits the transaction; the renderer's IPC promise
  is only awaited to report a failure, so its removal and kill stay immediate.
- closeHeadlessMobileTerminalTab takes main's tab-row signature.
…nd while it waited

The renderer has already removed and killed a tab it closes, so its close intent now skips the
owner fence phone and CLI closes use. Before, a split pane whose binding was admitted between the
close request and its durable write made main refuse the close, and the tab came back on the next
launch whenever its processes did not exit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No new issues in this delta. Moving the close transaction onto the durable writer and letting a desktop tab close skip the owner fence are both sound and well covered. The previously flagged persistence-lag concern (thread 4109091881 on terminal-surface-close.ts) is unchanged by these commits and remains open, so this is not an approval.

Reviewed changes

  • Close transaction now commits through the durable write queue — closeTerminalSurface is async and runs terminalSurfaceCloseMutation via store.runDurableMutation; refusals are returned as values, a thrown write error is logged and swallowed, and the in-memory removal plus the kill survive a failed flush.
  • Renderer intent no longer waits on the write — closeTerminalSurfaceFromRenderer returns a promise the IPC handler forwards, while the renderer's commitTerminalSurfaceClose fires it fire-and-forget, so the tab or pane leaves and is killed immediately.
  • Renderer tab closes skip the owner fence — closedByLayoutOwner lets a desktop tab close remove the tab even when a split bound while the close waited; CLI and phone closes keep the fence.
  • Async propagation and tests — every closeTerminalSurface/closeTerminalPane/closeHeadlessMobileTerminalTab/closeMobileSessionTerminalPane call site now awaits, and new gated-writer tests assert durability, kill-on-failed-flush, restart fencing, and the split-bound-while-waiting case.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…ofile store

Main now requires a SQLite profile-state authority for a writable Store, so the save-and-reload
close tests build and reopen their store through the shared SQLite test harness.
@brennanb2025

brennanb2025 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Review status: ready to merge

Head: 1c2c599877. It includes current main (with #22810 and #22919) as a merge commit, and GitHub reports it mergeable.

Review loops

Eight independent review passes ran against the whole PR, each fixing what it found and pushing new commits. The last one found no defects.

  • A pane close could close the whole tab. Review found this in four separate places: the close transaction, orca terminal close with an unconfirmed stop, a phone pane close on a host with no desktop window, and a phone pane close with no live process. Because the same kind of bug kept appearing, the close design was reworked rather than patched site by site:
    • every close states its target (a tab, or one pane);
    • a single function, resolveTerminalCloseTarget, is the only place a pane close can become a tab close;
    • missing or empty layout information never widens a pane close.
      A table-driven test covers every close entry point under each failure condition, and each of the four original bugs fails it when its fix is removed.
  • Kill retry promise. orca terminal close used to promise a kill retry by matching error text, and the promise could come before the retry was actually recorded. The host now records the kill for replay first, and reports it in an optional receipt field, pendingKillRecorded.
  • Port onto the new SQLite store. After main moved profile persistence to SQLite with background writes (Persist profile state in SQLite with a durable background writer #22612), the close transaction was moved onto the store's durable write:
    • A failed write never undoes a user's close. The removal stays in memory and is saved on the next write, and the kill still runs.
    • The desktop never waits for the write.
    • A test against the real delayed-write store fails if the old synchronous flush comes back.
      One defect from the port is fixed: a split that finished binding mid-write could refuse the desktop's own tab close.
  • CI. A red static analysis check (a lint rule on the close-target parser) was fixed, and a test fixture was moved onto the SQLite test store after the latest main merge.

Live QA

All runs used hidden Electron windows driven by Playwright over CDP. Screenshots are in the PR body.

  • Desktop, on a separate Mac and on macOS locally:
    • tab close, in a git worktree and in a folder workspace;
    • UI split-pane close;
    • orca terminal close on a typed pane and on a never-typed pane;
    • a pane whose shell ignores signals;
    • the pinned-tab dialog;
    • exit.
      Every closed surface stayed closed across a reload and a full restart on the SQLite store, and the surviving tab or pane came back. PASS.
  • SSH, on a throwaway Linux container: closing a tab and a split pane while the host was paused. Both stayed closed across unpause, reload and restart, and only the addressed pane closed. PASS.
  • Mobile, on an iOS Simulator paired to a dev desktop: a phone close of a pinned tab is refused exactly as on main. An unpinned phone close stays closed after a desktop restart. PASS.

Found during review, but not caused by this PR

  • A brand-new terminal whose shell is exited by orca terminal send, with no keyboard input, stays on the tab bar until a reload, although main has already dropped it. main does the same, which was confirmed with a before/after run. The next slice of this work removes that disagreement.
  • persistence (windows-11-arm) fails in an orcad launcher test that this PR doesn't touch. The same test fails on unrelated PRs.
  • The phone gives no reason when a pinned-tab close is refused. main does the same.

Deferred to the next slice (listed in the PR body)

  • A pane closed in the milliseconds between a split and its new process binding, whose exit is never confirmed.
  • A single-pane terminal that no layout copy records.
  • A pane re-bind racing the desktop's own pane close.

CI at 1c2c599877

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Ready to merge

Checked at head 1c2c599877: mergeable, and every required check passes.

@brennanb2025
brennanb2025 merged commit 993183a into main Sep 27, 2026
86 of 90 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant