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)