From a4b2a83031f4f7dc558bebaf77f2f7c6f6fc3596 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:14:17 -0700 Subject: [PATCH] fix(renderer): defer agent activation until restore --- .../worktree-agent-activation-gate.test.ts | 46 +++++++++++++++++ .../src/lib/worktree-agent-activation-gate.ts | 36 +++++++++++++ ...ivation-preserved-pane-replacement.test.ts | 1 + ...worktree-reactivation-tab-forkbomb.test.ts | 51 +++++++++++++++++++ 4 files changed, 134 insertions(+) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 7853647e4f9..8b274a9eab0 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -45,6 +45,52 @@ function testDeps(args: { sessions?: PtyListedSession[]; structured?: boolean }) } describe('worktree agent activation gate', () => { + it('uses immediately ready development restore inventory', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const awaitReady = vi.fn(async () => true) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady }) + ).resolves.toBe('adopted') + + expect(awaitReady).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledOnce() + expect(resume).not.toHaveBeenCalled() + }) + + it('waits for packaged restore hydration before reading daemon inventory', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + let releaseReady!: (ready: boolean) => void + const awaitReady = vi.fn(() => new Promise((resolve) => (releaseReady = resolve))) + + const activation = runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady }) + await vi.waitFor(() => expect(awaitReady).toHaveBeenCalledOnce()) + expect(deps.listSessions).not.toHaveBeenCalled() + + releaseReady(true) + await expect(activation).resolves.toBe('adopted') + expect(deps.listSessions).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledOnce() + expect(resume).not.toHaveBeenCalled() + }) + + it('blocks automatic resume when packaged restore never becomes ready', async () => { + const { deps, createTab, resume } = testDeps({}) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + awaitReady: async () => false + }) + ).resolves.toBe('blocked') + + expect(deps.listSessions).not.toHaveBeenCalled() + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + it('adopts a live daemon PTY before activation can resume another agent', async () => { const ptyId = `${WORKTREE_ID}@@live-pty` const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index 9873eb4894b..bcb93aff331 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -12,6 +12,7 @@ type ActivationStore = Pick< type ActivationGateDeps = { getState: () => ActivationStore + awaitReady?: () => Promise listSessions: () => Promise hasStructuredSession?: (worktreeId: string) => Promise resume: (worktreeId: string) => number @@ -20,6 +21,33 @@ type ActivationGateDeps = { export type WorktreeAgentActivationOutcome = 'adopted' | 'structured' | 'resumed' | 'blocked' const inFlightByWorktreeId = new Map>() +const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000 + +function waitForWorkspaceSessionReady(): Promise { + if (useAppStore.getState().workspaceSessionReady) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + let unsubscribe: (() => void) | null = null + const settle = (ready: boolean) => { + clearTimeout(timeout) + unsubscribe?.() + resolve(ready) + } + const timeout = setTimeout( + () => settle(useAppStore.getState().workspaceSessionReady), + WORKSPACE_SESSION_READY_TIMEOUT_MS + ) + unsubscribe = useAppStore.subscribe((state) => { + if (state.workspaceSessionReady) { + settle(true) + } + }) + if (useAppStore.getState().workspaceSessionReady) { + settle(true) + } + }) +} export function workspaceHasSleepingAgentSessions( state: Pick, 'sleepingAgentSessionsByPaneKey'>, @@ -56,6 +84,13 @@ export async function runWorktreeAgentActivationGate( worktreeId: string, deps: ActivationGateDeps ): Promise { + try { + if (deps.awaitReady && !(await deps.awaitReady())) { + return 'blocked' + } + } catch { + return 'blocked' + } let sessions: PtyListedSession[] try { sessions = await deps.listSessions() @@ -105,6 +140,7 @@ export function gateWorktreeAgentActivation( } const gate = runWorktreeAgentActivationGate(worktreeId, { getState: () => useAppStore.getState(), + awaitReady: waitForWorkspaceSessionReady, listSessions: () => typeof window === 'undefined' ? Promise.resolve([]) : window.api.pty.listSessions(), hasStructuredSession: async (candidate) => { diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index 479244ddc81..027661ec7e6 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -30,6 +30,7 @@ function baseState(worktree: ReturnType): Partial worktreesByRepo: { 'repo-1': [worktree] }, activeRepoId: 'repo-1', activeView: 'terminal', + workspaceSessionReady: true, tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, diff --git a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts index 2f76ca24307..667a7dff132 100644 --- a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts @@ -21,6 +21,7 @@ function baseState(worktree: ReturnType): Partial worktreesByRepo: { 'repo-1': [worktree] }, activeRepoId: 'repo-1', activeView: 'terminal', + workspaceSessionReady: true, tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, @@ -49,10 +50,60 @@ function baseState(worktree: ReturnType): Partial } afterEach(() => { + vi.unstubAllGlobals() useAppStore.setState(initialAppStoreState, true) }) describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { + it('defers packaged-startup resume until restored PTYs finish reconnecting', async () => { + const worktree = { ...makeWorktree(), createdWithAgent: undefined } + useAppStore.setState({ ...baseState(worktree), workspaceSessionReady: false }) + const paneKey = 'packaged-restart-pane:0' + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'packaged-restart-pane', + worktreeId: worktree.id, + agent: 'codex', + providerSession: { key: 'session_id', id: 'packaged-session' }, + prompt: 'resume prior task', + state: 'working', + origin: 'quit', + capturedAt: 1000, + updatedAt: 1000, + terminalTitle: 'Codex' + } + } + }) + vi.stubGlobal('window', { + api: { + pty: { + listSessions: vi.fn(async () => [ + { + id: `${worktree.id}@@daemon-live`, + cwd: worktree.path, + title: 'Codex', + agentOwnership: 'present' as const + } + ]) + } + } + }) + + activateAndRevealWorktree(worktree.id) + const gate = waitForWorktreeAgentActivationGateForTests(worktree.id) + await Promise.resolve() + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + + useAppStore.setState({ workspaceSessionReady: true }) + await gate + const restored = useAppStore.getState() + expect(restored.tabsByWorktree[worktree.id]).toHaveLength(1) + expect(restored.automaticAgentResumeClaimsByTabId).toEqual({}) + expect(restored.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + }) + it('re-captured sleeping codex session resumes once, not once per reopen', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } useAppStore.setState(baseState(worktree))