Repository navigation
Keep SSH chat providers alive after the desktop disconnects #26473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
brennanb2025
merged 10 commits into
main
from
brennanb2025/ssh-chat-owed-work-keeps-server
Oct 12, 2026
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
824c8fa
fix: preserve structured chat work during automatic server stops
brennanb2025 c925a56
fix: require stop-request support before automatic cleanup
brennanb2025 c486702
fix: retain unverified session owners until exit is proven
brennanb2025 c8213bb
fix: preserve unreadable work and retry expired prompt stops
brennanb2025 392cc53
fix: give unheld reservations and queued sends retirement exits
brennanb2025 e093875
fix: clear empty recovery leases and preserve unverified owners in tests
brennanb2025 24a8457
Keep managed servers alive while chat providers are loaded
brennanb2025 da06daa
Recheck returning clients before managed idle shutdown
brennanb2025 8875c7d
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 6d3c504
test: run the managed idle exit chat provider test in the Node runtime
brennanb2025 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
119 changes: 119 additions & 0 deletions
119
src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
74 changes: 74 additions & 0 deletions
74
src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| } | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
Repository: stablyai/orca
Length of output: 8902
🏁 Script executed:
Repository: stablyai/orca
Length of output: 30637
🏁 Script executed:
Repository: stablyai/orca
Length of output: 16489
🏁 Script executed:
Repository: stablyai/orca
Length of output: 15820
🏁 Script executed:
Repository: stablyai/orca
Length of output: 16531
Keep idle exit busy while provider acquisition is in flight.
hasLoadedProviders()is false afteronSpawnedcommits the provider process and beforeindexProviderChild()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