mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(renderer): defer agent activation until restore
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user