fix(renderer): defer agent activation until restore

This commit is contained in:
Brennan Benson
2026-08-13 17:14:17 -07:00
parent 839acf70e0
commit a4b2a83031
4 changed files with 134 additions and 0 deletions
@@ -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<boolean>((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)] })
@@ -12,6 +12,7 @@ type ActivationStore = Pick<
type ActivationGateDeps = {
getState: () => ActivationStore
awaitReady?: () => Promise<boolean>
listSessions: () => Promise<PtyListedSession[]>
hasStructuredSession?: (worktreeId: string) => Promise<boolean>
resume: (worktreeId: string) => number
@@ -20,6 +21,33 @@ type ActivationGateDeps = {
export type WorktreeAgentActivationOutcome = 'adopted' | 'structured' | 'resumed' | 'blocked'
const inFlightByWorktreeId = new Map<string, Promise<WorktreeAgentActivationOutcome>>()
const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000
function waitForWorkspaceSessionReady(): Promise<boolean> {
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<ReturnType<typeof useAppStore.getState>, 'sleepingAgentSessionsByPaneKey'>,
@@ -56,6 +84,13 @@ export async function runWorktreeAgentActivationGate(
worktreeId: string,
deps: ActivationGateDeps
): Promise<WorktreeAgentActivationOutcome> {
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) => {
@@ -30,6 +30,7 @@ function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState>
worktreesByRepo: { 'repo-1': [worktree] },
activeRepoId: 'repo-1',
activeView: 'terminal',
workspaceSessionReady: true,
tabsByWorktree: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
@@ -21,6 +21,7 @@ function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState>
worktreesByRepo: { 'repo-1': [worktree] },
activeRepoId: 'repo-1',
activeView: 'terminal',
workspaceSessionReady: true,
tabsByWorktree: {},
unifiedTabsByWorktree: {},
groupsByWorktree: {},
@@ -49,10 +50,60 @@ function baseState(worktree: ReturnType<typeof makeWorktree>): Partial<AppState>
}
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))