diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index 91c8795d716..5dd2908413b 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -1,5 +1,9 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { recordPtySurface } from './pty-recorded-surface-topology' +import { + recordPtySurface, + spawnSurfaceClaimSequence, + SURFACE_CLAIM_WITHOUT_STANDING +} from './pty-recorded-surface-topology' import { observeStructuredWorker, resolveStructuredWorkerAuthority @@ -61,8 +65,10 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null, getLeaves: (ptyId) => this.getLeavesForPty(ptyId), getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)), - recordSurface: (pty, tabId, paneKey) => - recordPtySurface(pty, tabId, paneKey, this.graphSequence), + replayPersistedSurface: (pty, tabId, paneKey) => + recordPtySurface(pty, tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING), + recordAdoptedSurface: (pty, tabId, paneKey) => + recordPtySurface(pty, tabId, paneKey, spawnSurfaceClaimSequence(this.graphSequence)), getMobileSnapshots: () => this.mobileSessionTabsByWorktree.values(), getSession: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId), setSession: (worktreeId, next) => this.setWorkspaceSessionForWorktree(worktreeId, next), diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index a0a5113e90b..59fbf76b857 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -19,7 +19,10 @@ type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[] getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined - recordSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void + /** Replays a binding the session already held: names the pane without claiming the graph holds it. */ + replayPersistedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void + /** Names a pane this adoption just wrote, ahead of the graph statement that will carry it. */ + recordAdoptedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void getMobileSnapshots: () => Iterable getSession: (worktreeId: string) => WorkspaceSessionState | null setSession: (worktreeId: string, session: WorkspaceSessionState) => void @@ -135,7 +138,7 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { }) if (isExactPersisted && sessionWorktreeId === workspace.id) { for (const { claim, pty, paneKey } of validated) { - ports.recordSurface(pty, claim.tabId, paneKey) + ports.replayPersistedSurface(pty, claim.tabId, paneKey) } return { adopted: false, @@ -235,7 +238,7 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { throw error } for (const { claim, pty, paneKey } of validated) { - ports.recordSurface(pty, claim.tabId, paneKey) + ports.recordAdoptedSurface(pty, claim.tabId, paneKey) } ports.hydrateSession(workspace.id) ports.notifySessionChanged(workspace.id) diff --git a/src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts b/src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts index abdaa6feb4f..e7ab25ba396 100644 --- a/src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-removed-frame-retires-live-publisher.test.ts @@ -148,6 +148,39 @@ describe('a removal frame must not retire the publisher that is still live', () } }) + /** + * The boundary must outlive the bound. A ledger entry may be dropped once nothing can be ranked + * against it, but dropping a retraction boundary readmits every pre-close frame it was fencing — + * so a long-lived session that has seen many worktrees must not lose the one thing standing + * between a stale list and a resurrected tab. + */ + it('keeps a worktree fence after enough other worktrees to evict its receipt', () => { + const liveReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, liveFrame(1)) + expect(admits(liveFrame(1), liveReceived)).toBe(true) + + const delayedReceived = nextReceivedSessionTabsFrame() + const removedReceived = recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, removalFrame()) + expect(admits(removalFrame(), removedReceived)).toBe(true) + + // Churn other worktrees through the same open-and-close cycle, past the bound and past the + // frame-age horizon, so both ledgers are over capacity when the delayed list finally lands. + for (let index = 0; index < MAX_TRACKED_SESSION_TABS_RECEIPTS + 16; index += 1) { + const worktree = `repo::/churn-${index}` + recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, { ...liveFrame(1), worktree }) + recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, { ...removalFrame(), worktree }) + } + + const delayed = liveFrame(9) + recordReceivedWebSessionTabsSnapshot( + ENVIRONMENT_ID, + delayed, + delayedReceived, + undefined, + 'bootstrap' + ) + expect(admits(delayed, delayedReceived)).toBe(false) + }) + /** * A worktree the host has published nothing for still answers a forced list, with a synthesized * `none`/v0 frame that means "ask me later" (host-session-snapshot-authority.ts). Every diff --git a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx index 788403036c5..d6930eeae5e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-sync-visibility-collision.test.tsx @@ -696,16 +696,18 @@ describe('useWebSessionTabsSync visibility collision recovery', () => { ...makeTerminalSnapshot(index === 0 ? '-a' : '-b', index + 1) }) } - expect(_getWebSessionTabsReceiptTrackingCountsForTest().receipts).toBe(1) - for (const [index, recovery] of recoveries.entries()) { recovery.resolve(makeTerminalSnapshot(index === 0 ? '-a' : '-b', index + 1)) } await act(settle) + // The receipt slot is per worktree, so what repeated frames could grow is the tracking behind + // it; the watermark stays absent because nothing retracted, and the mirror holds one tab. expect(_getWebSessionTabsReceiptTrackingCountsForTest()).toEqual({ receipts: 1, removalWatermarks: 0 }) + expect(_getWebSessionTabsTrackingCountsForTest().freshness).toBe(1) + expect(useAppStore.getState().tabsByWorktree[WORKTREE]?.length).toBe(1) hook.unmount() }) }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index da0ddebf779..1aa40b04725 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -127,6 +127,11 @@ export const latestReceivedSessionTabsInventoryFrameByEnvironment = new Map() export const trackedSessionTabsWorktreeIdsByEnvironment = new Map>() diff --git a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts index bda6f92389d..f117ef4a714 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/tracking.ts @@ -178,12 +178,7 @@ export function recordReceivedWebSessionTabsRemoval( } const watermark = sessionTabsRemovalWatermarkByWorktree.get(key) ?? 0 if (receivedFrame > watermark) { - setBoundedSessionTabsReceipt( - sessionTabsRemovalWatermarkByWorktree, - key, - receivedFrame, - (entry) => entry - ) + sessionTabsRemovalWatermarkByWorktree.set(key, receivedFrame) } }