Repository navigation
Conversation
Focusing a workspace owned by a remote runtime created a terminal every time, and sometimes two. The mirror could never record the closed-last-terminal state. A host snapshot with no terminals produced `nextTerminalTabs === null`, which `withWorktreeEntry` turns into a deleted key -- and a missing row is exactly how every seeder spells "never initialized" (initial-terminal.ts). Keep an explicit empty row instead, so the remote path reads the same tombstone the local one already honours. A worktree that never had a terminal still gets no row, because `sameTerminalTabs` treats a missing row and an empty one as equal; removal frames and synthesized unpublished frames keep deleting, since neither is evidence the user emptied anything. The duplicate had a second cause. `requestedInitialTerminal` was a `let` inside the session-tabs subscription closure, so "one focus creates at most one terminal" held only for as long as that closure lived. Its effect re-runs whenever the environment, connection generation, pairing revision, or session-ready flag settles -- all of which move during a workspace switch -- so a second closure re-armed the flag while the first create was still in flight. That is the asymmetry in the report: one terminal when arriving from the landing screen, two when arriving from another workspace. Latch the bootstrap per worktree in a module-scoped set instead, modelled on web-runtime-wake-terminal-respawn.ts, released when the create settles. The closure flag stays alongside it so a failed create still does not retry on every later frame of the same subscription. Fixes STA-6173.
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe change preserves explicit empty terminal rows when the host closes the last terminal. It omits rows for removed worktrees and unpublished snapshots. Initial terminal bootstrap state is keyed by environment and worktree. Bootstrap state now tracks creation and mirror-wait phases. Bootstrap dispatch releases state after failed creation, and mirror frames release completed claims. Tests cover snapshot handling, bootstrap conditions, relaunches, environment isolation, and test-state reset. Priority: ➖ Normal — Impact reflects medium issue severity. Merge Risk: 🟡 Moderate · up to During a teardown and reconnect race, runtime workspaces can create duplicate initial terminals after a user has closed them. The bootstrap claim needs ownership protection before this change is ready to merge. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Team
Run ID: 69995875-a7d7-4cce-9153-896d5ac24c01
📒 Files selected for processing (7)
src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap.tssrc/renderer/src/runtime/web-session-tabs-sync-emptied-workspace-tombstone.test.tssrc/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.tssrc/renderer/src/runtime/web-session-tabs-sync-test-harness.tssrc/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.tssrc/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.tssrc/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.
There was a problem hiding this comment.
Important
The bootstrap latch is missing the teardown wiring its model has — see the inline comment. Otherwise the two-part fix is sound: the empty-row tombstone aligns the runtime mirror with the Object.hasOwn(tabsByWorktree, worktreeId) convention the local seeder and direct-SSH merge already honour, and the latch correctly closes the two-terminals race.
Reviewed changes
- Empty-row tombstone —
prepareWebSessionTabsSnapshotBasenow writes[]for an empty affirmed host snapshot (retraction of the last terminal) instead ofnull/deleted key, while removal frames and synthesized unpublished frames still delete. - Shared bootstrap latch — a module-scoped per-worktree
Set(web-runtime-initial-terminal-bootstrap.ts) replaces the closure-localrequestedInitialTerminal, so "one focus → one terminal" survives the subscription effect re-runs a workspace switch triggers. - Shared seeding predicate —
shouldBootstrapInitialWebRuntimeTerminaldelegates toshouldAutoCreateInitialTerminal(localTerminalCount, hasPersistedTerminalState)rather than a barelocalTerminalCount === 0.
DeepSeek Pro (free via Pullfrog for OSS) | 𝕏
Follow-up on the STA-6173 fix, addressing restore-time safety gaps found in review. - Decline the bootstrap on a synthesized unpublished frame (`UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH` at version 0). That frame is the runtime saying "ask me later", not a host with zero terminals; seeding on it can duplicate a pane the host is about to republish after a restart. This is the same "ask me later" frame the tombstone write already refuses to treat as the user emptying the workspace. - Release the module-scoped bootstrap latch on worktree tracking teardown and environment teardown, mirroring web-runtime-wake-terminal-respawn. A create RPC that never settles during a disconnect would otherwise leave the per-worktree key set and suppress the next bootstrap after reconnect. Tests: - New per-worktree and per-environment latch-release cases. - New "unpublished frame declines" bootstrap case. - New hook-level regression pinning the second defect end to end: a forced active-subscription re-run while the first create is in flight seeds exactly one terminal (two on the pre-fix tree).
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 86308740-927e-4db8-b45d-06c9be8cff82
📒 Files selected for processing (4)
src/renderer/src/runtime/web-session-tabs-sync-initial-terminal-relaunch.test.tsxsrc/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.tssrc/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.tssrc/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/renderer/src/runtime/web-session-tabs-sync/tracking-decisions.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.
Addresses review on the STA-6173 hardening. - Key the bootstrap latch by (environment, worktree), not worktree alone. A worktree id is `repoId::path` with no host component, so the same id can be live on two paired runtimes at once. The latch was cleared wholesale on any environment teardown, so tearing down environment A released environment B's in-flight key and a fresh B subscription could seed a duplicate — the STA-6173 defect through another door. Environment teardown now clears only its own keys; worktree teardown clears only that (environment, worktree). - Hold the latch after a successful create until a mirrored `tabsByWorktree` row exists. The snapshot refresh the create awaits can resolve on an empty, unconfirmed frame that leaves no row; releasing then let a later effect re-run seed a second terminal. A failed create still releases for retry. The latch claim/create/release now lives in web-runtime-initial-terminal-bootstrap-dispatch.ts, keeping active-session-subscription within its line budget. Tests (each mutation-tested against its own regression): - Cross-environment: a create for env B in flight, env A torn down, a fresh B closure must still decline. Fails when env teardown sweeps all environments. - Hold-until-row: a create that resolved without mirroring a row must not let an effect re-run seed again. Fails on unconditional release.
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 82244c4d-b645-4698-a57b-33400c1e0fa7
📒 Files selected for processing (6)
src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap-dispatch.tssrc/renderer/src/runtime/web-runtime-initial-terminal-bootstrap.tssrc/renderer/src/runtime/web-session-tabs-sync-initial-terminal-relaunch.test.tsxsrc/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.tssrc/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.tssrc/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- src/renderer/src/runtime/web-session-tabs-sync/tracking-lifecycle.ts
- src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap.ts
- src/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.
There was a problem hiding this comment.
Important
The teardown wiring from the prior review is in place and the per-environment keying is a correct refinement — but the new dispatch helper never releases the latch when a create fails with a returned { status: 'failed' } outcome, only when it throws. See the inline comment.
Reviewed changes
- Added the requested teardown wiring —
clearWebSessionTabsTrackingForWorktreeandclearWebSessionTabsTrackingForEnvironmentnow release the bootstrap latch, and the seeding predicate additionally declines on synthesized unpublished frames. - Re-keyed the latch per environment — the module latch became a
Map<environmentId, Set<worktreeId>>, so a per-environment teardown releases only its own in-flight keys and preserves a sibling environment's pending create. - Extracted claim/create/release into a dispatch helper —
dispatchWebRuntimeInitialTerminalBootstrapnow holds the latch until atabsByWorktreerow exists (row-conditional release) instead of a plain.finally.
DeepSeek Pro (free via Pullfrog for OSS) | 𝕏
createWebRuntimeSessionTerminal never throws: the operation catches RPC and
network failures and returns `{ status: 'failed' }`. The dispatch helper
released the latch only from `catch` (dead for that path) or once a
tabsByWorktree row existed (false after a failure), so a failed create left
the latch held and suppressed every later auto-seed for that environment's
worktree until teardown -- the opposite of its own doc comment.
Capture the outcome and release on a returned failure as well, so the next
focus can retry.
Regression: a create that resolves `{ status: 'failed' }` followed by an
effect re-run must create again. Fails on the previous release condition.
The previous commit released the latch on a returned failure, but a create that *succeeded* with no mirrored row yet had no release at all: the row-conditional check was the only exit for the success path, so a host that accepted the tab while the mirror never got a frame held the latch until environment teardown and suppressed every later auto-seed for the worktree. Give the latch two phases. `creating` blocks other closures while the RPC is in flight. A success with no row is parked as `awaiting-mirror` instead of held, and the next frame the mirror accepts for that worktree releases it -- that frame is the mirror's answer either way (a row now exists and the predicate declines on its own, or the host genuinely has no terminal and a retry is right). A create still in flight keeps its claim: releasing it on a frame would reopen the re-armed-closure race the latch exists to close. Also correct the closure-flag comment: `requestedInitialTerminal` is set only after the dispatch resolves, so a thrown create never sets it and a later frame may retry. The flag records that this subscription already owned a create; it never described a failed one. Regressions, each mutation-tested against its own term: - success with no row, then the mirror's empty answer, then a fresh closure -> must create again (fails when success-with-no-row is held instead of parked, and when the subscription does not call the frame release) - a mirror frame releases an awaiting-mirror claim but never a pending create (fails when the release ignores the phase)
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The two commits since the prior Pullfrog review close the returned-failure gap it flagged and add an awaiting-mirror phase so a successful-but-unmirrored create cannot silently suppress later auto-seeds.
- Released the latch on a returned failed create —
dispatchWebRuntimeInitialTerminalBootstrapnow captures the create outcome and releases the latch onstatus === 'failed'(in addition to the thrown path and the existing-row path), so an RPC/network failure that returns rather than throws no longer suppresses every later auto-seed until teardown. - Added an
awaiting-mirrorphase to the latch — a successful create whosetabsByWorktreerow has not landed parks the latch inawaiting-mirror(instead of holding it until environment teardown), and the active subscription releases it on the next applied frame for the worktree while a still-in-flight create keeps its claim. - Pinned both paths with regression tests — two relaunch tests drive the real
useWebSessionTabsSynchook through a returned-failure retry and a never-mirrored-success retry, and latch-phase unit tests cover theawaiting-mirrorrelease and the never-claimed no-op.
DeepSeek Pro (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: 8bdba7cd-b49b-485e-9312-6486dbcb037a
📒 Files selected for processing (5)
src/renderer/src/runtime/web-runtime-initial-terminal-bootstrap-dispatch.tssrc/renderer/src/runtime/web-runtime-initial-terminal-bootstrap.tssrc/renderer/src/runtime/web-session-tabs-sync-initial-terminal-relaunch.test.tsxsrc/renderer/src/runtime/web-session-tabs-sync-terminal-bootstrap.test.tssrc/renderer/src/runtime/web-session-tabs-sync/active-session-subscription.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 4 remain after this review.
| ): void { | ||
| const phases = phaseByWorktreeByEnvironment.get(environmentId) | ||
| if (phases?.has(worktreeId)) { | ||
| phases.set(worktreeId, 'awaiting-mirror') |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Give each bootstrap claim an ownership token.
A create can resolve after teardown clears its latch and a replacement subscription claims the same environment and worktree. At Line 60, the stale dispatcher can change the replacement claim from creating to awaiting-mirror. Its later cleanup can also delete that replacement claim. A mirror frame can then release a still-pending replacement create and allow a duplicate terminal request.
Store a unique claim token with each phase. Apply markWebRuntimeInitialTerminalBootstrapAwaitingMirror and endWebRuntimeInitialTerminalBootstrap only when the caller owns that token. Add a teardown-and-reconnect race test.
The stated teardown lifecycle makes this race reachable.
The returned-failure release freed the module latch but the caller still
latched its closure-local requestedInitialTerminal whenever the dispatch
reported it owned the create, including a create that returned
{ status: 'failed' }. A thrown failure never set it and retried on the
next frame; since every RPC and network failure is reported as a return,
the live path was the one that suppressed the whole subscription. The
dispatch now reports false for a failure whichever way it arrives.
ensureWebRuntimeWorktreeTerminalAfterWake read tabsByWorktree through `?? []`, and the only guard that could have seen presence was &&-gated on the value that erased it, so it could never fire for a tombstone. It runs on every activateAndRevealWorktree, not only after a wake, and a tombstoned workspace routes exclusively here: the stream-frame path returns at localTerminalCount === 0 before reaching its own guard. So the workspace was re-seeded on every focus regardless of the mirror fix. Two states shared one line and now do not. With no rows the workspace is being seeded for the first time and the decision goes to shouldAutoCreateInitialTerminal with presence read by Object.hasOwn; with rows present the question is whether a woke workspace's chrome outlived its PTYs, which the tombstone says nothing about. Both arms are pinned, because reverting only the respawn arm broke no existing test.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The two commits since the prior review close the last re-seed door and fix a third suppression path, both pinned with regression tests.
- Let a failed create retry within its own subscription —
dispatchWebRuntimeInitialTerminalBootstrapnow returnsfalseon a returned{ status: 'failed' }outcome (not only on a throw), so the caller no longer latches its closure-localrequestedInitialTerminaland the same subscription can retry on a later frame. - Closed the second re-seed door —
ensureWebRuntimeWorktreeTerminalAfterWakenow consultsshouldAutoCreateInitialTerminalwhen the workspace has no tab rows, so an explicit empty-row tombstone (the user emptied it) declines the wake-time respawn instead of seeding on everyactivateAndRevealWorktree. - Pinned both paths — a relaunch test drives the real hook through a returned-failure retry within one subscription, and three activation tests cover the emptied-workspace decline, the no-render decline, and the outlived-PTY respawn.
DeepSeek Pro (free via Pullfrog for OSS) | 𝕏
canonicalizeTerminalSessionWorktreeId read the source row through `?? []` and always wrote the target one, so a workspace with NO tabsByWorktree row came out the other side with an explicit empty one. That is the closed-last-terminal tombstone, and this PR's new activation-path reader honours it, so the re-keyed workspace never gets its initial terminal. Guarded on the source row's presence, which is the guard the sibling keyed maps six lines below already use. Mutating the guard to test emptiness instead of presence breaks the assertion that a real tombstone still survives the re-key, which is the distinction that matters.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
One commit since the prior Pullfrog review closes the last tombstone-fabrication path this PR introduced.
- Guarded the worktree-id re-key on source-row presence —
canonicalizeTerminalSessionWorktreeIdnow wraps thetabsByWorktreemove inObject.hasOwn(session.tabsByWorktree, sourceWorktreeId), so a workspace with no row no longer exits the re-key with a fabricated[]closed-last-terminal tombstone (which the new activation-path reader would have honoured, suppressing its initial terminal). A deliberate empty row still survives the re-key, matching the sibling keyed maps' presence check. - Pinned the guard with three tests —
workspace-session-worktree-id.test.tscovers the no-fabrication case, the tombstone-carry case, and the re-key of the tabs' own worktree ids. The suite passes locally.
DeepSeek Pro (free via Pullfrog for OSS) | 𝕏
|
Closing as superseded after checking current Merged #21630 incorporated the empty-workspace initialization fix and subsequent hardening. Current main preserves explicit empty terminal tombstones and uses an environment/workspace bootstrap latch, so a workspace the user emptied is not re-seeded by this path. Closing this PR only; its branch is being retained. |

Fixes STA-6173 / #17681.
ELI5
When you close the last terminal in a workspace, Orca is supposed to remember "the user emptied this on purpose" and leave it empty next time you look at it. That memory worked for workspaces on your own machine but not for workspaces owned by a remote
orca serveruntime: every time you focused a remote-owned workspace it made a new terminal, and coming back from another workspace sometimes made two. Two independent bugs caused it, and this PR fixes both, then hardens the fix so it can't misbehave at app-restart time.1. The remote path had no closed-last-terminal tombstone
The local seeder tells apart never initialized (no
tabsByWorktreerow) from the user closed the last terminal (an explicit empty row) —shouldAutoCreateInitialTerminalininitial-terminal.ts. The mirror could never record the second state: a host snapshot with no terminals producednextTerminalTabs === null, whichwithWorktreeEntryturns into a deleted key. Every focus therefore read the workspace as never-initialized and re-seeded.Keep an explicit empty row instead, so the remote path reads the same tombstone the local one already honours:
sameTerminalTabstreats a missing row and an empty one as equal, so the write is a no-op.UNPUBLISHED_WORKTREE_PUBLICATION_EPOCHat version 0) keep deleting too. That frame is the runtime saying ask me later, not evidence the user emptied anything —hostSnapshotAffirmsWorktreeContentsalready spells this out for browser rows.Because
tabsByWorktreeis part of the persisted workspace session, the tombstone survives an app restart, matching local behaviour. (Verified it round-trips through the host split/merge and hydration — see restore-time safety below.)2. The bootstrap latch was closure-scoped
requestedInitialTerminalwas aletinside the session-tabs subscription closure, so "one focus creates at most one terminal" held only for as long as that closure lived. Its effect re-runs whenever the environment, connection generation, pairing revision, or session-ready flag settles — all of which move during a workspace switch — so a second closure re-armed the flag while the first create was still in flight.That is exactly the asymmetry in the report: one terminal arriving from the landing screen, two arriving from another workspace, and the capture where the visible count stayed at 1 but the PTY id was replaced within 4s (two creates, one lost the race).
web-runtime-initial-terminal-bootstrap.tslatches the bootstrap per worktree in a module-scoped set, modelled on the existingweb-runtime-wake-terminal-respawn.ts. It is released when the create settles with a mirrored row, or at once on a thrown or returned failure so a later focus can retry; a create that succeeded but whose row has not landed yet is parked until the next frame the mirror accepts for that worktree (see §4). The closure flag stays alongside it and records that this subscription already owned a create, so the same closure never seeds twice once the shared latch is released — a create that threw never set it, so a later frame may retry.3. Hardening (follow-up commit, from review)
Two restore-time safety gaps, both fixed with mutation-tested pins:
UNPUBLISHED_WORKTREE_PUBLICATION_EPOCH/v0 frame is "ask me later", not a host with zero terminals — seeding on it can duplicate a pane the host is about to republish after a restart. The predicate now requireshostSnapshotAffirmsWorktreeContents(event), symmetric with the tombstone write.clearWebSessionTabsTrackingForWorktree) and environment teardown (clearWebSessionTabsTrackingForEnvironment) now release it, mirroring the wake-respawn latch exactly.4. Follow-ups from review (commits 3 and 4)
repoId::pathid has no host component and can be live on two paired runtimes at once, so a worktree-only key let one environment's teardown release a sibling environment's in-flight create — the STA-6173 defect through another door. Environment teardown now clears only its own keys.tabsByWorktreerow; releasing then let a later effect re-run seed again. The claim/create/release now live inweb-runtime-initial-terminal-bootstrap-dispatch.ts.createWebRuntimeSessionTerminalnever throws — it reports RPC and network failures as{ status: 'failed' }— so a release that lived only incatchor behind the row check left the latch held after a failed create and would have suppressed every later auto-seed for that worktree until teardown. The dispatch helper now captures the outcome and releases onstatus === 'failed'. The inverse hazard existed too: a create that succeeded but whose frame never landed had no release at all until environment teardown. That case is now parked asawaiting-mirrorand released by the next frame the mirror accepts for the worktree — the mirror's answer, whichever way it goes — while a create still in flight keeps its claim (releasing that on a frame would reopen the re-armed-closure race). These are the changes in the series that remove suppressions rather than add guards, and they are the reason the restore-time claim below still holds.Reproduction
orca serve.Before / after proof (measured, not inferred)
Both defects are pinned by tests that fail on
origin/mainand pass here, run against a built baseline worktree at the current main tip:web-session-tabs-sync-emptied-workspace-tombstone.test.ts→ "leaves an explicit empty row when the host retracts the last terminal": on baselineapplyWebSessionTabsSnapshotdeletes the key (Object.hasOwn(...) === false); here it keeps[].web-session-tabs-sync-initial-terminal-relaunch.test.tsxdrives the realuseWebSessionTabsSynchook: an empty active snapshot (create left pending), then a forced active-subscription re-run (connection-generation bump = the workspace-switch-back shape), then a second empty snapshot on the new closure. Baseline callscreateWebRuntimeSessionTerminal2 times; here 1.Per-assertion mutation testing (each specific production line reverted in isolation; each breaks only its own assertion, the rest stay green):
[]→nulllocalTerminalCount === 0hostSnapshotAffirmsWorktreeContentsguardbeginalways returns true(Removing the subscription's
|| isInFlight(...)read alone failed no test — determined to be an inert mutation, not a weak test: thebegin()gate is a redundant second consumer of the same latch, and defeating the latch entirely does fail the end-to-end test.)Restore-time safety (P0-adjacent check)
This PR changes terminal-seeding decisions on runtime-owned (paired
orca serve) workspaces only. It was checked against the in-flight "restart restores no SSH tabs" report:ssh:hosts resolve toruntimeEnvironmentId: null, so direct-SSH workspaces never reach either path. SSH tabs restore through persistence/lease recovery, which this PR does not touch.shouldAutoCreateInitialTerminal's existing local behaviour is unchanged — this PR only adds a new caller of it in the runtime predicate.[](assignWorktreeKeyed), the mergeObject.assignes it back, and hydration's row plan explicitly keepslength === 0rows. A restored workspace with real terminals has a non-empty row → not seeded (they're mirrored/restored); only an explicitly-emptied row suppresses seeding.Notes on the report's other hypotheses
clientMutationIdis a real gap but secondary. Fixing the latch removes the second create at the source, so no mutation id is added here.queueHookCommandsForFirstWorktreeTabattaches to an existing tab).Unblocks STA-6174 (keep a workspace selected and empty when its last tab closes) on runtime-owned workspaces.
Testing
pnpm tcclean;pnpm run check:code-quality:changed— 0 findings across 9 changed files (incl. React Doctor);pnpm format(staged only).origin/main.web-session-tabs-sync-*suites: 51 tests pass (bootstrap, tombstone, relaunch, window-visibility, visibility-collision).Not verified against a live desktop↔
orca serveUI session — the pins are hook-level and mirror-level, run against a built baseline for before/after; the end-to-end double-create is reproduced through the real subscription hook.Second review pass: the third suppression path, and the second re-seed door
Two reviewers had each found one way the latch could stay held. A third existed, and — more seriously — the fix as it stood did not actually deliver the PR's own headline.
5. The third suppression path: a returned failure suppressed its own subscription
Commit
0b7b1612f01released the module latch on a create that returns{ status: 'failed' }. But the caller sets its closure-localrequestedInitialTerminalwhenever the dispatch reports it owned the create — and that included the failure:A thrown failure never set it, so the very next frame could retry; the comment beside it says exactly that. A returned failure did set it. Since
createWebRuntimeSessionTerminalreports every RPC and network failure as a return rather than a throw, the live path was the one that suppressed the whole subscription: after one transient failure, that closure never seeded again for as long as it lived — a workspace stuck empty until an environment, connection-generation or pairing-revision change reinstalled the effect.The same suppression, one level up from the one that was just fixed. The dispatch now reports
falsefor a failure whichever way it arrives, so the two paths are symmetric.Why the existing returned-failure test did not catch it: it bumps the connection generation between the two frames, so it always retries from a fresh closure. The new assertion publishes the second frame on the same subscription.
Every exit from the dispatch helper, enumerated — the outcome union is closed (
created | failed), so this is exhaustive:begin()returns false (another closure holds it)failedcreated, row existscreated, no rowawaiting-mirror6. The second re-seed door — the PR did not deliver its title without this
ensureWebRuntimeWorktreeTerminalAfterWake(src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts) read the row through?? [], and the only guard that could have seen presence was&&-gated on the value that erased it:It is called from
worktree-activation.ts:329on everyactivateAndRevealWorktree— "after wake" names one reason activation runs, not a precondition. And the tombstone state routes exclusively here: with rows present the stream-frame path handles it, and with rows absent-or-empty that path returns atlocalTerminalCount === 0before reaching its own guard. So an emptied runtime-owned workspace was re-seeded on every focus regardless of the mirror tombstone this PR adds.This is the defect shape the tombstone exists to prevent, arriving through a reader that never consults the record: the write side was correct in isolation and one consumer decided seeding on a count.
Deliberately not a predicate swap. Two states shared that line and now do not:
shouldAutoCreateInitialTerminal(renderableTabCount, Object.hasOwn(state.tabsByWorktree, worktreeId));renderableTabCount === 0still declines.A blind swap would have silently dropped the respawn arm. The mutation proves it: reverting only that arm broke no test in the repo, so an assertion was added for it and the mutation now fails exactly that one. Recording it because it is the honest reading — that mutation survived because the test was weak, not because the mutation was inert.
What was checked and deliberately not changed
getLastKnownHostTerminalTabCountreturns0both for "the host has no terminals" and for "we have never heard from this environment", which isunverifiableread asexitedand would normally deserve thehostSnapshotAffirmsWorktreeContentstreatment applied in §3. It does not apply at this call site: having no tracking entry is the normal state on a first activation, and declining there would break the primary case this function exists for —creates a host terminal when waking an empty remote workspacecovers exactly that. Left alone on evidence rather than on principle.Sibling latch: still out of scope, now filed
web-runtime-wake-terminal-respawn.tskeeps main's worktree-only keying and is still cleared globally on per-environment teardown. Re-verified unreachable from this repro —shouldRespawnWebRuntimeTerminalAfterWakereturnsfalsewhenlocalTerminalCount === 0, so an emptied workspace never reaches it through the stream-frame path. Not widened here; filed as #19899's sibling, #19898, with the fix shape (mirror the bootstrap latch's(environmentId, worktreeId)keying and per-environment teardown).The shared-semantics boundary with #19572
The tombstone this PR writes and #19572's adoption read the same value in opposite directions:
[]means "the user closed the last terminal" here, and "a gap to fill" there. Six assertions in #19572 pin the boundary through the shipping write path and this PR's ownshouldAutoCreateInitialTerminal— including that the legacy-transition resurrection happens once and cannot recur, which had been argued in that gate'sknownGapsrather than driven.Both merge orders were built and run. The two PRs share no file, so
main + #18513 + #19572andmain + #19572 + #18513produce a byte-identical tree (c02d18c18386f51689b18763290ce1f0b4e2e521); the combined suites pass identically on both — 56 tests.Mutation testing, second pass
trueagain (pre-fix behaviour)falsebut never releases the shared latchcreatingConfirming §4's claim directly: the
awaiting-mirrorguard is pinned — a create still increatingis never released by a frame, and mutating that check to release unconditionally fails exactly the assertion that says so.Residual risk, recorded
The latch has no claim token, so a teardown that lands mid-create followed by a new closure claiming the latch lets the stale create's resolution mutate the new claim (
endreleases it, ormarkAwaitingMirrordowngradescreatingtoawaiting-mirrorso a frame may release a genuinely in-flight create). It requires teardown plus re-subscribe plus a new frame inside one create RPC. Teardown already accepts a duplicate over permanent suppression by design (§3), so this is a second-order effect of an accepted trade rather than a new one — but it is the narrow case where "one focus creates at most one terminal" can still fail, and a claim token is the fix if it is ever observed.Verification, this pass
pnpm tcclean ·pnpm test src/renderer src/shared— 38381 passed, 0 failed ·oxlint0 ·pnpm run check:code-quality:changed0 new findings across 12 files (incl. React Doctor) ·pnpm run check:max-lines-ratchet·pnpm format. Nomax-linessuppression added.7. A re-key that invented the tombstone
canonicalizeTerminalSessionWorktreeId(src/main/runtime/workspace-session-worktree-id.ts) read the source row through?? []and then always wrote the target one:So a workspace with no
tabsByWorktreerow came out of a worktree-id re-key with an explicit empty one — a closed-last-terminal tombstone the user never created. Six lines below, the sibling keyed maps (tabGroupLayouts,activeTabIdByWorktree,activeGroupIdByWorktree) already guard withObject.hasOwn; only this one did not.This belongs to this PR rather than to a cleanup, because §6 adds the reader that makes it bite: with the activation path now honouring the tombstone, a fabricated one means the re-keyed workspace never gets its initial terminal. The normal orphan-adoption flow overwrites the fabricated row with the claimed tabs, so the reachable case is the narrow one where the claim set is empty.
The guard is on the source row's presence, not on the tabs being non-empty, and the mutation is what says that matters: guarding on
length > 0instead breaks carries a real closed-last-terminal tombstone across the re-key, because a deliberate empty row must survive a re-key too. Dropping the guard entirely breaks does not fabricate a tombstone for a workspace that had no tabs row. One assertion each.Findings filed rather than fixed
Sweeping the rest of the readers turned up three more that collapse an absent row into an empty one, and one that looks mechanical and is not. All are on #19899, with the reviewer-facing summary: six readers lose the missing-vs-empty distinction; two are predicate-shaped and four need the presence test this codebase already uses correctly at several sites — none requires new logic. Two of the six are fixed here (§6, §7); the rest are client-side and independent, and one of them — the remote-workspace publish gate — carries the only version boundary in the set, so it wants its own PR and its own wire-compat paragraph rather than being smuggled in here.
CI, read from job steps
All 8
test / tests node 24shards,typecheck,static analysis,pullfrog,e2e / changed e2e specs,e2e / build e2e app,package (windows),test vs non-test LoCandroot directory guardgreen.One failure, investigated rather than waved through: the Linux
packagejob failed at step 5, Test Linux Electron lifecycle boundary, onsrc/main/browser/browser-route-webrtc-egress.electron.test.ts > blocks direct UDP after applying the exact guest policy, withContextResult::kTransientFailure: Failed to send GpuControl.CreateCommandBufferin the log. This PR contains nosrc/main/browsercode and no WebRTC or GPU surface,package (windows)passed on the same commit, and the sibling PR #19572 — which touches strictly more main-process code — passed the same Linuxpackagejob on the same infrastructure. Re-run rather than argued from the diff alone, and it passed.Final run on
ae38bb1: every check green, 20 non-skipped, includingverify,pullfrog,package,package (windows)ande2e / changed e2e specs.On the skips:
e2e / ssh docker watcher isolationis skipped here and that is correct rather than a gap — this PR changes seeding on runtime-owned (pairedorca serve) workspaces only, and a puressh:host resolves toruntimeEnvironmentId: null, so direct-SSH workspaces never reach either changed path. The dimension this PR does exist to check ise2e / changed e2e specs, which ran and passed.Skips are path-filtered on dimensions this PR does not touch (
Git compatibility,real WSL terminal,shell contracts,cross-version wire compatibility, the Docker SSH e2e jobs). None of them is the dimension this PR exists to check — that one ise2e / changed e2e specs, which ran and passed.