fix activation recovery ownership boundary

This commit is contained in:
Brennan Benson
2026-09-14 13:58:19 -07:00
parent 6f3465bc95
commit 5efc24fcfc
53 changed files with 2180 additions and 785 deletions
+4 -1
View File
@@ -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')
@@ -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(<Watcher activeWorktreeId="worktree-1" />))
@@ -95,7 +112,12 @@ describe('passive activation recovery', () => {
await act(async () => root?.render(<Watcher activeWorktreeId="worktree-2" />))
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 () => {
@@ -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<string | null>(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()')
})
})
@@ -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<string> = new Set()
@@ -81,7 +83,7 @@ export function useTerminalWatcherEffects(controller: TerminalWatcherController)
workspaceSessionReady,
workspaceSurfaceIds
} = controller
const startupRecoverySettledRef = useRef(false)
const startupRecoveryTargetRef = useRef<string | null>(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,
@@ -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', () => {
@@ -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<typeof useAppStore.getState>,
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
}
@@ -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 {
@@ -0,0 +1,43 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
type ActivationAttemptIdentity = {
workspaceKey: string
executionHostId: ExecutionHostId
attemptId: string
}
const latestAttemptIdByTarget = new Map<string, string>()
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)
}
}
}
@@ -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' }
@@ -0,0 +1,20 @@
const surfaceIdsByProducerAttempt = new Map<string, ReadonlySet<string>>()
export function readActivationRecoveryFailureSurfaceIds(
attemptId: string
): ReadonlySet<string> | undefined {
return surfaceIdsByProducerAttempt.get(attemptId)
}
export function recordActivationRecoveryFailureSurfaceIds(
attemptId: string,
surfaceIds: ReadonlySet<string>
): void {
surfaceIdsByProducerAttempt.set(attemptId, surfaceIds)
}
export function clearActivationRecoveryFailureSnapshots(attemptIds: readonly string[]): void {
for (const attemptId of attemptIds) {
surfaceIdsByProducerAttempt.delete(attemptId)
}
}
@@ -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<WorkspaceActivationRecoveryResult | 'empty' | 'produced'> {
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<WorkspaceActivationRecoveryResult> {
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
}
}
}
}
@@ -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()
})
})
@@ -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)
}
@@ -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({})
}
@@ -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 })
}
}
}
@@ -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<string, ReadonlySet<string>>()
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<typeof readWorkspaceSurfaceProducerEntries>
): 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<WorkspaceActivationRecoveryResult | null> {
while (true) {
if (!isActivationRecoveryFresh(identity, capturedSelectionRevision, context)) {
return { kind: 'stale' }
}
const assessment = assessActivationProducerAttempts(identity, context)
if (assessment.kind === 'complete') {
return assessment.result
@@ -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<string, string>()
const gateIdentityByPromise = new WeakMap<Promise<unknown>, 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<unknown>): string | undefined {
return gateIdentityByPromise.get(gate)
}
export function recordActivationRecoveryGateRoute(gate: Promise<unknown>, 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<T>(
promise: Promise<T>,
deadlineAt: number,
signal: AbortSignal | undefined
): Promise<
| { kind: 'settled'; value: T }
| { kind: 'rejected'; error: unknown }
| Exclude<RecoveryWaitResult, 'changed'>
> {
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<RecoveryWaitResult, 'changed'>
): 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'
}
@@ -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
}
}
@@ -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()
})
})
@@ -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<typeof readActivationRenderableSurface>
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
}
@@ -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]
@@ -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<WorkspaceSurfaceProductionResult>
}
@@ -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({})
}
@@ -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)
})
})
@@ -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<PtyListedSession[]> {
const target = runtimeTargetForActivationRoute(route)
if (!target) {
throw new Error('activation PTY inventory route unavailable')
}
const result = await callRuntimeRpc<RuntimeTerminalListResult>(
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)
}
]
: []
)
}
@@ -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
})
})
})
@@ -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)
}
@@ -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<unknown> | 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()
})
})
+19 -76
View File
@@ -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) {
@@ -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<typeof useAppStore.getState>,
| 'ptyIdsByTabId'
| 'sleepingAgentSessionsByPaneKey'
| 'terminalLayoutsByTabId'
| 'unifiedTabsByWorktree'
>
export function workspaceHasSleepingAgentSessions(
state: Pick<ActivationClaimsStore, 'sleepingAgentSessionsByPaneKey'>,
worktreeId: string
): boolean {
return Object.values(state.sleepingAgentSessionsByPaneKey).some(
(record) => record.worktreeId === worktreeId
)
}
export function workspaceHasStructuredAgentSession(
store: Pick<ActivationClaimsStore, 'unifiedTabsByWorktree'>,
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<string>,
structuredInventory: StructuredActivationInventory | null
): Set<string> {
const keys = new Set<string>()
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
}
@@ -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()
})
})
@@ -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<boolean>
listSessions: () => Promise<PtyListedSession[]>
awaitReady?: (operation?: ActivationGateOperation) => Promise<boolean>
listSessions: (operation?: ActivationGateOperation) => Promise<PtyListedSession[]>
/** Host-recorded PTY→surface ownership; null when the host could not answer. */
listSurfaceOwners: (worktreeId: string) => Promise<LiveTerminalSurfaceOwnerIndex | null>
hasStructuredSession?: (worktreeId: string) => Promise<boolean | StructuredActivationInventory>
listSurfaceOwners: (
worktreeId: string,
operation?: ActivationGateOperation
) => Promise<LiveTerminalSurfaceOwnerIndex | null>
hasStructuredSession?: (
worktreeId: string,
operation?: ActivationGateOperation
) => Promise<boolean | StructuredActivationInventory>
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<string, Promise<WorktreeAgentActivationOutcome>>()
const inFlightByRoute = new Map<string, Promise<WorktreeAgentActivationOutcome>>()
const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000
function waitForWorkspaceSessionReady(): Promise<boolean> {
function waitForWorkspaceSessionReady(operation?: ActivationGateOperation): Promise<boolean> {
const isReady = () => {
const state = useAppStore.getState()
return state.workspaceSessionReady && state.terminalStartupRestorationReady
@@ -62,106 +79,67 @@ function waitForWorkspaceSessionReady(): Promise<boolean> {
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<ReturnType<typeof useAppStore.getState>, '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<string>,
structuredInventory: StructuredActivationInventory | null
): Set<string> {
const keys = new Set<string>()
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<WorktreeAgentActivationOutcome> {
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<WorktreeAgentActivationOutcome> {
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<WorktreeAgentActivationOutcome | null> {
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)
}
@@ -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)
})
})
@@ -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
}
@@ -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' }
@@ -80,8 +80,12 @@ function adoptHostOwnedSurface(
getState: () => LiveSurfaceAdoptionStore,
worktreeId: string,
owner: LiveTerminalSurfaceOwner,
materializedTabIds: Set<string>
materializedTabIds: Set<string>,
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<LiveTerminalSurfaceOwnerIndex | null>
): Promise<{ surfaced: boolean; declinedPtyIds: string[] }> {
listSurfaceOwners: (worktreeId: string) => Promise<LiveTerminalSurfaceOwnerIndex | null>,
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<string>()
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 }
}
@@ -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<false | StructuredActivationInventory> {
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<RuntimeMobileSessionTabsResult>(
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
}
})
}
})
]
: []
)
@@ -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.
@@ -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<string, { id: string }[]> = {}
const store = {
settings: {
@@ -54,7 +55,8 @@ const store = {
updateWorktreeMeta: vi.fn(),
createWorktree: vi.fn(),
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>,
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 })
)
})
@@ -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<LiveTerminalSurfaceOwnerIndex | null> {
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<RuntimeTerminalListResult>(
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
)
}
@@ -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<typeof makeWorktree>): Partial<AppState> {
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', () => {
@@ -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<typeof makeCreatedAgentWorktree> {
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', () => {
@@ -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<TestRuntimeResponse>) {
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<typeof makeWorktree>): Partial<AppState> {
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 () => []) }
}
})
@@ -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()
})
})
@@ -6,6 +6,79 @@ export function createRuntimeRpcAbortError(): Error {
return error
}
export function waitForAbortableRuntimeDependency<T>(
promise: Promise<T>,
signal?: AbortSignal
): Promise<T> {
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<RuntimeRpcResponse<unknown>> {
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,
+20 -22
View File
@@ -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<string, RuntimeCompatibilityCacheEntry>()
// 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<TResult>(
target: RuntimeClientTarget,
method: string,
@@ -69,10 +64,13 @@ export async function callRuntimeRpc<TResult>(
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<TResult>(
: 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
@@ -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<TResult>(response: RuntimeRpcResponse<TResult>): TResult {
if (response.ok === false) {
throw new RuntimeRpcCallError(response)
@@ -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) =>
@@ -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<StateCreator<AppState>>[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)
+8 -2
View File
@@ -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<AppState, [], [], SshSlice> = (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)
@@ -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))
}
}
@@ -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<string>()
const survivingRepoIds = new Set<string>()
@@ -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 = <T>(m: Record<string, T> | undefined) => omitRecordKeys(m, worktreeIds)
@@ -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)