From 0de5288dbc94efa49fea56ea274cbffff077ee47 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 16 Sep 2026 14:01:21 -0700 Subject: [PATCH] fix(runtime): keep a closed-tab worktree under the epoch already publishing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `closeHeadlessMobileTerminalTab` minted `headless:` on every close. Its sibling headless writers carry the stored `publicationEpoch` forward and mint only when there is no snapshot to inherit from — because a write to a worktree is not a claim to publish it. The close was the one writer that claimed. A paired client retires the epoch a new publisher displaces, and the web mirror's retirement is final: there is no revive lane, and the per-worktree tracking teardown deliberately keeps the epoch history. So an ordinary close published a stranger for a worktree the renderer generation still owned, retired that generation on every client, and the renderer's next publication — carrying the epoch the close had just retired — was rejected forever. The user emptied a workspace, created a terminal, and it never arrived on either machine while `session.tabs.list` showed the host holding it. This is the same thesis the retraction path already states, through the door next to it: a retraction is not a handover, and neither is a close. Measured on `paired-two-client-emptied-workspace-reseed.spec.ts`, six runs each: phase 2 failed 3/6 before (`A=null B=null`, both clients blind for the full 30s budget) and 0/6 after, with both clients adopting in single-digit milliseconds. --- ...less-close-keeps-publication-epoch.test.ts | 102 ++++++++++++++++++ ...time-close-headless-mobile-terminal-tab.ts | 5 +- 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/headless-close-keeps-publication-epoch.test.ts diff --git a/src/main/runtime/headless-close-keeps-publication-epoch.test.ts b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts new file mode 100644 index 00000000000..db7317a67df --- /dev/null +++ b/src/main/runtime/headless-close-keeps-publication-epoch.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' + +/** + * Closing a tab is not a handover to a new publisher. + * + * Every other headless writer carries the stored `publicationEpoch` forward and mints one only when + * there is no snapshot to inherit from. The close minted unconditionally, so an ordinary close + * published a stranger's epoch for a worktree the renderer generation still owns. A paired client + * retires the epoch it displaces, and the web mirror's retirement is final — so the renderer's next + * publication, carrying the epoch the close had just retired, was rejected forever. The user + * emptied a workspace, created a terminal, and watched it never arrive. + */ +const WORKTREE_ID = 'repo-1::/tmp/headless-close' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const LIVE_EPOCH = 'renderer-generation-1' + +function makeStore() { + const session = getDefaultWorkspaceSession() + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + flushOrThrow: vi.fn(), + getRepos: vi.fn(() => [ + { + id: 'repo-1', + path: '/tmp/headless-close', + displayName: 'headless', + badgeColor: '#000000', + addedAt: 0 + } + ]), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +function terminalTab(parentTabId: string, leafId: string) { + return { + type: 'terminal' as const, + id: `${parentTabId}::${leafId}`, + parentTabId, + leafId, + title: 'Terminal', + isActive: true, + status: 'ready' as const, + terminal: `term_${parentTabId}` + } +} + +/** A worktree the live renderer generation published, holding two terminals. */ +function storedSnapshot(): RuntimeMobileSessionTabsSnapshot { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this suite reads only the epoch, version and tabs. + return { + worktree: WORKTREE_ID, + publicationEpoch: LIVE_EPOCH, + snapshotVersion: 4, + activeGroupId: null, + activeTabId: `tab-a::${LEAF_ID}`, + activeTabType: 'terminal', + tabs: [terminalTab('tab-a', LEAF_ID), terminalTab('tab-b', LEAF_ID)] + } as RuntimeMobileSessionTabsSnapshot +} + +function closeOneTab(): RuntimeMobileSessionTabsSnapshot { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: makeStore covers the reads this suite drives. + const runtime = new OrcaRuntimeService(makeStore() as never) + const snapshot = storedSnapshot() + const internals = runtime as unknown as { + closeHeadlessMobileTerminalTab: ( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: ReturnType, + options?: Record + ) => void + mobileSessionTabsByWorktree: Map + } + internals.mobileSessionTabsByWorktree.set(WORKTREE_ID, snapshot) + internals.closeHeadlessMobileTerminalTab(WORKTREE_ID, snapshot, snapshot.tabs[0] as never, { + allowMissingPersistedTab: true, + killPtys: false + }) + return internals.mobileSessionTabsByWorktree.get(WORKTREE_ID) as RuntimeMobileSessionTabsSnapshot +} + +describe('closing a headless mobile terminal tab', () => { + it('keeps the worktree under the epoch that was already publishing it', () => { + expect(closeOneTab().publicationEpoch).toBe(LIVE_EPOCH) + }) + + it('still advances the version so clients accept the frame', () => { + const published = closeOneTab() + expect(published.snapshotVersion).toBe(5) + expect(published.tabs.map((tab) => tab.id)).toEqual([`tab-b::${LEAF_ID}`]) + }) +}) diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index c9f61edfdb8..b326d00ec6c 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts @@ -86,9 +86,12 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi return false }) const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + // A close is not a handover: the generation publishing this worktree still is. Minting an epoch + // here published a stranger for a worktree the renderer owns, and a client that retires what it + // displaces then rejected that renderer's own next frame. The sibling headless writers carry the + // stored epoch forward for the same reason; `...snapshot` is what does it here. const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { ...snapshot, - publicationEpoch: `headless:${Date.now().toString(36)}`, snapshotVersion: snapshot.snapshotVersion + 1, activeTabId: active?.id ?? null, activeTabType: active?.type ?? null,