mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 00:02:24 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user