Harden mobile session tab sync
Harden mobile session-tab snapshot reconciliation and terminal creation. Rejects stale mobile snapshots, tombstones locally closed tabs until the publisher catches up, rolls back half-created terminal tabs when their surface never appears, and scopes terminal-create idempotency by worktree.
This commit is contained in:
parent
423c2befe1
commit
e253d46e72
|
|
@ -163,6 +163,11 @@ import {
|
|||
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
|
||||
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
applyClosedTabTombstones,
|
||||
type AppliedSnapshotMarker
|
||||
} from '../../../../src/session/session-tab-snapshot-gate'
|
||||
import {
|
||||
buildMarkdownDiskFallbackDoc,
|
||||
shouldReadMarkdownFromDiskAfterReadTabFailure
|
||||
|
|
@ -908,6 +913,16 @@ export default function SessionScreen() {
|
|||
const terminalsRef = useRef<Terminal[]>([])
|
||||
const [sessionTabs, setSessionTabs] = useState<MobileSessionTab[]>([])
|
||||
const sessionTabsRef = useRef<MobileSessionTab[]>([])
|
||||
// Why: subscription, 2s polling, and post-mutation refetch race to apply tab
|
||||
// snapshots. Track the last applied (publicationEpoch, snapshotVersion) so a
|
||||
// late-arriving older snapshot from the same publisher can't overwrite (and
|
||||
// resurrect closed tabs in) a newer one. See session-tab-snapshot-gate.
|
||||
const appliedSnapshotMarkerRef = useRef<AppliedSnapshotMarker>({ epoch: null, version: -1 })
|
||||
// Why: after an optimistic local close, suppress the tab until the publisher
|
||||
// confirms its absence, so an in-flight snapshot generated before the close
|
||||
// propagated (and thus newer by version) can't flash the tab back. Maps tab id
|
||||
// to an expiry timestamp so a failed host-side close can't hide a tab forever.
|
||||
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
|
||||
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
// Why: baseline terminal zoom, reloaded on focus so a Settings → Terminal change
|
||||
|
|
@ -941,6 +956,10 @@ export default function SessionScreen() {
|
|||
const [pendingDiffNotesDelivery, setPendingDiffNotesDelivery] =
|
||||
useState<DiffNotesDelivery | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
// Why: React state isn't a synchronous lock — a fast double-tap can fire two
|
||||
// creates before `creating` re-renders. This ref blocks the second one in the
|
||||
// same tick (server idempotency only dedupes identical clientMutationIds).
|
||||
const creatingTerminalRef = useRef(false)
|
||||
const [creatingBrowser, setCreatingBrowser] = useState(false)
|
||||
const [creatingMarkdown, setCreatingMarkdown] = useState(false)
|
||||
const [createError, setCreateError] = useState('')
|
||||
|
|
@ -1648,7 +1667,16 @@ export default function SessionScreen() {
|
|||
|
||||
const applySessionTabs = useCallback(
|
||||
(result: SessionTabsResult) => {
|
||||
let nextTabs = result.tabs
|
||||
// Reject out-of-order snapshots, then suppress just-closed tabs until the
|
||||
// publisher confirms their absence. See session-tab-snapshot-gate.
|
||||
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
|
||||
return
|
||||
}
|
||||
let nextTabs = applyClosedTabTombstones(
|
||||
result.tabs,
|
||||
closedTabTombstonesRef.current,
|
||||
Date.now()
|
||||
)
|
||||
const presentTabIds = new Set(nextTabs.map((tab) => tab.id))
|
||||
const orphanedDraftTabs: MobileSessionTab[] = []
|
||||
const currentMarkdownDocs = markdownDocsRef.current
|
||||
|
|
@ -2541,6 +2569,12 @@ export default function SessionScreen() {
|
|||
pendingBrowserFocusPageIdRef.current = null
|
||||
pendingTerminalActivationAttemptRef.current = null
|
||||
initialEmptySessionAutoCreateRef.current = null
|
||||
// Why: snapshot version floor and close tombstones are per-worktree. This
|
||||
// screen can be reused across worktrees, so a prior worktree's high version
|
||||
// would reject the next one's first snapshot (same renderer epoch) and stale
|
||||
// tombstones could suppress same-id tabs.
|
||||
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
|
||||
closedTabTombstonesRef.current.clear()
|
||||
for (const queued of terminalGestureInputQueuesRef.current.values()) {
|
||||
if (queued.timer) {
|
||||
clearTimeout(queued.timer)
|
||||
|
|
@ -3730,17 +3764,27 @@ export default function SessionScreen() {
|
|||
agent?: MobileNewTabAgentOption['agent'],
|
||||
options?: { initialPrompt?: string; onPromptSent?: () => void }
|
||||
) {
|
||||
if (!client || creating) {
|
||||
if (!client || creatingTerminalRef.current) {
|
||||
return
|
||||
}
|
||||
creatingTerminalRef.current = true
|
||||
|
||||
setCreating(true)
|
||||
setCreateError('')
|
||||
|
||||
// Why: idempotency key so a transport-level retry (reconnect replay) of this
|
||||
// create resolves to the same terminal instead of spawning a duplicate. Kept
|
||||
// compact (no worktree id) to stay under the schema's length cap; the ref
|
||||
// guard above blocks concurrent taps synchronously.
|
||||
const clientMutationId = `mobile-create:${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 10)}`
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('session.tabs.createTerminal', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
afterTabId: activeSessionTabId ?? undefined,
|
||||
clientMutationId,
|
||||
...(agent ? { agent } : {})
|
||||
})
|
||||
if (response.ok) {
|
||||
|
|
@ -3829,6 +3873,7 @@ export default function SessionScreen() {
|
|||
} catch {
|
||||
setCreateError('Failed to create terminal')
|
||||
} finally {
|
||||
creatingTerminalRef.current = false
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -4019,7 +4064,6 @@ export default function SessionScreen() {
|
|||
subscribeToTerminal(replacement.handle)
|
||||
}
|
||||
}
|
||||
scheduleDelayedAction(() => void fetchTerminals(), 300)
|
||||
}
|
||||
} catch {
|
||||
// Close failed — keep the local tab list unchanged.
|
||||
|
|
@ -4042,13 +4086,16 @@ export default function SessionScreen() {
|
|||
initializedHandlesRef.current.delete(tab.terminal)
|
||||
}
|
||||
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
|
||||
// Why: tombstone the closed tab and rely on the subscription/poll
|
||||
// snapshot (gated by snapshotVersion) instead of a blind 300ms refetch
|
||||
// that re-applied whatever the host had — often the not-yet-closed list.
|
||||
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
|
||||
if (activeSessionTabId === tab.id) {
|
||||
activeSessionTabTypeRef.current = null
|
||||
setActiveSessionTabId(null)
|
||||
activeHandleRef.current = null
|
||||
setActiveHandle(null)
|
||||
}
|
||||
scheduleDelayedAction(() => void fetchSessionTabs(), 300)
|
||||
}
|
||||
} catch {
|
||||
// Close failed — keep the authoritative session snapshot visible.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
applyClosedTabTombstones,
|
||||
type AppliedSnapshotMarker
|
||||
} from './session-tab-snapshot-gate'
|
||||
|
||||
describe('acceptSessionSnapshot', () => {
|
||||
it('accepts a newer version from the same publisher and advances the floor', () => {
|
||||
const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 5 }
|
||||
expect(acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 6 }, marker)).toBe(
|
||||
true
|
||||
)
|
||||
expect(marker).toEqual({ epoch: 'renderer:a', version: 6 })
|
||||
})
|
||||
|
||||
it('rejects a strictly older version from the same publisher without moving the floor', () => {
|
||||
const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 42 }
|
||||
expect(
|
||||
acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 41 }, marker)
|
||||
).toBe(false)
|
||||
expect(marker).toEqual({ epoch: 'renderer:a', version: 42 })
|
||||
})
|
||||
|
||||
it('accepts an equal version (reprocess) so close tombstones can expire and clear', () => {
|
||||
const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 42 }
|
||||
// Polling returns the same cached version repeatedly; reprocessing is needed
|
||||
// so applyClosedTabTombstones can run its TTL/clear pass.
|
||||
expect(
|
||||
acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 42 }, marker)
|
||||
).toBe(true)
|
||||
expect(marker).toEqual({ epoch: 'renderer:a', version: 42 })
|
||||
})
|
||||
|
||||
it('accepts any version from a new publisher (epoch change) and resets the floor', () => {
|
||||
const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 99 }
|
||||
// Lower version but different epoch (renderer reload / headless) → accepted.
|
||||
expect(
|
||||
acceptSessionSnapshot({ publicationEpoch: 'headless:b', snapshotVersion: 1 }, marker)
|
||||
).toBe(true)
|
||||
expect(marker).toEqual({ epoch: 'headless:b', version: 1 })
|
||||
})
|
||||
|
||||
it('treats a missing epoch as its own publisher key', () => {
|
||||
const marker: AppliedSnapshotMarker = { epoch: null, version: -1 }
|
||||
expect(acceptSessionSnapshot({ snapshotVersion: 5 }, marker)).toBe(true)
|
||||
// A strictly older snapshot from the same (null) publisher is rejected.
|
||||
expect(acceptSessionSnapshot({ snapshotVersion: 4 }, marker)).toBe(false)
|
||||
expect(marker).toEqual({ epoch: null, version: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyClosedTabTombstones', () => {
|
||||
const tab = (id: string): { id: string } => ({ id })
|
||||
|
||||
it('returns the tabs untouched when there are no tombstones', () => {
|
||||
const tabs = [tab('a'), tab('b')]
|
||||
expect(applyClosedTabTombstones(tabs, new Map(), 1000)).toBe(tabs)
|
||||
})
|
||||
|
||||
it('suppresses a tombstoned tab while it is still present and not expired', () => {
|
||||
const tombstones = new Map([['a', 5000]])
|
||||
const result = applyClosedTabTombstones([tab('a'), tab('b')], tombstones, 1000)
|
||||
expect(result.map((t) => t.id)).toEqual(['b'])
|
||||
// Still present in the publisher snapshot → tombstone retained for next time.
|
||||
expect(tombstones.has('a')).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the tombstone once the publisher snapshot no longer includes the tab', () => {
|
||||
const tombstones = new Map([['a', 5000]])
|
||||
const result = applyClosedTabTombstones([tab('b')], tombstones, 1000)
|
||||
expect(result.map((t) => t.id)).toEqual(['b'])
|
||||
expect(tombstones.has('a')).toBe(false)
|
||||
})
|
||||
|
||||
it('stops suppressing and clears an expired tombstone even if still present', () => {
|
||||
const tombstones = new Map([['a', 5000]])
|
||||
const result = applyClosedTabTombstones([tab('a'), tab('b')], tombstones, 5000)
|
||||
expect(result.map((t) => t.id)).toEqual(['a', 'b'])
|
||||
expect(tombstones.has('a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// Pure helpers for reconciling incoming session-tab snapshots on mobile. Kept
|
||||
// free of react-native imports so they stay unit-testable in the node test env.
|
||||
// Two races motivate these: out-of-order snapshots (subscription vs poll vs
|
||||
// post-mutation refetch) and the brief window after a local close before the
|
||||
// publisher's snapshot reflects it.
|
||||
|
||||
/** The last snapshot applied from a publisher, used to reject older ones. */
|
||||
export type AppliedSnapshotMarker = { epoch: string | null; version: number }
|
||||
|
||||
/**
|
||||
* Whether an incoming snapshot should be applied. Rejects only snapshots
|
||||
* STRICTLY older than the last applied one from the same publisher (epoch) so an
|
||||
* out-of-order response can't revive a tab a newer snapshot dropped. Equal
|
||||
* versions are accepted (and reprocessed) — polling returns the same cached
|
||||
* version repeatedly, and reprocessing is what lets close tombstones expire and
|
||||
* clear; rejecting equal versions would strand them. A different epoch is a new
|
||||
* publisher (renderer reload / headless), accepted as the new floor. Mutates
|
||||
* `marker` to record the accepted snapshot.
|
||||
*/
|
||||
export function acceptSessionSnapshot(
|
||||
incoming: { publicationEpoch?: string; snapshotVersion: number },
|
||||
marker: AppliedSnapshotMarker
|
||||
): boolean {
|
||||
const incomingEpoch = incoming.publicationEpoch ?? null
|
||||
if (incomingEpoch === marker.epoch) {
|
||||
if (incoming.snapshotVersion < marker.version) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
marker.epoch = incomingEpoch
|
||||
}
|
||||
marker.version = incoming.snapshotVersion
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops tabs the user just closed locally (tombstoned) until the publisher's
|
||||
* snapshot also drops them or the tombstone expires. Mutates `tombstones`,
|
||||
* clearing entries the publisher has confirmed gone (absent from `tabs`) or
|
||||
* whose TTL elapsed — the TTL guards against a failed host-side close hiding a
|
||||
* tab forever.
|
||||
*/
|
||||
export function applyClosedTabTombstones<T extends { id: string }>(
|
||||
tabs: T[],
|
||||
tombstones: Map<string, number>,
|
||||
now: number
|
||||
): T[] {
|
||||
if (tombstones.size === 0) {
|
||||
return tabs
|
||||
}
|
||||
const suppressed = new Set<string>()
|
||||
const next = tabs.filter((tab) => {
|
||||
const expiry = tombstones.get(tab.id)
|
||||
if (expiry === undefined) {
|
||||
return true
|
||||
}
|
||||
if (now >= expiry) {
|
||||
tombstones.delete(tab.id)
|
||||
return true
|
||||
}
|
||||
suppressed.add(tab.id)
|
||||
return false
|
||||
})
|
||||
for (const [id, expiry] of tombstones) {
|
||||
if (!suppressed.has(id) || now >= expiry) {
|
||||
tombstones.delete(id)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
|
@ -13887,6 +13887,250 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(result.tab).toMatchObject({ parentTabId: 'tab-renderer', isActive: false })
|
||||
})
|
||||
|
||||
it('dedupes concurrent mobile terminal creates that share a clientMutationId', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({
|
||||
focusTerminal: vi.fn(),
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
closeSessionTab: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-renderer::pane:1',
|
||||
parentTabId: 'tab-renderer',
|
||||
leafId: 'pane:1',
|
||||
title: 'Terminal',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{},
|
||||
{ requestId: payload.requestId, tabId: 'tab-renderer', title: 'Terminal' }
|
||||
)
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send }
|
||||
})
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false,
|
||||
clientMutationId: 'mutation-1'
|
||||
}),
|
||||
runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false,
|
||||
clientMutationId: 'mutation-1'
|
||||
})
|
||||
])
|
||||
|
||||
const createRequests = send.mock.calls.filter(
|
||||
([channel]) => channel === 'terminal:requestTabCreate'
|
||||
)
|
||||
expect(createRequests).toHaveLength(1)
|
||||
expect(second).toBe(first)
|
||||
expect(first.tab).toMatchObject({ parentTabId: 'tab-renderer' })
|
||||
})
|
||||
|
||||
it('does not dedupe mobile terminal creates across worktrees with the same clientMutationId', async () => {
|
||||
const otherWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-b`
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
...MOCK_GIT_WORKTREES,
|
||||
{
|
||||
path: '/tmp/worktree-b',
|
||||
head: 'def',
|
||||
branch: 'feature/bar',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => ({
|
||||
[TEST_WORKTREE_ID]: store.getAllWorktreeMeta()[TEST_WORKTREE_ID],
|
||||
[otherWorktreeId]: makeWorktreeMeta({ displayName: 'other' })
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setNotifier({
|
||||
focusTerminal: vi.fn(),
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
closeSessionTab: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string; worktreeId: string }) => {
|
||||
const parentTabId =
|
||||
payload.worktreeId === TEST_WORKTREE_ID ? 'tab-renderer-a' : 'tab-renderer-b'
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{},
|
||||
{ requestId: payload.requestId, tabId: parentTabId, title: 'Terminal' }
|
||||
)
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send }
|
||||
})
|
||||
|
||||
const firstCreate = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false,
|
||||
clientMutationId: 'mutation-1'
|
||||
})
|
||||
const secondCreate = runtime.createMobileSessionTerminal(`id:${otherWorktreeId}`, {
|
||||
activate: false,
|
||||
clientMutationId: 'mutation-1'
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
const createRequests = send.mock.calls.filter(
|
||||
([channel]) => channel === 'terminal:requestTabCreate'
|
||||
)
|
||||
expect(createRequests).toHaveLength(2)
|
||||
})
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-a',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-renderer-a::pane:1',
|
||||
parentTabId: 'tab-renderer-a',
|
||||
leafId: 'pane:1',
|
||||
title: 'Terminal',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
worktree: otherWorktreeId,
|
||||
publicationEpoch: 'epoch-b',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-renderer-b::pane:1',
|
||||
parentTabId: 'tab-renderer-b',
|
||||
leafId: 'pane:1',
|
||||
title: 'Terminal',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const [first, second] = await Promise.all([firstCreate, secondCreate])
|
||||
|
||||
const createRequests = send.mock.calls.filter(
|
||||
([channel]) => channel === 'terminal:requestTabCreate'
|
||||
)
|
||||
expect(createRequests).toHaveLength(2)
|
||||
expect(first.tab).toMatchObject({ parentTabId: 'tab-renderer-a' })
|
||||
expect(second.tab).toMatchObject({ parentTabId: 'tab-renderer-b' })
|
||||
})
|
||||
|
||||
it('rolls back a half-created terminal whose surface never publishes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const closeTerminal = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({
|
||||
focusTerminal: vi.fn(),
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
closeTerminal,
|
||||
closeSessionTab: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
// Why: reply with a tabId but never sync a matching surface graph, so
|
||||
// waitForMobileTerminalSurface times out and the rollback path runs.
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{},
|
||||
{ requestId: payload.requestId, tabId: 'tab-ghost', title: 'Terminal' }
|
||||
)
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send }
|
||||
})
|
||||
|
||||
const pending = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
activate: false
|
||||
})
|
||||
const settled = pending.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error: Error) => ({ ok: false as const, error })
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(11_000)
|
||||
const outcome = await settled
|
||||
|
||||
expect(outcome.ok).toBe(false)
|
||||
expect(closeTerminal).toHaveBeenCalledWith('tab-ghost')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports browser tab creation as unsupported for a windowless host with no offscreen backend', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
|
|
|||
|
|
@ -1784,6 +1784,12 @@ export class OrcaRuntimeService {
|
|||
private authoritativeWindowId: number | null = null
|
||||
private tabs = new Map<string, RuntimeSyncedTab>()
|
||||
private mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
|
||||
// Why: idempotency map for mobile terminal creation — a retried create with the
|
||||
// same clientMutationId returns the in-flight operation instead of duplicating.
|
||||
private mobileTerminalCreateByMutationId = new Map<
|
||||
string,
|
||||
Promise<RuntimeMobileSessionCreateTerminalResult>
|
||||
>()
|
||||
private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>()
|
||||
private leaves = new Map<string, RuntimeLeafRecord>()
|
||||
// Why: PTY output is a per-keystroke hot path. Looking up affected leaves by
|
||||
|
|
@ -14641,6 +14647,47 @@ export class OrcaRuntimeService {
|
|||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchAgent?: TuiAgent
|
||||
activate?: boolean
|
||||
clientMutationId?: string
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
const mutationId = opts.clientMutationId
|
||||
if (!mutationId) {
|
||||
return this.runCreateMobileSessionTerminal(worktreeSelector, opts)
|
||||
}
|
||||
const mutationKey = `${worktreeSelector}\0${mutationId}`
|
||||
// Why: a retried create (double-tap, reconnect replay) with the same
|
||||
// idempotency key must return the in-flight operation instead of spawning a
|
||||
// duplicate terminal. Settled entries are dropped so a later retry — after a
|
||||
// failure or after the result is consumed — can start a fresh create.
|
||||
const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey)
|
||||
if (inflight) {
|
||||
return inflight
|
||||
}
|
||||
const run = this.runCreateMobileSessionTerminal(worktreeSelector, opts)
|
||||
this.mobileTerminalCreateByMutationId.set(mutationKey, run)
|
||||
void run
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) {
|
||||
this.mobileTerminalCreateByMutationId.delete(mutationKey)
|
||||
}
|
||||
})
|
||||
return run
|
||||
}
|
||||
|
||||
private async runCreateMobileSessionTerminal(
|
||||
worktreeSelector: string,
|
||||
opts: {
|
||||
afterTabId?: string
|
||||
targetGroupId?: string
|
||||
command?: string
|
||||
env?: Record<string, string>
|
||||
startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery']
|
||||
agent?: TuiAgent
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchAgent?: TuiAgent
|
||||
activate?: boolean
|
||||
clientMutationId?: string
|
||||
} = {}
|
||||
): Promise<RuntimeMobileSessionCreateTerminalResult> {
|
||||
this.assertGraphReady()
|
||||
|
|
@ -14713,7 +14760,16 @@ export class OrcaRuntimeService {
|
|||
if (opts.activate !== false) {
|
||||
this.notifier?.focusTerminal(reply.tabId, worktreeId, null)
|
||||
}
|
||||
return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId)
|
||||
try {
|
||||
return await this.waitForMobileTerminalSurface(worktreeId, reply.tabId)
|
||||
} catch (error) {
|
||||
// Why: the renderer created the tab but its terminal surface never
|
||||
// published (PTY spawn/handle failure). Roll the half-created tab back via
|
||||
// the renderer close path so it can't linger as a ghost in mobile
|
||||
// snapshots, then surface the failure to the caller.
|
||||
this.notifier?.closeTerminal(reply.tabId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveMobileSessionTerminalCommand(
|
||||
|
|
|
|||
|
|
@ -125,7 +125,10 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({
|
|||
message: 'Unknown launch agent'
|
||||
})
|
||||
.optional(),
|
||||
activate: z.boolean().optional()
|
||||
activate: z.boolean().optional(),
|
||||
// Why: idempotency key so a retried create (double-tap, reconnect replay)
|
||||
// returns the in-flight operation instead of spawning a duplicate terminal.
|
||||
clientMutationId: z.string().min(1).max(128).optional()
|
||||
})
|
||||
|
||||
const MoveTabBase = {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
|
|||
...(params.launchConfig ? { launchConfig: params.launchConfig } : {}),
|
||||
...(params.launchToken ? { launchToken: params.launchToken } : {}),
|
||||
...(params.launchAgent ? { launchAgent: params.launchAgent } : {}),
|
||||
activate: params.activate
|
||||
activate: params.activate,
|
||||
clientMutationId: params.clientMutationId
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
|
|
|
|||
Loading…
Reference in New Issue