From 5efc24fcfc3ca32d64f0031bc1af832b53ace041 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 14 Sep 2026 13:58:19 -0700
Subject: [PATCH] fix activation recovery ownership boundary
---
src/renderer/src/app-startup-routing.test.ts | 5 +-
...nitial-terminal-structured-launch.test.tsx | 32 ++-
.../terminal/initial-terminal-wiring.test.ts | 15 +-
.../use-terminal-watcher-effects.ts | 72 +++--
...irect-work-item-surface-production.test.ts | 5 +-
.../src/lib/resume-sleeping-agent-session.ts | 40 +++
.../src/lib/sleeping-agent-session-launch.ts | 4 +
.../workspace-activation-recovery-attempts.ts | 43 +++
...rkspace-activation-recovery-coordinator.ts | 176 ++++--------
...e-activation-recovery-failure-snapshots.ts | 20 ++
.../lib/workspace-activation-recovery-gate.ts | 100 -------
...pace-activation-recovery-lifecycle.test.ts | 99 +++++++
...workspace-activation-recovery-lifecycle.ts | 15 ++
...kspace-activation-recovery-presentation.ts | 22 +-
.../workspace-activation-recovery-retry.ts | 6 +-
...orkspace-activation-recovery-settlement.ts | 68 ++++-
.../workspace-activation-recovery-state.ts | 116 +++-----
.../workspace-activation-requested-surface.ts | 87 ++++++
...kspace-activation-surface-producer.test.ts | 248 +++++++++++++++++
.../workspace-activation-surface-producer.ts | 162 +++++++++++
.../src/lib/workspace-execution-evidence.ts | 3 +-
.../src/lib/workspace-surface-production.ts | 52 +++-
...ctivation-emptied-workspace-reseed.test.ts | 28 +-
.../lib/worktree-activation-pty-inventory.ts | 53 ++++
...ctivation-recovery-boundary-census.test.ts | 34 ++-
.../worktree-activation-recovery-routing.ts | 18 +-
.../lib/worktree-activation-recovery.test.ts | 252 ++++++++++--------
src/renderer/src/lib/worktree-activation.ts | 95 ++-----
.../lib/worktree-agent-activation-claims.ts | 81 ++++++
.../worktree-agent-activation-gate.test.ts | 140 +++++++++-
.../src/lib/worktree-agent-activation-gate.ts | 229 ++++++++--------
.../worktree-agent-activation-route.test.ts | 35 +++
.../lib/worktree-agent-activation-route.ts | 33 +++
.../worktree-agent-activation-seam.test.ts | 27 +-
.../worktree-agent-live-surface-adoption.ts | 26 +-
.../worktree-agent-structured-inventory.ts | 101 ++++---
.../src/lib/worktree-creation-flow-execute.ts | 38 ++-
...ree-creation-flow-stranded-surface.test.ts | 16 +-
.../worktree-live-terminal-surface-owners.ts | 62 ++++-
...ivation-preserved-pane-replacement.test.ts | 8 +-
...tion-runtime-owned-resume-deferral.test.ts | 8 +-
...worktree-reactivation-tab-forkbomb.test.ts | 45 +++-
...abortable-runtime-environment-call.test.ts | 74 +++++
.../abortable-runtime-environment-call.ts | 73 +++++
.../src/runtime/runtime-rpc-client.ts | 42 ++-
.../src/runtime/runtime-rpc-result.ts | 6 +
.../folder-workspace-mutations.ts | 5 +
.../project-groups/project-group-mutations.ts | 16 +-
src/renderer/src/store/slices/ssh.ts | 10 +-
.../session/worktree-slice-lookups.ts | 7 +
.../purge-stale-runtime-host-state.ts | 6 +
.../teardown/remove-worktree-store-cleanup.ts | 5 +
...eted-worker-retirement-resume.unit.test.ts | 2 +
53 files changed, 2180 insertions(+), 785 deletions(-)
create mode 100644 src/renderer/src/lib/workspace-activation-recovery-attempts.ts
create mode 100644 src/renderer/src/lib/workspace-activation-recovery-failure-snapshots.ts
delete mode 100644 src/renderer/src/lib/workspace-activation-recovery-gate.ts
create mode 100644 src/renderer/src/lib/workspace-activation-recovery-lifecycle.test.ts
create mode 100644 src/renderer/src/lib/workspace-activation-recovery-lifecycle.ts
create mode 100644 src/renderer/src/lib/workspace-activation-requested-surface.ts
create mode 100644 src/renderer/src/lib/workspace-activation-surface-producer.test.ts
create mode 100644 src/renderer/src/lib/workspace-activation-surface-producer.ts
create mode 100644 src/renderer/src/lib/worktree-agent-activation-claims.ts
create mode 100644 src/renderer/src/lib/worktree-agent-activation-route.test.ts
create mode 100644 src/renderer/src/lib/worktree-agent-activation-route.ts
create mode 100644 src/renderer/src/runtime/abortable-runtime-environment-call.test.ts
diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts
index 8e8598a1c29..9a20684a668 100644
--- a/src/renderer/src/app-startup-routing.test.ts
+++ b/src/renderer/src/app-startup-routing.test.ts
@@ -30,7 +30,10 @@ describe('renderer startup runtime routing', () => {
const recoveryEffect = source.slice(recoveryStart, recoveryEnd)
expect(recoveryStart).toBeGreaterThanOrEqual(0)
- expect(recoveryEffect).toContain("{ mode: 'startup', signal: abort.signal }")
+ expect(recoveryEffect).toContain(
+ "startWorkspaceActivationSurfaceProducer(identity, { mode: 'startup' })"
+ )
+ expect(recoveryEffect).toContain('signal: abort.signal')
expect(recoveryEffect).toContain('abort.abort()')
expect(recoveryEffect).not.toContain('gateWorktreeAgentActivation')
expect(recoveryEffect).not.toContain('resumeSleepingAgentSessionsForWorktree')
diff --git a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
index 7896c3ca03a..349568848fe 100644
--- a/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
+++ b/src/renderer/src/components/terminal/initial-terminal-structured-launch.test.tsx
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => {
let uuid = 0
return {
recover: vi.fn(),
+ produce: vi.fn(),
nextUuid: () => `startup-recovery-${++uuid}`,
resetUuid: () => {
uuid = 0
@@ -17,13 +18,24 @@ const mocks = vi.hoisted(() => {
})
vi.mock('@/store', () => ({
- useAppStore: Object.assign(() => 'none', {
- getState: () => ({ activeWorktreeId: 'folder:workspace-1' })
- })
+ useAppStore: Object.assign(
+ (
+ selector: (state: {
+ activeWorkspaceExecutionHostId: null
+ runtimeEnvironments: readonly []
+ }) => unknown
+ ) => selector({ activeWorkspaceExecutionHostId: null, runtimeEnvironments: [] }),
+ {
+ getState: () => ({ activeWorktreeId: 'folder:workspace-1' })
+ }
+ )
}))
vi.mock('@/lib/worktree-activation-recovery', () => ({
recoverWorkspaceActivation: mocks.recover
}))
+vi.mock('@/lib/workspace-activation-surface-producer', () => ({
+ startWorkspaceActivationSurfaceProducer: mocks.produce
+}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getExecutionHostIdForWorktree: () => 'local',
getRuntimeEnvironmentIdForWorktree: () => null
@@ -53,6 +65,7 @@ afterEach(async () => {
await act(async () => root?.unmount())
root = undefined
mocks.recover.mockReset()
+ mocks.produce.mockReset()
mocks.resetUuid()
})
@@ -85,9 +98,13 @@ describe('passive activation recovery', () => {
},
{ mode: 'startup', signal: expect.any(AbortSignal) }
)
+ expect(mocks.produce).toHaveBeenCalledWith(
+ expect.objectContaining({ workspaceKey: 'folder:workspace-1' }),
+ { mode: 'startup' }
+ )
})
- it('does not turn later state-only selections into new recovery launches', async () => {
+ it('observes every later general-setter selection', async () => {
mocks.recover.mockResolvedValue({ kind: 'materialized' })
root = createRoot(document.createElement('div'))
await act(async () => root?.render())
@@ -95,7 +112,12 @@ describe('passive activation recovery', () => {
await act(async () => root?.render())
- expect(mocks.recover).toHaveBeenCalledOnce()
+ expect(mocks.recover).toHaveBeenCalledTimes(2)
+ expect(mocks.produce).toHaveBeenCalledTimes(2)
+ expect(mocks.recover.mock.calls.map(([request]) => request.workspaceKey)).toEqual([
+ 'worktree-1',
+ 'worktree-2'
+ ])
})
it('aborts an unsettled request without consuming the next startup assessment', async () => {
diff --git a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts
index bb4fdcc7210..95c33c58755 100644
--- a/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts
+++ b/src/renderer/src/components/terminal/initial-terminal-wiring.test.ts
@@ -14,7 +14,11 @@ describe('Terminal startup recovery wiring', () => {
it('routes startup materialization through the recovery boundary', () => {
expect(source.split('recoverWorkspaceActivation(').length - 1).toBe(1)
- expect(source).toContain("{ mode: 'startup', signal: abort.signal }")
+ expect(source.split('startWorkspaceActivationSurfaceProducer(').length - 1).toBe(1)
+ expect(source).toContain(
+ "startWorkspaceActivationSurfaceProducer(identity, { mode: 'startup' })"
+ )
+ expect(source).toContain('signal: abort.signal')
})
it('captures target, host, runtime, and attempt identity', () => {
@@ -24,10 +28,11 @@ describe('Terminal startup recovery wiring', () => {
expect(source).toContain('attemptId: createBrowserUuid()')
})
- it('keeps recovery retryable until the asynchronous assessment settles', () => {
- expect(source).toContain(
- 'if (!abort.signal.aborted) {\n startupRecoverySettledRef.current = true'
- )
+ it('re-arms startup ownership for every route and authority transition', () => {
+ expect(source).toContain('const startupRecoveryTargetRef = useRef(null)')
+ expect(source).toContain('activeRuntimeRoute.revision,')
+ expect(source).toContain('const target = JSON.stringify([')
+ expect(source).toContain('startupRecoveryTargetRef.current = null')
expect(source).toContain('return () => {\n abort.abort()')
})
})
diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts
index c7b9868272e..0e87d950ee8 100644
--- a/src/renderer/src/components/use-terminal-watcher-effects.ts
+++ b/src/renderer/src/components/use-terminal-watcher-effects.ts
@@ -18,6 +18,8 @@ import {
getRuntimeEnvironmentIdForWorktree
} from '@/lib/worktree-runtime-owner'
import { createBrowserUuid } from '@/lib/browser-uuid'
+import { startWorkspaceActivationSurfaceProducer } from '@/lib/workspace-activation-surface-producer'
+import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
// Why shared: surfaces without watchable live tabs need no per-pass allocation.
const NO_PARKED_TAB_IDS: ReadonlySet = new Set()
@@ -81,7 +83,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
workspaceSessionReady,
workspaceSurfaceIds
} = controller
- const startupRecoverySettledRef = useRef(false)
+ const startupRecoveryTargetRef = useRef(null)
useEffect(() => {
pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds(workspaceSurfaceIds))
@@ -194,38 +196,70 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
const activeWorkspaceExecutionHostId = useAppStore(
(state) => state.activeWorkspaceExecutionHostId
)
+ const runtimeEnvironments = useAppStore((state) => state.runtimeEnvironments)
+ const activeRuntimeRoute = useMemo(() => {
+ const routeKey = `${activeWorkspaceExecutionHostId ?? ''}|${activeWorktreeHostAuthority}`
+ if (!activeWorktreeId) {
+ return { routeKey, revision: null }
+ }
+ const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
+ useAppStore.getState(),
+ activeWorktreeId
+ )
+ const environment = runtimeEnvironments.find(({ id }) => id === runtimeEnvironmentId)
+ return {
+ routeKey,
+ revision: environment
+ ? (environment.pairingRevision ?? environment.createdAt)
+ : runtimeEnvironmentId
+ ? (getRuntimeEnvironmentRevision(runtimeEnvironmentId) ?? null)
+ : null
+ }
+ }, [
+ activeWorkspaceExecutionHostId,
+ activeWorktreeHostAuthority,
+ activeWorktreeId,
+ runtimeEnvironments
+ ])
useEffect(() => {
if (!workspaceSessionReady || !terminalStartupRestorationReady) {
- startupRecoverySettledRef.current = false
+ startupRecoveryTargetRef.current = null
return
}
- if (!activeWorktreeId || startupRecoverySettledRef.current) {
+ if (!activeWorktreeId) {
return
}
const state = useAppStore.getState()
+ const identity = {
+ workspaceKey: activeWorktreeId,
+ executionHostId: getExecutionHostIdForWorktree(state, activeWorktreeId),
+ runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId),
+ attemptId: createBrowserUuid()
+ }
+ const target = JSON.stringify([
+ identity.executionHostId,
+ identity.runtimeEnvironmentId,
+ activeRuntimeRoute.revision,
+ activeWorktreeId,
+ activeRuntimeRoute.routeKey
+ ])
+ if (startupRecoveryTargetRef.current === target) {
+ return
+ }
+ startupRecoveryTargetRef.current = target
const abort = new AbortController()
- void recoverWorkspaceActivation(
- {
- workspaceKey: activeWorktreeId,
- executionHostId: getExecutionHostIdForWorktree(state, activeWorktreeId),
- runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, activeWorktreeId),
- attemptId: createBrowserUuid()
- },
- { mode: 'startup', signal: abort.signal }
- ).then(
- () => {
- if (!abort.signal.aborted) {
- startupRecoverySettledRef.current = true
- }
- },
- () => undefined
- )
+ startWorkspaceActivationSurfaceProducer(identity, { mode: 'startup' })
+ void recoverWorkspaceActivation(identity, {
+ mode: 'startup',
+ signal: abort.signal
+ }).catch(() => undefined)
return () => {
abort.abort()
}
}, [
activeWorkspaceExecutionHostId,
+ activeRuntimeRoute,
activeWorktreeHostAuthority,
activeWorktreeId,
terminalStartupRestorationReady,
diff --git a/src/renderer/src/lib/direct-work-item-surface-production.test.ts b/src/renderer/src/lib/direct-work-item-surface-production.test.ts
index c17e8d1605a..a518c3dc11d 100644
--- a/src/renderer/src/lib/direct-work-item-surface-production.test.ts
+++ b/src/renderer/src/lib/direct-work-item-surface-production.test.ts
@@ -32,7 +32,10 @@ const producer: WorkspaceSurfaceProducer = {
materialized: vi.fn(),
declined: vi.fn(),
failed: vi.fn(),
- unverifiable: vi.fn()
+ unverifiable: vi.fn(),
+ blocked: vi.fn(),
+ unexpected: vi.fn(),
+ intentionalEmpty: vi.fn()
}
describe('direct work item surface production', () => {
diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts
index e0b5b79ac6e..16b46175751 100644
--- a/src/renderer/src/lib/resume-sleeping-agent-session.ts
+++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts
@@ -17,6 +17,11 @@ import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-syn
import { findUnhydratedHostMirrorForPane } from './host-mirrored-pane-liveness'
import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority'
import { parkUntilHostSessionMirrorHydrates } from '@/runtime/host-session-mirror-hydration'
+import {
+ getExecutionHostIdForWorktree,
+ getRuntimeEnvironmentIdForWorktree
+} from './worktree-runtime-owner'
+import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
export type { ResumeSleepingAgentSessionsOptions } from './sleeping-agent-session-launch'
@@ -166,16 +171,48 @@ function parkWorktreeResumeSweepUntilHostMirrorHydrates(
// wakes, and a latch that has since failed must stay resumable here.
resumeSleepingAgentSessionsForWorktree(worktreeId, {
...(options?.onSessionLaunched ? { onSessionLaunched: options.onSessionLaunched } : {}),
+ ...(options?.expectedExecutionHostId
+ ? { expectedExecutionHostId: options.expectedExecutionHostId }
+ : {}),
+ ...('expectedRuntimeEnvironmentId' in (options ?? {})
+ ? { expectedRuntimeEnvironmentId: options?.expectedRuntimeEnvironmentId ?? null }
+ : {}),
+ ...(options?.expectedRuntimeEnvironmentRevision === undefined
+ ? {}
+ : { expectedRuntimeEnvironmentRevision: options.expectedRuntimeEnvironmentRevision }),
...(isActive ? {} : { suppressNavigation: true })
})
})
}
+function resumeRouteIsCurrent(
+ state: ReturnType,
+ worktreeId: string,
+ options: ResumeSleepingAgentSessionsOptions | undefined
+): boolean {
+ return (
+ (options?.expectedExecutionHostId === undefined ||
+ getExecutionHostIdForWorktree(state, worktreeId) === options.expectedExecutionHostId) &&
+ (!options ||
+ !('expectedRuntimeEnvironmentId' in options) ||
+ getRuntimeEnvironmentIdForWorktree(state, worktreeId) ===
+ options.expectedRuntimeEnvironmentId) &&
+ (options?.expectedRuntimeEnvironmentRevision === undefined ||
+ (options.expectedRuntimeEnvironmentId !== null &&
+ options.expectedRuntimeEnvironmentId !== undefined &&
+ getRuntimeEnvironmentRevision(options.expectedRuntimeEnvironmentId) ===
+ options.expectedRuntimeEnvironmentRevision))
+ )
+}
+
export function resumeSleepingAgentSessionsForWorktree(
worktreeId: string,
options?: ResumeSleepingAgentSessionsOptions
): number {
const state = useAppStore.getState()
+ if (!resumeRouteIsCurrent(state, worktreeId, options)) {
+ return 0
+ }
// Why: every branch below reads local rows as the verdict on what the execution host is running,
// and before it answers "I hold no pane for this record" is `unverifiable`, not `exited`. Resuming
// on it forks a second agent onto a transcript the host is still writing (STA-3500). Declining is
@@ -200,6 +237,9 @@ export function resumeSleepingAgentSessionsForWorktree(
let launched = 0
for (const record of worktreeRecords) {
const currentState = useAppStore.getState()
+ if (!resumeRouteIsCurrent(currentState, worktreeId, options)) {
+ return launched
+ }
if (currentState.sleepingAgentSessionsByPaneKey[record.paneKey] !== record) {
continue
}
diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts
index 43d7bdb3b30..bbcac45f42e 100644
--- a/src/renderer/src/lib/sleeping-agent-session-launch.ts
+++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts
@@ -14,6 +14,7 @@ import {
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
+import type { ExecutionHostId } from '../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
export type ResumeSleepingAgentSessionsOptions = {
@@ -26,6 +27,9 @@ export type ResumeSleepingAgentSessionsOptions = {
/** Called with the tab id of each freshly launched resume tab, so
* navigation-suppressed callers can background-mount exactly those tabs. */
onSessionLaunched?: (tabId: string) => void
+ expectedExecutionHostId?: ExecutionHostId
+ expectedRuntimeEnvironmentId?: string | null
+ expectedRuntimeEnvironmentRevision?: number
}
function getResumeLaunchTarget(worktreeId: string): AgentResumeLaunchTarget {
diff --git a/src/renderer/src/lib/workspace-activation-recovery-attempts.ts b/src/renderer/src/lib/workspace-activation-recovery-attempts.ts
new file mode 100644
index 00000000000..002ba157d75
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-attempts.ts
@@ -0,0 +1,43 @@
+import type { ExecutionHostId } from '../../../shared/execution-host'
+
+type ActivationAttemptIdentity = {
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+ attemptId: string
+}
+
+const latestAttemptIdByTarget = new Map()
+
+export function activationRecoveryTargetKey(identity: {
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+}): string {
+ return `${identity.executionHostId}|${identity.workspaceKey}`
+}
+
+export function markLatestActivationRecoveryAttempt(identity: ActivationAttemptIdentity): void {
+ latestAttemptIdByTarget.set(activationRecoveryTargetKey(identity), identity.attemptId)
+}
+
+export function readLatestActivationRecoveryAttempt(
+ identity: ActivationAttemptIdentity
+): string | undefined {
+ return latestAttemptIdByTarget.get(activationRecoveryTargetKey(identity))
+}
+
+export function clearLatestActivationRecoveryAttempts(args: {
+ workspaceKey?: string
+ executionHostId?: ExecutionHostId
+}): void {
+ for (const key of latestAttemptIdByTarget.keys()) {
+ const separator = key.indexOf('|')
+ const executionHostId = key.slice(0, separator)
+ const workspaceKey = key.slice(separator + 1)
+ if (
+ (args.workspaceKey === undefined || workspaceKey === args.workspaceKey) &&
+ (args.executionHostId === undefined || executionHostId === args.executionHostId)
+ ) {
+ latestAttemptIdByTarget.delete(key)
+ }
+ }
+}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts b/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts
index 868a827c384..406fb86cfd7 100644
--- a/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts
+++ b/src/renderer/src/lib/workspace-activation-recovery-coordinator.ts
@@ -1,7 +1,5 @@
import { useAppStore } from '@/store'
-import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { clearWorkspaceActivationRecoveryPresentation } from './workspace-activation-recovery-presentation'
-import { readWorkspaceSurfaceProducerEntries } from './workspace-surface-production'
import {
resolveWorkspaceExecutionEvidence,
type WorkspaceExecutionEvidence
@@ -12,7 +10,6 @@ import type {
} from './worktree-activation-recovery'
import type { WorkspaceActivationRecoveryOwnerContext } from './workspace-activation-recovery-retry'
import {
- canInspectAgentActivationInventory,
captureActivationRecoverySelectionRevision,
hasLiveActivationTerminalTombstone,
installActivationRecoverySelectionTracker,
@@ -31,99 +28,6 @@ import {
publishActivationRecovery,
waitForActivationProducerAttempts
} from './workspace-activation-recovery-settlement'
-import {
- runActivationRecoveryGate,
- waitForActivationProducedSurface
-} from './workspace-activation-recovery-gate'
-
-let seedingTargetKey: string | null = null
-
-function seedPrivateRecoverySurface(
- identity: WorkspaceActivationIdentity,
- context: WorkspaceActivationRecoveryOwnerContext,
- capturedSelectionRevision: number
-): WorkspaceActivationRecoveryResult {
- const key = `${identity.executionHostId}|${identity.workspaceKey}`
- if (
- !isActivationRecoveryFresh(identity, capturedSelectionRevision, context) ||
- seedingTargetKey === key
- ) {
- return { kind: 'stale' }
- }
- seedingTargetKey = key
- try {
- const { renderableTabCount, surface } = readActivationRenderableInventory(identity)
- // Why: reconciliation can synchronously notify subscribers and start a newer activation.
- if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
- return { kind: 'stale' }
- }
- if (surface) {
- return activationRecoveryMaterializedResult(identity, surface)
- }
- if (renderableTabCount > 0) {
- publishActivationRecovery(
- identity,
- context,
- 'unexpected',
- 'Workspace content exists, but no renderable surface could be selected.'
- )
- return activationRecoveryFailedResult(identity, 'unexpected')
- }
- if (context.mode === 'startup' && hasLiveActivationTerminalTombstone(identity.workspaceKey)) {
- clearActivationRecoveryPresentation(identity)
- return { kind: 'intentional-empty' }
- }
- const pendingProducer = readWorkspaceSurfaceProducerEntries(identity).find(
- (entry) => entry.result === null || entry.result?.kind === 'unverifiable'
- )
- if (pendingProducer) {
- publishActivationRecovery(
- identity,
- context,
- 'unverifiable',
- 'A surface producer still owns this workspace.'
- )
- return {
- kind: 'deferred',
- reason: 'A surface producer still owns this workspace.',
- ownerAttemptId: pendingProducer.attempt.id
- }
- }
- const evidence: WorkspaceExecutionEvidence = resolveWorkspaceExecutionEvidence(
- useAppStore.getState(),
- identity.workspaceKey,
- identity.executionHostId
- )
- if (evidence !== 'exited') {
- const detail =
- evidence === 'live'
- ? 'The execution host owns this workspace surface. Wait for it to publish or reconnect.'
- : 'Orca cannot verify the execution host. Reconnect before retrying recovery.'
- publishActivationRecovery(identity, context, 'unverifiable', detail)
- return { kind: 'deferred', reason: detail, ownerAttemptId: null }
- }
- if (!shouldAutoCreateInitialTerminal(renderableTabCount, false)) {
- publishActivationRecovery(
- identity,
- context,
- 'unexpected',
- 'Workspace recovery could not select a surface.'
- )
- return activationRecoveryFailedResult(identity, 'unexpected')
- }
- const tab = useAppStore.getState().createTab(identity.workspaceKey, undefined, undefined, {
- pendingActivationSpawn: true
- })
- clearActivationRecoveryPresentation(identity)
- return { kind: 'materialized', surface: { id: tab.id, type: 'terminal' } }
- } catch (error) {
- const detail = error instanceof Error ? error.message : String(error)
- publishActivationRecovery(identity, context, 'unexpected', detail)
- return activationRecoveryFailedResult(identity, 'unexpected')
- } finally {
- seedingTargetKey = null
- }
-}
export async function recoverWorkspaceActivationOwned(
identity: WorkspaceActivationIdentity,
@@ -157,51 +61,61 @@ export async function recoverWorkspaceActivationOwned(
const producerAssessment = assessActivationProducerAttempts(identity, context)
const producerResult =
producerAssessment.kind === 'wait'
- ? await waitForActivationProducerAttempts(identity, context, deadlineAt)
+ ? await waitForActivationProducerAttempts(
+ identity,
+ context,
+ deadlineAt,
+ capturedSelectionRevision
+ )
: producerAssessment.kind === 'complete'
? producerAssessment.result
: null
- const hasSleepingAgentSession = Object.values(
- useAppStore.getState().sleepingAgentSessionsByPaneKey
- ).some((record) => record.worktreeId === identity.workspaceKey)
- if (producerResult && (producerResult.kind !== 'materialized' || !hasSleepingAgentSession)) {
+ if (producerResult) {
return producerResult
}
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
return { kind: 'stale' }
}
- const shouldGate =
- hasSleepingAgentSession || (!producerResult && canInspectAgentActivationInventory())
- if (shouldGate) {
- const gateResult = await runActivationRecoveryGate(identity, context, deadlineAt)
- if (gateResult !== 'empty' && gateResult !== 'produced') {
- return gateResult
- }
- if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
- return { kind: 'stale' }
- }
- const gateSurface = readActivationRenderableSurface(identity)
- if (gateSurface) {
- return activationRecoveryMaterializedResult(identity, gateSurface)
- }
- if (producerResult?.kind === 'materialized') {
- return producerResult
- }
- if (gateResult === 'produced') {
- return waitForActivationProducedSurface(identity, context, deadlineAt)
- }
+ const { renderableTabCount, surface } = readActivationRenderableInventory(identity)
+ if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
+ return { kind: 'stale' }
}
- const seedResult = seedPrivateRecoverySurface(identity, context, capturedSelectionRevision)
- if (
- seedResult.kind !== 'stale' ||
- !isActivationRecoveryFresh(identity, capturedSelectionRevision, context)
- ) {
- return seedResult
+ if (surface) {
+ return activationRecoveryMaterializedResult(identity, surface)
}
- // Why: a reentrant subscriber can start the newer attempt while the superseded attempt still
- // owns the synchronous seed guard; retry once after that critical section unwinds.
- await Promise.resolve()
- return seedPrivateRecoverySurface(identity, context, capturedSelectionRevision)
+ if (renderableTabCount > 0) {
+ publishActivationRecovery(
+ identity,
+ context,
+ 'unexpected',
+ 'Workspace content exists, but no renderable surface could be selected.'
+ )
+ return activationRecoveryFailedResult(identity, 'unexpected')
+ }
+ if (context.mode === 'startup' && hasLiveActivationTerminalTombstone(identity.workspaceKey)) {
+ clearActivationRecoveryPresentation(identity)
+ return { kind: 'intentional-empty' }
+ }
+ const evidence: WorkspaceExecutionEvidence = resolveWorkspaceExecutionEvidence(
+ useAppStore.getState(),
+ identity.workspaceKey,
+ identity.executionHostId
+ )
+ const detail =
+ evidence === 'live'
+ ? 'The execution host owns this workspace surface. Wait for it to publish or reconnect.'
+ : evidence === 'unverifiable'
+ ? 'Orca cannot verify the execution host. Reconnect before retrying recovery.'
+ : 'No concrete surface producer owns this empty workspace activation.'
+ publishActivationRecovery(
+ identity,
+ context,
+ evidence === 'exited' ? 'unexpected' : 'unverifiable',
+ detail
+ )
+ return evidence === 'exited'
+ ? activationRecoveryFailedResult(identity, 'unexpected')
+ : { kind: 'deferred', reason: detail, ownerAttemptId: null }
} catch (error) {
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
return { kind: 'stale' }
diff --git a/src/renderer/src/lib/workspace-activation-recovery-failure-snapshots.ts b/src/renderer/src/lib/workspace-activation-recovery-failure-snapshots.ts
new file mode 100644
index 00000000000..030fae95852
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-failure-snapshots.ts
@@ -0,0 +1,20 @@
+const surfaceIdsByProducerAttempt = new Map>()
+
+export function readActivationRecoveryFailureSurfaceIds(
+ attemptId: string
+): ReadonlySet | undefined {
+ return surfaceIdsByProducerAttempt.get(attemptId)
+}
+
+export function recordActivationRecoveryFailureSurfaceIds(
+ attemptId: string,
+ surfaceIds: ReadonlySet
+): void {
+ surfaceIdsByProducerAttempt.set(attemptId, surfaceIds)
+}
+
+export function clearActivationRecoveryFailureSnapshots(attemptIds: readonly string[]): void {
+ for (const attemptId of attemptIds) {
+ surfaceIdsByProducerAttempt.delete(attemptId)
+ }
+}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-gate.ts b/src/renderer/src/lib/workspace-activation-recovery-gate.ts
deleted file mode 100644
index 2f9ef462dba..00000000000
--- a/src/renderer/src/lib/workspace-activation-recovery-gate.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate'
-import type {
- WorkspaceActivationIdentity,
- WorkspaceActivationRecoveryResult
-} from './worktree-activation-recovery'
-import type { WorkspaceActivationRecoveryOwnerContext } from './workspace-activation-recovery-retry'
-import {
- activationRecoveryRouteKey,
- readActivationRecoveryGateRoute,
- readActivationRenderableSurface,
- recordActivationRecoveryGateRoute,
- waitForActivationRecoveryChange,
- waitForActivationRecoveryPromise
-} from './workspace-activation-recovery-state'
-import {
- activationRecoveryFailedResult,
- activationRecoveryMaterializedResult,
- publishActivationRecovery
-} from './workspace-activation-recovery-settlement'
-
-export async function runActivationRecoveryGate(
- identity: WorkspaceActivationIdentity,
- context: WorkspaceActivationRecoveryOwnerContext,
- deadlineAt: number
-): Promise {
- const gate = gateWorktreeAgentActivation(identity.workspaceKey)
- const gateRoute = readActivationRecoveryGateRoute(gate)
- if (gateRoute && gateRoute !== activationRecoveryRouteKey(identity)) {
- publishActivationRecovery(
- identity,
- context,
- 'unverifiable',
- 'Another execution host owns the in-progress recovery assessment. Retry after it settles.'
- )
- return {
- kind: 'deferred',
- reason: 'A recovery gate from another execution host is still in progress.',
- ownerAttemptId: null
- }
- }
- recordActivationRecoveryGateRoute(gate, activationRecoveryRouteKey(identity))
- const outcome = await waitForActivationRecoveryPromise(gate, deadlineAt, context.signal)
- if (outcome === 'cancelled') {
- return { kind: 'stale' }
- }
- if (outcome === 'timeout') {
- publishActivationRecovery(
- identity,
- context,
- 'unexpected',
- 'Workspace inventory did not finish before the recovery deadline.'
- )
- return activationRecoveryFailedResult(identity, 'unexpected')
- }
- if (outcome.kind === 'rejected') {
- const detail = outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
- publishActivationRecovery(identity, context, 'unexpected', detail)
- return activationRecoveryFailedResult(identity, 'unexpected')
- }
- if (outcome.value === 'blocked') {
- publishActivationRecovery(
- identity,
- context,
- 'blocked',
- 'Orca deliberately paused recovery because the execution host did not provide complete ownership evidence.'
- )
- return activationRecoveryFailedResult(identity, 'blocked')
- }
- return outcome.value === 'empty' ? 'empty' : 'produced'
-}
-
-export async function waitForActivationProducedSurface(
- identity: WorkspaceActivationIdentity,
- context: WorkspaceActivationRecoveryOwnerContext,
- deadlineAt: number
-): Promise {
- while (true) {
- const surface = readActivationRenderableSurface(identity)
- if (surface) {
- return activationRecoveryMaterializedResult(identity, surface)
- }
- const wait = await waitForActivationRecoveryChange(deadlineAt, context.signal)
- if (wait === 'cancelled') {
- return { kind: 'stale' }
- }
- if (wait === 'timeout') {
- publishActivationRecovery(
- identity,
- context,
- 'unverifiable',
- 'The execution host reported work, but no renderable surface became visible.'
- )
- return {
- kind: 'deferred',
- reason: 'Host work did not publish a renderable surface before the recovery deadline.',
- ownerAttemptId: null
- }
- }
- }
-}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-lifecycle.test.ts b/src/renderer/src/lib/workspace-activation-recovery-lifecycle.test.ts
new file mode 100644
index 00000000000..3410fdecc5e
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-lifecycle.test.ts
@@ -0,0 +1,99 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { toSshExecutionHostId } from '../../../shared/execution-host'
+import { clearWorkspaceActivationRecoveryLifecycle } from './workspace-activation-recovery-lifecycle'
+import {
+ markLatestActivationRecoveryAttempt,
+ readLatestActivationRecoveryAttempt
+} from './workspace-activation-recovery-attempts'
+import {
+ readActivationRecoveryFailureSurfaceIds,
+ recordActivationRecoveryFailureSurfaceIds
+} from './workspace-activation-recovery-failure-snapshots'
+import {
+ publishWorkspaceActivationRecoveryPresentation,
+ readWorkspaceActivationRecoveryPresentation
+} from './workspace-activation-recovery-presentation'
+import {
+ readWorkspaceSurfaceProducerEntries,
+ registerWorkspaceSurfaceProducer
+} from './workspace-surface-production'
+
+const SSH_HOST = toSshExecutionHostId('box')
+
+function registerLifecycle(
+ workspaceKey: string,
+ executionHostId: 'local' | typeof SSH_HOST,
+ attemptId: string
+): void {
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey,
+ executionHostId,
+ attemptId
+ })
+ producer.failed('failed')
+ recordActivationRecoveryFailureSurfaceIds(attemptId, new Set([`${attemptId}-surface`]))
+ markLatestActivationRecoveryAttempt({ workspaceKey, executionHostId, attemptId })
+ publishWorkspaceActivationRecoveryPresentation({
+ workspaceKey,
+ executionHostId,
+ attemptId,
+ kind: 'producer-failed',
+ retry: () => undefined
+ })
+}
+
+beforeEach(() => {
+ clearWorkspaceActivationRecoveryLifecycle({})
+})
+
+describe('workspace activation recovery lifecycle', () => {
+ it('clears all recovery ownership when a workspace is deleted', () => {
+ registerLifecycle('worktree-1', 'local', 'local-1')
+ registerLifecycle('worktree-1', SSH_HOST, 'ssh-1')
+ registerLifecycle('worktree-2', 'local', 'local-2')
+
+ clearWorkspaceActivationRecoveryLifecycle({ workspaceKey: 'worktree-1' })
+
+ expect(
+ readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-1', executionHostId: 'local' })
+ ).toEqual([])
+ expect(
+ readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-1', executionHostId: SSH_HOST })
+ ).toEqual([])
+ expect(readWorkspaceActivationRecoveryPresentation('worktree-1', 'local')).toBeNull()
+ expect(
+ readLatestActivationRecoveryAttempt({
+ workspaceKey: 'worktree-1',
+ executionHostId: 'local',
+ attemptId: 'unused'
+ })
+ ).toBeUndefined()
+ expect(readActivationRecoveryFailureSurfaceIds('local-1')).toBeUndefined()
+ expect(readActivationRecoveryFailureSurfaceIds('ssh-1')).toBeUndefined()
+ expect(
+ readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-2', executionHostId: 'local' })
+ ).toHaveLength(1)
+ })
+
+ it('clears only the retired execution host across workspaces', () => {
+ registerLifecycle('worktree-1', SSH_HOST, 'ssh-1')
+ registerLifecycle('worktree-2', SSH_HOST, 'ssh-2')
+ registerLifecycle('worktree-1', 'local', 'local-1')
+
+ clearWorkspaceActivationRecoveryLifecycle({ executionHostId: SSH_HOST })
+
+ expect(
+ readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-1', executionHostId: SSH_HOST })
+ ).toEqual([])
+ expect(
+ readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-2', executionHostId: SSH_HOST })
+ ).toEqual([])
+ expect(readWorkspaceActivationRecoveryPresentation('worktree-2', SSH_HOST)).toBeNull()
+ expect(readActivationRecoveryFailureSurfaceIds('ssh-1')).toBeUndefined()
+ expect(readActivationRecoveryFailureSurfaceIds('ssh-2')).toBeUndefined()
+ expect(
+ readWorkspaceSurfaceProducerEntries({ workspaceKey: 'worktree-1', executionHostId: 'local' })
+ ).toHaveLength(1)
+ expect(readWorkspaceActivationRecoveryPresentation('worktree-1', 'local')).not.toBeNull()
+ })
+})
diff --git a/src/renderer/src/lib/workspace-activation-recovery-lifecycle.ts b/src/renderer/src/lib/workspace-activation-recovery-lifecycle.ts
new file mode 100644
index 00000000000..fed2d9b53a2
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-recovery-lifecycle.ts
@@ -0,0 +1,15 @@
+import type { ExecutionHostId } from '../../../shared/execution-host'
+import { clearWorkspaceActivationRecoveryPresentations } from './workspace-activation-recovery-presentation'
+import { clearLatestActivationRecoveryAttempts } from './workspace-activation-recovery-attempts'
+import { clearActivationRecoveryFailureSnapshots } from './workspace-activation-recovery-failure-snapshots'
+import { clearWorkspaceSurfaceProducerAttempts } from './workspace-surface-production'
+
+export function clearWorkspaceActivationRecoveryLifecycle(args: {
+ workspaceKey?: string
+ executionHostId?: ExecutionHostId
+}): void {
+ const removedAttemptIds = clearWorkspaceSurfaceProducerAttempts(args)
+ clearActivationRecoveryFailureSnapshots(removedAttemptIds)
+ clearWorkspaceActivationRecoveryPresentations(args)
+ clearLatestActivationRecoveryAttempts(args)
+}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-presentation.ts b/src/renderer/src/lib/workspace-activation-recovery-presentation.ts
index 2b42aac5f4d..afaf41520a7 100644
--- a/src/renderer/src/lib/workspace-activation-recovery-presentation.ts
+++ b/src/renderer/src/lib/workspace-activation-recovery-presentation.ts
@@ -46,6 +46,25 @@ export function clearWorkspaceActivationRecoveryPresentation(args: {
notifyListeners()
}
+export function clearWorkspaceActivationRecoveryPresentations(args: {
+ workspaceKey?: string
+ executionHostId?: ExecutionHostId
+}): void {
+ let changed = false
+ for (const [key, presentation] of presentationsByTarget) {
+ if (
+ (args.workspaceKey === undefined || presentation.workspaceKey === args.workspaceKey) &&
+ (args.executionHostId === undefined || presentation.executionHostId === args.executionHostId)
+ ) {
+ presentationsByTarget.delete(key)
+ changed = true
+ }
+ }
+ if (changed) {
+ notifyListeners()
+ }
+}
+
export function readWorkspaceActivationRecoveryPresentation(
workspaceKey: string,
executionHostId: ExecutionHostId
@@ -59,6 +78,5 @@ export function subscribeWorkspaceActivationRecoveryPresentation(listener: () =>
}
export function resetWorkspaceActivationRecoveryPresentationsForTests(): void {
- presentationsByTarget.clear()
- notifyListeners()
+ clearWorkspaceActivationRecoveryPresentations({})
}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-retry.ts b/src/renderer/src/lib/workspace-activation-recovery-retry.ts
index c80c56441af..d7ac37f1348 100644
--- a/src/renderer/src/lib/workspace-activation-recovery-retry.ts
+++ b/src/renderer/src/lib/workspace-activation-recovery-retry.ts
@@ -22,10 +22,8 @@ export function createWorkspaceActivationRecoveryOwnerContext(
return {
...context,
retry: () => {
- void recoverWorkspaceActivation(
- { ...identity, attemptId: createBrowserUuid() },
- { mode: context.mode }
- )
+ const retryIdentity = { ...identity, attemptId: createBrowserUuid() }
+ void recoverWorkspaceActivation(retryIdentity, { mode: context.mode })
}
}
}
diff --git a/src/renderer/src/lib/workspace-activation-recovery-settlement.ts b/src/renderer/src/lib/workspace-activation-recovery-settlement.ts
index c01e192cfe6..d87145e204e 100644
--- a/src/renderer/src/lib/workspace-activation-recovery-settlement.ts
+++ b/src/renderer/src/lib/workspace-activation-recovery-settlement.ts
@@ -1,6 +1,7 @@
import type { WorkspaceVisibleTabType } from '../../../shared/tab-types'
import {
consumeWorkspaceSurfaceProducerAttempt,
+ discardWorkspaceSurfaceProducerAttempt,
readWorkspaceSurfaceProducerEntries
} from './workspace-surface-production'
import {
@@ -10,6 +11,7 @@ import {
} from './workspace-activation-recovery-presentation'
import {
isActivationRecoveryCurrent,
+ isActivationRecoveryFresh,
readActivationRenderableSurface,
readActivationRenderableSurfaceById,
readActivationRenderableSurfaceIds,
@@ -21,8 +23,11 @@ import type {
WorkspaceActivationRecoveryResult
} from './worktree-activation-recovery'
import type { WorkspaceActivationRecoveryOwnerContext } from './workspace-activation-recovery-retry'
-
-const failureSurfaceIdsByProducerAttempt = new Map>()
+import {
+ clearActivationRecoveryFailureSnapshots,
+ readActivationRecoveryFailureSurfaceIds,
+ recordActivationRecoveryFailureSurfaceIds
+} from './workspace-activation-recovery-failure-snapshots'
export function publishActivationRecovery(
identity: WorkspaceActivationIdentity,
@@ -80,25 +85,41 @@ export type ProducerAssessment =
| { kind: 'idle' }
| { kind: 'wait' }
+function materializedFromVisibleInventory(
+ identity: WorkspaceActivationIdentity,
+ entries: ReturnType
+): WorkspaceActivationRecoveryResult | null {
+ const surface = readActivationRenderableSurface(identity)
+ if (!surface) {
+ return null
+ }
+ for (const entry of entries) {
+ if (entry.result?.kind === 'unverifiable') {
+ discardWorkspaceSurfaceProducerAttempt(entry.attempt.id)
+ }
+ }
+ return activationRecoveryMaterializedResult(identity, surface)
+}
+
function materializedAfterProducerFailure(
identity: WorkspaceActivationIdentity,
producerAttemptId: string
): WorkspaceActivationRecoveryResult | null {
const currentSurfaceIds = readActivationRenderableSurfaceIds(identity)
- const failureSurfaceIds = failureSurfaceIdsByProducerAttempt.get(producerAttemptId)
+ const failureSurfaceIds = readActivationRecoveryFailureSurfaceIds(producerAttemptId)
const laterSurfaceId = failureSurfaceIds
? [...currentSurfaceIds].find((surfaceId) => !failureSurfaceIds.has(surfaceId))
: undefined
if (laterSurfaceId) {
const laterSurface = readActivationRenderableSurfaceById(identity, laterSurfaceId)
if (laterSurface) {
- failureSurfaceIdsByProducerAttempt.delete(producerAttemptId)
+ clearActivationRecoveryFailureSnapshots([producerAttemptId])
consumeWorkspaceSurfaceProducerAttempt(producerAttemptId)
return activationRecoveryMaterializedResult(identity, laterSurface)
}
}
if (!failureSurfaceIds) {
- failureSurfaceIdsByProducerAttempt.set(producerAttemptId, currentSurfaceIds)
+ recordActivationRecoveryFailureSurfaceIds(producerAttemptId, currentSurfaceIds)
}
return null
}
@@ -120,8 +141,34 @@ export function assessActivationProducerAttempts(
result: activationRecoveryFailedResult(identity, 'producer-failed')
}
}
+ const unexpected = entries.find((entry) => entry.result?.kind === 'unexpected')
+ if (unexpected?.result?.kind === 'unexpected') {
+ publishActivationRecovery(identity, context, 'unexpected', unexpected.result.reason)
+ return {
+ kind: 'complete',
+ result: activationRecoveryFailedResult(identity, 'unexpected')
+ }
+ }
+ const blocked = entries.find((entry) => entry.result?.kind === 'blocked')
+ if (blocked?.result?.kind === 'blocked') {
+ publishActivationRecovery(identity, context, 'blocked', blocked.result.reason)
+ return {
+ kind: 'complete',
+ result: activationRecoveryFailedResult(identity, 'blocked')
+ }
+ }
+ const intentionalEmpty = entries.find((entry) => entry.result?.kind === 'intentional-empty')
+ if (intentionalEmpty) {
+ consumeWorkspaceSurfaceProducerAttempt(intentionalEmpty.attempt.id)
+ clearActivationRecoveryPresentation(identity)
+ return { kind: 'complete', result: { kind: 'intentional-empty' } }
+ }
const unverifiable = entries.find((entry) => entry.result?.kind === 'unverifiable')
if (unverifiable?.result?.kind === 'unverifiable') {
+ const visibleSurface = materializedFromVisibleInventory(identity, entries)
+ if (visibleSurface) {
+ return { kind: 'complete', result: visibleSurface }
+ }
publishActivationRecovery(identity, context, 'unverifiable', unverifiable.result.reason)
return {
kind: 'complete',
@@ -184,18 +231,19 @@ export function assessActivationProducerAttempts(
}
: { kind: 'wait' }
}
- const surface = readActivationRenderableSurface(identity)
- return surface
- ? { kind: 'complete', result: activationRecoveryMaterializedResult(identity, surface) }
- : { kind: 'idle' }
+ return { kind: 'idle' }
}
export async function waitForActivationProducerAttempts(
identity: WorkspaceActivationIdentity,
context: WorkspaceActivationRecoveryOwnerContext,
- deadlineAt: number
+ deadlineAt: number,
+ capturedSelectionRevision: number
): Promise {
while (true) {
+ if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
+ return { kind: 'stale' }
+ }
const assessment = assessActivationProducerAttempts(identity, context)
if (assessment.kind === 'complete') {
return assessment.result
diff --git a/src/renderer/src/lib/workspace-activation-recovery-state.ts b/src/renderer/src/lib/workspace-activation-recovery-state.ts
index 9ada6728295..ca4836e31ed 100644
--- a/src/renderer/src/lib/workspace-activation-recovery-state.ts
+++ b/src/renderer/src/lib/workspace-activation-recovery-state.ts
@@ -14,51 +14,38 @@ import type {
WorkspaceActivationContext,
WorkspaceActivationIdentity
} from './worktree-activation-recovery'
+import { readLatestActivationRecoveryAttempt } from './workspace-activation-recovery-attempts'
+import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
+
+export {
+ activationRecoveryTargetKey,
+ clearLatestActivationRecoveryAttempts,
+ markLatestActivationRecoveryAttempt,
+ readLatestActivationRecoveryAttempt
+} from './workspace-activation-recovery-attempts'
export type RecoveryWaitResult = 'changed' | 'cancelled' | 'timeout'
export const WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS = 30_000
export const WORKSPACE_ACTIVATION_RECOVERY_PROGRESS_DELAY_MS = 200
-const latestAttemptIdByTarget = new Map()
-const gateIdentityByPromise = new WeakMap, string>()
let selectionRevision = 0
let previousSelectionKey: string | null = null
let disposeSelectionTracker: (() => void) | null = null
-export function activationRecoveryTargetKey(identity: WorkspaceActivationIdentity): string {
- return `${identity.executionHostId}|${identity.workspaceKey}`
-}
-
-export function activationRecoveryRouteKey(identity: WorkspaceActivationIdentity): string {
- return `${identity.executionHostId}|${identity.runtimeEnvironmentId ?? ''}`
-}
-
-export function markLatestActivationRecoveryAttempt(identity: WorkspaceActivationIdentity): void {
- latestAttemptIdByTarget.set(activationRecoveryTargetKey(identity), identity.attemptId)
-}
-
-export function readLatestActivationRecoveryAttempt(
- identity: WorkspaceActivationIdentity
-): string | undefined {
- return latestAttemptIdByTarget.get(activationRecoveryTargetKey(identity))
-}
-
-export function readActivationRecoveryGateRoute(gate: Promise): string | undefined {
- return gateIdentityByPromise.get(gate)
-}
-
-export function recordActivationRecoveryGateRoute(gate: Promise, route: string): void {
- gateIdentityByPromise.set(gate, route)
-}
-
function currentSelectionKey(): string {
const state = useAppStore.getState()
const workspaceKey = state.activeWorktreeId
if (!workspaceKey) {
return 'none'
}
- return `${getExecutionHostIdForWorktree(state, workspaceKey)}|${getRuntimeEnvironmentIdForWorktree(state, workspaceKey) ?? ''}|${workspaceKey}`
+ const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, workspaceKey)
+ return JSON.stringify([
+ getExecutionHostIdForWorktree(state, workspaceKey),
+ runtimeEnvironmentId,
+ runtimeEnvironmentId ? (getRuntimeEnvironmentRevision(runtimeEnvironmentId) ?? null) : null,
+ workspaceKey
+ ])
}
export function installActivationRecoverySelectionTracker(): void {
@@ -96,12 +83,27 @@ export function isActivationRecoveryCurrent(
) {
return false
}
+ return isActivationExecutionRouteCurrent(identity)
+}
+
+export function isActivationExecutionRouteCurrent(
+ identity: Pick<
+ WorkspaceActivationIdentity,
+ 'workspaceKey' | 'executionHostId' | 'runtimeEnvironmentId'
+ > & { runtimeEnvironmentRevision?: number | null }
+): boolean {
const state = useAppStore.getState()
return (
state.activeWorktreeId === identity.workspaceKey &&
getExecutionHostIdForWorktree(state, identity.workspaceKey) === identity.executionHostId &&
getRuntimeEnvironmentIdForWorktree(state, identity.workspaceKey) ===
- identity.runtimeEnvironmentId
+ identity.runtimeEnvironmentId &&
+ (identity.runtimeEnvironmentRevision === undefined ||
+ (identity.runtimeEnvironmentRevision === null
+ ? identity.runtimeEnvironmentId === null
+ : identity.runtimeEnvironmentId !== null &&
+ getRuntimeEnvironmentRevision(identity.runtimeEnvironmentId) ===
+ identity.runtimeEnvironmentRevision))
)
}
@@ -223,53 +225,11 @@ export function waitForActivationRecoveryChange(
})
}
-export function waitForActivationRecoveryPromise(
- promise: Promise,
- deadlineAt: number,
- signal: AbortSignal | undefined
-): Promise<
- | { kind: 'settled'; value: T }
- | { kind: 'rejected'; error: unknown }
- | Exclude
-> {
- const remaining = Math.max(0, deadlineAt - Date.now())
- if (remaining === 0) {
- return Promise.resolve('timeout')
+export function canInspectAgentActivationInventory(runtimeEnvironmentId: string | null): boolean {
+ if (typeof window === 'undefined') {
+ return false
}
- return new Promise((resolve) => {
- let settled = false
- const finish = (
- result:
- | { kind: 'settled'; value: T }
- | { kind: 'rejected'; error: unknown }
- | Exclude
- ): void => {
- if (settled) {
- return
- }
- settled = true
- clearTimeout(timeout)
- signal?.removeEventListener('abort', onAbort)
- resolve(result)
- }
- const onAbort = (): void => finish('cancelled')
- const timeout = setTimeout(() => finish('timeout'), remaining)
- signal?.addEventListener('abort', onAbort, { once: true })
- if (signal?.aborted) {
- finish('cancelled')
- return
- }
- void promise.then(
- (value) => finish({ kind: 'settled', value }),
- (error: unknown) => finish({ kind: 'rejected', error })
- )
- })
-}
-
-export function canInspectAgentActivationInventory(): boolean {
- return (
- typeof window !== 'undefined' &&
- typeof window.api?.runtime?.call === 'function' &&
- typeof window.api?.pty?.listSessions === 'function'
- )
+ return runtimeEnvironmentId
+ ? typeof window.api?.runtimeEnvironments?.subscribe === 'function'
+ : typeof window.api?.runtime?.subscribe === 'function'
}
diff --git a/src/renderer/src/lib/workspace-activation-requested-surface.ts b/src/renderer/src/lib/workspace-activation-requested-surface.ts
new file mode 100644
index 00000000000..db7ce831c2d
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-requested-surface.ts
@@ -0,0 +1,87 @@
+import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
+import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
+import type { WorkspaceActivationIdentity } from './worktree-activation-recovery'
+import {
+ captureActivationRenderableSurfaceIds,
+ settleActivationSeedProducer
+} from './worktree-activation-recovery-routing'
+import { isActivationExecutionRouteCurrent } from './workspace-activation-recovery-state'
+import type { WorkspaceExecutionEvidence } from './workspace-execution-evidence'
+import {
+ consumeWorkspaceSurfaceProducerAttempt,
+ registerWorkspaceSurfaceProducer
+} from './workspace-surface-production'
+
+type RequestedSurfaceOwner = 'local' | 'runtime-transfer' | 'backend-confirmed'
+
+type RequestedSurfaceProduction = {
+ identity: WorkspaceActivationIdentity
+ executionEvidence: WorkspaceExecutionEvidence
+ owner: RequestedSurfaceOwner
+ createSurface: () => string | null
+}
+
+export function produceRequestedWorkspaceSurface({
+ identity,
+ executionEvidence,
+ owner,
+ createSurface
+}: RequestedSurfaceProduction): string | null {
+ const producer = registerWorkspaceSurfaceProducer(identity)
+ if (owner === 'runtime-transfer') {
+ try {
+ createSurface()
+ producer.declined('Surface production transferred to the paired execution host.')
+ consumeWorkspaceSurfaceProducerAttempt(producer.attempt.id)
+ } catch (error) {
+ producer.failed(error)
+ }
+ return null
+ }
+ if (owner === 'backend-confirmed') {
+ try {
+ const primaryTabId = createSurface()
+ if (primaryTabId) {
+ producer.materialized({ kind: 'tab', id: primaryTabId })
+ } else {
+ producer.unverifiable(
+ 'The execution host accepted the startup, but its surface is not visible yet.'
+ )
+ }
+ return primaryTabId
+ } catch (error) {
+ producer.failed(error)
+ return null
+ }
+ }
+ if (executionEvidence !== 'exited') {
+ producer.unverifiable('Orca cannot verify the execution host for this requested surface.')
+ return null
+ }
+ const route = {
+ ...identity,
+ runtimeEnvironmentRevision: identity.runtimeEnvironmentId
+ ? (getRuntimeEnvironmentRevision(identity.runtimeEnvironmentId) ?? null)
+ : null
+ }
+ try {
+ const existingSurfaceIds = captureActivationRenderableSurfaceIds(identity.workspaceKey)
+ resumeSleepingAgentSessionsForWorktree(identity.workspaceKey, {
+ expectedExecutionHostId: route.executionHostId,
+ expectedRuntimeEnvironmentId: route.runtimeEnvironmentId,
+ ...(route.runtimeEnvironmentRevision === null
+ ? {}
+ : { expectedRuntimeEnvironmentRevision: route.runtimeEnvironmentRevision })
+ })
+ if (!isActivationExecutionRouteCurrent(route)) {
+ producer.unverifiable('The workspace execution route changed before surface creation.')
+ return null
+ }
+ const primaryTabId = createSurface()
+ settleActivationSeedProducer(producer, identity.workspaceKey, primaryTabId, existingSurfaceIds)
+ return primaryTabId
+ } catch (error) {
+ producer.failed(error)
+ return null
+ }
+}
diff --git a/src/renderer/src/lib/workspace-activation-surface-producer.test.ts b/src/renderer/src/lib/workspace-activation-surface-producer.test.ts
new file mode 100644
index 00000000000..33fc38088f5
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-surface-producer.test.ts
@@ -0,0 +1,248 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { WorkspaceActivationIdentity } from './worktree-activation-recovery'
+import {
+ readWorkspaceSurfaceProducerEntries,
+ resetWorkspaceSurfaceProducersForTests
+} from './workspace-surface-production'
+import { startWorkspaceActivationSurfaceProducer } from './workspace-activation-surface-producer'
+
+const mocks = vi.hoisted(() => ({
+ evidence: vi.fn(),
+ gate: vi.fn(),
+ hasSleeping: vi.fn(),
+ hasTombstone: vi.fn(),
+ isRouteCurrent: vi.fn(),
+ readSurface: vi.fn(),
+ runtimeRevision: vi.fn(),
+ seed: vi.fn(),
+ state: {}
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: { getState: () => mocks.state }
+}))
+vi.mock('./workspace-execution-evidence', () => ({
+ resolveWorkspaceExecutionEvidence: mocks.evidence
+}))
+vi.mock('./workspace-activation-recovery-state', () => ({
+ canInspectAgentActivationInventory: () => true,
+ hasLiveActivationTerminalTombstone: mocks.hasTombstone,
+ isActivationExecutionRouteCurrent: mocks.isRouteCurrent,
+ readActivationRenderableSurface: mocks.readSurface,
+ WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS: 30_000
+}))
+vi.mock('./worktree-agent-activation-gate', () => ({
+ gateWorktreeAgentActivation: mocks.gate
+}))
+vi.mock('./worktree-agent-activation-claims', () => ({
+ workspaceHasSleepingAgentSessions: mocks.hasSleeping
+}))
+vi.mock('./worktree-initial-terminal-seeding', () => ({
+ ensureWorktreeHasInitialTerminal: mocks.seed
+}))
+vi.mock('@/runtime/runtime-environment-revision', () => ({
+ getRuntimeEnvironmentRevision: mocks.runtimeRevision
+}))
+
+const IDENTITY: WorkspaceActivationIdentity = {
+ workspaceKey: 'worktree-1',
+ executionHostId: 'local',
+ runtimeEnvironmentId: null,
+ attemptId: 'activation-1'
+}
+
+beforeEach(() => {
+ resetWorkspaceSurfaceProducersForTests()
+ mocks.evidence.mockReset().mockReturnValue('exited')
+ mocks.gate.mockReset().mockResolvedValue('empty')
+ mocks.hasSleeping.mockReset().mockReturnValue(false)
+ mocks.hasTombstone.mockReset().mockReturnValue(false)
+ mocks.isRouteCurrent.mockReset().mockReturnValue(true)
+ mocks.readSurface.mockReset().mockReturnValue(null)
+ mocks.runtimeRevision.mockReset().mockReturnValue(undefined)
+ mocks.seed.mockReset().mockReturnValue(null)
+})
+
+describe('workspace activation surface producer', () => {
+ it('contains an initial inventory error as producer settlement', () => {
+ mocks.readSurface.mockImplementation(() => {
+ throw new Error('inventory unavailable')
+ })
+
+ expect(() =>
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+ ).not.toThrow()
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
+ { result: { kind: 'unexpected', reason: 'inventory unavailable' } }
+ ])
+ })
+
+ it('owns shell creation after the route-scoped gate proves it safe', async () => {
+ mocks.readSurface
+ .mockReturnValueOnce(null)
+ .mockReturnValueOnce(null)
+ .mockReturnValueOnce({ id: 'seeded-tab', type: 'terminal' })
+ mocks.seed.mockReturnValue('seeded-tab')
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+
+ await vi.waitFor(() =>
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
+ { result: { kind: 'materialized', surface: { kind: 'tab', id: 'seeded-tab' } } }
+ ])
+ )
+ expect(mocks.gate).toHaveBeenCalledWith(
+ { ...IDENTITY, runtimeEnvironmentRevision: null },
+ { timeoutMs: 30_000 }
+ )
+ expect(mocks.seed).toHaveBeenCalledOnce()
+ })
+
+ it('captures the saved runtime pairing revision in the gate route', async () => {
+ mocks.runtimeRevision.mockReturnValue(17)
+ const remoteIdentity: WorkspaceActivationIdentity = {
+ ...IDENTITY,
+ executionHostId: 'runtime:environment-1',
+ runtimeEnvironmentId: 'environment-1'
+ }
+
+ startWorkspaceActivationSurfaceProducer(remoteIdentity, { mode: 'explicit' })
+
+ await vi.waitFor(() =>
+ expect(mocks.gate).toHaveBeenCalledWith(
+ { ...remoteIdentity, runtimeEnvironmentRevision: 17 },
+ { timeoutMs: 30_000 }
+ )
+ )
+ })
+
+ it('still gates sleeping-session resumption when a husk surface remains visible', async () => {
+ mocks.hasSleeping.mockReturnValue(true)
+ mocks.readSurface.mockReturnValue({ id: 'husk-tab', type: 'terminal' })
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+
+ await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
+ { result: { kind: 'materialized', surface: { kind: 'tab', id: 'husk-tab' } } }
+ ])
+ })
+
+ it('publishes a blocked settlement without starting a writer', async () => {
+ mocks.gate.mockResolvedValue('blocked')
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+
+ await vi.waitFor(() =>
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
+ { result: { kind: 'blocked' } }
+ ])
+ )
+ expect(mocks.seed).not.toHaveBeenCalled()
+ })
+
+ it.each(['adopted', 'structured', 'resumed'] as const)(
+ 'does not seed before a %s gate outcome becomes visible',
+ async (outcome) => {
+ mocks.gate.mockResolvedValue(outcome)
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+
+ await vi.waitFor(() =>
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
+ { result: { kind: 'unverifiable' } }
+ ])
+ )
+ expect(mocks.seed).not.toHaveBeenCalled()
+ }
+ )
+
+ it('lets a later activation replace settled recovery ownership', async () => {
+ mocks.gate.mockResolvedValueOnce('blocked').mockResolvedValueOnce('empty')
+ mocks.readSurface
+ .mockReturnValueOnce(null)
+ .mockReturnValueOnce(null)
+ .mockReturnValueOnce(null)
+ .mockReturnValueOnce({ id: 'retry-tab', type: 'terminal' })
+ mocks.seed.mockReturnValue('retry-tab')
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+ await vi.waitFor(() =>
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)[0]?.result?.kind).toBe('blocked')
+ )
+
+ startWorkspaceActivationSurfaceProducer(
+ { ...IDENTITY, attemptId: 'activation-2' },
+ { mode: 'explicit' }
+ )
+
+ await vi.waitFor(() =>
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
+ { result: { kind: 'materialized', surface: { id: 'retry-tab' } } }
+ ])
+ )
+ expect(mocks.gate).toHaveBeenCalledTimes(2)
+ })
+
+ it('discards stale route ownership without starting a writer', async () => {
+ mocks.gate.mockResolvedValue('stale')
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+
+ await vi.waitFor(() => expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toEqual([]))
+ expect(mocks.seed).not.toHaveBeenCalled()
+ })
+
+ it('rechecks the route after a gate settles before starting a writer', async () => {
+ let settleGate!: (outcome: 'empty') => void
+ mocks.gate.mockReturnValue(
+ new Promise((resolve) => {
+ settleGate = resolve
+ })
+ )
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+ mocks.isRouteCurrent.mockReturnValue(false)
+ settleGate('empty')
+
+ await vi.waitFor(() => expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toEqual([]))
+ expect(mocks.seed).not.toHaveBeenCalled()
+ })
+
+ it('rechecks the route after final inventory reconciliation', async () => {
+ mocks.readSurface.mockReturnValueOnce(null).mockImplementation(() => {
+ mocks.isRouteCurrent.mockReturnValue(false)
+ return null
+ })
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+
+ await vi.waitFor(() => expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toEqual([]))
+ expect(mocks.seed).not.toHaveBeenCalled()
+ })
+
+ it('keeps genuinely unverifiable execution writer-free', async () => {
+ mocks.evidence.mockReturnValue('unverifiable')
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'explicit' })
+
+ await vi.waitFor(() =>
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
+ { result: { kind: 'unverifiable' } }
+ ])
+ )
+ expect(mocks.seed).not.toHaveBeenCalled()
+ })
+
+ it('preserves a startup tombstone as intentional empty', async () => {
+ mocks.hasTombstone.mockReturnValue(true)
+
+ startWorkspaceActivationSurfaceProducer(IDENTITY, { mode: 'startup' })
+
+ await vi.waitFor(() =>
+ expect(readWorkspaceSurfaceProducerEntries(IDENTITY)).toMatchObject([
+ { result: { kind: 'intentional-empty' } }
+ ])
+ )
+ expect(mocks.seed).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/lib/workspace-activation-surface-producer.ts b/src/renderer/src/lib/workspace-activation-surface-producer.ts
new file mode 100644
index 00000000000..a44a9b7345c
--- /dev/null
+++ b/src/renderer/src/lib/workspace-activation-surface-producer.ts
@@ -0,0 +1,162 @@
+import { useAppStore } from '@/store'
+import { resolveWorkspaceExecutionEvidence } from './workspace-execution-evidence'
+import {
+ canInspectAgentActivationInventory,
+ hasLiveActivationTerminalTombstone,
+ isActivationExecutionRouteCurrent,
+ readActivationRenderableSurface,
+ WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS
+} from './workspace-activation-recovery-state'
+import {
+ discardWorkspaceSurfaceProducerAttempt,
+ readWorkspaceSurfaceProducerEntries,
+ registerWorkspaceSurfaceProducer,
+ type WorkspaceSurfaceProducer
+} from './workspace-surface-production'
+import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
+import { gateWorktreeAgentActivation } from './worktree-agent-activation-gate'
+import { workspaceHasSleepingAgentSessions } from './worktree-agent-activation-claims'
+import type { WorktreeAgentActivationRoute } from './worktree-agent-activation-route'
+import type { WorkspaceActivationIdentity } from './worktree-activation-recovery'
+import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision'
+
+type ActivationSurfaceProductionContext = { mode: 'explicit' | 'startup' }
+
+function settleProducedSurface(
+ producer: WorkspaceSurfaceProducer,
+ identity: WorkspaceActivationIdentity & WorktreeAgentActivationRoute,
+ context: ActivationSurfaceProductionContext,
+ maySeedEmptySurface: boolean
+): string | null {
+ if (!isActivationExecutionRouteCurrent(identity)) {
+ discardWorkspaceSurfaceProducerAttempt(producer.attempt.id)
+ return null
+ }
+ const visible = readActivationRenderableSurface(identity)
+ if (!isActivationExecutionRouteCurrent(identity)) {
+ discardWorkspaceSurfaceProducerAttempt(producer.attempt.id)
+ return null
+ }
+ if (visible) {
+ producer.materialized({ kind: 'tab', id: visible.id })
+ return visible.id
+ }
+ const state = useAppStore.getState()
+ const evidence = resolveWorkspaceExecutionEvidence(
+ state,
+ identity.workspaceKey,
+ identity.executionHostId
+ )
+ if (evidence !== 'exited') {
+ producer.unverifiable('Orca cannot verify the execution host for this workspace surface.')
+ return null
+ }
+ if (!maySeedEmptySurface) {
+ producer.unverifiable(
+ 'The activation gate found execution ownership, but its surface is not visible yet.'
+ )
+ return null
+ }
+ const primaryTabId = ensureWorktreeHasInitialTerminal(
+ state,
+ identity.workspaceKey,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ { reseedEmptiedWorkspace: context.mode === 'explicit' }
+ )
+ const materialized = primaryTabId ? readActivationRenderableSurface(identity) : null
+ if (materialized) {
+ producer.materialized({ kind: 'tab', id: materialized.id })
+ return materialized.id
+ }
+ if (context.mode === 'startup' && hasLiveActivationTerminalTombstone(identity.workspaceKey)) {
+ producer.intentionalEmpty()
+ return null
+ }
+ producer.unverifiable('The activation producer did not publish a renderable surface.')
+ return null
+}
+
+function clearSettledActivationOwnership(identity: WorkspaceActivationIdentity): void {
+ for (const entry of readWorkspaceSurfaceProducerEntries(identity)) {
+ if (entry.attempt.purpose === 'activation-recovery' && entry.result !== null) {
+ discardWorkspaceSurfaceProducerAttempt(entry.attempt.id)
+ }
+ }
+}
+
+export function startWorkspaceActivationSurfaceProducer(
+ identity: WorkspaceActivationIdentity,
+ context: ActivationSurfaceProductionContext
+): string | null {
+ const route: WorkspaceActivationIdentity & WorktreeAgentActivationRoute = {
+ ...identity,
+ runtimeEnvironmentRevision: identity.runtimeEnvironmentId
+ ? (getRuntimeEnvironmentRevision(identity.runtimeEnvironmentId) ?? null)
+ : null
+ }
+ let visible: ReturnType
+ try {
+ visible = readActivationRenderableSurface(identity)
+ } catch (error) {
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: identity.workspaceKey,
+ executionHostId: identity.executionHostId,
+ purpose: 'activation-recovery'
+ })
+ producer.unexpected(error)
+ return null
+ }
+ if (!isActivationExecutionRouteCurrent(route)) {
+ return null
+ }
+ const hasSleepingSessions = workspaceHasSleepingAgentSessions(
+ useAppStore.getState(),
+ identity.workspaceKey
+ )
+ if (visible && !hasSleepingSessions) {
+ return visible.id
+ }
+ clearSettledActivationOwnership(identity)
+ if (readWorkspaceSurfaceProducerEntries(identity).length > 0) {
+ return null
+ }
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: identity.workspaceKey,
+ executionHostId: identity.executionHostId,
+ purpose: 'activation-recovery'
+ })
+ if (!canInspectAgentActivationInventory(identity.runtimeEnvironmentId) && !hasSleepingSessions) {
+ try {
+ return settleProducedSurface(producer, route, context, true)
+ } catch (error) {
+ producer.unexpected(error)
+ return null
+ }
+ }
+ void gateWorktreeAgentActivation(route, {
+ timeoutMs: WORKSPACE_ACTIVATION_RECOVERY_DEADLINE_MS
+ }).then(
+ (outcome) => {
+ if (outcome === 'stale') {
+ discardWorkspaceSurfaceProducerAttempt(producer.attempt.id)
+ return
+ }
+ if (outcome === 'blocked') {
+ producer.blocked(
+ 'Orca paused activation because the execution host did not provide complete ownership evidence.'
+ )
+ return
+ }
+ try {
+ settleProducedSurface(producer, route, context, outcome === 'empty')
+ } catch (error) {
+ producer.unexpected(error)
+ }
+ },
+ (error: unknown) => producer.unexpected(error)
+ )
+ return null
+}
diff --git a/src/renderer/src/lib/workspace-execution-evidence.ts b/src/renderer/src/lib/workspace-execution-evidence.ts
index 9c15aa35a94..adbb466b8e5 100644
--- a/src/renderer/src/lib/workspace-execution-evidence.ts
+++ b/src/renderer/src/lib/workspace-execution-evidence.ts
@@ -1,5 +1,4 @@
import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
-import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
resolveWorkspaceTerminalHostAuthority,
type WorkspaceTerminalHostAuthorityState
@@ -20,7 +19,7 @@ export function resolveWorkspaceExecutionEvidence(
if (!host || host.kind === 'local') {
return 'exited'
}
- if (host.kind === 'runtime' || parseWorkspaceKey(workspaceKey)?.type === 'folder') {
+ if (host.kind === 'runtime') {
return 'unverifiable'
}
const syncStatus = state.remoteWorkspaceSyncStatusByTargetId?.[host.targetId]
diff --git a/src/renderer/src/lib/workspace-surface-production.ts b/src/renderer/src/lib/workspace-surface-production.ts
index ea329317039..11826f8a62f 100644
--- a/src/renderer/src/lib/workspace-surface-production.ts
+++ b/src/renderer/src/lib/workspace-surface-production.ts
@@ -10,11 +10,15 @@ export type WorkspaceSurfaceProductionResult =
| { kind: 'declined'; reason: string }
| { kind: 'failed'; reason: string }
| { kind: 'unverifiable'; reason: string }
+ | { kind: 'blocked'; reason: string }
+ | { kind: 'unexpected'; reason: string }
+ | { kind: 'intentional-empty' }
export type WorkspaceSurfaceProducerAttempt = {
id: string
workspaceKey: string
executionHostId: ExecutionHostId
+ purpose?: 'activation-recovery'
result: Promise
}
@@ -24,6 +28,9 @@ export type WorkspaceSurfaceProducer = {
declined: (reason: unknown) => void
failed: (reason: unknown) => void
unverifiable: (reason: unknown) => void
+ blocked: (reason: unknown) => void
+ unexpected: (reason: unknown) => void
+ intentionalEmpty: () => void
}
export type WorkspaceSurfaceProducerEntry = {
@@ -53,6 +60,7 @@ export function registerWorkspaceSurfaceProducer(args: {
workspaceKey: string
executionHostId: ExecutionHostId
attemptId?: string
+ purpose?: 'activation-recovery'
}): WorkspaceSurfaceProducer {
const id = args.attemptId?.trim() || createBrowserUuid()
if (entriesByAttemptId.has(id)) {
@@ -66,17 +74,21 @@ export function registerWorkspaceSurfaceProducer(args: {
id,
workspaceKey: args.workspaceKey,
executionHostId: args.executionHostId,
+ ...(args.purpose ? { purpose: args.purpose } : {}),
result
}
const entry: WorkspaceSurfaceProducerEntry = {
attempt,
result: null,
settle: (settlement) => {
- if (entry.result) {
+ if (entry.result && entry.result.kind !== 'unverifiable') {
return
}
+ const firstSettlement = entry.result === null
entry.result = settlement
- settlePromise(settlement)
+ if (firstSettlement) {
+ settlePromise(settlement)
+ }
notifyListeners()
}
}
@@ -87,7 +99,10 @@ export function registerWorkspaceSurfaceProducer(args: {
materialized: (surface) => entry.settle({ kind: 'materialized', surface }),
declined: (reason) => entry.settle({ kind: 'declined', reason: reasonText(reason) }),
failed: (reason) => entry.settle({ kind: 'failed', reason: reasonText(reason) }),
- unverifiable: (reason) => entry.settle({ kind: 'unverifiable', reason: reasonText(reason) })
+ unverifiable: (reason) => entry.settle({ kind: 'unverifiable', reason: reasonText(reason) }),
+ blocked: (reason) => entry.settle({ kind: 'blocked', reason: reasonText(reason) }),
+ unexpected: (reason) => entry.settle({ kind: 'unexpected', reason: reasonText(reason) }),
+ intentionalEmpty: () => entry.settle({ kind: 'intentional-empty' })
}
}
@@ -104,19 +119,44 @@ export function readWorkspaceSurfaceProducerEntries(args: {
export function consumeWorkspaceSurfaceProducerAttempt(attemptId: string): void {
const entry = entriesByAttemptId.get(attemptId)
- if (!entry || entry.result?.kind === 'unverifiable' || entry.result === null) {
+ if (!entry || entry.result === null || entry.result.kind === 'unverifiable') {
return
}
entriesByAttemptId.delete(attemptId)
notifyListeners()
}
+export function discardWorkspaceSurfaceProducerAttempt(attemptId: string): void {
+ if (entriesByAttemptId.delete(attemptId)) {
+ notifyListeners()
+ }
+}
+
+export function clearWorkspaceSurfaceProducerAttempts(args: {
+ workspaceKey?: string
+ executionHostId?: ExecutionHostId
+}): string[] {
+ const removed: string[] = []
+ for (const [attemptId, entry] of entriesByAttemptId) {
+ if (
+ (args.workspaceKey === undefined || entry.attempt.workspaceKey === args.workspaceKey) &&
+ (args.executionHostId === undefined || entry.attempt.executionHostId === args.executionHostId)
+ ) {
+ entriesByAttemptId.delete(attemptId)
+ removed.push(attemptId)
+ }
+ }
+ if (removed.length > 0) {
+ notifyListeners()
+ }
+ return removed
+}
+
export function subscribeWorkspaceSurfaceProducers(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function resetWorkspaceSurfaceProducersForTests(): void {
- entriesByAttemptId.clear()
- notifyListeners()
+ clearWorkspaceSurfaceProducerAttempts({})
}
diff --git a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts
index 73aa2674fcb..7c0f974c0d6 100644
--- a/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts
+++ b/src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts
@@ -117,6 +117,12 @@ describe('selection-free empty-workspace recovery', () => {
resolveGate = resolve
})
)
+ vi.stubGlobal('window', {
+ api: {
+ runtime: { subscribe: vi.fn() },
+ runtimeEnvironments: { subscribe: vi.fn() }
+ }
+ })
activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
useAppStore.getState().createTab(worktree.id)
@@ -193,15 +199,6 @@ describe('folder activation recovery', () => {
)
})
- it('keeps the direct general setter state-only', async () => {
- seedEmptyFolderWorkspace('local')
-
- useAppStore.getState().setActiveWorktree(FOLDER_KEY, 'local')
- await Promise.resolve()
-
- expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([])
- })
-
it('does not start a writer for an unverifiable SSH folder', async () => {
seedEmptyFolderWorkspace(SSH_HOST_ID)
@@ -210,4 +207,17 @@ describe('folder activation recovery', () => {
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toEqual([])
})
+
+ it('seeds a connected SSH folder after its current inventory is synchronized', async () => {
+ seedEmptyFolderWorkspace(SSH_HOST_ID)
+ useAppStore.setState({
+ remoteWorkspaceHydratedTargetIds: new Set(['conn-1']),
+ remoteWorkspaceSyncStatusByTargetId: { 'conn-1': { phase: 'synced' } }
+ })
+
+ const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: SSH_HOST_ID })
+
+ expect(result).toEqual({ primaryTabId: expect.any(String) })
+ expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1)
+ })
})
diff --git a/src/renderer/src/lib/worktree-activation-pty-inventory.ts b/src/renderer/src/lib/worktree-activation-pty-inventory.ts
index 26b701938de..24923285590 100644
--- a/src/renderer/src/lib/worktree-activation-pty-inventory.ts
+++ b/src/renderer/src/lib/worktree-activation-pty-inventory.ts
@@ -1,6 +1,9 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { parseExecutionHostId } from '../../../shared/execution-host'
import type { PtyListedSession, PtySessionListScope } from '../../../shared/pty-listed-session'
+import type { RuntimeTerminalListResult } from '../../../shared/runtime-types'
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
import {
@@ -11,6 +14,10 @@ import {
resolveWorktreeOperationRouteResult,
type WorktreeOperationRouteState
} from './worktree-operation-route'
+import {
+ runtimeTargetForActivationRoute,
+ type WorktreeAgentActivationRoute
+} from './worktree-agent-activation-route'
/** Main rejects a scoped list with this prefix when the relay is detached (pty/provider/registry.ts). */
const DETACHED_PROVIDER_REJECTION = 'No PTY provider for connection'
@@ -85,3 +92,49 @@ export async function listActivationPtySessions(
return window.api.pty.listSessions()
}
}
+
+export async function listActivationPtySessionsForRoute(
+ route: WorktreeAgentActivationRoute,
+ options: { timeoutMs?: number; signal?: AbortSignal } = {}
+): Promise {
+ const target = runtimeTargetForActivationRoute(route)
+ if (!target) {
+ throw new Error('activation PTY inventory route unavailable')
+ }
+ const result = await callRuntimeRpc(
+ target,
+ 'terminal.list',
+ {
+ worktree: toRuntimeWorktreeSelector(route.workspaceKey),
+ requireFreshPtyLiveness: true,
+ includeVisualLayouts: false
+ },
+ {
+ ...options,
+ ...(route.runtimeEnvironmentRevision === null
+ ? {}
+ : { expectedEnvironmentPairingRevision: route.runtimeEnvironmentRevision })
+ }
+ )
+ if (
+ result.truncated ||
+ !result.hostScope?.hostIds.includes(route.executionHostId) ||
+ !Array.isArray(result.terminals)
+ ) {
+ throw new Error('activation PTY inventory scope unavailable')
+ }
+ return result.terminals.flatMap((terminal) =>
+ terminal.ptyId &&
+ (terminal.executionHostId === undefined || terminal.executionHostId === route.executionHostId)
+ ? [
+ {
+ id: terminal.ptyId,
+ cwd: terminal.worktreePath,
+ title: terminal.title ?? '',
+ worktreeId: terminal.worktreeId,
+ agentOwnership: terminal.agentIdentity ? ('present' as const) : ('unknown' as const)
+ }
+ ]
+ : []
+ )
+}
diff --git a/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts b/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts
index b1bb41afbbb..24b08335cea 100644
--- a/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts
+++ b/src/renderer/src/lib/worktree-activation-recovery-boundary-census.test.ts
@@ -63,11 +63,14 @@ function selectionCouplingViolations(source: string): string[] {
) {
violations.push('selection controls recovery ownership')
}
+ if (/\.find\s*\([\s\S]{0,240}\blaunchAgent\b/.test(source)) {
+ violations.push('selection-stamped legacy tab controls recovery')
+ }
return violations
}
describe('activation recovery architecture census', () => {
- it('classifies every general setter caller and keeps only the public activation path recovery-triggering', () => {
+ it('classifies every general setter caller', () => {
expect(callerCounts('setActiveWorktree')).toEqual({
...stateOnlySetActiveWorktreeCallers,
'src/renderer/src/lib/worktree-activation.ts': 1
@@ -93,7 +96,8 @@ describe('activation recovery architecture census', () => {
])
expect(source).not.toContain('worktree-creation')
expect(source).not.toContain('ensureWorktreeHasInitialTerminal')
- expect(selectionCouplingViolations(source)).toEqual([])
+ expect(source).not.toContain('gateWorktreeAgentActivation')
+ expect(source).not.toContain('.createTab(')
})
it('detects aliases, option spreading, picker conditionals, and picker-minted fake claims', () => {
@@ -101,7 +105,11 @@ describe('activation recovery architecture census', () => {
`const callerWillProvideSurface = options.agent != null\nrecoverWorkspaceActivation(identity, { mode: 'explicit', callerWillProvideSurface })`,
`recoverWorkspaceActivation({ ...options }, context)`,
`if (selection.agent) recoverWorkspaceActivation(identity, context)`,
- `if (picker.agent) registerWorkspaceSurfaceProducer(identity)`
+ `if (picker.agent) registerWorkspaceSurfaceProducer(identity)`,
+ `const launchAgent = selection.agent
+ const tab = tabs.find((candidate) => candidate.launchAgent === launchAgent)
+ if (tab) useSurface(tab)
+ else recoverWorkspaceActivation(identity, context)`
]
for (const fixture of fixtures) {
expect(selectionCouplingViolations(fixture)).not.toEqual([])
@@ -109,12 +117,30 @@ describe('activation recovery architecture census', () => {
})
it('routes all production recovery requests through the owner, watcher, or create failure adapter', () => {
- expect(callerCounts('recoverWorkspaceActivation')).toEqual({
+ const callers = {
'src/renderer/src/components/use-terminal-watcher-effects.ts': 1,
'src/renderer/src/lib/workspace-activation-recovery-retry.ts': 1,
'src/renderer/src/lib/worktree-activation-recovery-routing.ts': 1,
'src/renderer/src/lib/worktree-activation-recovery.ts': 1,
'src/renderer/src/lib/worktree-creation-flow-execute.ts': 1
+ }
+ expect(callerCounts('recoverWorkspaceActivation')).toEqual(callers)
+ for (const path of Object.keys(callers)) {
+ expect(selectionCouplingViolations(readFileSync(join(process.cwd(), path), 'utf8'))).toEqual(
+ []
+ )
+ }
+ })
+
+ it('cleans recovery ownership on workspace deletion and execution-host retirement', () => {
+ expect(callerCounts('clearWorkspaceActivationRecoveryLifecycle')).toEqual({
+ 'src/renderer/src/lib/workspace-activation-recovery-lifecycle.ts': 1,
+ 'src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts': 1,
+ 'src/renderer/src/store/project-groups/project-group-mutations.ts': 1,
+ 'src/renderer/src/store/slices/ssh.ts': 1,
+ 'src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts': 1,
+ 'src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts': 1,
+ 'src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts': 1
})
})
})
diff --git a/src/renderer/src/lib/worktree-activation-recovery-routing.ts b/src/renderer/src/lib/worktree-activation-recovery-routing.ts
index 5f4c3174907..c1f56b1156f 100644
--- a/src/renderer/src/lib/worktree-activation-recovery-routing.ts
+++ b/src/renderer/src/lib/worktree-activation-recovery-routing.ts
@@ -19,6 +19,7 @@ import {
getExecutionHostIdForWorktree,
getRuntimeEnvironmentIdForWorktree
} from './worktree-runtime-owner'
+import { startWorkspaceActivationSurfaceProducer } from './workspace-activation-surface-producer'
export function ensureFolderWorkspaceInitialTerminal(
folderWorkspace: FolderWorkspace,
@@ -90,6 +91,7 @@ export function recoverActivatedWorkspace(identity: WorkspaceActivationIdentity)
const existingTabIds = new Set(
(useAppStore.getState().tabsByWorktree[identity.workspaceKey] ?? []).map((tab) => tab.id)
)
+ startWorkspaceActivationSurfaceProducer(identity, { mode: 'explicit' })
void recoverWorkspaceActivation(identity, { mode: 'explicit' })
return (
useAppStore
@@ -103,6 +105,17 @@ export function finalizeActivatedWorkspaceSurface(
primaryTabId: string | null,
initialCwd?: string
): string | null {
+ if (primaryTabId) {
+ for (const entry of readWorkspaceSurfaceProducerEntries(identity)) {
+ if (
+ entry.result?.kind === 'materialized' &&
+ entry.result.surface.kind === 'tab' &&
+ entry.result.surface.id === primaryTabId
+ ) {
+ consumeWorkspaceSurfaceProducerAttempt(entry.attempt.id)
+ }
+ }
+ }
const settledPrimaryTabId = primaryTabId ?? recoverActivatedWorkspace(identity)
if (settledPrimaryTabId && initialCwd) {
useAppStore.getState().queueTabInitialCwd(settledPrimaryTabId, initialCwd)
@@ -118,8 +131,3 @@ export function hasOutstandingActivationSurfaceProducer(
entry.result?.kind !== 'materialized' || entry.result.surface.kind === 'workspace-content'
)
}
-
-export function consumeTransferredActivationProducer(producer: WorkspaceSurfaceProducer): void {
- producer.declined('Surface production transferred to the paired execution host.')
- consumeWorkspaceSurfaceProducerAttempt(producer.attempt.id)
-}
diff --git a/src/renderer/src/lib/worktree-activation-recovery.test.ts b/src/renderer/src/lib/worktree-activation-recovery.test.ts
index e19476e8626..a606c3b0828 100644
--- a/src/renderer/src/lib/worktree-activation-recovery.test.ts
+++ b/src/renderer/src/lib/worktree-activation-recovery.test.ts
@@ -14,6 +14,8 @@ import {
registerWorkspaceSurfaceProducer,
resetWorkspaceSurfaceProducersForTests
} from './workspace-surface-production'
+import { isActivationExecutionRouteCurrent } from './workspace-activation-recovery-state'
+import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
type FakeUnifiedTab = {
id: string
@@ -115,7 +117,6 @@ const mocks = vi.hoisted(() => {
storeListeners.add(listener)
return () => storeListeners.delete(listener)
},
- gate: vi.fn(),
authority: vi.fn(() => 'none'),
structuredStatus: vi.fn(() => 'idle'),
subscribeStructured: (listener: () => void) => {
@@ -135,9 +136,6 @@ vi.mock('@/store', () => ({
vi.mock('@/components/terminal/initial-terminal', () => ({
shouldAutoCreateInitialTerminal: (count: number) => count === 0
}))
-vi.mock('./worktree-agent-activation-gate', () => ({
- gateWorktreeAgentActivation: mocks.gate
-}))
vi.mock('./workspace-terminal-host-authority', () => ({
resolveWorkspaceTerminalHostAuthority: mocks.authority
}))
@@ -166,12 +164,6 @@ function identity(
}
}
-function forceGate(): void {
- mocks.state().sleepingAgentSessionsByPaneKey = {
- pane: { worktreeId: mocks.state().activeWorktreeId }
- }
-}
-
function showSurface(contentType: FakeUnifiedTab['contentType'], id = 'surface-1'): void {
mocks.state().unifiedTabsByWorktree[mocks.state().activeWorktreeId] = [{ id, contentType }]
mocks.notifyStore()
@@ -179,12 +171,11 @@ function showSurface(contentType: FakeUnifiedTab['contentType'], id = 'surface-1
beforeEach(() => {
mocks.reset()
- mocks.gate.mockReset()
- mocks.gate.mockResolvedValue('empty')
mocks.authority.mockReset()
mocks.authority.mockReturnValue('none')
mocks.structuredStatus.mockReset()
mocks.structuredStatus.mockReturnValue('idle')
+ replaceRuntimeEnvironmentRevisions([])
resetWorkspaceSurfaceProducersForTests()
resetWorkspaceActivationRecoveryPresentationsForTests()
})
@@ -194,9 +185,34 @@ afterEach(() => {
})
describe('activation recovery failures', () => {
+ it('invalidates a captured route when the saved runtime is re-paired', () => {
+ mocks.state().activeWorkspaceExecutionHostId = 'runtime:environment-1'
+ mocks.state().executionHostId = 'runtime:environment-1'
+ mocks.state().runtimeEnvironmentId = 'environment-1'
+ replaceRuntimeEnvironmentRevisions([{ id: 'environment-1', createdAt: 1, pairingRevision: 17 }])
+ const route = {
+ ...identity('paired-runtime-attempt', {
+ executionHostId: 'runtime:environment-1',
+ runtimeEnvironmentId: 'environment-1'
+ }),
+ runtimeEnvironmentRevision: 17
+ }
+
+ expect(isActivationExecutionRouteCurrent({ ...route, runtimeEnvironmentRevision: null })).toBe(
+ false
+ )
+ expect(isActivationExecutionRouteCurrent(route)).toBe(true)
+ replaceRuntimeEnvironmentRevisions([{ id: 'environment-1', createdAt: 1, pairingRevision: 18 }])
+ expect(isActivationExecutionRouteCurrent(route)).toBe(false)
+ })
+
it('publishes a blocked error and starts no writer', async () => {
- forceGate()
- mocks.gate.mockResolvedValue('blocked')
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'blocked-producer'
+ })
+ producer.blocked('host ownership is incomplete')
const result = await recoverWorkspaceActivation(identity('blocked-attempt'), {
mode: 'explicit'
@@ -209,39 +225,34 @@ describe('activation recovery failures', () => {
)
})
- it.each(['resume', 'adoption'])(
- 'contains an escaped %s rejection as an unexpected error',
- async () => {
- forceGate()
- mocks.gate.mockRejectedValue(new Error('inventory mutation failed'))
-
- const result = await recoverWorkspaceActivation(identity('rejected-attempt'), {
- mode: 'explicit'
- })
-
- expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' })
- expect(mocks.state().createTab).not.toHaveBeenCalled()
- expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
- kind: 'unexpected',
- detail: 'inventory mutation failed'
- })
- }
- )
-
- it('turns a private seeder throw into an actionable error', async () => {
- mocks.state().createTab.mockImplementationOnce(() => {
- throw new Error('tab commit failed')
+ it('contains an observer reconciliation rejection as an unexpected error', async () => {
+ mocks.state().reconcileWorktreeTabModel = vi.fn(() => {
+ throw new Error('inventory reconciliation failed')
})
- const result = await recoverWorkspaceActivation(identity('seeder-attempt'), {
+ const result = await recoverWorkspaceActivation(identity('rejected-attempt'), {
mode: 'explicit'
})
expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' })
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
kind: 'unexpected',
- detail: 'tab commit failed'
+ detail: 'inventory reconciliation failed'
})
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('does not seed when no concrete producer owns an empty activation', async () => {
+ const result = await recoverWorkspaceActivation(identity('observer-only-attempt'), {
+ mode: 'explicit'
+ })
+
+ expect(result).toMatchObject({ kind: 'failed', reason: 'unexpected' })
+ expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')).toMatchObject({
+ kind: 'unexpected',
+ detail: 'No concrete surface producer owns this empty workspace activation.'
+ })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
})
it('keeps a failed concrete producer visible without substituting a shell', async () => {
@@ -353,70 +364,89 @@ describe('activation recovery failures', () => {
it('bounds an inventory assessment and publishes an actionable timeout', async () => {
vi.useFakeTimers()
- forceGate()
- mocks.gate.mockReturnValue(new Promise(() => undefined))
+ registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'pending-producer'
+ })
const recovery = recoverWorkspaceActivation(identity('deadline-attempt'), {
mode: 'explicit'
})
await vi.advanceTimersByTimeAsync(30_000)
- await expect(recovery).resolves.toMatchObject({ kind: 'failed', reason: 'unexpected' })
+ await expect(recovery).resolves.toMatchObject({
+ kind: 'deferred',
+ ownerAttemptId: 'pending-producer'
+ })
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe(
- 'unexpected'
+ 'unverifiable'
)
expect(mocks.state().createTab).not.toHaveBeenCalled()
})
- it.each(['adopted', 'structured', 'resumed'] as const)(
- 'requires real publication after a %s gate outcome',
- async (outcome) => {
- vi.useFakeTimers()
- forceGate()
- mocks.gate.mockResolvedValue(outcome)
+ it('accepts a later producer update after an unverifiable settlement', async () => {
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'updated-producer'
+ })
+ producer.unverifiable('publication acknowledgement was lost')
- const recovery = recoverWorkspaceActivation(identity(`gate-${outcome}`), {
- mode: 'explicit'
- })
- await vi.advanceTimersByTimeAsync(30_000)
+ await expect(
+ recoverWorkspaceActivation(identity('unverifiable-observation'), { mode: 'explicit' })
+ ).resolves.toMatchObject({ kind: 'deferred', ownerAttemptId: 'updated-producer' })
- await expect(recovery).resolves.toMatchObject({ kind: 'deferred' })
- expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, 'local')?.kind).toBe(
- 'unverifiable'
- )
- expect(mocks.state().createTab).not.toHaveBeenCalled()
- }
- )
+ producer.materialized({ kind: 'tab', id: 'published-later' })
+ const recovery = recoverWorkspaceActivation(identity('updated-observation'), {
+ mode: 'explicit'
+ })
+ showSurface('agent-session', 'published-later')
+
+ await expect(recovery).resolves.toEqual({
+ kind: 'materialized',
+ surface: { id: 'published-later', type: 'agent-session' }
+ })
+ expect(readWorkspaceSurfaceProducerEntries(identity('unused'))).toEqual([])
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
+ })
+
+ it('reconciles visible inventory over an unverifiable producer verdict', async () => {
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'reconciled-producer'
+ })
+ producer.unverifiable('publication acknowledgement was lost')
+ showSurface('browser', 'visible-after-uncertainty')
+
+ await expect(
+ recoverWorkspaceActivation(identity('reconciled-observation'), { mode: 'explicit' })
+ ).resolves.toEqual({
+ kind: 'materialized',
+ surface: { id: 'visible-after-uncertainty', type: 'browser' }
+ })
+ expect(readWorkspaceSurfaceProducerEntries(identity('unused'))).toEqual([])
+ })
})
describe('activation recovery settlement', () => {
- it('uses the live startup tombstone at final settlement', async () => {
- forceGate()
- let settleGate!: (outcome: string) => void
- mocks.gate.mockReturnValue(
- new Promise((resolve) => {
- settleGate = resolve
- })
- )
- const recovery = recoverWorkspaceActivation(identity('startup-tombstone'), {
- mode: 'startup'
- })
- await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalled())
+ it('uses the live startup tombstone during observation', async () => {
mocks.state().tabsByWorktree[WORKSPACE_KEY] = []
- mocks.notifyStore()
- settleGate('empty')
- await expect(recovery).resolves.toEqual({ kind: 'intentional-empty' })
+ await expect(
+ recoverWorkspaceActivation(identity('startup-tombstone'), { mode: 'startup' })
+ ).resolves.toEqual({ kind: 'intentional-empty' })
expect(mocks.state().createTab).not.toHaveBeenCalled()
})
- it('lets an explicit reopen override a live tombstone', async () => {
+ it('does not let the observer override a live tombstone with a writer', async () => {
mocks.state().tabsByWorktree[WORKSPACE_KEY] = []
await expect(
recoverWorkspaceActivation(identity('explicit-tombstone'), { mode: 'explicit' })
- ).resolves.toMatchObject({ kind: 'materialized', surface: { type: 'terminal' } })
- expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ ).resolves.toMatchObject({ kind: 'failed', reason: 'unexpected' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
})
it.each([
@@ -443,59 +473,64 @@ describe('activation recovery settlement', () => {
)
it('cancels one startup request without consuming a later assessment', async () => {
- forceGate()
- mocks.gate.mockReturnValueOnce(new Promise(() => undefined)).mockResolvedValueOnce('empty')
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'startup-producer'
+ })
const abort = new AbortController()
const first = recoverWorkspaceActivation(identity('cancelled-startup'), {
mode: 'startup',
signal: abort.signal
})
- await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
+ await Promise.resolve()
abort.abort()
await expect(first).resolves.toEqual({ kind: 'stale' })
+ producer.intentionalEmpty()
await expect(
recoverWorkspaceActivation(identity('replacement-startup'), { mode: 'startup' })
- ).resolves.toMatchObject({ kind: 'materialized' })
- expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ ).resolves.toEqual({ kind: 'intentional-empty' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
})
it('invalidates an old request across an away-and-back selection cycle', async () => {
- forceGate()
- let settleGate!: (outcome: string) => void
- const sharedGate = new Promise((resolve) => {
- settleGate = resolve
+ const producer = registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'selection-producer'
})
- mocks.gate.mockReturnValue(sharedGate)
const first = recoverWorkspaceActivation(identity('before-away-and-back'), {
mode: 'explicit'
})
- await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
+ await Promise.resolve()
mocks.state().activeWorktreeId = 'worktree-2'
mocks.notifyStore()
mocks.state().activeWorktreeId = WORKSPACE_KEY
mocks.notifyStore()
- settleGate('empty')
await expect(first).resolves.toEqual({ kind: 'stale' })
expect(mocks.state().createTab).not.toHaveBeenCalled()
+ producer.materialized({ kind: 'workspace-content', id: 'new-selection-content' })
await expect(
recoverWorkspaceActivation(identity('after-away-and-back'), { mode: 'explicit' })
- ).resolves.toMatchObject({ kind: 'materialized' })
- expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ ).resolves.toEqual({
+ kind: 'materialized',
+ surface: { id: 'new-selection-content', type: 'workspace-content' }
+ })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
})
- it('rejects a joined gate whose host tuple conflicts', async () => {
- forceGate()
- let settleGate!: (outcome: string) => void
- const sharedGate = new Promise((resolve) => {
- settleGate = resolve
+ it('invalidates an observer whose captured host tuple changes', async () => {
+ registerWorkspaceSurfaceProducer({
+ workspaceKey: WORKSPACE_KEY,
+ executionHostId: 'local',
+ attemptId: 'host-a-producer'
})
- mocks.gate.mockReturnValue(sharedGate)
const first = recoverWorkspaceActivation(identity('host-a'), { mode: 'explicit' })
- await vi.waitFor(() => expect(mocks.gate).toHaveBeenCalledOnce())
+ await Promise.resolve()
const sshHost = toSshExecutionHostId('box')
mocks.state().executionHostId = sshHost
@@ -507,13 +542,12 @@ describe('activation recovery settlement', () => {
await expect(second).resolves.toMatchObject({
kind: 'deferred',
- reason: expect.stringContaining('another execution host')
+ reason: expect.stringContaining('cannot verify the execution host')
})
expect(readWorkspaceActivationRecoveryPresentation(WORKSPACE_KEY, sshHost)?.kind).toBe(
'unverifiable'
)
expect(mocks.state().createTab).not.toHaveBeenCalled()
- settleGate('empty')
await expect(first).resolves.toEqual({ kind: 'stale' })
})
@@ -555,7 +589,7 @@ describe('activation recovery settlement', () => {
expect(mocks.state().createTab).not.toHaveBeenCalled()
})
- it('allows SSH recovery only after a current synced inventory proves emptiness', async () => {
+ it('keeps synced SSH observation writer-free without producer settlement', async () => {
const sshHost = toSshExecutionHostId('box')
mocks.state().executionHostId = sshHost
mocks.state().activeWorkspaceExecutionHostId = sshHost
@@ -567,8 +601,8 @@ describe('activation recovery settlement', () => {
recoverWorkspaceActivation(identity('ssh-synced', { executionHostId: sshHost }), {
mode: 'explicit'
})
- ).resolves.toMatchObject({ kind: 'materialized', surface: { type: 'terminal' } })
- expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ ).resolves.toMatchObject({ kind: 'failed', reason: 'unexpected' })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
})
it('keeps a producer-owned tab pending until real publication reaches inventory', async () => {
@@ -618,9 +652,10 @@ describe('activation recovery settlement', () => {
expect(mocks.state().createTab).not.toHaveBeenCalled()
})
- it('lets a reentrant newer activation own the final seed critical section', async () => {
+ it('lets a reentrant newer activation own final reconciliation', async () => {
let newerRecovery: Promise | null = null
mocks.runOnNextReconcile(() => {
+ showSurface('terminal', 'newer-surface')
newerRecovery = recoverWorkspaceActivation(identity('reentrant-newer'), {
mode: 'explicit'
})
@@ -628,8 +663,11 @@ describe('activation recovery settlement', () => {
await expect(
recoverWorkspaceActivation(identity('reentrant-older'), { mode: 'explicit' })
- ).resolves.toMatchObject({ kind: 'materialized' })
- await expect(newerRecovery).resolves.toMatchObject({ kind: 'materialized' })
- expect(mocks.state().createTab).toHaveBeenCalledOnce()
+ ).resolves.toEqual({ kind: 'stale' })
+ await expect(newerRecovery).resolves.toEqual({
+ kind: 'materialized',
+ surface: { id: 'newer-surface', type: 'terminal' }
+ })
+ expect(mocks.state().createTab).not.toHaveBeenCalled()
})
})
diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts
index aa4963c900f..7013dc9777c 100644
--- a/src/renderer/src/lib/worktree-activation.ts
+++ b/src/renderer/src/lib/worktree-activation.ts
@@ -6,7 +6,6 @@ import {
isWebRuntimeSessionActive
} from '@/runtime/web-runtime-session'
import { registerWorktreeActivation } from '@/lib/worktree-activation-nav-registration'
-import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
@@ -25,19 +24,15 @@ import type {
WorktreeActivationOptions,
WorktreeActivationSurfaceSelection
} from './worktree-activation-surface-selection'
-import { registerWorkspaceSurfaceProducer } from './workspace-surface-production'
import { resolveWorkspaceExecutionEvidence } from './workspace-execution-evidence'
import {
- captureActivationRenderableSurfaceIds,
- consumeTransferredActivationProducer,
createWorkspaceActivationIdentity,
ensureFolderWorkspaceInitialTerminal,
finalizeActivatedWorkspaceSurface,
hasOutstandingActivationSurfaceProducer,
- hasWorkspaceActivationWork,
- recoverActivatedWorkspace,
- settleActivationSeedProducer
+ hasWorkspaceActivationWork
} from './worktree-activation-recovery-routing'
+import { produceRequestedWorkspaceSurface } from './workspace-activation-requested-surface'
import {
clearWorktreeActivationSidebarFilters,
revealActivatedWorktree
@@ -124,19 +119,12 @@ export function activateAndRevealFolderWorkspace(
executionEvidence === 'live' && isWebRuntimeSessionActive(runtimeEnvironmentId)
let primaryTabId: string | null = null
if (opts?.startup && !delegatesToRuntime) {
- const producer = registerWorkspaceSurfaceProducer(identity)
- if (executionEvidence !== 'exited') {
- producer.unverifiable('Orca cannot verify the execution host for this requested surface.')
- } else {
- try {
- const existingSurfaceIds = captureActivationRenderableSurfaceIds(workspaceKey)
- resumeSleepingAgentSessionsForWorktree(workspaceKey)
- primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts.startup)
- settleActivationSeedProducer(producer, workspaceKey, primaryTabId, existingSurfaceIds)
- } catch (error) {
- producer.failed(error)
- }
- }
+ primaryTabId = produceRequestedWorkspaceSurface({
+ identity,
+ executionEvidence,
+ owner: 'local',
+ createSurface: () => ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts.startup)
+ })
}
if (opts?.revealInSidebar !== false) {
state.revealWorktreeInSidebar(
@@ -152,7 +140,7 @@ export function activateAndRevealFolderWorkspace(
agent: opts?.agent
})
}
- return { primaryTabId: primaryTabId ?? recoverActivatedWorkspace(identity) }
+ return { primaryTabId: finalizeActivatedWorkspaceSurface(identity, primaryTabId) }
}
export function activateAndRevealWorktree(
@@ -223,9 +211,15 @@ export function activateAndRevealWorktree(
// Why: sleeping destroys the local PTY but preserves the provider session id, so waking should
// restore those CLI sessions. Ordering is load-bearing: resuming synchronously creates the
// session's tab first, so the seeding below doesn't add a bare shell next to it.
- const producer = registerWorkspaceSurfaceProducer(identity)
- if (delegatesToRuntime) {
- try {
+ primaryTabId = produceRequestedWorkspaceSurface({
+ identity,
+ executionEvidence,
+ owner: delegatesToRuntime
+ ? 'runtime-transfer'
+ : opts?.backendStartupTerminalSpawned
+ ? 'backend-confirmed'
+ : 'local',
+ createSurface: () =>
ensureWorktreeHasInitialTerminal(
useAppStore.getState(),
worktreeId,
@@ -239,58 +233,7 @@ export function activateAndRevealWorktree(
reseedEmptiedWorkspace: true
}
)
- consumeTransferredActivationProducer(producer)
- } catch (error) {
- producer.failed(error)
- }
- } else if (opts?.backendStartupTerminalSpawned) {
- try {
- primaryTabId = ensureWorktreeHasInitialTerminal(
- useAppStore.getState(),
- worktreeId,
- opts.startup,
- opts.setup,
- opts.issueCommand,
- opts.defaultTabs,
- {
- backendStartupTerminalSpawned: true,
- ...(opts.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}),
- reseedEmptiedWorkspace: true
- }
- )
- if (primaryTabId) {
- producer.materialized({ kind: 'tab', id: primaryTabId })
- } else {
- producer.unverifiable(
- 'The execution host accepted the startup, but its surface is not visible yet.'
- )
- }
- } catch (error) {
- producer.failed(error)
- }
- } else if (executionEvidence !== 'exited') {
- producer.unverifiable('Orca cannot verify the execution host for this requested surface.')
- } else {
- try {
- const existingSurfaceIds = captureActivationRenderableSurfaceIds(worktreeId)
- resumeSleepingAgentSessionsForWorktree(worktreeId)
- primaryTabId = ensureWorktreeHasInitialTerminal(
- useAppStore.getState(),
- worktreeId,
- opts?.startup,
- opts?.setup,
- opts?.issueCommand,
- opts?.defaultTabs,
- {
- ...(opts?.createNewTerminalForStartup ? { createNewTerminalForStartup: true } : {}),
- reseedEmptiedWorkspace: true
- }
- )
- settleActivationSeedProducer(producer, worktreeId, primaryTabId, existingSurfaceIds)
- } catch (error) {
- producer.failed(error)
- }
- }
+ })
}
// 5. Clear sidebar filters hiding the target — reveal needs the card rendered, else it silently no-ops.
if (opts?.clearSidebarFilters !== false) {
diff --git a/src/renderer/src/lib/worktree-agent-activation-claims.ts b/src/renderer/src/lib/worktree-agent-activation-claims.ts
new file mode 100644
index 00000000000..bb44f70d39d
--- /dev/null
+++ b/src/renderer/src/lib/worktree-agent-activation-claims.ts
@@ -0,0 +1,81 @@
+import type { useAppStore } from '@/store'
+import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format'
+import { parsePaneKey } from '../../../shared/stable-pane-id'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership'
+import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record'
+import type { StructuredActivationInventory } from './worktree-agent-structured-inventory'
+
+type ActivationClaimsStore = Pick<
+ ReturnType,
+ | 'ptyIdsByTabId'
+ | 'sleepingAgentSessionsByPaneKey'
+ | 'terminalLayoutsByTabId'
+ | 'unifiedTabsByWorktree'
+>
+
+export function workspaceHasSleepingAgentSessions(
+ state: Pick,
+ worktreeId: string
+): boolean {
+ return Object.values(state.sleepingAgentSessionsByPaneKey).some(
+ (record) => record.worktreeId === worktreeId
+ )
+}
+
+export function workspaceHasStructuredAgentSession(
+ store: Pick,
+ worktreeId: string
+): boolean {
+ return (store.unifiedTabsByWorktree[worktreeId] ?? []).some(
+ (tab) => tab.contentType === 'agent-session'
+ )
+}
+
+export function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boolean {
+ if (parsePtySessionId(sessionId).worktreeId === worktreeId) {
+ return true
+ }
+ const scope = parseWorkspaceKey(worktreeId)
+ return (
+ scope?.type === 'folder' &&
+ sessionId.startsWith(`${worktreeId}${PTY_SESSION_ID_SEPARATOR}`) &&
+ sessionId.length > worktreeId.length + PTY_SESSION_ID_SEPARATOR.length
+ )
+}
+
+export function liveSleepingAgentClaimKeys(
+ store: ActivationClaimsStore,
+ worktreeId: string,
+ livePtyIds: ReadonlySet,
+ structuredInventory: StructuredActivationInventory | null
+): Set {
+ const keys = new Set()
+ for (const record of Object.values(store.sleepingAgentSessionsByPaneKey)) {
+ if (record.worktreeId !== worktreeId) {
+ continue
+ }
+ const stable = parsePaneKey(record.paneKey)
+ const tabId = record.tabId ?? stable?.tabId
+ const layoutPtyId = stable
+ ? store.terminalLayoutsByTabId[stable.tabId]?.ptyIdsByLeafId?.[stable.leafId]
+ : undefined
+ const tabPtyIds = tabId ? store.ptyIdsByTabId[tabId] : undefined
+ const structuredOwner =
+ stable && isStructuredAgentSyntheticSleepingRecord(record)
+ ? structuredInventory?.ownerBySessionId.get(record.providerSession.id)
+ : undefined
+ if (structuredOwner?.owner === 'native') {
+ keys.add(getProviderSessionClaimKey(record))
+ continue
+ }
+ const structuredOwnerPtyId =
+ structuredOwner?.owner === 'tui' ? structuredOwner.terminal?.ptyId : undefined
+ const persistedPtyId =
+ layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) ?? structuredOwnerPtyId
+ if (persistedPtyId && livePtyIds.has(persistedPtyId)) {
+ keys.add(getProviderSessionClaimKey(record))
+ }
+ }
+ return keys
+}
diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts
index d25e1f48baa..6c7d880096a 100644
--- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts
+++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts
@@ -6,14 +6,29 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-typ
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
import type { TerminalSlice } from '@/store/slices/terminals'
-import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate'
+import { useAppStore } from '@/store'
+import {
+ gateWorktreeAgentActivation,
+ runWorktreeAgentActivationGate,
+ waitForWorktreeAgentActivationGateForTests
+} from './worktree-agent-activation-gate'
import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners'
+import type { WorktreeAgentActivationRoute } from './worktree-agent-activation-route'
+import * as activationPtyInventory from './worktree-activation-pty-inventory'
+import * as structuredInventory from './worktree-agent-structured-inventory'
+import * as recoveryState from './workspace-activation-recovery-state'
const WORKTREE_ID = 'repo::/worktree'
const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale'
const LIVE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
const DEAD_LEAF_ID = '22222222-2222-4222-8222-222222222222'
const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333'
+const LOCAL_ROUTE: WorktreeAgentActivationRoute = {
+ workspaceKey: WORKTREE_ID,
+ executionHostId: 'local',
+ runtimeEnvironmentId: null,
+ runtimeEnvironmentRevision: null
+}
function listed(id: string): PtyListedSession {
return { id, cwd: '/worktree', title: 'Codex', agentOwnership: 'present' }
@@ -325,7 +340,7 @@ describe('worktree agent activation gate', () => {
runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession })
).resolves.toBe('structured')
- expect(hasStructuredSession).toHaveBeenCalledWith(WORKTREE_ID)
+ expect(hasStructuredSession).toHaveBeenCalledWith(WORKTREE_ID, undefined)
expect(createTab).not.toHaveBeenCalled()
expect(resume).not.toHaveBeenCalled()
})
@@ -340,6 +355,39 @@ describe('worktree agent activation gate', () => {
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
})
+ it('fences resumed sessions to the captured route', async () => {
+ const { deps, resume } = testDeps({})
+
+ await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps, LOCAL_ROUTE)).resolves.toBe(
+ 'resumed'
+ )
+
+ expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
+ skipClaimKeys: new Set(),
+ expectedExecutionHostId: 'local',
+ expectedRuntimeEnvironmentId: null
+ })
+ })
+
+ it('fences a paired-runtime resume to the captured pairing revision', async () => {
+ const { deps, resume } = testDeps({})
+ const route: WorktreeAgentActivationRoute = {
+ ...LOCAL_ROUTE,
+ executionHostId: 'runtime:environment-1',
+ runtimeEnvironmentId: 'environment-1',
+ runtimeEnvironmentRevision: 17
+ }
+
+ await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps, route)).resolves.toBe('resumed')
+
+ expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
+ skipClaimKeys: new Set(),
+ expectedExecutionHostId: 'runtime:environment-1',
+ expectedRuntimeEnvironmentId: 'environment-1',
+ expectedRuntimeEnvironmentRevision: 17
+ })
+ })
+
it('resumes a dead agent when the workspace only has a non-agent PTY', async () => {
const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session')
const plainPtyId = `${WORKTREE_ID}@@plain-shell`
@@ -767,4 +815,92 @@ describe('worktree agent activation gate', () => {
recordInteraction: false
})
})
+
+ it('stops before adoption when the captured route changes during owner inventory', async () => {
+ const livePtyId = `${WORKTREE_ID}@@live-agent`
+ const { deps, createTab, resume } = testDeps({ sessions: [listed(livePtyId)] })
+ let routeCurrent = true
+ let resolveOwners!: (owners: LiveTerminalSurfaceOwnerIndex) => void
+ deps.listSurfaceOwners.mockReturnValue(
+ new Promise((resolve) => {
+ resolveOwners = resolve
+ })
+ )
+
+ const activation = runWorktreeAgentActivationGate(
+ WORKTREE_ID,
+ { ...deps, isRouteCurrent: () => routeCurrent },
+ LOCAL_ROUTE
+ )
+ await vi.waitFor(() => expect(deps.listSurfaceOwners).toHaveBeenCalledOnce())
+ routeCurrent = false
+ resolveOwners(new Map())
+
+ await expect(activation).resolves.toBe('stale')
+ expect(createTab).not.toHaveBeenCalled()
+ expect(resume).not.toHaveBeenCalled()
+ })
+
+ it('does not resume after the gate deadline aborts inventory', async () => {
+ const { deps, createTab, resume } = testDeps({})
+ const controller = new AbortController()
+ deps.listSessions.mockImplementation(async () => {
+ controller.abort()
+ return []
+ })
+
+ await expect(
+ runWorktreeAgentActivationGate(WORKTREE_ID, deps, LOCAL_ROUTE, {
+ signal: controller.signal,
+ timeoutMs: 100
+ })
+ ).resolves.toBe('blocked')
+ expect(createTab).not.toHaveBeenCalled()
+ expect(resume).not.toHaveBeenCalled()
+ })
+
+ it('aborts deadline-bound inventory and releases the route dedupe entry', async () => {
+ vi.useFakeTimers()
+ const originalReadiness = {
+ workspaceSessionReady: useAppStore.getState().workspaceSessionReady,
+ terminalStartupRestorationReady: useAppStore.getState().terminalStartupRestorationReady
+ }
+ useAppStore.setState({ workspaceSessionReady: true, terminalStartupRestorationReady: true })
+ vi.stubGlobal('window', {})
+ const isCurrent = vi
+ .spyOn(recoveryState, 'isActivationExecutionRouteCurrent')
+ .mockReturnValue(true)
+ const structured = vi
+ .spyOn(structuredInventory, 'readWorktreeStructuredActivationInventory')
+ .mockResolvedValue(false)
+ const list = vi
+ .spyOn(activationPtyInventory, 'listActivationPtySessionsForRoute')
+ .mockImplementation(
+ (_route, operation = {}) =>
+ new Promise((_, reject) => {
+ operation.signal?.addEventListener(
+ 'abort',
+ () => reject(new Error('inventory aborted')),
+ { once: true }
+ )
+ })
+ )
+
+ const activation = gateWorktreeAgentActivation(LOCAL_ROUTE, { timeoutMs: 100 })
+ expect(waitForWorktreeAgentActivationGateForTests(WORKTREE_ID)).toBe(activation)
+ await vi.advanceTimersByTimeAsync(100)
+
+ await expect(activation).resolves.toBe('blocked')
+ await expect(waitForWorktreeAgentActivationGateForTests(WORKTREE_ID)).resolves.toBeNull()
+ expect(list).toHaveBeenCalledWith(
+ LOCAL_ROUTE,
+ expect.objectContaining({ signal: expect.any(AbortSignal), timeoutMs: 100 })
+ )
+ structured.mockRestore()
+ list.mockRestore()
+ isCurrent.mockRestore()
+ useAppStore.setState(originalReadiness)
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+ })
})
diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts
index 1fd6fa752ae..69dfd967a22 100644
--- a/src/renderer/src/lib/worktree-agent-activation-gate.ts
+++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts
@@ -1,15 +1,12 @@
import { useAppStore } from '@/store'
import type { PtyListedSession } from '../../../shared/pty-listed-session'
-import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format'
import { parsePaneKey } from '../../../shared/stable-pane-id'
-import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { worktreeIdsEqual } from '../../../shared/worktree/id'
-import { listActivationPtySessions } from './worktree-activation-pty-inventory'
+import { listActivationPtySessionsForRoute } from './worktree-activation-pty-inventory'
import {
resumeSleepingAgentSessionsForWorktree,
type ResumeSleepingAgentSessionsOptions
} from './resume-sleeping-agent-session'
-import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership'
import {
adoptLiveWorkspacePtySurfaces,
bindLivePtyToExactSurface,
@@ -17,11 +14,21 @@ import {
} from './worktree-agent-live-surface-adoption'
import type { LiveTerminalSurfaceOwnerIndex } from './worktree-live-terminal-surface-owners'
import { readWorktreeLiveTerminalSurfaceOwners } from './worktree-live-terminal-surface-owners'
-import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record'
import {
readWorktreeStructuredActivationInventory,
type StructuredActivationInventory
} from './worktree-agent-structured-inventory'
+import {
+ worktreeAgentActivationRouteKey,
+ type WorktreeAgentActivationRoute
+} from './worktree-agent-activation-route'
+import { isActivationExecutionRouteCurrent } from './workspace-activation-recovery-state'
+import {
+ liveSleepingAgentClaimKeys,
+ sessionBelongsToWorkspace,
+ workspaceHasSleepingAgentSessions,
+ workspaceHasStructuredAgentSession
+} from './worktree-agent-activation-claims'
type ActivationStore = LiveSurfaceAdoptionStore &
Pick<
@@ -31,25 +38,35 @@ type ActivationStore = LiveSurfaceAdoptionStore &
type ActivationGateDeps = {
getState: () => ActivationStore
- awaitReady?: () => Promise
- listSessions: () => Promise
+ awaitReady?: (operation?: ActivationGateOperation) => Promise
+ listSessions: (operation?: ActivationGateOperation) => Promise
/** Host-recorded PTY→surface ownership; null when the host could not answer. */
- listSurfaceOwners: (worktreeId: string) => Promise
- hasStructuredSession?: (worktreeId: string) => Promise
+ listSurfaceOwners: (
+ worktreeId: string,
+ operation?: ActivationGateOperation
+ ) => Promise
+ hasStructuredSession?: (
+ worktreeId: string,
+ operation?: ActivationGateOperation
+ ) => Promise
resume: (worktreeId: string, options?: ResumeSleepingAgentSessionsOptions) => number
+ isRouteCurrent?: () => boolean
}
+type ActivationGateOperation = { signal: AbortSignal; timeoutMs: number }
+
export type WorktreeAgentActivationOutcome =
| 'adopted'
| 'structured'
| 'resumed'
| 'empty'
| 'blocked'
+ | 'stale'
-const inFlightByWorktreeId = new Map>()
+const inFlightByRoute = new Map>()
const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000
-function waitForWorkspaceSessionReady(): Promise {
+function waitForWorkspaceSessionReady(operation?: ActivationGateOperation): Promise {
const isReady = () => {
const state = useAppStore.getState()
return state.workspaceSessionReady && state.terminalStartupRestorationReady
@@ -62,106 +79,67 @@ function waitForWorkspaceSessionReady(): Promise {
const settle = (ready: boolean) => {
clearTimeout(timeout)
unsubscribe?.()
+ operation?.signal.removeEventListener('abort', onAbort)
resolve(ready)
}
- const timeout = setTimeout(() => settle(isReady()), WORKSPACE_SESSION_READY_TIMEOUT_MS)
+ const onAbort = (): void => settle(false)
+ const timeout = setTimeout(
+ () => settle(isReady()),
+ operation?.timeoutMs ?? WORKSPACE_SESSION_READY_TIMEOUT_MS
+ )
unsubscribe = useAppStore.subscribe((state) => {
if (state.workspaceSessionReady && state.terminalStartupRestorationReady) {
settle(true)
}
})
+ operation?.signal.addEventListener('abort', onAbort, { once: true })
+ if (operation?.signal.aborted) {
+ settle(false)
+ return
+ }
if (isReady()) {
settle(true)
}
})
}
-export function workspaceHasSleepingAgentSessions(
- state: Pick, 'sleepingAgentSessionsByPaneKey'>,
- worktreeId: string
-): boolean {
- return Object.values(state.sleepingAgentSessionsByPaneKey).some(
- (record) => record.worktreeId === worktreeId
- )
-}
-
-function hasStructuredSession(store: ActivationStore, worktreeId: string): boolean {
- return (store.unifiedTabsByWorktree[worktreeId] ?? []).some(
- (tab) => tab.contentType === 'agent-session'
- )
-}
-
-function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boolean {
- if (parsePtySessionId(sessionId).worktreeId === worktreeId) {
- return true
- }
- const scope = parseWorkspaceKey(worktreeId)
- return (
- scope?.type === 'folder' &&
- sessionId.startsWith(`${worktreeId}${PTY_SESSION_ID_SEPARATOR}`) &&
- sessionId.length > worktreeId.length + PTY_SESSION_ID_SEPARATOR.length
- )
-}
-
-function liveSleepingAgentClaimKeys(
- store: ActivationStore,
- worktreeId: string,
- livePtyIds: ReadonlySet,
- structuredInventory: StructuredActivationInventory | null
-): Set {
- const keys = new Set()
- for (const record of Object.values(store.sleepingAgentSessionsByPaneKey)) {
- if (record.worktreeId !== worktreeId) {
- continue
- }
- const stable = parsePaneKey(record.paneKey)
- const tabId = record.tabId ?? stable?.tabId
- const layoutPtyId = stable
- ? store.terminalLayoutsByTabId[stable.tabId]?.ptyIdsByLeafId?.[stable.leafId]
- : undefined
- const tabPtyIds = tabId ? store.ptyIdsByTabId[tabId] : undefined
- const structuredOwner =
- stable && isStructuredAgentSyntheticSleepingRecord(record)
- ? structuredInventory?.ownerBySessionId.get(record.providerSession.id)
- : undefined
- if (structuredOwner?.owner === 'native') {
- keys.add(getProviderSessionClaimKey(record))
- continue
- }
- // Packaged hydration can omit renderer bindings while main retains this session's exact TUI.
- const structuredOwnerPtyId =
- structuredOwner?.owner === 'tui' ? structuredOwner.terminal?.ptyId : undefined
- const persistedPtyId =
- layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) ?? structuredOwnerPtyId
- if (persistedPtyId && livePtyIds.has(persistedPtyId)) {
- keys.add(getProviderSessionClaimKey(record))
- }
- }
- return keys
-}
-
export async function runWorktreeAgentActivationGate(
worktreeId: string,
- deps: ActivationGateDeps
+ deps: ActivationGateDeps,
+ route?: WorktreeAgentActivationRoute,
+ operation?: ActivationGateOperation
): Promise {
+ const routeIsCurrent = (): boolean => deps.isRouteCurrent?.() !== false
+ const mayAct = (): boolean => routeIsCurrent() && operation?.signal.aborted !== true
+ const interruptedOutcome = (): WorktreeAgentActivationOutcome =>
+ routeIsCurrent() ? 'blocked' : 'stale'
+ if (!mayAct()) {
+ return interruptedOutcome()
+ }
try {
- if (deps.awaitReady && !(await deps.awaitReady())) {
- return 'blocked'
+ if (deps.awaitReady && !(await deps.awaitReady(operation))) {
+ return interruptedOutcome()
}
} catch {
- return 'blocked'
+ return interruptedOutcome()
+ }
+ if (!mayAct()) {
+ return interruptedOutcome()
}
let structured = false
let structuredInventory: StructuredActivationInventory | null = null
try {
- const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId)
+ const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId, operation)
structuredInventory =
typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null
structured = Boolean(
- hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession
+ workspaceHasStructuredAgentSession(deps.getState(), worktreeId) || reportedStructuredSession
)
} catch {
- return 'blocked'
+ return interruptedOutcome()
+ }
+ if (!mayAct()) {
+ return interruptedOutcome()
}
const structuredTabs = structuredInventory?.snapshot.tabs.filter(
@@ -189,10 +167,13 @@ export async function runWorktreeAgentActivationGate(
let sessions: PtyListedSession[]
try {
- sessions = await deps.listSessions()
+ sessions = await deps.listSessions(operation)
} catch {
// Inventory uncertainty cannot authorize a second writer.
- return 'blocked'
+ return interruptedOutcome()
+ }
+ if (!mayAct()) {
+ return interruptedOutcome()
}
// Why either signal rather than a preference: a relay row's worktreeId can be seeded from the
@@ -208,6 +189,9 @@ export async function runWorktreeAgentActivationGate(
if (owner.owner !== 'tui') {
continue
}
+ if (!mayAct()) {
+ return interruptedOutcome()
+ }
if (
!owner.terminal ||
!liveWorkspacePtyIds.has(owner.terminal.ptyId) ||
@@ -224,8 +208,12 @@ export async function runWorktreeAgentActivationGate(
deps.getState,
worktreeId,
[...liveWorkspacePtyIds],
- deps.listSurfaceOwners
+ (targetWorktreeId) => deps.listSurfaceOwners(targetWorktreeId, operation),
+ mayAct
)
+ if (adoption.stale) {
+ return interruptedOutcome()
+ }
liveSurfaceAdopted = adoption.surfaced
// A live agent the user can no longer see has to be diagnosable from the console.
if (adoption.declinedPtyIds.length > 0) {
@@ -245,13 +233,25 @@ export async function runWorktreeAgentActivationGate(
if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) {
return 'structured'
}
+ if (!mayAct()) {
+ return interruptedOutcome()
+ }
const launched = deps.resume(worktreeId, {
skipClaimKeys: liveSleepingAgentClaimKeys(
deps.getState(),
worktreeId,
liveWorkspacePtyIds,
structuredInventory
- )
+ ),
+ ...(route
+ ? {
+ expectedExecutionHostId: route.executionHostId,
+ expectedRuntimeEnvironmentId: route.runtimeEnvironmentId,
+ ...(route.runtimeEnvironmentRevision === null
+ ? {}
+ : { expectedRuntimeEnvironmentRevision: route.runtimeEnvironmentRevision })
+ }
+ : {})
})
// 'empty' is the caller's directive — "this gate produced no surface, seed one" — not a
// claim the host had nothing; the callers re-check their own seeding guards first.
@@ -265,33 +265,54 @@ export async function runWorktreeAgentActivationGate(
}
export function gateWorktreeAgentActivation(
- worktreeId: string
+ route: WorktreeAgentActivationRoute,
+ options: { timeoutMs?: number } = {}
): Promise {
- const existing = inFlightByWorktreeId.get(worktreeId)
+ const key = worktreeAgentActivationRouteKey(route)
+ const existing = inFlightByRoute.get(key)
if (existing) {
return existing
}
- const gate = runWorktreeAgentActivationGate(worktreeId, {
- getState: () => useAppStore.getState(),
- awaitReady: waitForWorkspaceSessionReady,
- listSessions: () =>
- typeof window === 'undefined'
- ? Promise.resolve([])
- : listActivationPtySessions(useAppStore.getState(), worktreeId),
- listSurfaceOwners: readWorktreeLiveTerminalSurfaceOwners,
- hasStructuredSession: readWorktreeStructuredActivationInventory,
- resume: resumeSleepingAgentSessionsForWorktree
- }).finally(() => {
- if (inFlightByWorktreeId.get(worktreeId) === gate) {
- inFlightByWorktreeId.delete(worktreeId)
+ const controller = new AbortController()
+ const timeoutMs = options.timeoutMs ?? WORKSPACE_SESSION_READY_TIMEOUT_MS
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
+ const operation = { signal: controller.signal, timeoutMs }
+ const gate = runWorktreeAgentActivationGate(
+ route.workspaceKey,
+ {
+ getState: () => useAppStore.getState(),
+ awaitReady: waitForWorkspaceSessionReady,
+ listSessions: (request) =>
+ typeof window === 'undefined'
+ ? Promise.resolve([])
+ : listActivationPtySessionsForRoute(route, request),
+ listSurfaceOwners: (_worktreeId, request) =>
+ readWorktreeLiveTerminalSurfaceOwners(route, request),
+ hasStructuredSession: (_worktreeId, request) =>
+ readWorktreeStructuredActivationInventory(route, request),
+ resume: resumeSleepingAgentSessionsForWorktree,
+ isRouteCurrent: () => isActivationExecutionRouteCurrent(route)
+ },
+ route,
+ operation
+ ).finally(() => {
+ clearTimeout(timeout)
+ if (inFlightByRoute.get(key) === gate) {
+ inFlightByRoute.delete(key)
}
})
- inFlightByWorktreeId.set(worktreeId, gate)
+ inFlightByRoute.set(key, gate)
return gate
}
export function waitForWorktreeAgentActivationGateForTests(
worktreeId: string
): Promise {
- return inFlightByWorktreeId.get(worktreeId) ?? Promise.resolve(null)
+ for (const [key, gate] of inFlightByRoute) {
+ const routeKey: unknown = JSON.parse(key)
+ if (Array.isArray(routeKey) && routeKey[3] === worktreeId) {
+ return gate
+ }
+ }
+ return Promise.resolve(null)
}
diff --git a/src/renderer/src/lib/worktree-agent-activation-route.test.ts b/src/renderer/src/lib/worktree-agent-activation-route.test.ts
new file mode 100644
index 00000000000..4526515dfaf
--- /dev/null
+++ b/src/renderer/src/lib/worktree-agent-activation-route.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import {
+ worktreeAgentActivationRouteKey,
+ type WorktreeAgentActivationRoute
+} from './worktree-agent-activation-route'
+
+const LOCAL_ROUTE: WorktreeAgentActivationRoute = {
+ workspaceKey: 'repo::/worktree',
+ executionHostId: 'local',
+ runtimeEnvironmentId: null,
+ runtimeEnvironmentRevision: null
+}
+
+describe('worktree agent activation route', () => {
+ it('includes workspace, execution host, runtime, and pairing revision in the dedupe key', () => {
+ const keys = [
+ LOCAL_ROUTE,
+ { ...LOCAL_ROUTE, executionHostId: 'ssh:box' as const },
+ {
+ ...LOCAL_ROUTE,
+ executionHostId: 'runtime:environment-1' as const,
+ runtimeEnvironmentId: 'environment-1',
+ runtimeEnvironmentRevision: 1
+ },
+ {
+ ...LOCAL_ROUTE,
+ executionHostId: 'runtime:environment-1' as const,
+ runtimeEnvironmentId: 'environment-1',
+ runtimeEnvironmentRevision: 2
+ }
+ ].map(worktreeAgentActivationRouteKey)
+
+ expect(new Set(keys).size).toBe(4)
+ })
+})
diff --git a/src/renderer/src/lib/worktree-agent-activation-route.ts b/src/renderer/src/lib/worktree-agent-activation-route.ts
new file mode 100644
index 00000000000..f85249cfe94
--- /dev/null
+++ b/src/renderer/src/lib/worktree-agent-activation-route.ts
@@ -0,0 +1,33 @@
+import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
+import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
+
+export type WorktreeAgentActivationRoute = {
+ workspaceKey: string
+ executionHostId: ExecutionHostId
+ runtimeEnvironmentId: string | null
+ runtimeEnvironmentRevision: number | null
+}
+
+export function worktreeAgentActivationRouteKey(route: WorktreeAgentActivationRoute): string {
+ return JSON.stringify([
+ route.executionHostId,
+ route.runtimeEnvironmentId,
+ route.runtimeEnvironmentRevision,
+ route.workspaceKey
+ ])
+}
+
+export function runtimeTargetForActivationRoute(
+ route: WorktreeAgentActivationRoute
+): RuntimeClientTarget | null {
+ const host = parseExecutionHostId(route.executionHostId)
+ if (!host) {
+ return null
+ }
+ if (host.kind === 'runtime') {
+ return route.runtimeEnvironmentId === host.environmentId
+ ? { kind: 'environment', environmentId: host.environmentId }
+ : null
+ }
+ return route.runtimeEnvironmentId === null ? { kind: 'local' } : null
+}
diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts
index 6a3205b04b6..44e49d0000b 100644
--- a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts
+++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts
@@ -161,7 +161,22 @@ function stubInventory(args?: {
]
: []
)
- vi.stubGlobal('window', { api: { runtime: { call: runtimeCall }, pty: { listSessions } } })
+ const runtimeSubscribe = vi.fn(
+ async (
+ request: { method: string; params?: unknown },
+ callback: (response: unknown) => void
+ ) => {
+ callback(await runtimeCall(request))
+ return { unsubscribe: vi.fn() }
+ }
+ )
+ vi.stubGlobal('window', {
+ api: {
+ runtime: { call: runtimeCall, subscribe: runtimeSubscribe },
+ runtimeEnvironments: { subscribe: vi.fn() },
+ pty: { listSessions }
+ }
+ })
return { runtimeCall, listSessions }
}
@@ -331,7 +346,15 @@ describe('worktree agent activation seam', () => {
method: 'session.tabs.list',
params: { worktree: `id:${worktree.id}` }
})
- expect(listSessions).toHaveBeenCalledExactlyOnceWith({ connectionId: null })
+ expect(listSessions).not.toHaveBeenCalled()
+ expect(runtimeCall).toHaveBeenCalledWith({
+ method: 'terminal.list',
+ params: {
+ worktree: `id:${worktree.id}`,
+ requireFreshPtyLiveness: true,
+ includeVisualLayouts: false
+ }
+ })
expect(runtimeCall).toHaveBeenCalledWith({
method: 'agentSession.handoffStatus',
params: { sessionId: 'chat-1' }
diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts
index 3675b7575da..694dd18541d 100644
--- a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts
+++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts
@@ -80,8 +80,12 @@ function adoptHostOwnedSurface(
getState: () => LiveSurfaceAdoptionStore,
worktreeId: string,
owner: LiveTerminalSurfaceOwner,
- materializedTabIds: Set
+ materializedTabIds: Set,
+ canMutate: () => boolean
): boolean {
+ if (!canMutate()) {
+ return false
+ }
const store = getState()
const known = tabExists(store, owner.tabId)
if (bindLivePtyToExactSurface(store, worktreeId, owner)) {
@@ -101,6 +105,9 @@ function adoptHostOwnedSurface(
if (!layout?.root) {
return false
}
+ if (!canMutate()) {
+ return false
+ }
current.setTabLayout(owner.tabId, {
...layout,
root: {
@@ -128,8 +135,9 @@ export async function adoptLiveWorkspacePtySurfaces(
getState: () => LiveSurfaceAdoptionStore,
worktreeId: string,
livePtyIds: readonly string[],
- listSurfaceOwners: (worktreeId: string) => Promise
-): Promise<{ surfaced: boolean; declinedPtyIds: string[] }> {
+ listSurfaceOwners: (worktreeId: string) => Promise,
+ canMutate: () => boolean = () => true
+): Promise<{ surfaced: boolean; declinedPtyIds: string[]; stale: boolean }> {
// Why: ptyIdsByTabId holds only panes this renderer mounted, so a tab bound
// solely in tab.ptyId or the persisted layout used to read as unbound.
const unbound = livePtyIds.filter(
@@ -138,7 +146,7 @@ export async function adoptLiveWorkspacePtySurfaces(
let surfaced = unbound.length < livePtyIds.length
const declinedPtyIds: string[] = []
if (unbound.length === 0) {
- return { surfaced, declinedPtyIds }
+ return { surfaced, declinedPtyIds, stale: false }
}
let surfaceOwners: LiveTerminalSurfaceOwnerIndex | null
try {
@@ -146,8 +154,14 @@ export async function adoptLiveWorkspacePtySurfaces(
} catch {
surfaceOwners = null
}
+ if (!canMutate()) {
+ return { surfaced: false, declinedPtyIds: [], stale: true }
+ }
const materializedTabIds = new Set()
for (const ptyId of unbound) {
+ if (!canMutate()) {
+ return { surfaced: false, declinedPtyIds, stale: true }
+ }
// Why: a pane can mount while the census is in flight, so the pre-RPC
// verdict is stale by the time it would authorize a mint.
if (resolveTerminalTabPtyOwnership(getState(), worktreeId, ptyId).kind !== 'none') {
@@ -156,7 +170,7 @@ export async function adoptLiveWorkspacePtySurfaces(
}
const owner = surfaceOwners?.get(ptyId)
if (owner) {
- if (adoptHostOwnedSurface(getState, worktreeId, owner, materializedTabIds)) {
+ if (adoptHostOwnedSurface(getState, worktreeId, owner, materializedTabIds, canMutate)) {
surfaced = true
} else {
declinedPtyIds.push(ptyId)
@@ -176,5 +190,5 @@ export async function adoptLiveWorkspacePtySurfaces(
})
surfaced = true
}
- return { surfaced, declinedPtyIds }
+ return { surfaced, declinedPtyIds, stale: false }
}
diff --git a/src/renderer/src/lib/worktree-agent-structured-inventory.ts b/src/renderer/src/lib/worktree-agent-structured-inventory.ts
index b541e363504..3073a79ca98 100644
--- a/src/renderer/src/lib/worktree-agent-structured-inventory.ts
+++ b/src/renderer/src/lib/worktree-agent-structured-inventory.ts
@@ -1,6 +1,11 @@
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
import { worktreeIdsEqual } from '../../../shared/worktree/id'
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import {
+ runtimeTargetForActivationRoute,
+ type WorktreeAgentActivationRoute
+} from './worktree-agent-activation-route'
export type StructuredActivationInventory = {
snapshot: RuntimeMobileSessionTabsResult
@@ -14,19 +19,35 @@ export type StructuredActivationInventory = {
}
export async function readWorktreeStructuredActivationInventory(
- worktreeId: string
+ routeOrWorktreeId: WorktreeAgentActivationRoute | string,
+ options: { timeoutMs?: number; signal?: AbortSignal } = {}
): Promise {
if (typeof window === 'undefined') {
return false
}
- const response = await window.api.runtime.call({
- method: 'session.tabs.list',
- params: { worktree: toRuntimeWorktreeSelector(worktreeId) }
- })
- if (!response.ok) {
+ const route = typeof routeOrWorktreeId === 'string' ? null : routeOrWorktreeId
+ const worktreeId =
+ typeof routeOrWorktreeId === 'string' ? routeOrWorktreeId : routeOrWorktreeId.workspaceKey
+ const target = route ? runtimeTargetForActivationRoute(route) : { kind: 'local' as const }
+ if (!target) {
+ throw new Error('structured session inventory route unavailable')
+ }
+ let snapshot: RuntimeMobileSessionTabsResult
+ try {
+ snapshot = await callRuntimeRpc(
+ target,
+ 'session.tabs.list',
+ { worktree: toRuntimeWorktreeSelector(worktreeId) },
+ {
+ ...options,
+ ...(route?.runtimeEnvironmentRevision === null || route === null
+ ? {}
+ : { expectedEnvironmentPairingRevision: route.runtimeEnvironmentRevision })
+ }
+ )
+ } catch {
throw new Error('structured session inventory unavailable')
}
- const snapshot = response.result as RuntimeMobileSessionTabsResult
if (
!snapshot ||
typeof snapshot.worktree !== 'string' ||
@@ -49,35 +70,43 @@ export async function readWorktreeStructuredActivationInventory(
snapshot.tabs.flatMap((tab) =>
tab.type === 'agent-session'
? [
- window.api.runtime
- .call({ method: 'agentSession.handoffStatus', params: { sessionId: tab.sessionId } })
- .then((statusResponse) => {
- if (!statusResponse.ok) {
- return
- }
- const status = statusResponse.result as {
- owner?: unknown
- terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown }
- }
- if (status.owner === 'native') {
- ownerBySessionId.set(tab.sessionId, { owner: 'native' })
- } else if (
- status.owner === 'tui' &&
- typeof status.terminal?.paneKey === 'string' &&
- typeof status.terminal?.ptyId === 'string' &&
- status.terminal.ptyId.length > 0 &&
- typeof status.terminal.tabId === 'string'
- ) {
- ownerBySessionId.set(tab.sessionId, {
- owner: 'tui',
- terminal: {
- paneKey: status.terminal.paneKey,
- ptyId: status.terminal.ptyId,
- tabId: status.terminal.tabId
- }
- })
- }
- })
+ callRuntimeRpc<{
+ owner?: unknown
+ terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown }
+ }>(
+ target,
+ 'agentSession.handoffStatus',
+ { sessionId: tab.sessionId },
+ {
+ ...options,
+ ...(route?.runtimeEnvironmentRevision === null || route === null
+ ? {}
+ : { expectedEnvironmentPairingRevision: route.runtimeEnvironmentRevision })
+ }
+ ).then((status) => {
+ const typedStatus: {
+ owner?: unknown
+ terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown }
+ } = status
+ if (typedStatus.owner === 'native') {
+ ownerBySessionId.set(tab.sessionId, { owner: 'native' })
+ } else if (
+ typedStatus.owner === 'tui' &&
+ typeof typedStatus.terminal?.paneKey === 'string' &&
+ typeof typedStatus.terminal?.ptyId === 'string' &&
+ typedStatus.terminal.ptyId.length > 0 &&
+ typeof typedStatus.terminal.tabId === 'string'
+ ) {
+ ownerBySessionId.set(tab.sessionId, {
+ owner: 'tui',
+ terminal: {
+ paneKey: typedStatus.terminal.paneKey,
+ ptyId: typedStatus.terminal.ptyId,
+ tabId: typedStatus.terminal.tabId
+ }
+ })
+ }
+ })
]
: []
)
diff --git a/src/renderer/src/lib/worktree-creation-flow-execute.ts b/src/renderer/src/lib/worktree-creation-flow-execute.ts
index b05fed7d959..d0dd82e3ffe 100644
--- a/src/renderer/src/lib/worktree-creation-flow-execute.ts
+++ b/src/renderer/src/lib/worktree-creation-flow-execute.ts
@@ -197,27 +197,25 @@ export async function executeWorktreeCreation(
// infer a primary tab from default-tab ordering; only a fresh seed may
// return one here.
const stateAfterActivationFailure = useAppStore.getState()
- const existingTabs = stateAfterActivationFailure.tabsByWorktree[worktree.id] ?? []
- const launchAgent = startupOpt?.launchAgent ?? preparedRequest.agent
- const verifiedLaunchTabId =
- result.startupTerminal?.tabId ??
- (launchAgent ? existingTabs.find((tab) => tab.launchAgent === launchAgent)?.id : undefined)
- if (verifiedLaunchTabId) {
- // Startup terminal ids and stamped agent tabs are the only safe primary
- // ids when activation returned no result.
- primaryTabId = verifiedLaunchTabId
- } else {
- const recoveryState = useAppStore.getState()
- const identity = {
- workspaceKey: worktree.id,
- executionHostId: getExecutionHostIdForWorktree(recoveryState, worktree.id),
- runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(recoveryState, worktree.id),
- attemptId: createBrowserUuid()
- }
- const producer = registerWorkspaceSurfaceProducer(identity)
- producer.failed(error)
- void recoverWorkspaceActivation(identity, { mode: 'explicit' })
+ stateAfterActivationFailure.reconcileWorktreeTabModel(worktree.id)
+ const startupTerminalTabId = result.startupTerminal?.tabId
+ primaryTabId = startupTerminalTabId
+ ? ((useAppStore.getState().unifiedTabsByWorktree[worktree.id] ?? []).find(
+ (tab) => tab.id === startupTerminalTabId
+ )?.id ?? null)
+ : null
+ const identity = {
+ workspaceKey: worktree.id,
+ executionHostId: getExecutionHostIdForWorktree(stateAfterActivationFailure, worktree.id),
+ runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(
+ stateAfterActivationFailure,
+ worktree.id
+ ),
+ attemptId: createBrowserUuid()
}
+ const producer = registerWorkspaceSurfaceProducer(identity)
+ producer.failed(error)
+ void recoverWorkspaceActivation(identity, { mode: 'explicit' })
}
} else {
// Keep chat creation on its pending surface until the session is ready.
diff --git a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts
index 36d974fe8d0..74d06feba5e 100644
--- a/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts
+++ b/src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts
@@ -14,6 +14,7 @@ import type { WorkspaceSurfaceProducer } from '@/lib/workspace-surface-productio
// plus toast instead of a panel silently stuck at "creating".
type TestActiveView = 'terminal' | 'tasks'
+const initialUnifiedTabsByWorktree: Record = {}
const store = {
settings: {
@@ -54,7 +55,8 @@ const store = {
updateWorktreeMeta: vi.fn(),
createWorktree: vi.fn(),
tabsByWorktree: {} as Record,
- unifiedTabsByWorktree: {}
+ unifiedTabsByWorktree: initialUnifiedTabsByWorktree,
+ reconcileWorktreeTabModel: vi.fn()
}
const surfaceProducer: WorkspaceSurfaceProducer = {
@@ -67,7 +69,10 @@ const surfaceProducer: WorkspaceSurfaceProducer = {
materialized: vi.fn(),
declined: vi.fn(),
failed: vi.fn(),
- unverifiable: vi.fn()
+ unverifiable: vi.fn(),
+ blocked: vi.fn(),
+ unexpected: vi.fn(),
+ intentionalEmpty: vi.fn()
}
vi.mock('@/store', () => ({
@@ -202,6 +207,7 @@ beforeEach(() => {
store.activeView = 'terminal'
store.repos = [{ id: 'repo-1', connectionId: null }]
store.tabsByWorktree = {}
+ store.unifiedTabsByWorktree = {}
store.pendingWorktreeCreations = {}
store.activePendingCreationId = null
store.createWorktree.mockResolvedValue({
@@ -273,7 +279,7 @@ describe('a throw after createWorktree succeeds no longer strands the creation s
})
})
- it('activating branch: routes draft and follow-up delivery to the stamped agent tab', async () => {
+ it('activating branch: a selection-stamped legacy row cannot suppress recovery', async () => {
const request = makeRequest({
agent: 'codex',
startupPlan: {
@@ -296,8 +302,10 @@ describe('a throw after createWorktree succeeds no longer strands the creation s
await executeWorktreeCreation('creation-1', request)
expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
+ expect(surfaceProducer.failed).toHaveBeenCalledWith(expect.any(Error))
+ expect(recoverWorkspaceActivation).toHaveBeenCalledOnce()
expect(ensureAgentStartupInTerminal).toHaveBeenCalledWith(
- expect.objectContaining({ primaryTabId: 'agent-tab' })
+ expect.objectContaining({ primaryTabId: null })
)
})
diff --git a/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts b/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts
index d42c8e5ad4f..5656b107398 100644
--- a/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts
+++ b/src/renderer/src/lib/worktree-live-terminal-surface-owners.ts
@@ -6,6 +6,11 @@ import type {
} from '../../../shared/runtime-types'
import { worktreeIdsEqual } from '../../../shared/worktree/id'
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
+import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
+import {
+ runtimeTargetForActivationRoute,
+ type WorktreeAgentActivationRoute
+} from './worktree-agent-activation-route'
/** The exact surface the execution host records as owning a live PTY. */
export type LiveTerminalSurfaceOwner = {
@@ -94,28 +99,59 @@ export function indexLiveTerminalSurfaceOwners(
* complete one for the workspace.
*/
export async function readWorktreeLiveTerminalSurfaceOwners(
- worktreeId: string
+ routeOrWorktreeId: WorktreeAgentActivationRoute | string,
+ options: { timeoutMs?: number; signal?: AbortSignal } = {}
): Promise {
if (typeof window === 'undefined') {
return null
}
- const response = await window.api.runtime.call({
- method: 'terminal.list',
- params: {
- worktree: toRuntimeWorktreeSelector(worktreeId),
- limit: OWNER_LISTING_LIMIT,
- includeVisualLayouts: false
- }
- })
- if (!response.ok || !isScopedTerminalListResult(response.result)) {
+ const route = typeof routeOrWorktreeId === 'string' ? null : routeOrWorktreeId
+ const worktreeId =
+ typeof routeOrWorktreeId === 'string' ? routeOrWorktreeId : routeOrWorktreeId.workspaceKey
+ const target = route ? runtimeTargetForActivationRoute(route) : { kind: 'local' as const }
+ if (!target) {
return null
}
- const { hostScope, terminals, truncated } = response.result
+ let result: RuntimeTerminalListResult
+ try {
+ result = await callRuntimeRpc(
+ target,
+ 'terminal.list',
+ {
+ worktree: toRuntimeWorktreeSelector(worktreeId),
+ limit: OWNER_LISTING_LIMIT,
+ includeVisualLayouts: false,
+ requireFreshPtyLiveness: true
+ },
+ {
+ ...options,
+ ...(route?.runtimeEnvironmentRevision === null || route === null
+ ? {}
+ : { expectedEnvironmentPairingRevision: route.runtimeEnvironmentRevision })
+ }
+ )
+ } catch {
+ return null
+ }
+ if (!isScopedTerminalListResult(result)) {
+ return null
+ }
+ const { hostScope, terminals, truncated } = result
// A worktree-scoped listing names every host but the target's as omitted by
// design, so completeness here is "the workspace's own host answered" —
// `hostIds` holds exactly that host when it did. A truncated list never proves
// any PTY unowned.
- return truncated === true || hostScope.hostIds.length === 0
+ return truncated === true ||
+ (route ? !hostScope.hostIds.includes(route.executionHostId) : hostScope.hostIds.length === 0)
? null
- : indexLiveTerminalSurfaceOwners(terminals, worktreeId)
+ : indexLiveTerminalSurfaceOwners(
+ route
+ ? terminals.filter(
+ (terminal) =>
+ terminal.executionHostId === undefined ||
+ terminal.executionHostId === route.executionHostId
+ )
+ : terminals,
+ worktreeId
+ )
}
diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts
index 75be4ea395e..b734f015e62 100644
--- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts
+++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts
@@ -1,5 +1,5 @@
import path from 'node:path'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import { activateAndRevealWorktree } from './worktree-activation'
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
@@ -11,6 +11,7 @@ import {
markHostSessionMirrorHydrated,
resetHostSessionMirrorHydrationForTests
} from '@/runtime/host-session-mirror-hydration'
+import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
// Pins the activation contract behind the run6-review-pr-11959 incident shape:
// a persisted (husk) tab whose pane cannot resume in place gets ONE appended
@@ -24,6 +25,10 @@ const HUSK_TAB_ID = 'husk-tab-1'
const RUNTIME_ENV_ID = 'env-4f0a8c21'
const RUNTIME_HOST_ID = `runtime:${encodeURIComponent(RUNTIME_ENV_ID)}` as ExecutionHostId
+beforeEach(() => {
+ replaceRuntimeEnvironmentRevisions([{ id: RUNTIME_ENV_ID, createdAt: 1, pairingRevision: 1 }])
+})
+
function baseState(worktree: ReturnType): Partial {
return {
repos: [
@@ -132,6 +137,7 @@ function seedSleepingRecord(worktreeId: string, sessionId: string): void {
afterEach(() => {
useAppStore.setState(initialAppStoreState, true)
resetHostSessionMirrorHydrationForTests()
+ replaceRuntimeEnvironmentRevisions([])
})
describe('preserved-pane replacement contract on workspace activation', () => {
diff --git a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts
index 03253f7220f..58e7f527778 100644
--- a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts
+++ b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts
@@ -1,10 +1,11 @@
import path from 'node:path'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore, type AppState } from '@/store'
import { activateAndRevealWorktree } from './worktree-activation'
import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
+import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
// Red repro for the aug20 "windows 2" incident (restart-reattach/resume-relaunch):
// a runtime-owned (paired remote) worktree's web-mirror tab holds a sleeping
@@ -22,6 +23,10 @@ const LEAF_ID = '22222222-2222-4222-8222-222222222222'
const WEB_TAB_ID = 'web-terminal-host-tab-1'
const RUNTIME_ENV_ID = 'env-abfee683'
+beforeEach(() => {
+ replaceRuntimeEnvironmentRevisions([{ id: RUNTIME_ENV_ID, createdAt: 1, pairingRevision: 1 }])
+})
+
function makeRuntimeOwnedWorktree(): ReturnType {
const workspacePath = path.join(path.sep, 'workspace', 'feature')
return {
@@ -128,6 +133,7 @@ function seedSleepingRecord(worktreeId: string, sessionId: string): string {
afterEach(() => {
useAppStore.setState(initialAppStoreState, true)
+ replaceRuntimeEnvironmentRevisions([])
})
describe('runtime-owned worktree activation with an unhydrated host mirror', () => {
diff --git a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts
index 9b8ce4918bf..327beb74b11 100644
--- a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts
+++ b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts
@@ -25,6 +25,33 @@ function emptySessionTabsSnapshot(worktreeId: string): RuntimeMobileSessionTabsR
}
}
+function emptyTerminalList() {
+ return {
+ terminals: [],
+ truncated: false,
+ hostScope: { hostIds: ['local'], omittedHostIds: [] }
+ }
+}
+
+type TestRuntimeResponse = { ok: boolean; result: unknown }
+
+function subscribableRuntime(call: (request: { method: string }) => Promise) {
+ return {
+ call,
+ subscribe: vi.fn(
+ async (request: { method: string }, onData: (response: TestRuntimeResponse) => void) => {
+ let cancelled = false
+ void call(request).then((response) => {
+ if (!cancelled) {
+ onData(response)
+ }
+ })
+ return { unsubscribe: () => (cancelled = true) }
+ }
+ )
+ }
+}
+
function baseState(worktree: ReturnType): Partial {
return {
repos: [
@@ -111,8 +138,8 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
})
vi.stubGlobal('window', {
api: {
- runtime: {
- call: vi.fn(async ({ method }: { method: string }) =>
+ runtime: subscribableRuntime(
+ vi.fn(async ({ method }: { method: string }) =>
method === 'terminal.list'
? {
ok: true,
@@ -140,7 +167,7 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
}
: { ok: true, result: emptySessionTabsSnapshot(worktree.id) }
)
- },
+ ),
pty: {
listSessions: vi.fn(async () => [
{
@@ -178,9 +205,15 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => {
useAppStore.setState(baseState(worktree))
vi.stubGlobal('window', {
api: {
- runtime: {
- call: vi.fn(async () => ({ ok: true, result: emptySessionTabsSnapshot(worktree.id) }))
- },
+ runtime: subscribableRuntime(
+ vi.fn(async ({ method }: { method: string }) => ({
+ ok: true,
+ result:
+ method === 'terminal.list'
+ ? emptyTerminalList()
+ : emptySessionTabsSnapshot(worktree.id)
+ }))
+ ),
pty: { listSessions: vi.fn(async () => []) }
}
})
diff --git a/src/renderer/src/runtime/abortable-runtime-environment-call.test.ts b/src/renderer/src/runtime/abortable-runtime-environment-call.test.ts
new file mode 100644
index 00000000000..71d1ee3f1ae
--- /dev/null
+++ b/src/renderer/src/runtime/abortable-runtime-environment-call.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { callAbortableLocalRuntime } from './abortable-runtime-environment-call'
+
+afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+})
+
+function stubLocalSubscription() {
+ const unsubscribe = vi.fn()
+ let respond!: (response: { ok: true; result: unknown }) => void
+ const subscribe = vi.fn(
+ async (_request: unknown, callback: (response: { ok: true; result: unknown }) => void) => {
+ respond = callback
+ return { unsubscribe }
+ }
+ )
+ vi.stubGlobal('window', { api: { runtime: { subscribe } } })
+ return {
+ respond: (response: { ok: true; result: unknown }) => respond(response),
+ subscribe,
+ unsubscribe
+ }
+}
+
+describe('abortable local runtime call', () => {
+ it('unsubscribes the host request after a response', async () => {
+ const runtime = stubLocalSubscription()
+ const request = callAbortableLocalRuntime(
+ 'terminal.list',
+ { worktree: 'id:worktree-1' },
+ 100,
+ new AbortController().signal
+ )
+ await Promise.resolve()
+
+ runtime.respond({ ok: true, result: { terminals: [] } })
+
+ await expect(request).resolves.toEqual({ ok: true, result: { terminals: [] } })
+ expect(runtime.unsubscribe).toHaveBeenCalledOnce()
+ })
+
+ it('aborts and unsubscribes a request whose inventory never responds', async () => {
+ const runtime = stubLocalSubscription()
+ const controller = new AbortController()
+ const request = callAbortableLocalRuntime('terminal.list', {}, 100, controller.signal)
+ await Promise.resolve()
+
+ controller.abort()
+
+ await expect(request).rejects.toMatchObject({ name: 'AbortError' })
+ expect(runtime.unsubscribe).toHaveBeenCalledOnce()
+ })
+
+ it('enforces a response deadline and unsubscribes the host request', async () => {
+ vi.useFakeTimers()
+ const runtime = stubLocalSubscription()
+ const request = callAbortableLocalRuntime(
+ 'terminal.list',
+ {},
+ 100,
+ new AbortController().signal
+ )
+ const settled = request.catch((error: unknown) => error)
+ await Promise.resolve()
+
+ await vi.advanceTimersByTimeAsync(100)
+
+ await expect(settled).resolves.toMatchObject({
+ message: 'Runtime request timed out before terminal.list completed'
+ })
+ expect(runtime.unsubscribe).toHaveBeenCalledOnce()
+ })
+})
diff --git a/src/renderer/src/runtime/abortable-runtime-environment-call.ts b/src/renderer/src/runtime/abortable-runtime-environment-call.ts
index 0562dd9ad71..38f641a3548 100644
--- a/src/renderer/src/runtime/abortable-runtime-environment-call.ts
+++ b/src/renderer/src/runtime/abortable-runtime-environment-call.ts
@@ -6,6 +6,79 @@ export function createRuntimeRpcAbortError(): Error {
return error
}
+export function waitForAbortableRuntimeDependency(
+ promise: Promise,
+ signal?: AbortSignal
+): Promise {
+ if (!signal) {
+ return promise
+ }
+ if (signal.aborted) {
+ return Promise.reject(createRuntimeRpcAbortError())
+ }
+ return new Promise((resolve, reject) => {
+ const onAbort = (): void => {
+ signal.removeEventListener('abort', onAbort)
+ reject(createRuntimeRpcAbortError())
+ }
+ signal.addEventListener('abort', onAbort, { once: true })
+ void promise.then(
+ (value) => {
+ signal.removeEventListener('abort', onAbort)
+ resolve(value)
+ },
+ (error: unknown) => {
+ signal.removeEventListener('abort', onAbort)
+ reject(error)
+ }
+ )
+ })
+}
+
+export async function callAbortableLocalRuntime(
+ method: string,
+ params: unknown,
+ timeoutMs: number | undefined,
+ signal: AbortSignal
+): Promise> {
+ if (signal.aborted) {
+ throw createRuntimeRpcAbortError()
+ }
+ return new Promise((resolve, reject) => {
+ let handle: { unsubscribe: () => void } | null = null
+ let settled = false
+ const deadline =
+ timeoutMs === undefined
+ ? null
+ : setTimeout(() => {
+ finish(() => reject(new Error(`Runtime request timed out before ${method} completed`)))
+ }, timeoutMs)
+ const finish = (complete: () => void): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ if (deadline !== null) {
+ clearTimeout(deadline)
+ }
+ signal.removeEventListener('abort', onAbort)
+ handle?.unsubscribe()
+ complete()
+ }
+ const onAbort = (): void => finish(() => reject(createRuntimeRpcAbortError()))
+ signal.addEventListener('abort', onAbort, { once: true })
+ void window.api.runtime
+ .subscribe({ method, params }, (response) => finish(() => resolve(response)))
+ .then((subscription) => {
+ handle = subscription
+ if (settled) {
+ subscription.unsubscribe()
+ }
+ })
+ .catch((error) => finish(() => reject(error)))
+ })
+}
+
export async function callAbortableRuntimeEnvironment(
environmentId: string,
method: string,
diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts
index eb04233cc91..9e74c81031a 100644
--- a/src/renderer/src/runtime/runtime-rpc-client.ts
+++ b/src/renderer/src/runtime/runtime-rpc-client.ts
@@ -3,19 +3,21 @@ import type { RuntimeStatus } from '../../../shared/runtime-types'
import type { RuntimeCapability } from '../../../shared/protocol-version'
import { withBrowserPaneUiRuntimeRpcSource } from '../../../shared/runtime-rpc-feature-interaction-source'
import { assertRuntimeStatusCompatible } from './runtime-protocol-compat'
-import { createRuntimeRpcAbortError } from './abortable-runtime-environment-call'
+import {
+ callAbortableLocalRuntime,
+ createRuntimeRpcAbortError,
+ waitForAbortableRuntimeDependency
+} from './abortable-runtime-environment-call'
import { callRuntimeEnvironmentWithRevision } from './runtime-rpc-environment-call'
-import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from './runtime-rpc-result'
+import { unwrapRuntimeRpcResult } from './runtime-rpc-result'
import { captureRuntimeEnvironmentRequestRevision } from './runtime-environment-revision'
import type { RuntimeClientTarget } from './runtime-client-target'
-export {
- getActiveRuntimeTarget,
- settingsForRuntimeOwner,
- type RuntimeClientTarget
-} from './runtime-client-target'
+export { getActiveRuntimeTarget, settingsForRuntimeOwner } from './runtime-client-target'
+export type { RuntimeClientTarget } from './runtime-client-target'
export {
hasRuntimeRpcErrorCode,
+ isRuntimeScopeForbiddenError,
RuntimeRpcCallError,
unwrapRuntimeRpcResult
} from './runtime-rpc-result'
@@ -36,13 +38,6 @@ type RuntimeCompatibilityCacheEntry = {
const runtimeCompatibilityChecks = new Map()
-// Why: mobile-scope device tokens are denied non-allowlisted runtime methods
-// with code 'forbidden'. Callers use this to surface one scope-mismatch banner
-// instead of silently swallowing the failure into empty/retry-looping UI.
-export function isRuntimeScopeForbiddenError(error: unknown): boolean {
- return error instanceof RuntimeRpcCallError && error.code === 'forbidden'
-}
-
export async function callRuntimeRpc(
target: RuntimeClientTarget,
method: string,
@@ -69,10 +64,13 @@ export async function callRuntimeRpc(
method !== 'status.get' &&
options.skipCompatibilityCheck !== true
) {
- await ensureRuntimeEnvironmentCompatible(target.environmentId, {
- ...options,
- expectedEnvironmentPairingRevision
- })
+ await waitForAbortableRuntimeDependency(
+ ensureRuntimeEnvironmentCompatible(target.environmentId, {
+ ...options,
+ expectedEnvironmentPairingRevision
+ }),
+ options.signal
+ )
}
if (options.signal?.aborted) {
throw createRuntimeRpcAbortError()
@@ -82,7 +80,9 @@ export async function callRuntimeRpc(
: params
const response =
target.kind === 'local'
- ? await window.api.runtime.call({ method, params: nextParams })
+ ? options.signal
+ ? await callAbortableLocalRuntime(method, nextParams, options.timeoutMs, options.signal)
+ : await window.api.runtime.call({ method, params: nextParams })
: await callRuntimeEnvironmentWithRevision({
environmentId: target.environmentId,
method,
@@ -332,6 +332,4 @@ export async function assertRuntimeEnvironmentCapability(
}
}
-export function clearRuntimeCompatibilityCacheForTests(): void {
- clearRuntimeCompatibilityCache()
-}
+export const clearRuntimeCompatibilityCacheForTests = clearRuntimeCompatibilityCache
diff --git a/src/renderer/src/runtime/runtime-rpc-result.ts b/src/renderer/src/runtime/runtime-rpc-result.ts
index a0606cf76b6..3ff094b9c53 100644
--- a/src/renderer/src/runtime/runtime-rpc-result.ts
+++ b/src/renderer/src/runtime/runtime-rpc-result.ts
@@ -15,6 +15,12 @@ export class RuntimeRpcCallError extends Error {
}
}
+// Why: mobile-scope device tokens are denied non-allowlisted runtime methods
+// with code 'forbidden'. Callers use this to surface one scope-mismatch banner.
+export function isRuntimeScopeForbiddenError(error: unknown): boolean {
+ return error instanceof RuntimeRpcCallError && error.code === 'forbidden'
+}
+
export function unwrapRuntimeRpcResult(response: RuntimeRpcResponse): TResult {
if (response.ok === false) {
throw new RuntimeRpcCallError(response)
diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts
index b9eefe424a8..18fc4957f11 100644
--- a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts
+++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts
@@ -28,6 +28,7 @@ import {
getFolderWorkspaceUpdateIdentity,
reconcileFailedFolderWorkspaceUpdate
} from './folder-workspace-catalog'
+import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
export type FolderWorkspaceUpdateField = keyof FolderWorkspaceUpdates
@@ -248,6 +249,10 @@ export function createFolderWorkspaceMutationActions(
return false
}
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
+ clearWorkspaceActivationRecoveryLifecycle({
+ workspaceKey,
+ executionHostId: ownerHostId
+ })
set((s) => ({
folderWorkspaces: s.folderWorkspaces.filter(
(workspace) =>
diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts
index c0f64fed827..fadf7346e20 100644
--- a/src/renderer/src/store/project-groups/project-group-mutations.ts
+++ b/src/renderer/src/store/project-groups/project-group-mutations.ts
@@ -18,6 +18,9 @@ import { mergeProjectCompatibilityForHostRepoChange } from '../repos/repo-catalo
import { applyProjectGroupDeleteCascade } from './project-group-removal-state'
import { repoWithFetchedOwner, settingsForRepoOwner } from '../repos/owner-routing'
import { projectGroupWithFetchedOwner } from './project-group-owner-stamping'
+import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
+import { getFolderWorkspaceHostId } from '../folder-workspaces/folder-workspace-catalog'
+import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
export function createProjectGroupMutationActions(
set: Parameters>[0],
@@ -128,7 +131,18 @@ export function createProjectGroupMutationActions(
if (!deleted) {
return false
}
- set((s) => applyProjectGroupDeleteCascade(s, groupId, ownerHostId))
+ const current = get()
+ const next = applyProjectGroupDeleteCascade(current, groupId, ownerHostId)
+ set(next)
+ for (const workspace of current.folderWorkspaces) {
+ if (next.folderWorkspaces.includes(workspace)) {
+ continue
+ }
+ clearWorkspaceActivationRecoveryLifecycle({
+ workspaceKey: folderWorkspaceKey(workspace.id),
+ executionHostId: getFolderWorkspaceHostId(workspace, current.projectGroups)
+ })
+ }
return true
} catch (err) {
console.error('Failed to delete project group:', err)
diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts
index 7a7acec0467..4aafcd3c74e 100644
--- a/src/renderer/src/store/slices/ssh.ts
+++ b/src/renderer/src/store/slices/ssh.ts
@@ -13,6 +13,8 @@ import {
sshTargetGenerationsEqual,
sshTargetLabelsEqual
} from './ssh-target-cleanup'
+import { toSshExecutionHostId } from '../../../../shared/execution-host'
+import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
export type RemoteWorkspaceSyncStatus = {
phase: 'idle' | 'pulling' | 'pushing' | 'synced' | 'conflict' | 'error' | 'offline'
@@ -154,8 +156,12 @@ export const createSshSlice: StateCreator = (set) =>
sshTargetsHydrated: true
}
}),
- clearRemovedSshTargetState: (targetId) =>
- set((s) => buildRemovedSshTargetCleanupPatch(s, targetId) ?? s),
+ clearRemovedSshTargetState: (targetId) => {
+ clearWorkspaceActivationRecoveryLifecycle({
+ executionHostId: toSshExecutionHostId(targetId)
+ })
+ set((s) => buildRemovedSshTargetCleanupPatch(s, targetId) ?? s)
+ },
markRemoteWorkspaceHydrated: (targetId) =>
set((s) => {
const next = new Set(s.remoteWorkspaceHydratedTargetIds)
diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
index 78e0e397bca..6b59a0435ba 100644
--- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
+++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts
@@ -14,6 +14,7 @@ import type {
TerminalRecoveryRemountRequest,
TerminalRecoveryRemountResult
} from '../../../terminals/terminal-tab-recovery-ledger'
+import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
export function createSetRenamingWorktreeId(
set: WorktreeSliceSet,
@@ -150,6 +151,12 @@ export function createPurgeWorktreeTerminalState(
if (purgeableWorktreeTargets.length === 0) {
return
}
+ for (const target of purgeableWorktreeTargets) {
+ clearWorkspaceActivationRecoveryLifecycle({
+ workspaceKey: typeof target === 'string' ? target : target.id,
+ ...(typeof target === 'string' || !target.hostId ? {} : { executionHostId: target.hostId })
+ })
+ }
set((s) => buildWorktreePurgeState(s, purgeableWorktreeTargets))
}
}
diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts
index 8894499b714..5e73369ed19 100644
--- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts
+++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts
@@ -16,6 +16,7 @@ import {
} from '../../stale-runtime-host-rows'
import { buildWorktreePurgeState } from './worktree-purge-state'
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
+import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
export function createPurgeStaleRuntimeHostState(
set: WorktreeSliceSet,
@@ -26,6 +27,11 @@ export function createPurgeStaleRuntimeHostState(
if (removed.size === 0) {
return
}
+ for (const environmentId of removed) {
+ clearWorkspaceActivationRecoveryLifecycle({
+ executionHostId: toRuntimeExecutionHostId(environmentId)
+ })
+ }
set((s) => {
const repoIdsWithRemovedOwners = new Set()
const survivingRepoIds = new Set()
diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts
index 415697b883e..d0f95ff16b6 100644
--- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts
+++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts
@@ -5,6 +5,7 @@ import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
import { removeWorktreeVisitEntries } from '@/lib/worktree-visit-recency'
import { forgetAmbiguousOwnerWarnings } from '../listing/worktree-owner-settings'
import { omitRecordKeys } from './record-key-omission'
+import { clearWorkspaceActivationRecoveryLifecycle } from '@/lib/workspace-activation-recovery-lifecycle'
export function applyRemoveWorktreeSuccessState(
set: WorktreeSliceSet,
@@ -15,6 +16,10 @@ export function applyRemoveWorktreeSuccessState(
// Why outside `set`: it is module-scope, not store state. Dropping it also
// re-arms the once-per-workspace warning if this id is ever added back.
forgetAmbiguousOwnerWarnings([worktreeId])
+ clearWorkspaceActivationRecoveryLifecycle({
+ workspaceKey: worktreeId,
+ ...(executionHostId ? { executionHostId } : {})
+ })
set((s) => {
const worktreeIds = [worktreeId]
const omitByWorktree = (m: Record | undefined) => omitRecordKeys(m, worktreeIds)
diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts
index 82511787f57..a8023032c10 100644
--- a/tests/e2e/completed-worker-retirement-resume.unit.test.ts
+++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts
@@ -18,6 +18,7 @@ import { useAppStore, type AppState } from '@/store'
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
+import { resetWorkspaceSurfaceProducersForTests } from '@/lib/workspace-surface-production'
const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc'
const ORIGINAL_TAB_ID = '1c897bc8-973b-47b4-9449-ac5fc6b726c3'
@@ -86,6 +87,7 @@ function makeLayout(leafId: string, ptyId: string) {
}
function seedWorkspace(options: { helper?: boolean } = {}): void {
+ resetWorkspaceSurfaceProducersForTests()
useAppStore.setState(initialAppStoreState, true)
const target = makeWorktree(WORKTREE_ID, WORKTREE_PATH)
const canary = makeWorktree(CANARY_WORKTREE_ID, CANARY_WORKTREE_PATH)