diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 17d1522c97b1..80bd1d4744ab 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -194,6 +194,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/opencode/opencode-acp-stored-messages.test.ts', 'src/main/orcad/orcad-automations.test.ts', 'src/main/orcad/orcad-local-serve-selection.test.ts', + 'src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts', 'src/main/orcad/serve-profile-ssh-targets.test.ts', 'src/main/pi/rpc-launch-resolution.test.ts', 'src/main/runtime/agent-session-conversation-clear-commit.test.ts', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 681dd3088a82..8f9a97671a07 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -175,6 +175,8 @@ export class StructuredAgentSessionHost { private now = (): number => this.deps.now?.() ?? Date.now() hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) + hasLoadedProviders = (): boolean => + [...this.sessions.values()].some(({ child }) => child !== null) sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null handleAdapterEvent = (event: Parameters[0]) => diff --git a/src/main/orcad/orcad-idle-exit-monitor.test.ts b/src/main/orcad/orcad-idle-exit-monitor.test.ts index fbe31074a526..87ea58eefcf0 100644 --- a/src/main/orcad/orcad-idle-exit-monitor.test.ts +++ b/src/main/orcad/orcad-idle-exit-monitor.test.ts @@ -100,6 +100,22 @@ describe('OrcadIdleExitMonitor', () => { expect(h.onIdle).not.toHaveBeenCalled() }) + it('restarts the quiet period when the final idle stop declines after an asynchronous check', async () => { + const h = harness() + await h.monitor.check() + const decision = Promise.withResolvers() + h.onIdle.mockImplementationOnce(() => decision.promise) + h.advance(100) + const checking = h.monitor.check() + await vi.waitFor(() => expect(h.onIdle).toHaveBeenCalledOnce()) + decision.resolve(false) + expect(await checking).toBe(false) + h.advance(100) + expect(await h.monitor.check()).toBe(false) + h.advance(100) + expect(await h.monitor.check()).toBe(true) + }) + it('polls often enough for a short test timeout and at most once a minute', () => { expect(resolveOrcadIdlePollMs(15 * 60_000)).toBe(60_000) expect(resolveOrcadIdlePollMs(2_000)).toBe(400) diff --git a/src/main/orcad/orcad-idle-exit-monitor.ts b/src/main/orcad/orcad-idle-exit-monitor.ts index 58a053adffc8..4ddc0bf7329d 100644 --- a/src/main/orcad/orcad-idle-exit-monitor.ts +++ b/src/main/orcad/orcad-idle-exit-monitor.ts @@ -19,7 +19,7 @@ export type OrcadIdleExitMonitorOptions = { probes: readonly OrcadIdleProbe[] /** Any client request restarts the quiet period, even one that came and went between checks. */ lastClientActivityAt: () => number - onIdle: (evidence: OrcadIdleExitEvidence) => void + onIdle: (evidence: OrcadIdleExitEvidence) => void | boolean | Promise now?: () => number pollMs?: number log?: (line: string) => void @@ -80,8 +80,17 @@ export class OrcadIdleExitMonitor { if (now - quietSince < this.options.timeoutMs) { return false } + if ( + (await this.options.onIdle({ + quietSince, + stoppedAt: now, + timeoutMs: this.options.timeoutMs + })) === false + ) { + this.quietSince = null + return false + } this.stop() - this.options.onIdle({ quietSince, stoppedAt: now, timeoutMs: this.options.timeoutMs }) return true } diff --git a/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts b/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts new file mode 100644 index 000000000000..e86bff86da67 --- /dev/null +++ b/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import { + createRestTestRig, + foundRestTestChat, + IDLE_MS, + REST_TEST_SESSION, + REST_TEST_THREAD, + sweepOnce, + type RestTestRig +} from '../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { OrcadIdleExitMonitor } from './orcad-idle-exit-monitor' +import { createOrcadIdleProbes } from './orcad-managed-idle-exit' + +let rig: RestTestRig +const SERVER_IDLE_MS = 15 * 60_000 + +beforeEach(async () => { + rig = await createRestTestRig() + rig.host.collaboratorsForTests().lifetime.idleSweep.dispose() + await foundRestTestChat(rig) +}) + +afterEach(async () => { + await rig.dispose() +}) + +function idleMonitor() { + const onIdle = vi.fn() + const monitor = new OrcadIdleExitMonitor({ + timeoutMs: SERVER_IDLE_MS, + now: () => rig.clock.now, + log: () => {}, + lastClientActivityAt: () => 0, + onIdle, + probes: createOrcadIdleProbes( + { timeoutMs: SERVER_IDLE_MS, activationRoot: 'test-fence' }, + { + readClientActivity: () => ({ openConnections: 0, requestsInFlight: 0, lastRequestAt: 0 }), + listTerminals: async () => [], + countDaemonSessions: async () => 0, + hasDaemon: () => true, + hasChatProviders: () => rig.host.hasLoadedProviders(), + agentStates: () => [{ state: 'blocked' }], + hasStagedMigration: () => false, + automationsBusy: () => false, + activationFenceExists: async () => false + } + ) + }) + return { monitor, onIdle } +} + +function providerEvents() { + const events = rig.adapter.acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no provider was started') + } + return events +} + +it('U2: keeps the provider and disconnected server alive while an approval is unanswered', async () => { + providerEvents().appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'approval-turn', ordinal: 70 }, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: 'allow', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(REST_TEST_SESSION) + const { monitor, onIdle } = idleMonitor() + expect(await monitor.check()).toBe(false) + rig.clock.now += 7 * 24 * 60 * 60_000 + await sweepOnce(rig.host) + + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(await monitor.check()).toBe(false) + expect(onIdle).not.toHaveBeenCalled() +}) + +it('releases an idle provider after the existing window, then lets the disconnected server exit', async () => { + const { monitor, onIdle } = idleMonitor() + expect(await monitor.check()).toBe(false) + rig.clock.now += IDLE_MS - 1 + await sweepOnce(rig.host) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(await monitor.check()).toBe(false) + + rig.clock.now += 2 + await sweepOnce(rig.host) + expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION) + expect(rig.host.hasLoadedProviders()).toBe(false) + expect(await monitor.check()).toBe(false) + rig.clock.now += SERVER_IDLE_MS + expect(await monitor.check()).toBe(true) + expect(onIdle).toHaveBeenCalledOnce() +}) + +it('never releases a quiet running turn or idle-exits its server', async () => { + providerEvents().appendItem( + { provider: 'codex', threadId: REST_TEST_THREAD, turnId: 'working', ordinal: 71 }, + { kind: 'turn', turnId: 'working', state: 'running' }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await rig.host.flushStreamedEvents(REST_TEST_SESSION) + const { monitor, onIdle } = idleMonitor() + expect(await monitor.check()).toBe(false) + for (let pass = 0; pass < 3; pass += 1) { + rig.clock.now += 7 * 24 * 60 * 60_000 + await sweepOnce(rig.host) + expect(await monitor.check()).toBe(false) + } + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(onIdle).not.toHaveBeenCalled() +}) diff --git a/src/main/orcad/orcad-managed-idle-exit-host.ts b/src/main/orcad/orcad-managed-idle-exit-host.ts index 7ea30102d189..2bd311183bd3 100644 --- a/src/main/orcad/orcad-managed-idle-exit-host.ts +++ b/src/main/orcad/orcad-managed-idle-exit-host.ts @@ -15,6 +15,7 @@ import { } from './orcad-idle-stop-record' import type { OrcadShutdownTrigger } from './orcad-lifecycle' import type { OrcadIdleStopRecord } from '../../shared/orcad-idle-exit' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' let requestIdleShutdown: OrcadShutdownTrigger | null = null @@ -67,6 +68,8 @@ async function startOrcadManagedIdleExit( const { countLiveOrcadDaemonSessions, retireOrcadDaemonIfIdle } = await import('./orcad-daemon-retirement') const idleRecord = createIdleStopRecordOwnership(input.userDataPath) + const hasChatProviders = (): boolean => + getStructuredAgentSessionHost()?.hasLoadedProviders() ?? false const dispose = installOrcadManagedIdleExit({ config: input.config, ports: { @@ -75,14 +78,26 @@ async function startOrcadManagedIdleExit( countDaemonSessions: countLiveOrcadDaemonSessions, hasDaemon: () => getDaemonEndpointFacts() !== null, agentStates: input.agentStates, + hasChatProviders, hasStagedMigration: input.hasStagedMigration, automationsBusy: input.automationsBusy, activationFenceExists }, - stop: (evidence) => { - void stopForIdle(input, evidence, retireOrcadDaemonIfIdle).finally(() => - idleRecord.requestShutdown(requestIdleShutdown) - ) + stop: async (evidence) => { + await stopForIdle(input, evidence, retireOrcadDaemonIfIdle) + // Clients or providers may return while daemon retirement awaits its reply. + const activity = input.rpc.readClientActivity() + if ( + hasChatProviders() || + activity.openConnections > 0 || + activity.requestsInFlight > 0 || + activity.lastRequestAt > evidence.quietSince + ) { + discardOrcadIdleStopRecord(input.userDataPath) + return false + } + idleRecord.requestShutdown(requestIdleShutdown) + return true } }) input.registerCleanup(() => { diff --git a/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts b/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts new file mode 100644 index 000000000000..a5ac52b1612e --- /dev/null +++ b/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { ORCAD_MANAGED_ACTIVATION_ROOT_ENV } from '../../shared/orcad-idle-exit' +import { bindOrcadIdleShutdown, beginOrcadIdleExit } from './orcad-managed-idle-exit-host' +import { installOrcadManagedIdleExit } from './orcad-managed-idle-exit' +import type * as ManagedIdleExit from './orcad-managed-idle-exit' +import { retireOrcadDaemonIfIdle } from './orcad-daemon-retirement' +import { discardOrcadIdleStopRecord } from './orcad-idle-stop-record' + +const { hasLoadedProviders } = vi.hoisted(() => ({ hasLoadedProviders: vi.fn(() => false) })) + +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => ({ hasLoadedProviders }) +})) +vi.mock('../ipc/pty', () => ({ getLocalPtyProvider: () => ({ listProcesses: async () => [] }) })) +vi.mock('../daemon/daemon-init', () => ({ getDaemonEndpointFacts: () => null })) +vi.mock('./orcad-daemon-retirement', () => ({ + countLiveOrcadDaemonSessions: vi.fn(async () => 0), + retireOrcadDaemonIfIdle: vi.fn() +})) +vi.mock('./orcad-idle-stop-record', () => ({ + consumeOrcadIdleStopRecord: vi.fn(() => null), + discardOrcadIdleStopRecord: vi.fn(), + writeOrcadIdleStopRecord: vi.fn() +})) +vi.mock('./orcad-managed-idle-exit', async (importOriginal) => ({ + ...(await importOriginal()), + installOrcadManagedIdleExit: vi.fn(() => () => {}) +})) + +beforeEach(() => { + vi.clearAllMocks() + hasLoadedProviders.mockReturnValue(false) + vi.stubEnv(ORCAD_MANAGED_ACTIVATION_ROOT_ENV, 'test-fence') +}) + +afterEach(() => vi.unstubAllEnvs()) + +it.each(['provider', 'client', 'request', 'recent request'])( + 'rechecks a returning %s after daemon retirement before requesting an idle shutdown', + async (returning) => { + const activity = { openConnections: 0, requestsInFlight: 0, lastRequestAt: 0 } + const retire = Promise.withResolvers<{ retirement: string; reason: null }>() + vi.mocked(retireOrcadDaemonIfIdle).mockImplementationOnce(async () => ({ + ...(await retire.promise), + retirement: 'retired', + liveSessions: 0 + })) + const shutdown = vi.fn(() => true) + bindOrcadIdleShutdown(shutdown) + await beginOrcadIdleExit('test-profile').start({ + rpc: { + readClientActivity: () => activity + }, + agentStates: () => [], + hasStagedMigration: () => false, + automationsBusy: () => false, + registerCleanup: () => {} + }) + const installed = vi.mocked(installOrcadManagedIdleExit).mock.calls.at(-1)?.[0] + if (!installed) { + throw new Error('idle monitor was not installed') + } + const stopping = installed.stop({ quietSince: 0, stoppedAt: 900_000, timeoutMs: 900_000 }) + hasLoadedProviders.mockReturnValue(returning === 'provider') + activity.openConnections = returning === 'client' ? 1 : 0 + activity.requestsInFlight = returning === 'request' ? 1 : 0 + activity.lastRequestAt = returning === 'recent request' ? 1 : 0 + retire.resolve({ retirement: 'retired', reason: null }) + + expect(await stopping).toBe(false) + expect(shutdown).not.toHaveBeenCalled() + expect(discardOrcadIdleStopRecord).toHaveBeenCalledWith('test-profile') + } +) diff --git a/src/main/orcad/orcad-managed-idle-exit.test.ts b/src/main/orcad/orcad-managed-idle-exit.test.ts index d14532b2ff05..3092f2c13119 100644 --- a/src/main/orcad/orcad-managed-idle-exit.test.ts +++ b/src/main/orcad/orcad-managed-idle-exit.test.ts @@ -23,6 +23,7 @@ function idlePorts(): OrcadManagedIdleExitPorts { countDaemonSessions: async () => 0, hasDaemon: () => true, agentStates: () => [{ state: 'done' }], + hasChatProviders: () => false, hasStagedMigration: () => false, automationsBusy: () => false, activationFenceExists: async () => false @@ -73,6 +74,7 @@ describe('createOrcadIdleProbes', () => { clients: 'idle', terminals: 'idle', agents: 'idle', + 'chat-providers': 'idle', migration: 'idle', automations: 'idle', activation: 'idle' @@ -97,6 +99,7 @@ describe('createOrcadIdleProbes', () => { ['an in-process terminal', { listTerminals: async () => [{ id: 'pty-1' }] }, 'terminals'], ['a live daemon session', { countDaemonSessions: async () => 2 }, 'terminals'], ['a working agent', { agentStates: () => [{ state: 'working' }] }, 'agents'], + ['a loaded chat provider', { hasChatProviders: () => true }, 'chat-providers'], ['a staged migration', { hasStagedMigration: () => true }, 'migration'], ['an enabled or running automation', { automationsBusy: () => true }, 'automations'], ['a held activation fence', { activationFenceExists: async () => true }, 'activation'] diff --git a/src/main/orcad/orcad-managed-idle-exit.ts b/src/main/orcad/orcad-managed-idle-exit.ts index 74229ef2a65a..0963e0ad44ae 100644 --- a/src/main/orcad/orcad-managed-idle-exit.ts +++ b/src/main/orcad/orcad-managed-idle-exit.ts @@ -46,6 +46,7 @@ export type OrcadManagedIdleExitPorts = { countDaemonSessions: () => Promise hasDaemon: () => boolean agentStates: () => readonly { state: string }[] + hasChatProviders: () => boolean hasStagedMigration: () => boolean /** An enabled schedule or an unsettled run; nothing would fire either once the host exits. */ automationsBusy: () => boolean @@ -84,6 +85,7 @@ export function createOrcadIdleProbes( name: 'agents', read: () => verdict(ports.agentStates().some((entry) => entry.state === 'working')) }, + { name: 'chat-providers', read: () => verdict(ports.hasChatProviders()) }, { name: 'migration', read: () => verdict(ports.hasStagedMigration()) }, { name: 'automations', read: () => verdict(ports.automationsBusy()) }, { @@ -110,7 +112,7 @@ export function installOrcadManagedIdleExit(input: { config: OrcadManagedIdleExitConfig ports: OrcadManagedIdleExitPorts /** Records the clean stop, then runs the same graceful shutdown as SIGTERM. */ - stop: (evidence: OrcadIdleExitEvidence) => void + stop: (evidence: OrcadIdleExitEvidence) => void | boolean | Promise }): () => void { const monitor = new OrcadIdleExitMonitor({ timeoutMs: input.config.timeoutMs,