fix(runtime): keep a closed-tab worktree under the epoch already publishing it

`closeHeadlessMobileTerminalTab` minted `headless:<now>` 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.
This commit is contained in:
Neil
2026-09-16 14:01:21 -07:00
parent 3f66eea7c7
commit 0de5288dbc
2 changed files with 106 additions and 1 deletions
@@ -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<typeof terminalTab>,
options?: Record<string, unknown>
) => void
mobileSessionTabsByWorktree: Map<string, RuntimeMobileSessionTabsSnapshot>
}
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}`])
})
})
@@ -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,