Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3e7e49d
test(GAP-03): red tests for offline-client tab resurrection on reconnect
LesleyMurfin Sep 21, 2026
932bf33
fix(GAP-03): stop reconnect from re-adopting server-closed tabs
LesleyMurfin Sep 21, 2026
59a631d
test(GAP-03): Docker cross-site offline-reconnect tab resurrection ha…
LesleyMurfin Sep 22, 2026
dc15164
test(GAP-03): fix Docker harness fault-injection so it actually exerc…
LesleyMurfin Sep 22, 2026
8c1a960
fix(GAP-03): close the ordinary-restart false positive; verify remain…
LesleyMurfin Sep 22, 2026
117fe63
feat(GAP-03): extract remote-workspace host testimony predicate (slic…
LesleyMurfin Sep 22, 2026
24e85a0
feat(GAP-03): withhold answered shadow rows on write path (slice 2)
LesleyMurfin Sep 22, 2026
6d9dde7
test(GAP-03): edge-case hardening tests for shadowRowsTheHostHasNotAn…
LesleyMurfin Sep 22, 2026
1719be6
fix(GAP-03): normalize canonical worktree: keys in stranded-partition…
LesleyMurfin Sep 23, 2026
d6c3af9
fix(GAP-03): park and restore declined tab's dependent rows alongside…
LesleyMurfin Sep 23, 2026
843cf40
fix(GAP-03): infer connection status from remoteSessionIdsByTabId whe…
LesleyMurfin Sep 23, 2026
ef87a22
fix(GAP-03): order and discard in-flight pre-testimony writes against…
LesleyMurfin Sep 23, 2026
a4a5d94
fix(GAP-03): preserve non-terminal rows in write shadow when SSH test…
LesleyMurfin Sep 23, 2026
dfbe65e
fix(GAP-03): extract write queue and resolve quality gate findings
LesleyMurfin Sep 28, 2026
460193a
fix(GAP-03): resolve multi-agent review findings across write-queue, …
LesleyMurfin Sep 28, 2026
e80cd04
fix(GAP-03): clean redundant type assertion in write queue test
LesleyMurfin Sep 30, 2026
9645124
fix(GAP-03): discard pre-testimony write only when carrying parked sh…
LesleyMurfin Oct 1, 2026
60a05f6
fix(GAP-03): isolate testimony-invalidatable shadow rows and prune st…
LesleyMurfin Oct 1, 2026
bbd03a5
fix(GAP-03): break circular import between contention and shadow-test…
LesleyMurfin Oct 2, 2026
02332bb
fix(GAP-03): stop publish fallback from re-exporting declined offline…
LesleyMurfin Oct 14, 2026
5a89d75
fix(GAP-03): default carriesParkedShadowRows to false
LesleyMurfin Oct 14, 2026
b578e36
fix(GAP-03): withhold split-layout chrome under SSH host testimony
LesleyMurfin Oct 14, 2026
28a96c5
fix(GAP-03): normalize reconciled decline keys once
LesleyMurfin Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions config/scripts/run-offline-reconnect-tab-resurrection-docker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'

// Runs the GAP-03 (revive_labs#962 / stablyai/orca#22038) offline-reconnect tab resurrection
// harness: a two-launch Electron test that pairs a client to a Dockerized SSH host, opens N
// terminal tabs, quits the client, closes every one of those tabs "server-side" while it is
// offline, relaunches the client, and measures whether the closed tabs resurrect.
//
// Modeled directly on run-ssh-docker-e2e.mjs: same pnpm/Playwright invocation shape, same
// ORCA_E2E_SSH_DOCKER env-gate the target spec reads to skip itself outside a Docker-capable host.
//
// ORCA_GAP03_TAB_COUNT tunes the repro's N without editing the spec.
// Left unset here so the spec's own default (TAB_COUNT=2, CLOSE_COUNT=TAB_COUNT, i.e. every
// opened tab closes) applies — GAP-03's fix is an all-or-nothing repair (see the spec's own doc
// comment on CLOSE_COUNT), so a run that leaves any survivor tab in the worktree never exercises
// the regression at all, pre- or post-fix.
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1',
...(process.env.ORCA_GAP03_TAB_COUNT
? { ORCA_GAP03_TAB_COUNT: process.env.ORCA_GAP03_TAB_COUNT }
: {})
}

// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}

const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)

if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}

const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/gap-03-offline-reconnect-tab-resurrection.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)

process.exit(result.status ?? 1)
43 changes: 34 additions & 9 deletions src/main/ipc/remote-workspace-target-session-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import type { Repo } from '../../shared/repo-types'
import { exportRemoteWorkspaceSession } from '../../shared/remote-workspace-session-projection'
import type { RemoteWorkspaceSession } from '../../shared/remote-workspace-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import {
adoptStrandedHostPartitionSession,
workspaceIdsNamedByPartition
} from '../../shared/workspace-session-stranded-partition-adoption'
import { wasTargetConnectedAtLastShutdown } from '../../shared/workspace-session-shutdown-connection'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import {
resolveWorktreeExecutionHost,
Expand Down Expand Up @@ -88,8 +93,9 @@ export function exportSessionForTarget(
* deletion on the host (#12721). Resolved per target so one target's rows can never be published
* under another's key when both partitions hold the same worktree id.
*
* The contested verdict is computed the same way the renderer's read computes it, over the same two
* partitions. Main reaching a different one would let it publish rows the renderer never displays.
* The contested / foreign / confirmed verdicts match the renderer's boot read. Confirmed ids feed
* GAP-03 `reconciledWorktreeIds` so a publish with no renderer `session` cannot re-export stale
* ssh-partition tabs the hydration path already declined (stablyai/orca#22038).
*/
export function persistedSessionForTarget(
store: Store,
Expand All @@ -100,15 +106,27 @@ export function persistedSessionForTarget(
const hostId = toSshExecutionHostId(targetId)
const local = store.getWorkspaceSession()
const host = store.getWorkspaceSession(hostId)
const attribution = catalogAttributionForPartition(resolveWorktreeOwner, host, hostId)
const reconciledWorktreeIds = wasTargetConnectedAtLastShutdown(local, targetId)
? new Set<string>()
: attribution.confirmedSessionKeys
return adoptStrandedHostPartitionSession(local, host, {
...catalogAttributionForPartition(resolveWorktreeOwner, host, hostId)
contestedSessionKeys: attribution.contestedSessionKeys,
foreignSessionKeys: attribution.foreignSessionKeys,
ownedSessionKeys: attribution.ownedSessionKeys,
reconciledWorktreeIds
}).session
}

/** The same catalog reading the renderer's boot read applies to this partition: a repo id the
* catalog registers on more than one host is contested, and one it positively resolves to a
* different host is residue this partition does not own. Main reaching a different verdict would
* publish to the host rows the renderer never displays. */
* publish to the host rows the renderer never displays.
*
* `ownedSessionKeys` — catalog resolves to this host (incl. bare connectionId fallback).
* `confirmedSessionKeys` — catalog resolves to exactly this host via the row's own
* `executionHostId` (not bare `connectionId`). Same narrow GAP-03 signal as renderer
* `sshPartitionCatalogAttribution`. */
function catalogAttributionForPartition(
resolveWorktreeOwner: WorktreeOwnerResolver,
host: WorkspaceSessionState,
Expand All @@ -117,10 +135,12 @@ function catalogAttributionForPartition(
contestedSessionKeys: Set<string>
foreignSessionKeys: Set<string>
ownedSessionKeys: Set<string>
confirmedSessionKeys: Set<string>
} {
const contestedSessionKeys = new Set<string>()
const foreignSessionKeys = new Set<string>()
const ownedSessionKeys = new Set<string>()
const confirmedSessionKeys = new Set<string>()
for (const workspaceId of workspaceIdsNamedByPartition(host)) {
// A folder key names no repo, and `getRepoIdFromWorktreeId` hands back the whole key rather
// than nothing, so the catalog would be asked about `folder:<uuid>` and answer `unknown`.
Expand All @@ -133,11 +153,16 @@ function catalogAttributionForPartition(
if (resolution.reason === 'ambiguous') {
contestedSessionKeys.add(workspaceId)
}
} else if (resolution.hostId !== hostId) {
continue
}
if (resolution.hostId !== hostId) {
foreignSessionKeys.add(workspaceId)
} else {
ownedSessionKeys.add(workspaceId)
continue
}
ownedSessionKeys.add(workspaceId)
if (normalizeExecutionHostId(resolution.owner?.executionHostId ?? null) === hostId) {
confirmedSessionKeys.add(workspaceId)
}
}
return { contestedSessionKeys, foreignSessionKeys, ownedSessionKeys }
return { contestedSessionKeys, foreignSessionKeys, ownedSessionKeys, confirmedSessionKeys }
}
72 changes: 70 additions & 2 deletions src/main/ipc/ssh-host-partition-session-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
import type { Repo } from '../../shared/repo-types'
import type {
RemoteWorkspaceSession,
Expand Down Expand Up @@ -88,9 +89,22 @@ function sshTarget(id: string, host: string): SshTarget {
const target = sshTarget(TARGET_ID, 'one.example.com')
const otherTarget = sshTarget(OTHER_TARGET_ID, 'two.example.com')

function remoteRepo(id: string, path: string, connectionId: string): Repo {
function remoteRepo(
id: string,
path: string,
connectionId: string,
executionHostId?: string
): Repo {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the literal names every Repo field this export suite reads.
return { id, path, displayName: id, badgeColor: 'blue', addedAt: 1, connectionId } as Repo
return {
id,
path,
displayName: id,
badgeColor: 'blue',
addedAt: 1,
connectionId,
...(executionHostId !== undefined ? { executionHostId } : {})
} as Repo
}

function runtimeAuthoredTab(): TerminalTab {
Expand Down Expand Up @@ -118,6 +132,15 @@ afterEach(async () => {
})

beforeEach(() => {
setAppEnvironment({
getPath: () => tmpdir(),
getAppPath: () => tmpdir(),
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: () => []
} as AppEnvironment)
_resetRemoteWorkspaceCachesForTests()
ipcHandlers.clear()
hostSnapshot = {
Expand Down Expand Up @@ -236,3 +259,48 @@ describe('remoteWorkspace:setForConnectedTargets session fallback', () => {
expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]).not.toEqual([])
})
})

function createGap03OfflineStore(
activeConnectionIdsAtShutdown: string[]
): InstanceType<typeof Store> {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-ssh-partition-gap03-')))
directories.push(dir)
const store = createSqliteTestStore(Store, { dataFile: join(dir, 'orca-data.json') })
// executionHostId required so catalogAttribution marks WORKTREE_ID confirmed (GAP-03 signal).
store.addRepo(remoteRepo(REPO_ID, '/remote/checkout', TARGET_ID, SSH_HOST_ID))
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
// No base tabsByWorktree row at all — the offline / ordinary-restart shape for SSH-routed tabs.
activeConnectionIdsAtShutdown
})
store.setWorkspaceSession(
{
...getDefaultWorkspaceSession(),
tabsByWorktree: { [WORKTREE_ID]: [runtimeAuthoredTab()] }
},
SSH_HOST_ID
)
return store
}

describe('remoteWorkspace:setForConnectedTargets GAP-03 offline fallback', () => {
it('does not re-publish stale ssh-partition tabs after a genuine offline gap when session is omitted', async () => {
// Renderer hydration declines these; the shipping debounced writer used to omit `session` and
// let main re-adopt the parked ssh mirror into the replace-session payload.
const store = createGap03OfflineStore([])

await publishToConnectedTarget(store)

expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH] ?? []).toEqual([])
})

it('still publishes ssh-partition tabs when the client quit while connected (ordinary restart)', async () => {
const store = createGap03OfflineStore([TARGET_ID])

await publishToConnectedTarget(store)

expect(hostSnapshot.session.tabsByWorktreePath[WORKTREE_PATH]?.map((tab) => tab.id)).toEqual([
'tab-runtime'
])
})
})
20 changes: 13 additions & 7 deletions src/renderer/src/app-shell/use-app-session-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
} from '../lib/workspace-session'
import {
buildWorkspaceSessionHostSnapshots,
patchWorkspaceSessionByHost
patchWorkspaceSessionByHost,
registerLiveTestimonyProvider
} from '../lib/workspace-session-host-persistence'
import {
createShutdownCheckpointBeforeUnloadHandler,
Expand Down Expand Up @@ -118,7 +119,12 @@ function captureUploadedDirectSshLayoutEdits(
*/
export function useAppSessionPersistence(): void {
useEffect(() => registerUpdaterBeforeUnloadBypass(), [])

useEffect(() => {
registerLiveTestimonyProvider(() => useAppStore.getState())
return () => {
registerLiveTestimonyProvider(undefined)
}
}, [])
// Why: session persistence only writes to disk; a Zustand subscribe() outside React drops ~15 render-cycle subscriptions and their re-renders on every tab/file/browser change.
useEffect(() => {
return createSessionWriteSubscriber({
Expand All @@ -129,11 +135,10 @@ export function useAppSessionPersistence(): void {
persist: ({ patch }) => {
const state = useAppStore.getState()
// Why: route each host's worktree-scoped slice to its own partition; the upload chain below orders after the local write.
const { localWrite, written } = patchWorkspaceSessionByHost(
window.api.session,
patch,
state
)
const { localWrite, written } = patchWorkspaceSessionByHost(window.api.session, patch, {
...state,
getLiveTestimonyState: () => useAppStore.getState()
})
const uploadAuthorities = captureRemoteWorkspaceUploadAuthorities(state)
const pendingLayoutEdits = state.pendingDirectSshLayoutEditsByTabId
if (uploadAuthorities.length > 0) {
Expand All @@ -158,6 +163,7 @@ export function useAppSessionPersistence(): void {
])
)
const results = await window.api.remoteWorkspace?.setForConnectedTargets({
session: buildWorkspaceSessionPayload(currentState),
hydratedTargetIds,
expectedRevisionsByTargetId,
expectedHostObservationTokensByTargetId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ export function registerMobileAndTerminalCloseIpcBridge(
await persistWorkspaceSessionByHost(
window.api.session,
buildWorkspaceSessionPayload(state),
state
{
...state,
getLiveTestimonyState: () => useAppStore.getState()
}
)
respond()
})().catch((error: unknown) => {
Expand Down
46 changes: 46 additions & 0 deletions src/renderer/src/lib/remote-workspace-host-testimony.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
hostHasAnsweredForTarget,
type RemoteWorkspaceTestimonyState
} from './remote-workspace-host-testimony'

const TARGET_ID = 'ssh-target-1'

describe('hostHasAnsweredForTarget', () => {
it('is false until a snapshot has landed for the target', () => {
const emptyState: RemoteWorkspaceTestimonyState = {}
expect(hostHasAnsweredForTarget(emptyState, TARGET_ID)).toBe(false)

const pullingState: RemoteWorkspaceTestimonyState = {
remoteWorkspaceSyncStatusByTargetId: {
[TARGET_ID]: { phase: 'pulling', direction: 'pull' }
}
}
expect(hostHasAnsweredForTarget(pullingState, TARGET_ID)).toBe(false)
})

it('is false while the landed snapshot is in conflict, because the client picture is not the host picture', () => {
const conflictState: RemoteWorkspaceTestimonyState = {
remoteWorkspaceHydratedTargetIds: new Set([TARGET_ID]),
remoteWorkspaceSyncStatusByTargetId: {
[TARGET_ID]: { phase: 'conflict' }
}
}
expect(hostHasAnsweredForTarget(conflictState, TARGET_ID)).toBe(false)
})

it('is true once a non-conflicting snapshot has landed', () => {
const syncedState: RemoteWorkspaceTestimonyState = {
remoteWorkspaceHydratedTargetIds: new Set([TARGET_ID]),
remoteWorkspaceSyncStatusByTargetId: {
[TARGET_ID]: { phase: 'synced', direction: 'pull' }
}
}
expect(hostHasAnsweredForTarget(syncedState, TARGET_ID)).toBe(true)

const noStatusState: RemoteWorkspaceTestimonyState = {
remoteWorkspaceHydratedTargetIds: new Set([TARGET_ID])
}
expect(hostHasAnsweredForTarget(noStatusState, TARGET_ID)).toBe(true)
})
})
20 changes: 20 additions & 0 deletions src/renderer/src/lib/remote-workspace-host-testimony.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { RemoteWorkspaceSyncStatus } from '@/store/slices/ssh'

export type RemoteWorkspaceTestimonyState = {
remoteWorkspaceHydratedTargetIds?: ReadonlySet<string>
remoteWorkspaceSyncStatusByTargetId?: Record<string, RemoteWorkspaceSyncStatus>
}

/**
* The pair `use-app-session-persistence` gates uploads on: a snapshot has landed for this target
* and it did not conflict, so the client's picture IS the host's.
*/
export function hostHasAnsweredForTarget(
state: RemoteWorkspaceTestimonyState,
targetId: string
): boolean {
return (
state.remoteWorkspaceHydratedTargetIds?.has(targetId) === true &&
state.remoteWorkspaceSyncStatusByTargetId?.[targetId]?.phase !== 'conflict'
)
}
Loading