Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +178 to +179

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '215,290p' src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts
sed -n '60,110p' src/main/orcad/orcad-managed-idle-exit-host.ts
rg -n 'ensureProviderChild|requestIdleShutdown|indexProviderChild|onSpawned' src/main/native-chat/agent-session-wire src/main/orcad/orcad-managed-idle-exit-host.ts

Repository: stablyai/orca

Length of output: 8902


🏁 Script executed:

set -eu
printf '%s\n' '--- idle exit implementation ---'
rg -n -F --glob '*.ts' -- 'installOrcadManagedIdleExit' src/main/orcad src/main
rg -n -F --glob '*.ts' -- 'stopForIdle' src/main/orcad
printf '%s\n' '--- acquisition and attach entrypoints ---'
sed -n '1,190p' src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts
sed -n '1,230p' src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts
sed -n '1,245p' src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts
printf '%s\n' '--- idle monitor and stop coordination ---'
rg -n -F --glob '*.ts' -- 'quietSince' src/main/orcad
rg -n -F --glob '*.ts' -- 'requestShutdown' src/main/orcad

Repository: stablyai/orca

Length of output: 30637


🏁 Script executed:

set -eu
printf '%s\n' '--- idle monitor ---'
sed -n '1,125p' src/main/orcad/orcad-idle-exit-monitor.ts
printf '%s\n' '--- managed idle host stop path ---'
sed -n '1,190p' src/main/orcad/orcad-managed-idle-exit-host.ts
printf '%s\n' '--- provider race test ---'
sed -n '1,125p' src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts
printf '%s\n' '--- structured host lifetime and shutdown references ---'
rg -n -F --glob '*.ts' -- 'trackStart' src/main/native-chat/agent-session-wire
rg -n -F --glob '*.ts' -- 'holdClosedStructuredAgentSessionSends' src/main/native-chat/agent-session-wire
rg -n -F --glob '*.ts' -- 'dispose()' src/main/native-chat/agent-session-wire/structured-agent-session-host*.ts src/main/native-chat/agent-session-wire/structured-agent-session-host-*.ts
rg -n -F --glob '*.ts' -- 'closeStructuredAgentSession' src/main/native-chat/agent-session-wire

Repository: stablyai/orca

Length of output: 16489


🏁 Script executed:

set -eu
printf '%s\n' '--- shutdown binding and startup stop ---'
rg -n -F --glob '*.ts' -- 'installOrcadShutdownSignals' src/main
rg -n -F --glob '*.ts' -- 'bindOrcadIdleShutdown' src/main
rg -n -F --glob '*.ts' -- 'stop(): Promise' src/main
printf '%s\n' '--- lifecycle and host teardown ---'
rg -n -F --glob '*.ts' -- 'class StructuredAgentSessionHost' src/main/native-chat/agent-session-wire
sed -n '1,150p' src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts
sed -n '225,285p' src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
printf '%s\n' '--- task tracking and quit paths ---'
rg -n -F --glob '*.ts' -- 'trackAttach' src/main/native-chat/agent-session-wire
rg -n -F --glob '*.ts' -- 'trackAttach' src/main
rg -n -F --glob '*.ts' -- 'stopFor' src/main/orcad/orcad-entry.ts src/main/orcad src/main | head -80

Repository: stablyai/orca

Length of output: 15820


🏁 Script executed:

set -eu
printf '%s\n' '--- task queue ---'
sed -n '1,180p' src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts
printf '%s\n' '--- host construction and acquisition wiring ---'
sed -n '1,180p' src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
printf '%s\n' '--- entry stop binding ---'
sed -n '385,425p' src/main/orcad/orcad-entry.ts
printf '%s\n' '--- lifecycle shutdown trigger ---'
sed -n '1,105p' src/main/orcad/orcad-lifecycle.ts

Repository: stablyai/orca

Length of output: 16531


Keep idle exit busy while provider acquisition is in flight.

hasLoadedProviders() is false after onSpawned commits the provider process and before indexProviderChild() records the child. During this interval, stopForIdle() can yield for daemon retirement, then the final check can request shutdown without newer RPC activity. Shutdown aborts acquisitions before draining attaches, so it can interrupt the provider start and its queued delivery.

Include active attach or delivery-start tracking in the idle probe. The existing task queue already tracks these operations.

Suggested fix
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts
@@
   trackAttach<T>(operation: Promise<T>): Promise<T> {
     this.attaching.add(operation)
@@
     return operation
   }
 
+  hasActiveAttaches(): boolean {
+    return this.attaching.size > 0
+  }
+
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
@@
   hasSession = (sessionId: string): boolean => this.sessions.has(sessionId)
   hasLoadedProviders = (): boolean =>
-    [...this.sessions.values()].some(({ child }) => child !== null)
+    this.tasks.hasActiveAttaches() ||
+    [...this.sessions.values()].some(({ child }) => child !== null)

sessionAgent = (sessionId: string) => this.deps.store.getRecord(sessionId)?.provider ?? null

handleAdapterEvent = (event: Parameters<StructuredAgentSessionEventRecovery['handle']>[0]) =>
Expand Down
16 changes: 16 additions & 0 deletions src/main/orcad/orcad-idle-exit-monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>()
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)
Expand Down
13 changes: 11 additions & 2 deletions src/main/orcad/orcad-idle-exit-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void | boolean>
now?: () => number
pollMs?: number
log?: (line: string) => void
Expand Down Expand Up @@ -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
}

Expand Down
119 changes: 119 additions & 0 deletions src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
23 changes: 19 additions & 4 deletions src/main/orcad/orcad-managed-idle-exit-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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: {
Expand All @@ -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(() => {
Expand Down
74 changes: 74 additions & 0 deletions src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ManagedIdleExit>()),
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')
}
)
3 changes: 3 additions & 0 deletions src/main/orcad/orcad-managed-idle-exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ function idlePorts(): OrcadManagedIdleExitPorts {
countDaemonSessions: async () => 0,
hasDaemon: () => true,
agentStates: () => [{ state: 'done' }],
hasChatProviders: () => false,
hasStagedMigration: () => false,
automationsBusy: () => false,
activationFenceExists: async () => false
Expand Down Expand Up @@ -73,6 +74,7 @@ describe('createOrcadIdleProbes', () => {
clients: 'idle',
terminals: 'idle',
agents: 'idle',
'chat-providers': 'idle',
migration: 'idle',
automations: 'idle',
activation: 'idle'
Expand All @@ -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']
Expand Down
4 changes: 3 additions & 1 deletion src/main/orcad/orcad-managed-idle-exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type OrcadManagedIdleExitPorts = {
countDaemonSessions: () => Promise<number | null>
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
Expand Down Expand Up @@ -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()) },
{
Expand All @@ -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 | boolean>
}): () => void {
const monitor = new OrcadIdleExitMonitor({
timeoutMs: input.config.timeoutMs,
Expand Down
Loading