From 824c8fa5f7ff166d4f989a5f728843ad2d1f0d97 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:39:46 -0700 Subject: [PATCH 1/9] fix: preserve structured chat work during automatic server stops --- .../structured-agent-session-host.ts | 17 ++- .../structured-agent-session-idle-sweep.ts | 43 ++---- .../structured-agent-session-owed-work.ts | 39 +++++ ...ructured-agent-session-provider-support.ts | 12 ++ ...ured-agent-session-server-lifetime.test.ts | 97 +++++++++++++ ...tructured-agent-session-server-lifetime.ts | 133 ++++++++++++++++++ .../structured-agent-session-task-queue.ts | 18 +++ src/main/orcad/orcad-entry.ts | 7 + src/main/orcad/orcad-health.ts | 7 +- .../orcad/orcad-idle-exit-monitor.test.ts | 12 ++ src/main/orcad/orcad-idle-exit-monitor.ts | 10 +- .../orcad/orcad-managed-idle-exit-host.ts | 9 ++ ...-managed-idle-exit-structured-work.test.ts | 33 +++++ .../orcad/orcad-managed-idle-exit.test.ts | 2 + src/main/orcad/orcad-managed-idle-exit.ts | 10 +- .../orcad-managed-stop-cancellation.test.ts | 1 + .../orcad/orcad-managed-stop-intent.test.ts | 62 ++++++++ src/main/orcad/orcad-managed-stop-intent.ts | 44 ++++++ .../orcad/orcad-stop-request-listener.test.ts | 90 +++++++++++- src/main/orcad/orcad-stop-request-listener.ts | 35 ++++- .../orcad/orcad-structured-work-boundary.ts | 37 +++++ .../orcad-unanswered-prompt-expiry.test.ts | 55 ++++++++ .../rpc/methods/orcad-terminal-census.test.ts | 11 +- .../rpc/methods/orcad-terminal-census.ts | 11 +- src/main/ssh/orcad-managed-remote-stop.ts | 13 +- src/main/ssh/orcad-managed-runtime-context.ts | 5 +- src/main/ssh/orcad-recovery-slot.ts | 15 +- src/main/ssh/orcad-remote-deploy.test.ts | 23 ++- .../orcad-remote-lifecycle-windows.test.ts | 8 +- .../orcad-remote-process-control-windows.ts | 5 +- src/main/ssh/orcad-remote-process-control.ts | 21 ++- src/main/ssh/orcad-remote-rollback.test.ts | 16 ++- ...-remote-shell-commands.integration.test.ts | 39 ++++- src/main/ssh/orcad-remote-stop.ts | 5 +- .../ssh/orcad-remote-windows-scripts.test.ts | 24 +++- src/main/ssh/orcad-runtime-deployment.test.ts | 7 +- .../ssh/orcad-runtime-maintenance.test.ts | 7 +- .../orcad-update-plan-structured-work.test.ts | 45 ++++++ src/main/ssh/orcad-update-plan.test.ts | 103 +++++++++++--- src/main/ssh/orcad-update-plan.ts | 18 +++ src/main/ssh/orcad-windows-host-script.ts | 10 +- src/shared/orcad-stop-request.ts | 6 +- src/shared/orcad-terminal-census.ts | 4 +- 43 files changed, 1051 insertions(+), 118 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-owed-work.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts create mode 100644 src/main/orcad/orcad-managed-idle-exit-structured-work.test.ts create mode 100644 src/main/orcad/orcad-managed-stop-intent.test.ts create mode 100644 src/main/orcad/orcad-managed-stop-intent.ts create mode 100644 src/main/orcad/orcad-structured-work-boundary.ts create mode 100644 src/main/orcad/orcad-unanswered-prompt-expiry.test.ts create mode 100644 src/main/ssh/orcad-update-plan-structured-work.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 e0f193ba0b49..d4dbffb73d6c 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 @@ -1,6 +1,7 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' +import { createStructuredAgentSessionServerLifetime } from './structured-agent-session-server-lifetime' // Structured agent-session host: where the lease, journal, and provider adapter meet. // Mutations share one durable admission path and serialize per session. A conversation is reached // only through `conversation`, which opens it at rest; an agent is started only by work that needs @@ -83,6 +84,13 @@ export class StructuredAgentSessionHost { ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() + readonly serverRetirement = createStructuredAgentSessionServerLifetime({ + context: () => this.lifetimeContext(), + tasks: this.tasks, + deliveryActive: (id) => this.conversationDelivery.loop.isRunning(id), + childWork: (id) => this.clientDelivery.readChildWork(id), + stopDelivery: () => this.stopDelivery() + }) private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType private readonly restore: ReturnType @@ -211,12 +219,7 @@ export class StructuredAgentSessionHost { agentDefinitions = () => this.deps.agents.definitions() /** Saved chats can outlive their registration; both vocabularies bound a client's audience. */ - knownAgentIds = (): readonly string[] => [ - ...new Set([ - ...this.deps.agents.definitions().map(({ agent }) => agent), - ...this.deps.store.listRecords().map(({ provider }) => provider) - ]) - ] + knownAgentIds = (): readonly string[] => providerSupport.knownAgentIds(this.deps) private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( this, @@ -269,7 +272,7 @@ export class StructuredAgentSessionHost { idleSweep: this.lifetime, tasks: this.tasks, restartResume: this.restartResume, - serialize: this.serialize, + serialize: (id, task) => this.tasks.serializeDuringShutdown(id, task), trigger: options?.trigger ?? 'quit' }).finally(() => this.clientDelivery.closeAll()) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 1b67f259ed95..08ffd1f38221 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -8,10 +8,6 @@ // // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. -import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -43,16 +39,8 @@ export type StructuredAgentSessionIdleSweepDeps = { idleMs?: number } -/** A prompt the user has not answered. A subagent can raise one the lead turn cannot see. */ -export function hasPendingStructuredAgentSessionPrompt( - items: readonly AgentJournalRenderItem[] -): boolean { - return items.some( - (item) => - (item.body.kind === 'approval' || item.body.kind === 'question') && - item.body.resolution.state === 'pending' - ) -} +export { hasPendingStructuredAgentSessionPrompt } from './structured-agent-session-owed-work' +import { structuredAgentSessionOwesWork } from './structured-agent-session-owed-work' export class StructuredAgentSessionIdleSweep { private timer: ReturnType | null = null @@ -129,24 +117,15 @@ export class StructuredAgentSessionIdleSweep { await this.deps.closeConversation(sessionId) } - private queuedOrDelivering(sessionId: string, session: StructuredAgentSessionHostSession) { - return ( - this.deps.deliveryActive(sessionId) || - session.journal.submissions().some(isQueuedAgentJournalSubmission) - ) - } - - /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the - * handle, and a leftover prompt or turn row is only history. */ private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { - const items = session.journal.snapshot().items - return ( - activeStructuredAgentSessionTurnId(items) !== null || - this.queuedOrDelivering(sessionId, session) || - agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || - this.deps.hasOpenDispatch(sessionId) || - this.deps.providerHoldsDispatch(sessionId) || - hasPendingStructuredAgentSessionPrompt(items) - ) + return structuredAgentSessionOwesWork({ + snapshot: session.journal.snapshot(), + hasChild: session.child !== null, + stopping: session.child?.close !== undefined, + deliveryActive: this.deps.deliveryActive(sessionId), + childWork: this.deps.childWork(sessionId), + openDispatch: this.deps.hasOpenDispatch(sessionId), + providerHoldsDispatch: this.deps.providerHoldsDispatch(sessionId) + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work.ts new file mode 100644 index 000000000000..39a950f441c9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work.ts @@ -0,0 +1,39 @@ +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' + +/** Includes prompts from children, whose requests need not belong to the lead's turn. */ +export function hasPendingStructuredAgentSessionPrompt( + items: AgentJournalSnapshot['items'] +): boolean { + return items.some( + ({ body }) => + (body.kind === 'approval' || body.kind === 'question') && body.resolution.state === 'pending' + ) +} + +/** The same execution-host evidence governs child retirement and server retirement. */ +export function structuredAgentSessionOwesWork(input: { + snapshot: Pick + hasChild: boolean + deliveryActive?: boolean + stopping?: boolean + childWork?: readonly AgentChildWorkView[] + openDispatch?: boolean + providerHoldsDispatch?: boolean +}): boolean { + return ( + input.deliveryActive === true || + input.stopping === true || + input.snapshot.submissions.some(isQueuedAgentJournalSubmission) || + (input.hasChild && + (activeStructuredAgentSessionTurnId(input.snapshot.items) !== null || + input.snapshot.submissions.some(({ dispatchState }) => dispatchState === 'pending') || + agentChildWorkLiveness(input.childWork) !== null || + input.openDispatch === true || + input.providerHoldsDispatch === true || + hasPendingStructuredAgentSessionPrompt(input.snapshot.items))) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index 9215c4908445..db307d6e8b6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -65,3 +65,15 @@ export function hostCanStartRecord( agentDrivesSession(deps.agents, record) ) } + +export function knownAgentIds(deps: { + agents: Pick + store: { listRecords(): readonly AgentSessionRecord[] } +}): readonly string[] { + return [ + ...new Set([ + ...deps.agents.definitions().map(({ agent }) => agent), + ...deps.store.listRecords().map(({ provider }) => provider) + ]) + ] +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.test.ts new file mode 100644 index 000000000000..aa08434ba19e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.test.ts @@ -0,0 +1,97 @@ +import { expect, it, vi } from 'vitest' +import { createQueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' + +it('defers an automatic stop across an accepted send, provider handover, and Stop settlement', async () => { + const rig = await createQueuedMessageTestRig() + try { + const held = Promise.withResolvers() + const queued = rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, () => held.promise) + const sending = rig.send('accepted behind the lock') + const commit = vi.fn(() => true) + expect(rig.host.serverRetirement.admitStop(commit)).toBe(false) + expect(commit).not.toHaveBeenCalled() + held.resolve() + await queued + await sending.result + await vi.waitFor(() => expect(rig.dispatch).toHaveBeenCalled()) + expect(rig.host.serverRetirement.read()).toBeGreaterThan(0) + expect(rig.host.serverRetirement.admitStop(commit)).toBe(false) + const stopping = rig.stop() + expect(rig.host.serverRetirement.admitStop(commit)).toBe(false) + await stopping + } finally { + await rig.dispose() + } +}) + +it('closes admission atomically when an idle host commits an automatic stop', async () => { + const rig = await createQueuedMessageTestRig() + try { + await vi.waitFor(() => expect(rig.host.serverRetirement.read()).toBe(0)) + expect(rig.host.serverRetirement.admitStop(() => true)).toBe(true) + await expect(rig.send('raced the stop').result).rejects.toMatchObject({ + message: 'agent_session_ownership_unknown' + }) + expect(rig.dispatch).not.toHaveBeenCalled() + } finally { + await rig.dispose() + } +}) + +it('keeps admission open when a stop loses its decision to cancellation', async () => { + const rig = await createQueuedMessageTestRig() + try { + await vi.waitFor(() => expect(rig.host.serverRetirement.read()).toBe(0)) + expect(rig.host.serverRetirement.admitStop(() => false)).toBe(false) + await expect(rig.send('still usable').result).resolves.toMatchObject({ ok: true }) + } finally { + await rig.dispose() + } +}) + +it('treats an unreadable host journal as unknown and never invokes an automatic stop', async () => { + const rig = await createQueuedMessageTestRig() + try { + vi.spyOn(rig.store, 'listRecords').mockImplementation(() => { + throw new Error('unreadable') + }) + const commit = vi.fn(() => true) + expect(rig.host.serverRetirement.read()).toBeNull() + expect(rig.host.serverRetirement.admitStop(commit)).toBe(false) + expect(commit).not.toHaveBeenCalled() + } finally { + vi.restoreAllMocks() + await rig.dispose() + } +}) + +it('rechecks an unknown prior owner so proven exit ends its retirement obligation', async () => { + const rig = await createQueuedMessageTestRig() + try { + const owner = rig.store.getRecord(HOST_TEST_SESSION)?.lease.ownerProcess + expect(owner).toBeTruthy() + await rig.stop() + await rig.host.close(HOST_TEST_SESSION, 'evict') + await rig.store.transitionHandoff(HOST_TEST_SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + ownerProcess: owner ?? null, + claimStatus: 'conflicted', + handoffStage: 'recovering', + deathEvidence: null + } + })) + rig.host.deps.probeOwner = async () => ({ outcome: 'indeterminate', reason: 'unreachable' }) + expect(await rig.host.serverRetirement.observe()).toBeNull() + expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) + rig.host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) + await rig.host.serverRetirement.observe() + await vi.waitFor(() => expect(rig.host.serverRetirement.read()).toBe(0)) + expect(rig.store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus).toBe('released') + expect(rig.dispatch).not.toHaveBeenCalled() + } finally { + await rig.dispose() + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts new file mode 100644 index 000000000000..3694a12cfcb2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts @@ -0,0 +1,133 @@ +import { + stopStructuredAgentSessionAgentUnderSerialize, + type StructuredAgentSessionLifetimeContext +} from './structured-agent-session-host-lifetime' +import { replayJournal } from '../agent-session-journal/journal-open' +import { renderJournalState } from '../agent-session-journal/journal-reducer' +import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' +import { structuredAgentSessionOwesWork } from './structured-agent-session-owed-work' + +export const STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS = 24 * 60 * 60_000 + +export function createStructuredAgentSessionServerLifetime(input: { + context: () => StructuredAgentSessionLifetimeContext + tasks: StructuredAgentSessionTaskQueue + deliveryActive: (id: string) => boolean + childWork: (id: string) => readonly AgentChildWorkView[] | undefined + stopDelivery: () => void +}) { + const read = (): number | null => { + const deps = input.context().deps + try { + if (deps.store.readOnly || deps.journalDatabase.readOnly) { + return null + } + let count = 0 + for (const record of deps.store.listRecords()) { + const session = input.context().sessions.get(record.sessionId) + // A lease without its transport must be reconciled by its execution host. + if (!session?.child && record.lease.claimStatus !== 'released') { + return null + } + const loaded = session ? null : replayJournal(deps.journalDatabase.db, record.sessionId) + if (loaded?.damage || loaded?.newer) { + return null + } + const snapshot = + session?.journal.snapshot() ?? (loaded ? renderJournalState(loaded.state) : null) + if ( + snapshot && + structuredAgentSessionOwesWork({ + snapshot, + hasChild: session?.child != null, + stopping: session?.child?.close !== undefined, + deliveryActive: input.deliveryActive(record.sessionId), + childWork: input.childWork(record.sessionId), + openDispatch: deps.hasOpenDispatch?.(record) === true, + providerHoldsDispatch: deps.adapter.holdsDispatch?.(record.sessionId) === true + }) + ) { + count++ + } + } + return count || (input.tasks.hasPending() ? 1 : 0) + } catch (error) { + deps.logger.warn('reading structured work before server retirement failed', { + scope: 'server-retirement', + error + }) + return null + } + } + return { + read, + observe: async (): Promise => { + try { + const context = input.context() + for (const { sessionId, lease } of context.deps.store.listRecords()) { + if (!context.sessions.get(sessionId)?.child && lease.handoffStage === 'recovering') { + await input.tasks.serialize(sessionId, async () => { + if (!input.context().sessions.get(sessionId)?.child) { + await input.context().runtimeState.resolveRecovery(sessionId) + } + }) + } + } + return read() + } catch (error) { + input.context().deps.logger.warn('rechecking structured work recovery failed', { + scope: 'server-retirement', + error + }) + return null + } + }, + expireUnansweredPrompts: async (): Promise => { + const expired = (id: string): boolean => { + const session = input.context().sessions.get(id) + const now = input.context().deps.now?.() ?? Date.now() + return ( + session?.child != null && + session.child.close === undefined && + session.journal + .snapshot() + .items.some( + ({ body, observedAt }) => + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' && + now - observedAt >= STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS + ) + ) + } + await Promise.allSettled( + [...input.context().sessions.keys()].filter(expired).map((id) => + input.tasks + .serialize(id, async () => { + if (expired(id)) { + await stopStructuredAgentSessionAgentUnderSerialize(input.context(), id, { + cause: 'evict' + }) + } + }) + .catch((error: unknown) => + input.context().deps.logger.warn('expiring an unanswered prompt failed', { + scope: 'prompt-expiry', + sessionId: id, + error + }) + ) + ) + ) + }, + /** No await between evidence, the request's commit, and closing admission. */ + admitStop: (commit: () => boolean): boolean => { + if (read() !== 0 || !commit()) { + return false + } + input.tasks.closeAdmission() + input.stopDelivery() + return true + } + } +} 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 index f0237835c756..f7da8299d36e 100644 --- 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 @@ -1,13 +1,31 @@ import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' export class StructuredAgentSessionTaskQueue { private readonly chains = new Map>() private readonly attaching = new Set>() + private admissionClosed = false serialize(sessionId: string, task: () => Promise): Promise { + if (this.admissionClosed) { + return Promise.reject( + agentSessionRefusalError('agent_session_ownership_unknown', { + reason: 'sessionNotAttached' + }) + ) + } + return this.serializeDuringShutdown(sessionId, task) + } + + serializeDuringShutdown(sessionId: string, task: () => Promise): Promise { return runKeyedSerializedOperation(this.chains, sessionId, task) } + hasPending = (): boolean => this.chains.size > 0 || this.attaching.size > 0 + closeAdmission = (): void => { + this.admissionClosed = true + } + trackAttach(operation: Promise): Promise { this.attaching.add(operation) void operation.then( diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index b89c813774fc..957051c8c909 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -23,6 +23,10 @@ import { } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' +import { + admitOrcadAutomaticStop, + prepareOrcadStructuredWorkBoundary +} from './orcad-structured-work-boundary' import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' import type { OrcadManagedStopContext } from '../../shared/orcad-stop-request' @@ -337,6 +341,8 @@ async function startOrcadRuntime( const { publishHeadlessRuntimeGraph } = await import('../runtime/headless-runtime-graph') publishHeadlessRuntimeGraph(runtime) + await prepareOrcadStructuredWorkBoundary(runtime) + const bindHost = resolveOrcadBindHost(options.bind) const rpc = new OrcaRuntimeRpcServer({ runtime, @@ -417,6 +423,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise requestShutdown('stop request'), { installRoot: resolveOrcadInstallRoot(), managedStop: handle.managedStop, + admitAutomaticStop: admitOrcadAutomaticStop, beforeManagedStop: prepareOrcadManagedStop }) bindOrcadIdleShutdown(requestShutdown) diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index d8cf2f705a28..abf52edbc83c 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -18,7 +18,10 @@ import { readDaemonPidRecord } from '../daemon/daemon-init' import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' -import { ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' +import { + ORCAD_STOP_REQUESTS_CAPABILITY, + ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY +} from '../../shared/orcad-stop-request' import type { OrcadIdleStopRecord } from '../../shared/orcad-idle-exit' /** @@ -74,6 +77,7 @@ export type OrcadHealth = { * Absent on older builds, which a client must keep stopping with SIGTERM. */ stopRequests?: typeof ORCAD_STOP_REQUESTS_CAPABILITY + structuredWorkProtection?: typeof ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY /** * Managed launches only: how the previous run ended if it stopped for idleness, else null * (a crash, a signal, or a first start). Absent on user-started and older builds. @@ -183,6 +187,7 @@ export async function collectOrcadHealth( terminalDaemon: await collectTerminalDaemonHealth(), ...(profileStateAuthority ? { profileStateAuthority } : {}), stopRequests: ORCAD_STOP_REQUESTS_CAPABILITY, + structuredWorkProtection: ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY, ...(previousIdleStop !== undefined ? { previousIdleStop } : {}) } } diff --git a/src/main/orcad/orcad-idle-exit-monitor.test.ts b/src/main/orcad/orcad-idle-exit-monitor.test.ts index fbe31074a526..29bb8e83b541 100644 --- a/src/main/orcad/orcad-idle-exit-monitor.test.ts +++ b/src/main/orcad/orcad-idle-exit-monitor.test.ts @@ -91,6 +91,18 @@ describe('OrcadIdleExitMonitor', () => { expect(await h.monitor.check()).toBe(true) }) + it('restarts the quiet window when the final host boundary defers a racing send', async () => { + const h = harness() + h.onIdle.mockReturnValueOnce(false) + await h.monitor.check() + h.advance(100) + expect(await h.monitor.check()).toBe(false) + expect(await h.monitor.check()).toBe(false) + h.advance(100) + expect(await h.monitor.check()).toBe(true) + expect(h.onIdle).toHaveBeenCalledTimes(2) + }) + it('does not fire after it was stopped', async () => { const h = harness() await h.monitor.check() diff --git a/src/main/orcad/orcad-idle-exit-monitor.ts b/src/main/orcad/orcad-idle-exit-monitor.ts index 58a053adffc8..75cc5165f2ae 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 | false now?: () => number pollMs?: number log?: (line: string) => void @@ -80,8 +80,14 @@ export class OrcadIdleExitMonitor { if (now - quietSince < this.options.timeoutMs) { return false } + if ( + 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-host.ts b/src/main/orcad/orcad-managed-idle-exit-host.ts index c243ee16fb66..bec7da002850 100644 --- a/src/main/orcad/orcad-managed-idle-exit-host.ts +++ b/src/main/orcad/orcad-managed-idle-exit-host.ts @@ -13,6 +13,10 @@ import { writeOrcadIdleStopRecord } from './orcad-idle-stop-record' import type { OrcadShutdownTrigger } from './orcad-lifecycle' +import { + expireOrcadUnansweredPrompts, + admitOrcadAutomaticStop +} from './orcad-structured-work-boundary' import type { OrcadIdleStopRecord } from '../../shared/orcad-idle-exit' let requestIdleShutdown: OrcadShutdownTrigger | null = null @@ -74,14 +78,19 @@ async function startOrcadManagedIdleExit( countDaemonSessions: countLiveOrcadDaemonSessions, hasDaemon: () => getDaemonEndpointFacts() !== null, agentStates: input.agentStates, + readStructuredWork: expireOrcadUnansweredPrompts, hasStagedMigration: input.hasStagedMigration, automationsBusy: input.automationsBusy, activationFenceExists }, stop: (evidence) => { + if (!admitOrcadAutomaticStop(() => true)) { + return false + } void stopForIdle(input, evidence, retireOrcadDaemonIfIdle).finally(() => idleRecord.requestShutdown(requestIdleShutdown) ) + return undefined } }) input.registerCleanup(() => { diff --git a/src/main/orcad/orcad-managed-idle-exit-structured-work.test.ts b/src/main/orcad/orcad-managed-idle-exit-structured-work.test.ts new file mode 100644 index 000000000000..f9a4a8c470f6 --- /dev/null +++ b/src/main/orcad/orcad-managed-idle-exit-structured-work.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from 'vitest' +import { createOrcadIdleProbes } from './orcad-managed-idle-exit' +import { + attach, + hostTestState, + seedApproval +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' + +it('U2: managed idle exit protects with an attached provider and pending approval', async () => { + await attach() + const pending = await seedApproval() + const snapshot = await hostTestState().host.journalSnapshot(HOST_TEST_SESSION) + expect(snapshot.items.find((item) => item.itemId === pending.itemId)?.body).toMatchObject({ + kind: 'approval', + resolution: { state: 'pending' } + }) + const probes = createOrcadIdleProbes( + { timeoutMs: 900_000, activationRoot: 'test-fence' }, + { + readClientActivity: () => ({ openConnections: 0, requestsInFlight: 0, lastRequestAt: 0 }), + listTerminals: async () => [], + countDaemonSessions: async () => 0, + hasDaemon: () => true, + readStructuredWork: () => hostTestState().host.serverRetirement.read(), + agentStates: () => [{ state: 'blocked' }], + hasStagedMigration: () => false, + automationsBusy: () => false, + activationFenceExists: async () => false + } + ) + expect(await probes.find((probe) => probe.name === 'structured-work')?.read()).toBe('busy') +}) diff --git a/src/main/orcad/orcad-managed-idle-exit.test.ts b/src/main/orcad/orcad-managed-idle-exit.test.ts index d14532b2ff05..66b741d77579 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' }], + readStructuredWork: () => 0, hasStagedMigration: () => false, automationsBusy: () => false, activationFenceExists: async () => false @@ -73,6 +74,7 @@ describe('createOrcadIdleProbes', () => { clients: 'idle', terminals: 'idle', agents: 'idle', + 'structured-work': 'idle', migration: 'idle', automations: 'idle', activation: 'idle' diff --git a/src/main/orcad/orcad-managed-idle-exit.ts b/src/main/orcad/orcad-managed-idle-exit.ts index 74229ef2a65a..9c932bc20af7 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 }[] + readStructuredWork: () => number | null | Promise hasStagedMigration: () => boolean /** An enabled schedule or an unsettled run; nothing would fire either once the host exits. */ automationsBusy: () => boolean @@ -59,6 +60,13 @@ export function createOrcadIdleProbes( ): OrcadIdleProbe[] { const verdict = (busy: boolean): OrcadIdleVerdict => (busy ? 'busy' : 'idle') return [ + { + name: 'structured-work', + read: async () => { + const work = await ports.readStructuredWork() + return work === null ? 'unverifiable' : verdict(work > 0) + } + }, { name: 'clients', read: () => { @@ -110,7 +118,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 | false }): () => void { const monitor = new OrcadIdleExitMonitor({ timeoutMs: input.config.timeoutMs, diff --git a/src/main/orcad/orcad-managed-stop-cancellation.test.ts b/src/main/orcad/orcad-managed-stop-cancellation.test.ts index d4c3c2291086..40a84a56b398 100644 --- a/src/main/orcad/orcad-managed-stop-cancellation.test.ts +++ b/src/main/orcad/orcad-managed-stop-cancellation.test.ts @@ -93,6 +93,7 @@ describe('cancelling a managed stop', () => { const root = mkdtempSync(join(tmpdir(), 'orcad-stop-cancel-slot-')) roots.push(root) const listener = installOrcadStopRequestListeners(onRequest, { + admitAutomaticStop: () => false, installRoot: root, managedStop: { version: request.version, diff --git a/src/main/orcad/orcad-managed-stop-intent.test.ts b/src/main/orcad/orcad-managed-stop-intent.test.ts new file mode 100644 index 000000000000..02df7cf973f6 --- /dev/null +++ b/src/main/orcad/orcad-managed-stop-intent.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { expect, it } from 'vitest' +import { + createOrcadDecommissionTransaction, + withOrcadDecommissionStopDispatched +} from '../ssh/orcad-decommission-transaction' +import { emptyOrcadActivationRecord } from '../ssh/orcad-activation-record' +import { ORCAD_ACTIVATION_TRANSACTION_FILENAME } from '../ssh/orcad-activation-transaction' +import { orcadManagedStopIsUserRequested } from './orcad-managed-stop-intent' + +const request = { + schemaVersion: 1 as const, + transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', + version: '1.0.0', + runtimeId: 'runtime-1', + instance: { pid: 42, startedAtMs: 1, nonce: 'nonce', lockPath: '/data/lock' } +} + +it('recognizes an older desktop Settings stop from its matching existing transaction', () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-stop-intent-')) + try { + const transaction = withOrcadDecommissionStopDispatched( + createOrcadDecommissionTransaction({ + transactionId: request.transactionId, + recordBefore: { ...emptyOrcadActivationRecord(), active: request.version }, + now: new Date(1) + }), + request, + new Date(2) + ) + writeFileSync(join(root, ORCAD_ACTIVATION_TRANSACTION_FILENAME), JSON.stringify(transaction)) + expect(orcadManagedStopIsUserRequested(request, root)).toBe(true) + expect(orcadManagedStopIsUserRequested({ ...request, runtimeId: 'other' }, root)).toBe(false) + expect( + orcadManagedStopIsUserRequested( + { ...request, instance: { ...request.instance, nonce: 'other' } }, + root + ) + ).toBe(false) + expect( + orcadManagedStopIsUserRequested( + { ...request, transactionId: 'f11f43dc-e3d9-497c-8c6e-d86ae7e54f7c' }, + root + ) + ).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +it('honors explicit user intent even when the activation journal is unreadable', () => { + const root = mkdtempSync(join(tmpdir(), 'orcad-stop-intent-')) + try { + writeFileSync(join(root, ORCAD_ACTIVATION_TRANSACTION_FILENAME), '{') + expect(orcadManagedStopIsUserRequested(request, root)).toBe(false) + expect(orcadManagedStopIsUserRequested({ ...request, intent: 'user' }, root)).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/orcad/orcad-managed-stop-intent.ts b/src/main/orcad/orcad-managed-stop-intent.ts new file mode 100644 index 000000000000..359c1f4701c0 --- /dev/null +++ b/src/main/orcad/orcad-managed-stop-intent.ts @@ -0,0 +1,44 @@ +import { join } from 'node:path' +import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import { ORCAD_MANAGED_ACTIVATION_ROOT_ENV } from '../../shared/orcad-idle-exit' +import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' +import { + ORCAD_ACTIVATION_TRANSACTION_FILENAME, + parseOrcadActivationTransaction +} from '../ssh/orcad-activation-transaction' +import { sameOrcadManagedStopInstance } from './orcad-managed-stop-request' + +/** Older Settings stops carry their intent in the existing decommission transaction. */ +export function orcadManagedStopIsUserRequested( + request: OrcadManagedStopRequest, + activationRoot = process.env[ORCAD_MANAGED_ACTIVATION_ROOT_ENV] +): boolean { + if (request.intent === 'user') { + return true + } + if (!activationRoot) { + return false + } + try { + const { buffer } = readNodeFileSyncWithinLimit( + join(activationRoot, ORCAD_ACTIVATION_TRANSACTION_FILENAME), + 64 * 1024 + ) + const parsed = parseOrcadActivationTransaction(buffer.toString('utf8')) + if (parsed.state !== 'ok') { + return false + } + const transaction = parsed.transaction + return ( + transaction.operation === 'decommission' && + transaction.phase === 'stop-dispatched' && + transaction.transactionId === request.transactionId && + transaction.request !== null && + transaction.request.runtimeId === request.runtimeId && + transaction.request.version === request.version && + sameOrcadManagedStopInstance(transaction.request.instance, request.instance) + ) + } catch { + return false + } +} diff --git a/src/main/orcad/orcad-stop-request-listener.test.ts b/src/main/orcad/orcad-stop-request-listener.test.ts index dc98966f6c45..cd8d796be495 100644 --- a/src/main/orcad/orcad-stop-request-listener.test.ts +++ b/src/main/orcad/orcad-stop-request-listener.test.ts @@ -52,7 +52,7 @@ describe('orcad stop-request listeners', () => { const installRoot = directory() writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') const onRequest = vi.fn() - listen(onRequest, { installRoot }) + listen(onRequest, { installRoot, admitAutomaticStop: (commit) => commit() }) expect(onRequest).toHaveBeenCalledOnce() expect(existsSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) }) @@ -60,7 +60,7 @@ describe('orcad stop-request listeners', () => { it('picks up a slot request written later, once', async () => { const installRoot = directory() const onRequest = vi.fn() - listen(onRequest, { installRoot }) + listen(onRequest, { installRoot, admitAutomaticStop: (commit) => commit() }) writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') @@ -73,6 +73,7 @@ describe('orcad stop-request listeners', () => { const onRequest = vi.fn() const report = vi.spyOn(console, 'error').mockImplementation(() => {}) listen(onRequest, { + admitAutomaticStop: (commit) => commit(), installRoot: directory(), managedStop, beforeManagedStop: async () => { @@ -98,7 +99,11 @@ describe('orcad stop-request listeners', () => { it('stops on a valid managed request and keeps it as evidence', async () => { const managedStop = managedContext() const onRequest = vi.fn() - listen(onRequest, { installRoot: directory(), managedStop }) + listen(onRequest, { + admitAutomaticStop: (commit) => commit(), + installRoot: directory(), + managedStop + }) const path = orcadManagedStopRequestPath(managedStop.instance) writeFileSync( path, @@ -125,7 +130,12 @@ describe('orcad stop-request listeners', () => { const onRequest = vi.fn() const prepare = vi.fn(async () => {}) const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { installRoot: directory(), managedStop, beforeManagedStop: prepare }) + listen(onRequest, { + admitAutomaticStop: (commit) => commit(), + installRoot: directory(), + managedStop, + beforeManagedStop: prepare + }) await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) await new Promise((resolve) => setTimeout(resolve, 50)) expect(prepare).toHaveBeenCalledOnce() @@ -137,7 +147,11 @@ describe('orcad stop-request listeners', () => { const managedStop = managedContext() const onRequest = vi.fn() const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { installRoot: directory(), managedStop }) + listen(onRequest, { + admitAutomaticStop: (commit) => commit(), + installRoot: directory(), + managedStop + }) writeFileSync( orcadManagedStopRequestPath(managedStop.instance), JSON.stringify({ @@ -156,9 +170,73 @@ describe('orcad stop-request listeners', () => { it('stops watching once closed', async () => { const installRoot = directory() const onRequest = vi.fn() - listen(onRequest, { installRoot }).close() + listen(onRequest, { installRoot, admitAutomaticStop: (commit) => commit() }).close() writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') await new Promise((resolve) => setTimeout(resolve, 50)) expect(onRequest).not.toHaveBeenCalled() }) + + it.each([1, null])('defers an older desktop slot request while work is %s', async (work) => { + const installRoot = directory() + const path = join(installRoot, ORCAD_STOP_REQUEST_FILENAME) + let observed = work + const onRequest = vi.fn() + listen(onRequest, { + installRoot, + admitAutomaticStop: (commit) => observed === 0 && commit() + }) + writeFileSync(path, '') + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(onRequest).not.toHaveBeenCalled() + expect(existsSync(path)).toBe(true) + observed = 0 + await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) + }) + + it('defers a generation-bound request without claiming its decision', async () => { + const managedStop = managedContext() + const onRequest = vi.fn() + let owingWork = true + listen(onRequest, { + installRoot: directory(), + managedStop, + admitAutomaticStop: (commit) => !owingWork && commit() + }) + const path = orcadManagedStopRequestPath(managedStop.instance) + writeFileSync( + path, + JSON.stringify({ + schemaVersion: 1, + transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', + ...managedStop + }) + ) + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(onRequest).not.toHaveBeenCalled() + owingWork = false + await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) + }) + + it.each(['slot', 'managed'])('always honors an explicit user stop through %s', async (kind) => { + const installRoot = directory() + const managedStop = managedContext() + const onRequest = vi.fn() + const automatic = vi.fn(() => false) + listen(onRequest, { installRoot, managedStop, admitAutomaticStop: automatic }) + if (kind === 'slot') { + writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '{"intent":"user"}') + } else { + writeFileSync( + orcadManagedStopRequestPath(managedStop.instance), + JSON.stringify({ + schemaVersion: 1, + transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', + ...managedStop, + intent: 'user' + }) + ) + } + await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) + expect(automatic).not.toHaveBeenCalled() + }) }) diff --git a/src/main/orcad/orcad-stop-request-listener.ts b/src/main/orcad/orcad-stop-request-listener.ts index 1bde6a3a65aa..3928d054f834 100644 --- a/src/main/orcad/orcad-stop-request-listener.ts +++ b/src/main/orcad/orcad-stop-request-listener.ts @@ -6,6 +6,7 @@ * root, which is validated and kept as evidence until the completion command proves exit. */ import { unlinkSync, watch, type FSWatcher } from 'node:fs' +import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import { basename, dirname, join } from 'node:path' import { ORCAD_STOP_REQUEST_FILENAME, @@ -16,9 +17,13 @@ import { orcadManagedStopRequestPath, validateOrcadManagedStopRequest } from './orcad-managed-stop-request' -import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' +import { + claimOrcadManagedStopDecision, + readOrcadManagedStopDecision +} from './orcad-managed-stop-decision' import { withdrawOrcadManagedStopRequest } from './orcad-managed-stop-cancellation' import { hasErrorCode } from '../daemon/daemon-process-inspection' +import { orcadManagedStopIsUserRequested } from './orcad-managed-stop-intent' export type OrcadStopRequestListener = { close(): void } @@ -92,6 +97,8 @@ export function installOrcadStopRequestListeners( options: { installRoot: string managedStop?: OrcadManagedStopContext + /** Commits only after a host observation proves no work, then closes chat admission. */ + admitAutomaticStop: (commit: () => boolean) => boolean /** Runs once for a validated managed request before the stop; it cannot prevent it. */ beforeManagedStop?: (request: OrcadManagedStopRequest) => Promise pollIntervalMs?: number @@ -109,8 +116,14 @@ export function installOrcadStopRequestListeners( listenForRequest( join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME), (path) => { - unlinkSync(path) - return true + const user = + readNodeFileSyncWithinLimit(path, 1024).buffer.toString('utf8').trim() === + '{"intent":"user"}' + const consume = (): boolean => { + unlinkSync(path) + return true + } + return user ? consume() : options.admitAutomaticStop(consume) ? true : null }, () => onRequest(), pollIntervalMs @@ -124,13 +137,21 @@ export function installOrcadStopRequestListeners( orcadManagedStopRequestPath(managedStop.instance), (path) => { const request = validateOrcadManagedStopRequest(managedStop, path) - // A cancelled request is never acted on; keep listening for the next transaction. - if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') { - // A cancelled request left behind would block every later transaction's request. + if (readOrcadManagedStopDecision(request) === 'canceled') { withdrawOrcadManagedStopRequest(request) throw new Error('orcad_managed_stop_canceled') } - return request + const commit = (): boolean => { + if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') { + withdrawOrcadManagedStopRequest(request) + throw new Error('orcad_managed_stop_canceled') + } + return true + } + const admitted = orcadManagedStopIsUserRequested(request) + ? commit() + : options.admitAutomaticStop(commit) + return admitted ? request : null }, (request) => { close() diff --git a/src/main/orcad/orcad-structured-work-boundary.ts b/src/main/orcad/orcad-structured-work-boundary.ts new file mode 100644 index 000000000000..e27370a60205 --- /dev/null +++ b/src/main/orcad/orcad-structured-work-boundary.ts @@ -0,0 +1,37 @@ +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' + +export function admitOrcadAutomaticStop(commit: () => boolean): boolean { + return getStructuredAgentSessionHost()?.serverRetirement.admitStop(commit) ?? false +} + +export async function prepareOrcadStructuredWorkBoundary(runtime: { + ensureStructuredAgentSessionHost: () => Promise +}): Promise { + try { + await runtime.ensureStructuredAgentSessionHost() + const host = getStructuredAgentSessionHost() + await host?.reconcileRestartLeases() + await host?.restoreReadableSessions() + } catch (error) { + console.error('[orcad] structured work is unverifiable:', error) + } +} + +/** An unknown prior owner is probed again; its saved lease never becomes an idle-exit latch. */ +export async function observeOrcadStructuredWork(): Promise { + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + await host.reconcileRestartLeases() + return host.serverRetirement.observe() +} + +export async function expireOrcadUnansweredPrompts(): Promise { + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + await host.serverRetirement.expireUnansweredPrompts() + return observeOrcadStructuredWork() +} diff --git a/src/main/orcad/orcad-unanswered-prompt-expiry.test.ts b/src/main/orcad/orcad-unanswered-prompt-expiry.test.ts new file mode 100644 index 000000000000..1268b7132abc --- /dev/null +++ b/src/main/orcad/orcad-unanswered-prompt-expiry.test.ts @@ -0,0 +1,55 @@ +import { expect, it, vi } from 'vitest' +import { + attach, + hostTestState, + seedApproval +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS as MAX_AGE } from '../native-chat/agent-session-wire/structured-agent-session-server-lifetime' + +it('keeps an unanswered approval alive for 24 hours, then cancels it and releases the child', async () => { + await attach() + const { host, acquire } = hostTestState() + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('provider not attached') + } + events.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'pending', ordinal: 50 }, + { kind: 'turn', turnId: 'pending', state: 'running' }, + { turnScope: { kind: 'thread' } } + ) + await host.flushStreamedEvents(HOST_TEST_SESSION) + const approval = await seedApproval() + const requestedAt = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( + (item) => item.itemId === approval.itemId + )?.observedAt + if (requestedAt === undefined) { + throw new Error('approval missing') + } + const now = vi.spyOn(host.deps, 'now').mockReturnValue(requestedAt + MAX_AGE - 1) + try { + await host.serverRetirement.expireUnansweredPrompts() + expect(host.serverRetirement.read()).toBeGreaterThan(0) + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( + (item) => item.itemId === approval.itemId + )?.body + ).toMatchObject({ resolution: { state: 'pending' } }) + now.mockReturnValue(requestedAt + MAX_AGE) + await host.serverRetirement.expireUnansweredPrompts() + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( + (item) => item.itemId === approval.itemId + )?.body + ).toMatchObject({ resolution: { state: 'cancelled' } }) + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).items.some( + ({ body }) => body.kind === 'turn' && body.state === 'interrupted' + ) + ).toBe(true) + await vi.waitFor(() => expect(host.serverRetirement.read()).toBe(0)) + } finally { + now.mockRestore() + } +}) diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.test.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.test.ts index 5759f9f8de12..092e8786276d 100644 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.test.ts +++ b/src/main/runtime/rpc/methods/orcad-terminal-census.test.ts @@ -1,12 +1,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('../../../orcad/orcad-structured-work-boundary', () => ({ + observeOrcadStructuredWork: async () => 0 +})) + const collect = vi.hoisted(() => vi.fn()) vi.mock('../../../orcad/orcad-terminal-census', () => ({ collectOrcadTerminalCensus: collect })) import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad-terminal-census' const handler = ORCAD_TERMINAL_CENSUS_METHODS[0]!.handler -const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 } +const idle = { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 7 +} function call(params: Record, released: number) { const runtime = { releaseFinishedAutomationRunTerminals: vi.fn(async () => released) } diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.ts index f48c553156d7..5598c78148da 100644 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.ts +++ b/src/main/runtime/rpc/methods/orcad-terminal-census.ts @@ -1,3 +1,4 @@ +import { observeOrcadStructuredWork } from '../../../orcad/orcad-structured-work-boundary' import { defineMethod } from '../core' import { ORCAD_TERMINAL_CENSUS_METHOD, @@ -13,13 +14,17 @@ export const ORCAD_TERMINAL_CENSUS_METHODS = [ // Why lazy: the census reaches the daemon modules, whose xterm polyfill defines a global // `window`; importing them statically would load it into every process with the dispatcher. const { collectOrcadTerminalCensus } = await import('../../../orcad/orcad-terminal-census') + const collect = async () => ({ + ...(await collectOrcadTerminalCensus(params.activatedAt)), + structuredWork: await observeOrcadStructuredWork() + }) if (!params.releaseFinishedAutomationTerminals) { - return collectOrcadTerminalCensus(params.activatedAt) + return collect() } - const before = await collectOrcadTerminalCensus(params.activatedAt) + const before = await collect() const released = await runtime.releaseFinishedAutomationRunTerminals() return settledCensus( - () => collectOrcadTerminalCensus(params.activatedAt), + collect, before.liveSessions === null ? null : before.liveSessions - released ) } diff --git a/src/main/ssh/orcad-managed-remote-stop.ts b/src/main/ssh/orcad-managed-remote-stop.ts index 0c76dfc405a2..675a066d79bf 100644 --- a/src/main/ssh/orcad-managed-remote-stop.ts +++ b/src/main/ssh/orcad-managed-remote-stop.ts @@ -30,6 +30,7 @@ import { ORCAD_COMPLETE_MANAGED_STOP_FLAG, ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG, ORCAD_STOP_REQUESTS_CAPABILITY, + ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY, OrcadManagedStopCancellationSchema, OrcadManagedStopCompletionSchema, type OrcadManagedStopCancellation, @@ -51,7 +52,8 @@ function refused(code: string, reason: string): OrcadManagedStopTarget { /** Names the running instance of `version`, or says why the host could not prove which it is. */ export async function readRemoteOrcadManagedStopTarget( options: OrcadSlotOptions, - version: string + version: string, + intent?: 'user' ): Promise { const slotDir = orcadSlotDir(options, version) const readiness = parseOrcadReadinessWaitOutput( @@ -71,6 +73,15 @@ export async function readRemoteOrcadManagedStopTarget( `orcad ${version} predates managed stop requests; it can only be stopped by signal.` ) } + if ( + intent !== 'user' && + health.structuredWorkProtection !== ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY + ) { + return refused( + 'orcad_structured_work_protection_unavailable', + 'This server cannot protect chat work during an automatic stop. Use an explicit update or stop instead.' + ) + } const lockPath = joinRemotePath(options.host, options.userDataDir, ORCAD_LOCK_FILE_NAME) const lockRead = await readBoundedOrcadRemoteRecord(options, lockPath, LOCK_RECORD_MAX_BYTES) const lock = lockRead.state === 'present' ? parseOrcadInstanceLockRecord(lockRead.raw) : null diff --git a/src/main/ssh/orcad-managed-runtime-context.ts b/src/main/ssh/orcad-managed-runtime-context.ts index 6195028ab75a..2cb8e509654f 100644 --- a/src/main/ssh/orcad-managed-runtime-context.ts +++ b/src/main/ssh/orcad-managed-runtime-context.ts @@ -106,8 +106,7 @@ export function probeManagedOrcadReadiness( ) } -// Why only this code: deploy never has a session count to force past, so forcing an unknown -// census lands on the daemon-protocol deferral, which force cannot clear. +// Unknown terminal protocol still cannot be forced; explicit chat interruption needs no protocol. export function isForceableOrcadDeferral(code: string): boolean { - return code === 'orcad_update_terminals_running' + return code === 'orcad_update_terminals_running' || code === 'orcad_update_structured_work' } diff --git a/src/main/ssh/orcad-recovery-slot.ts b/src/main/ssh/orcad-recovery-slot.ts index 29b1f6b607ee..de752ca5dda9 100644 --- a/src/main/ssh/orcad-recovery-slot.ts +++ b/src/main/ssh/orcad-recovery-slot.ts @@ -36,6 +36,8 @@ export type OrcadSlotOptions = OrcadRemoteExecTarget & { userDataDir: string bindHost: string port: number + /** Only an explicit update-now request may interrupt structured work. */ + force?: boolean readinessTimeoutMs?: number sleep?: (ms: number) => Promise } @@ -115,8 +117,17 @@ export async function stopOrcadSlot( options.host, remoteDir, justLaunched - ? { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, justLaunched: true } - : { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, nodePath: options.nodePath } + ? { + waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, + justLaunched: true, + nodePath: options.nodePath, + user: options.force === true + } + : { + waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, + nodePath: options.nodePath, + user: options.force === true + } ) ) ) diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index 6742623a91ab..eae92f768a29 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -190,7 +190,12 @@ function options(overrides: Partial = {}): OrcadDeployOption userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, readinessTimeoutMs: 50, sleep: async () => {}, now: () => new Date('2026-02-02T00:00:00.000Z'), @@ -335,7 +340,12 @@ describe('deployOrcad', () => { options({ host: getRemoteHostPlatform(platform), target, - census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { + structuredWork: 0, + liveSessions: 1, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + } }) ) const chmod = mockExec.mock.calls.findIndex(([, command]) => @@ -551,7 +561,14 @@ describe('deployOrcad', () => { } scriptHost(script) const result = await deployOrcad( - options({ census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 } }) + options({ + census: { + structuredWork: 0, + liveSessions: 2, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + } + }) ) expect(result).toMatchObject({ outcome: 'installed-not-activated', diff --git a/src/main/ssh/orcad-remote-lifecycle-windows.test.ts b/src/main/ssh/orcad-remote-lifecycle-windows.test.ts index 23f6624b8311..d63cbffb3b80 100644 --- a/src/main/ssh/orcad-remote-lifecycle-windows.test.ts +++ b/src/main/ssh/orcad-remote-lifecycle-windows.test.ts @@ -84,6 +84,7 @@ function readyLine(version = VERSION): string { arch: 'x64', pid: 4242, stopRequests: 1, + structuredWorkProtection: 1, terminalDaemon: { state: 'live', ownsFreshSessions: true, @@ -168,7 +169,12 @@ function options(): OrcadDeployOptions { userDataDir: 'C:/Users/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, readinessTimeoutMs: 1_000, sleep: async () => {}, now: () => new Date('2026-10-02T00:00:00.000Z') diff --git a/src/main/ssh/orcad-remote-process-control-windows.ts b/src/main/ssh/orcad-remote-process-control-windows.ts index 5898a65fe013..dc5136c543bd 100644 --- a/src/main/ssh/orcad-remote-process-control-windows.ts +++ b/src/main/ssh/orcad-remote-process-control-windows.ts @@ -12,11 +12,12 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' export function windowsStopOrcadCommand( host: RemoteHostPlatform, remoteInstallDir: string, - options: { waitSeconds: number; justLaunched: boolean } + options: { waitSeconds: number; justLaunched: boolean; user?: boolean } ): string { return orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'stop', [ remoteInstallDir, String(options.waitSeconds), - options.justLaunched ? '1' : '0' + options.justLaunched ? '1' : '0', + options.user ? '1' : '0' ]) } diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index 3650d5a6a71a..3b9ad832cf6b 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -9,7 +9,8 @@ import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' import { ORCAD_STOP_REQUEST_FILENAME, - ORCAD_STOP_REQUESTS_CAPABILITY + ORCAD_STOP_REQUESTS_CAPABILITY, + ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY } from '../../shared/orcad-stop-request' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { ORCAD_PID_FILENAME, posixProcessAliveShellFunction } from './orcad-remote-host-support' @@ -27,15 +28,16 @@ import { windowsStopOrcadCommand } from './orcad-remote-process-control-windows' export function stopOrcadCommand( host: RemoteHostPlatform, remoteInstallDir: string, - options: { waitSeconds: number } & ( - | { justLaunched: true } + options: { waitSeconds: number; user?: boolean } & ( + | { justLaunched: true; nodePath?: string } | { justLaunched?: false; nodePath: string } ) ): string { if (isWindowsRemoteHost(host)) { return windowsStopOrcadCommand(host, remoteInstallDir, { waitSeconds: options.waitSeconds, - justLaunched: options.justLaunched === true + justLaunched: options.justLaunched === true, + user: options.user }) } const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) @@ -47,6 +49,11 @@ export function stopOrcadCommand( `const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`, `const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`, `if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`, + ...(options.user + ? [] + : [ + `if (r.health?.structuredWorkProtection !== ${ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY}) process.exit(1);` + ]), `const mode = r.health?.stopRequests === ${ORCAD_STOP_REQUESTS_CAPABILITY} ? 'request' : 'signal';`, `process.stdout.write(String(pid) + ':' + mode);` ].join(' ') @@ -56,17 +63,17 @@ export function stopOrcadCommand( 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', 'stop_mode=signal;', // Older launchers recorded a waiting shell, whose exit does not prove runtime exit. - ...(options.justLaunched + ...(options.justLaunched && options.user ? [] : [ - `runtime_answer=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + + `runtime_answer=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath ?? 'node')}; ` + `"$orcad_runtime" -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, '[ "$pid" = "${runtime_answer%%:*}" ] || { echo UNKNOWN; exit 0; };', 'stop_mode=${runtime_answer#*:};' ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', 'if [ "$stop_mode" = request ]; then', - `( umask 077; : > ${requestFile} ) 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };`, + `( umask 077; printf %s ${shellEscape(options.user ? '{"intent":"user"}' : '')} > ${requestFile} ) 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };`, 'else kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; }; fi;', // Past here the stop may be under way, so a lost or unknown answer must keep the fence. 'echo SIGNALED;', diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index 59baa86881a7..2d9c5ea91ab4 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -185,7 +185,12 @@ function options(overrides: Partial = {}): OrcadRollbackOp userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, targetBuildHash: BUILD_HASH, targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, readinessTimeoutMs: 50, @@ -236,7 +241,14 @@ describe('rollbackOrcad', () => { const log: string[] = [] scriptHost(log) const result = await rollbackOrcad( - options({ census: { liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 3 } }) + options({ + census: { + structuredWork: 0, + liveSessions: 3, + startedSinceActivation: 2, + daemonProtocolVersion: 3 + } + }) ) expect(result).toMatchObject({ outcome: 'refused', diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index fffede27a02a..87d7549bc56f 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -127,7 +127,7 @@ async function launchTestRuntime( ] : []), `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid${ - stopRequests ? ', stopRequests: 1' : '' + stopRequests ? ', stopRequests: 1, structuredWorkProtection: 1' : '' }}}));`, `setTimeout(() => process.exit(1), 10_000);` ].join('\n') @@ -166,7 +166,10 @@ async function launchTestRuntime( return { runtimePid, recordedPid, terminatedFile } } -function stopTestRuntime(justLaunched = false): ReturnType { +function stopTestRuntime( + justLaunched = false, + user = true +): ReturnType { return parseOrcadStopOutcome( execFileSync( '/bin/sh', @@ -174,6 +177,7 @@ function stopTestRuntime(justLaunched = false): ReturnType { expect(existsSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) }) + it('refuses an automatic stop of an older server with no structured protection', async () => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + expect(stopTestRuntime(false, false)).toBe('unknown') + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + expect(existsSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) + }) + + it('never signals an automatic candidate cleanup before readiness proves protection', async () => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + rmSync(join(versionDir, ORCAD_READINESS_FILENAME)) + expect(stopTestRuntime(true, false)).toBe('unknown') + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + }) + it('clears a stop request the previous process never consumed before launching', async () => { writeFileSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME), '') const { runtimePid } = await launchTestRuntime(false, true) @@ -452,7 +472,7 @@ describe('liveness and stop commands, run for real', () => { } ) - it('can stop a candidate just launched with exec without readiness', async () => { + it('can explicitly stop a candidate just launched with exec without readiness', async () => { const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() expect(recordedPid).toBe(runtimePid) rmSync(join(versionDir, ORCAD_READINESS_FILENAME)) @@ -474,6 +494,7 @@ describe('liveness and stop commands, run for real', () => { sh( `${deniedProbe} ${stopOrcadCommand(host, versionDir, { waitSeconds: 1, + user: true, nodePath: process.execPath })}` ) @@ -549,7 +570,13 @@ describe('liveness and stop commands, run for real', () => { ) expect( parseOrcadStopOutcome( - sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) + sh( + stopOrcadCommand(host, versionDir, { + waitSeconds: 10, + justLaunched: true, + user: true + }) + ) ) ).toBe('stopped') expect(await exited).toBe('SIGTERM') @@ -574,7 +601,7 @@ describe('liveness and stop commands, run for real', () => { expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') expect( parseOrcadStopOutcome( - sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true, user: true })) ) ).toBe('already-exited') } finally { @@ -587,7 +614,7 @@ describe('liveness and stop commands, run for real', () => { writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) expect( parseOrcadStopOutcome( - sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true, user: true })) ) ).toBe('already-exited') }) diff --git a/src/main/ssh/orcad-remote-stop.ts b/src/main/ssh/orcad-remote-stop.ts index 345926b0d727..a615891844bb 100644 --- a/src/main/ssh/orcad-remote-stop.ts +++ b/src/main/ssh/orcad-remote-stop.ts @@ -89,7 +89,7 @@ export async function decommissionRemoteOrcad( if (census) { return census } - const target = await readRemoteOrcadManagedStopTarget(options, activeVersion) + const target = await readRemoteOrcadManagedStopTarget(options, activeVersion, 'user') if (target.state === 'refused') { return refuse(target.verdict, target.code, target.reason) } @@ -106,7 +106,8 @@ export async function decommissionRemoteOrcad( transactionId: transaction.transactionId, ...target.context, // Best effort: an idle daemon goes with orcad, a busy one keeps its terminals. - retireIdleDaemon: true as const + retireIdleDaemon: true as const, + intent: 'user' as const } // Durable before it can reach the host, so recovery can settle exactly this request. transaction = withOrcadDecommissionStopDispatched(transaction, request, now()) diff --git a/src/main/ssh/orcad-remote-windows-scripts.test.ts b/src/main/ssh/orcad-remote-windows-scripts.test.ts index d1699ac18d31..c2c4686c21f9 100644 --- a/src/main/ssh/orcad-remote-windows-scripts.test.ts +++ b/src/main/ssh/orcad-remote-windows-scripts.test.ts @@ -141,8 +141,10 @@ describe('Windows liveness script', () => { describe('Windows stop script', () => { const requestFile = () => join(dir, ORCAD_STOP_REQUEST_FILENAME) - const stop = async (justLaunched: boolean, waitSeconds = 5) => - (await runOp('stop', [dir, String(waitSeconds), justLaunched ? '1' : '0'])).stdout + const stop = async (justLaunched: boolean, waitSeconds = 5, user = true) => + ( + await runOp('stop', [dir, String(waitSeconds), justLaunched ? '1' : '0', user ? '1' : '0']) + ).stdout .trim() .split(/\r?\n/u) .at(-1) @@ -168,8 +170,11 @@ describe('Windows stop script', () => { const pid = fakeOrcad() stageAddon({ [pid]: 1000 }) recordProcess(pid, 1000) - writeFileSync(join(dir, ORCAD_READINESS_FILENAME), readyLine({ pid, stopRequests: 1 })) - expect(await stop(false)).toBe('STOPPED') + writeFileSync( + join(dir, ORCAD_READINESS_FILENAME), + readyLine({ pid, stopRequests: 1, structuredWorkProtection: 1 }) + ) + expect(await stop(false, 5, false)).toBe('STOPPED') }) it('writes the request for a just-launched candidate that has not published readiness', async () => { @@ -179,6 +184,17 @@ describe('Windows stop script', () => { expect(await stop(true)).toBe('STOPPED') }) + it('refuses an automatic stop when an older server cannot protect chats', async () => { + stageAddon({ [process.pid]: 1000 }) + recordProcess(process.pid, 1000) + writeFileSync( + join(dir, ORCAD_READINESS_FILENAME), + readyLine({ pid: process.pid, stopRequests: 1 }) + ) + expect(await stop(false, 0, false)).toBe('UNKNOWN') + expect(existsSync(requestFile())).toBe(false) + }) + it('refuses a build that cannot be asked, without writing anything', async () => { stageAddon({ [process.pid]: 1000 }) recordProcess(process.pid, 1000) diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts index f0f5be86e484..551c200229e0 100644 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ b/src/main/ssh/orcad-runtime-deployment.test.ts @@ -192,7 +192,12 @@ describe('createManagedOrcadEnvironment', () => { it('deploys an empty host with a zero census and an unknown one over an active slot', async () => { await deploy() expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: null + }, nodePath: '' }) }) diff --git a/src/main/ssh/orcad-runtime-maintenance.test.ts b/src/main/ssh/orcad-runtime-maintenance.test.ts index 33506c397ca1..f992ca6f919b 100644 --- a/src/main/ssh/orcad-runtime-maintenance.test.ts +++ b/src/main/ssh/orcad-runtime-maintenance.test.ts @@ -103,10 +103,12 @@ describe('updateManagedOrcadEnvironment', () => { mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-not-activated', fullVersion: '0.3.0+new', - code: 'orcad_update_terminals_running', + code: 'orcad_update_structured_work', reason: 'busy' }) - await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) + await expect( + updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) + ).resolves.toMatchObject({ outcome: 'deferred', forceable: true }) mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-and-activated', fullVersion: '0.3.0+new' @@ -116,6 +118,7 @@ describe('updateManagedOrcadEnvironment', () => { force: true }) expect(result).toMatchObject({ outcome: 'updated', activeVersion: '0.3.0+new' }) + expect(mocks.deploy.mock.calls.at(-1)?.[0]).toMatchObject({ force: true }) expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), { buildHash: 'local-hash', fullVersion: '0.3.0+new' diff --git a/src/main/ssh/orcad-update-plan-structured-work.test.ts b/src/main/ssh/orcad-update-plan-structured-work.test.ts new file mode 100644 index 000000000000..21d086893d42 --- /dev/null +++ b/src/main/ssh/orcad-update-plan-structured-work.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from 'vitest' +import { planOrcadUpdate } from './orcad-update-plan' +import { emptyOrcadActivationRecord } from './orcad-activation-record' +import { createQueuedMessageTestRig } from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' + +it('U1: automatic update defers with zero PTYs while native provider work is in flight', async () => { + const rig = await createQueuedMessageTestRig() + try { + const sent = await rig.workingSend() + expect(await rig.submission(sent)).toMatchObject({ + dispatchState: 'pending', + handedOverAt: expect.any(Number) + }) + expect(rig.closeSession).not.toHaveBeenCalled() + expect( + planOrcadUpdate({ + candidateDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, + record: { ...emptyOrcadActivationRecord(), active: '0.2.0+aa01' }, + candidateVersion: '0.3.0+bb01', + census: { + structuredWork: rig.host.serverRetirement.read(), + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + } + }) + ).toMatchObject({ action: 'defer', code: 'orcad_update_structured_work' }) + } finally { + await rig.dispose() + } +}) + +it('defers automatic updates of an older host without a structured work observation', () => { + const input = { + candidateDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, + record: { ...emptyOrcadActivationRecord(), active: '0.2.0+aa01' }, + candidateVersion: '0.3.0+bb01', + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + } + expect(planOrcadUpdate(input)).toMatchObject({ + action: 'defer', + code: 'orcad_update_structured_work' + }) + expect(planOrcadUpdate({ ...input, force: true })).toMatchObject({ action: 'proceed' }) +}) diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts index daa2014a016f..e32bd91f6b37 100644 --- a/src/main/ssh/orcad-update-plan.test.ts +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -35,7 +35,12 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.2.0+bb01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + } }) expect(plan).toMatchObject({ action: 'noop' }) }) @@ -45,7 +50,12 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 3, startedSinceActivation: 1, daemonProtocolVersion: 3 } + census: { + structuredWork: 0, + liveSessions: 3, + startedSinceActivation: 1, + daemonProtocolVersion: 3 + } }) expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_terminals_running' }) expect(plan.action === 'defer' && plan.reason).toContain('would not kill them') @@ -56,7 +66,12 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 } + census: { + structuredWork: 0, + liveSessions: null, + startedSinceActivation: null, + daemonProtocolVersion: 3 + } }) expect(plan).toMatchObject({ action: 'defer', @@ -69,7 +84,12 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: null, + startedSinceActivation: null, + daemonProtocolVersion: 3 + }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -80,7 +100,12 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 2, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -106,7 +131,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census, + census: { ...census, structuredWork: 0 }, force }) expect(plan).toMatchObject({ @@ -127,7 +152,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census, + census: { ...census, structuredWork: 0 }, force: true }) expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_ends_in_process_terminals' }) @@ -138,7 +163,12 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + } }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) }) @@ -150,7 +180,12 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'clean', target: '0.1.0+aa01' }) @@ -161,7 +196,12 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 1, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ safety: 'lossy', target: '0.1.0+aa01' }) @@ -173,7 +213,12 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, stateWritesSinceActivation: null }) expect(safety).toMatchObject({ safety: 'lossy' }) @@ -185,7 +230,12 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 4, startedSinceActivation: 1, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 4, + startedSinceActivation: 1, + daemonProtocolVersion: 3 + }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ @@ -200,7 +250,12 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: false, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -212,7 +267,12 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record({ snapshot: null }), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -223,7 +283,12 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { liveSessions: 2, startedSinceActivation: null, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 2, + startedSinceActivation: null, + daemonProtocolVersion: 3 + }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_census_unavailable' }) @@ -234,7 +299,12 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record({ previous: null }), snapshotPresent: true, - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, + census: { + structuredWork: 0, + liveSessions: 0, + startedSinceActivation: 0, + daemonProtocolVersion: 3 + }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_no_target' }) @@ -243,6 +313,7 @@ describe('assessOrcadRollback', () => { describe('D7 daemon protocol crossing', () => { const census = (daemonProtocolVersion: number | null, liveSessions: number | null = 2) => ({ + structuredWork: 0, liveSessions, startedSinceActivation: 0, daemonProtocolVersion diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts index f634e7395b5f..a15d6232e9ec 100644 --- a/src/main/ssh/orcad-update-plan.ts +++ b/src/main/ssh/orcad-update-plan.ts @@ -38,6 +38,7 @@ export type OrcadUpdateDecision = | { action: 'defer'; code: OrcadUpdateDeferCode; reason: string } export type OrcadUpdateDeferCode = + | 'orcad_update_structured_work' | 'orcad_update_terminals_running' | 'orcad_update_terminal_census_unavailable' | 'orcad_update_strands_live_terminals' @@ -66,6 +67,14 @@ export function planOrcadUpdate(input: { reason: `${input.candidateVersion} is already the active version; nothing to restart.` } } + if (input.record.active && !input.force && input.census.structuredWork !== 0) { + return { + action: 'defer', + code: 'orcad_update_structured_work', + reason: + 'Chat work is still running or could not be verified. Update when it finishes, or choose Update now to interrupt it.' + } + } const inProcess = input.census.inProcessSessions ?? 0 // First, and force cannot override it: these run inside orcad, so no daemon carries them // across, and an empty daemon list would otherwise read as an unreported protocol. @@ -167,6 +176,7 @@ export type OrcadRollbackSafety = | { safety: 'unsafe'; code: OrcadRollbackUnsafeCode; reason: string } export type OrcadRollbackUnsafeCode = + | 'orcad_rollback_structured_work' | 'orcad_rollback_no_target' | 'orcad_rollback_snapshot_missing' | 'orcad_rollback_orphans_live_terminals' @@ -206,6 +216,14 @@ export function assessOrcadRollback(input: { */ stateWritesSinceActivation: boolean | null }): OrcadRollbackSafety { + if (input.census.structuredWork !== 0) { + return { + safety: 'unsafe', + code: 'orcad_rollback_structured_work', + reason: + 'Chat work is still running or could not be verified. Nothing was stopped or restored.' + } + } const target = input.record.previous if (!target) { return { diff --git a/src/main/ssh/orcad-windows-host-script.ts b/src/main/ssh/orcad-windows-host-script.ts index c731244c6ffe..48f9e2b581cf 100644 --- a/src/main/ssh/orcad-windows-host-script.ts +++ b/src/main/ssh/orcad-windows-host-script.ts @@ -23,7 +23,8 @@ import { } from '../../shared/orcad-artifacts' import { ORCAD_STOP_REQUEST_FILENAME, - ORCAD_STOP_REQUESTS_CAPABILITY + ORCAD_STOP_REQUESTS_CAPABILITY, + ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY } from '../../shared/orcad-stop-request' import { ORCAD_STDIO_BRIDGE_FUNCTION } from './orcad-stdio-bridge-script' import { @@ -144,7 +145,7 @@ const ops = { }, // Never a signal: on Windows that is TerminateProcess, which skips the durable shutdown. - stop(dir, waitArg, launchedArg) { + stop(dir, waitArg, launchedArg, userArg) { const record = orcadRecord(dir) if (!record) return answer('NO_PID') const ready = readiness(dir) @@ -153,14 +154,15 @@ const ops = { // The readiness PID must corroborate the launcher's, so a reused PID is never addressed. if (health.pid !== record.pid) return answer('UNKNOWN') if (health.stopRequests !== ${ORCAD_STOP_REQUESTS_CAPABILITY}) return answer('UNSUPPORTED') - } else if (launchedArg !== '1') { + if (userArg !== '1' && health.structuredWorkProtection !== ${ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY}) return answer('UNKNOWN') + } else if (launchedArg !== '1' || userArg !== '1') { return answer('UNKNOWN') } // A just-launched candidate's listener consumes a request written before it started. const first = orcadState(dir, record) if (first === 'dead') return answer('ALREADY_EXITED') if (first !== 'alive') return answer('UNKNOWN') - try { fs.writeFileSync(path.join(dir, ${text(ORCAD_STOP_REQUEST_FILENAME)}), '', { mode: 0o600 }) } catch { return answer('SIGNAL_FAILED') } + try { fs.writeFileSync(path.join(dir, ${text(ORCAD_STOP_REQUEST_FILENAME)}), userArg === '1' ? '{"intent":"user"}' : '', { mode: 0o600 }) } catch { return answer('SIGNAL_FAILED') } process.stdout.write('SIGNALED\\n') const end = Date.now() + Number(waitArg) * 1000 const tick = () => { diff --git a/src/shared/orcad-stop-request.ts b/src/shared/orcad-stop-request.ts index f6e4dc243d43..f88f86f68029 100644 --- a/src/shared/orcad-stop-request.ts +++ b/src/shared/orcad-stop-request.ts @@ -35,7 +35,9 @@ export const OrcadManagedStopRequestSchema = z.object({ runtimeId: z.string().min(1).max(255), instance: OrcadManagedStopInstanceSchema, /** Best effort: also retire the terminal daemon if it is provably idle. Never blocks the stop. */ - retireIdleDaemon: z.literal(true).optional() + retireIdleDaemon: z.literal(true).optional(), + /** Missing intent is automatic, including requests from older desktops. */ + intent: z.literal('user').optional() }) export type OrcadManagedStopInstance = z.infer @@ -95,6 +97,8 @@ export type OrcadDaemonRetirementRecord = z.infer From c925a560108aa0d54a3b0a42077be0d536ffef5f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:51:27 -0700 Subject: [PATCH 2/9] fix: require stop-request support before automatic cleanup --- src/main/ssh/orcad-remote-process-control.ts | 2 +- ...orcad-remote-shell-commands.integration.test.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index 3b9ad832cf6b..bc732baf13ca 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -52,7 +52,7 @@ export function stopOrcadCommand( ...(options.user ? [] : [ - `if (r.health?.structuredWorkProtection !== ${ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY}) process.exit(1);` + `if (r.health?.structuredWorkProtection !== ${ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY} || r.health?.stopRequests !== ${ORCAD_STOP_REQUESTS_CAPABILITY}) process.exit(1);` ]), `const mode = r.health?.stopRequests === ${ORCAD_STOP_REQUESTS_CAPABILITY} ? 'request' : 'signal';`, `process.stdout.write(String(pid) + ':' + mode);` diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index 87d7549bc56f..ec3276fab5c6 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -428,6 +428,20 @@ describe('liveness and stop commands, run for real', () => { expect(existsSync(terminatedFile)).toBe(false) }) + it('never falls back to a signal when automatic readiness lacks stop-request support', async () => { + const { runtimePid, terminatedFile } = await launchTestRuntime() + writeFileSync( + join(versionDir, ORCAD_READINESS_FILENAME), + JSON.stringify({ + type: 'orca_server_ready', + health: { pid: runtimePid, structuredWorkProtection: 1 } + }) + ) + expect(stopTestRuntime(false, false)).toBe('unknown') + expect(() => process.kill(runtimePid, 0)).not.toThrow() + expect(existsSync(terminatedFile)).toBe(false) + }) + it('clears a stop request the previous process never consumed before launching', async () => { writeFileSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME), '') const { runtimePid } = await launchTestRuntime(false, true) From c486702754dd129b6437ded42836195cd68ccb7c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:34:51 -0700 Subject: [PATCH 3/9] fix: retain unverified session owners until exit is proven --- ...-agent-session-recovery-resolution.test.ts | 63 +++++++++++-------- ...tured-agent-session-recovery-resolution.ts | 36 ++++------- .../agent-session-lease-transitions.ts | 20 ------ .../agent-session-record-store.test.ts | 16 ++++- .../agent-session-lease-adjudication.test.ts | 8 +-- .../agent-session-lease-adjudication.ts | 28 ++++----- 6 files changed, 79 insertions(+), 92 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index d92eaddadc38..a4c9cf39432c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -118,7 +118,7 @@ function deps( } describe('structured session recovery resolution', () => { - it('releases an ownerless reservation: nothing it recorded can be holding it', async () => { + it('retains an unverified reservation until a token scan proves nothing spawned', async () => { const store = await openStore() await reserve(store) await latch(store) @@ -128,13 +128,25 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('resolved') + expect(result).toBe('unresolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'recovering', + runtimeFence: 1, + reservedSpawnToken: 'spawn-recovery' + }) + expect( + await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'reservation-unused' })), + SESSION + ) + ).toBe('resolved') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, runtimeFence: 2, reservedSpawnToken: null, - deathEvidence: null + deathEvidence: { kind: 'pid-absent' } }) }) @@ -190,7 +202,7 @@ describe('structured session recovery resolution', () => { }) }) - it('releases an owner that survives the stop ladder, with no death evidence', async () => { + it('retains an owner that survives the bounded stop ladder', async () => { const store = await openStore() await liveOwner(store) await latch(store) @@ -203,22 +215,21 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('resolved') + expect(result).toBe('unresolved') expect(stopOwnerProcess.mock.calls).toEqual([ [4242, 'SIGTERM'], [4242, 'SIGKILL'] ]) - // Its transport died with the runtime that held it; nothing proved it gone, so no evidence. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - runtimeFence: 2, - ownerProcess: null, + claimStatus: 'live', + handoffStage: 'recovering', + runtimeFence: 1, + ownerProcess: { pid: 4242 }, deathEvidence: null }) }) - it('releases an owner whose identity cannot be verified, and signals nothing', async () => { + it('retains an owner whose identity cannot be verified, and signals nothing', async () => { const store = await openStore() await liveOwner(store) await latch(store) @@ -231,19 +242,19 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('resolved') + expect(result).toBe('unresolved') // The pid may have been reused by an unrelated process. expect(stopOwnerProcess).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - runtimeFence: 2, - ownerProcess: null, + claimStatus: 'live', + handoffStage: 'recovering', + runtimeFence: 1, + ownerProcess: { pid: 4242 }, deathEvidence: null }) }) - it('releases a Windows owner the probe matches without signalling its saved pid', async () => { + it('retains a Windows owner without signalling its saved pid until exit is proven', async () => { const store = await openStore() await liveOwner(store) await latch(store) @@ -257,13 +268,13 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('resolved') + expect(result).toBe('unresolved') // Windows stops a tree only through a child it still holds; a saved pid is never signalled. expect(stopOwnerProcess).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, + claimStatus: 'live', + handoffStage: 'recovering', + ownerProcess: { pid: 4242 }, deathEvidence: null }) }) @@ -304,7 +315,7 @@ describe('structured session recovery resolution', () => { }) }) - it('releases a terminal reservation an older build left naming nobody at restart', async () => { + it('retains an older terminal reservation until the host proves it unused', async () => { const directory = await newStoreDirectory() await reserve(await openStore(directory)) await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) @@ -313,11 +324,10 @@ describe('structured session recovery resolution', () => { probe: async () => ({ outcome: 'indeterminate', reason: 'no scan' }), now: NOW }) - // No process is recorded for a conflict to name, so there is nothing to wait out. expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', - claimStatus: 'released', - handoffStage: null, + handoffStage: 'recovering', + reservedSpawnToken: 'spawn-recovery', deathEvidence: null }) expect( @@ -325,7 +335,8 @@ describe('structured session recovery resolution', () => { deps(store, () => ({ outcome: 'reservation-unused' })), SESSION ) - ).toBe('not-applicable') + ).toBe('resolved') + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 257072f7c23b..26e6b9b10ff7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -1,13 +1,7 @@ /** - * Exits from the `recovering` stage. A session lands there when evidence about its owner was - * unavailable; this re-asks with present-time evidence and always concludes. A dead owner is - * evicted on proof. A live one is stopped by identity and evicted once - * proven gone. One that outlives the stop, or whose identity cannot be verified, is released - * anyway: its transport died with the runtime that held it, so nothing can drive it, and no signal - * is sent to a pid that cannot be verified as the one recorded. Windows never signals a saved pid - * at all: Orca stops a Windows tree only through a child it still holds. Only a conflicted claim, - * which is how a terminal owner an older build recorded now loads, is waited out and never - * stopped: it is the user's own agent, and its exit is its way out. + * Recovery retains ownership until present-time evidence proves exit. Transport loss cannot + * prove exit. Native POSIX owners may be stopped by verified identity; Windows owners and + * terminal agents are only released on exit proof, since their saved pid is not a safe stop handle. */ import { @@ -17,7 +11,6 @@ import { } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' -import { releaseUnprovenAgentSessionOwner } from '../../runtime/agent-session-lease-transitions' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' export type StructuredSessionRecoveryStopSignal = 'SIGTERM' | 'SIGKILL' @@ -69,21 +62,16 @@ export async function resolveStructuredSessionRecovery( probe = await stopOwnerAndReprobe(deps, record, owner.pid) } } + if (owner ? !isProvenDeadProbe(probe) : probe.outcome !== 'reservation-unused') { + return 'unresolved' + } try { - await (owner && !isProvenDeadProbe(probe) - ? deps.store.transitionHandoff(sessionId, (latest) => - releaseUnprovenAgentSessionOwner({ - record: latest, - expectedFence: record.lease.runtimeFence, - now: deps.now() - }) - ) - : deps.store.evictProvenDeadOwner({ - sessionId, - expectedFence: record.lease.runtimeFence, - probe, - now: deps.now() - })) + await deps.store.evictProvenDeadOwner({ + sessionId, + expectedFence: record.lease.runtimeFence, + probe, + now: deps.now() + }) return 'resolved' } catch (error) { const code = error instanceof Error ? error.message : String(error) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 950e05a8e78e..1d21ddc13add 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -17,7 +17,6 @@ import { type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { agentSessionProviderHandleBelongsTo } from '../../shared/agent-session-provider-handle-encoding' -import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionDeathEvidence, AgentSessionLease, @@ -234,25 +233,6 @@ export function evictAgentSessionOwner(args: { return releasedAgentSessionLease(record, adjudication.nextFence, adjudication.evidence, args.now) } -/** - * Recovery's conclusion when proof never came: a recorded owner whose identity cannot be verified, - * or that survived the stop ladder. Its transport died with the runtime that held it, so nothing - * can drive it, and a verdict that never arrives must not hold the conversation. Nothing proved it - * gone, so no death evidence is written. - */ -export function releaseUnprovenAgentSessionOwner(args: { - record: AgentSessionRecord - expectedFence: number - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.expectedFence) - if (record.lease.handoffStage !== 'recovering') { - throw new Error('agent_session_ownership_unknown') - } - return releasedAgentSessionLease(record, nextAgentSessionFence(record.lease), null, args.now) -} - function releasedAgentSessionLease( record: AgentSessionRecord, runtimeFence: number, diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index cc0a83cc55f2..bac6075c02b0 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -584,14 +584,14 @@ describe('restart reconciliation', () => { probe: async () => ({ outcome: 'indeterminate', reason: 'no answer' }), now: NOW }) - // An unverifiable owner goes to recovery like any other; resolution concludes about it. + // Unverifiable execution retains its identity until a later probe can prove exit. expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ handoffStage: 'recovering', ownerProcess: { pid: expect.any(Number) } }) }) - it('releases a claim an older record marked conflicted that names no process', async () => { + it('retains an ownerless older conflict until its reservation is proven unused', async () => { const first = await open() await markLegacyConflicted(first, { ownerProcess: null }) @@ -601,9 +601,19 @@ describe('restart reconciliation', () => { now: NOW }) expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + claimStatus: 'conflicted', + handoffStage: 'recovering', + deathEvidence: null + }) + const verified = await open() + await verified.reconcileOnRestart({ + probe: async () => ({ outcome: 'reservation-unused' }), + now: NOW + 1 + }) + expect(verified.getRecord('session-alpha')?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - deathEvidence: null + deathEvidence: { kind: 'pid-absent' } }) }) diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index 35e794efd0d2..453abd5d733a 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -268,7 +268,7 @@ describe('restart reconciliation', () => { probe: INDETERMINATE, observedAt: 9_000 }) - ).toEqual({ disposition: 'evicted', nextFence: 8, evidence: null }) + ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) }) it('frees a conflict whose named owner is proven gone', () => { @@ -311,14 +311,12 @@ describe('restart reconciliation', () => { }) it.each([null, 'recovering'] as const)( - 'releases an ownerless reservation at stage %s, with evidence only when a scan proved nothing spawned', + 'retains an ownerless reservation at stage %s until a scan proves nothing spawned', (handoffStage) => { - // A child spawned before its identity was recorded lost its stdio with the runtime that - // crashed, and a token scan is the only proof there can be. const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage }) expect( adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) - ).toEqual({ disposition: 'evicted', nextFence: 8, evidence: null }) + ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) expect( adjudicateAgentSessionRestart({ lease: reserved, diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index 75de4a74c40d..c4de33297db1 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -242,23 +242,23 @@ export function adjudicateAgentSessionRestart(args: { // treating it as an unproven reservation is what re-latches every released record on restart. return { disposition: 'free', reason: 'lease has no owner and no reservation' } } - // Why: a child spawned before its identity was recorded lost its stdio with the runtime that - // crashed, so nothing can drive it. A token scan that proves no child is the only evidence there - // can be; without it the lease is released anyway. A live child still carrying the token is - // never signalled: the token is inherited by every descendant, so it cannot prove which one is - // the provider child. + // A spawn-token scan must prove the reservation unused; missing transport is not exit proof. + if (probe.outcome !== 'reservation-unused') { + return { + disposition: 'recovering', + stage: 'recovering', + reason: 'reservation process could not be verified' + } + } return { disposition: 'evicted', nextFence: nextAgentSessionFence(lease), - evidence: - probe.outcome === 'reservation-unused' - ? { - kind: 'pid-absent', - detail: 'reservation never spawned', - observedAt, - ownerFence: lease.runtimeFence - } - : null + evidence: { + kind: 'pid-absent', + detail: 'reservation never spawned', + observedAt, + ownerFence: lease.runtimeFence + } } } if (isProvenAliveProbe(probe)) { From c8213bb06b277c67862be173cd1a7ed20faca956 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:35:06 -0700 Subject: [PATCH 4/9] fix: preserve unreadable work and retry expired prompt stops --- ...tructured-agent-session-server-lifetime.ts | 28 +++- .../orcad/orcad-structured-work-boundary.ts | 2 +- ...orcad-structured-work-unverifiable.test.ts | 124 ++++++++++++++++++ ...cad-unanswered-prompt-expiry-retry.test.ts | 52 ++++++++ src/main/ssh/orcad-runtime-deployment.test.ts | 1 - 5 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/main/orcad/orcad-structured-work-unverifiable.test.ts create mode 100644 src/main/orcad/orcad-unanswered-prompt-expiry-retry.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts index 3694a12cfcb2..321dca5dbcfe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts @@ -7,6 +7,7 @@ import { renderJournalState } from '../agent-session-journal/journal-reducer' import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionOwesWork } from './structured-agent-session-owed-work' +import { isProvenDeadProbe } from '../../../shared/agent-session-lease-adjudication' export const STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS = 24 * 60 * 60_000 @@ -20,7 +21,11 @@ export function createStructuredAgentSessionServerLifetime(input: { const read = (): number | null => { const deps = input.context().deps try { - if (deps.store.readOnly || deps.journalDatabase.readOnly) { + if ( + deps.store.readOnly || + deps.journalDatabase.readOnly || + deps.store.listHeldSessionIds().some((id) => deps.store.isSessionUnreadable(id)) + ) { return null } let count = 0 @@ -66,10 +71,24 @@ export function createStructuredAgentSessionServerLifetime(input: { try { const context = input.context() for (const { sessionId, lease } of context.deps.store.listRecords()) { - if (!context.sessions.get(sessionId)?.child && lease.handoffStage === 'recovering') { + if (!context.sessions.get(sessionId)?.child && lease.claimStatus !== 'released') { await input.tasks.serialize(sessionId, async () => { - if (!input.context().sessions.get(sessionId)?.child) { - await input.context().runtimeState.resolveRecovery(sessionId) + const current = input.context() + const record = current.deps.store.getRecord(sessionId) + if (!record || current.sessions.get(sessionId)?.child) { + return + } + const probe = await current.runtimeState.probeRecord(record) + if ( + isProvenDeadProbe(probe) || + (record.lease.ownerProcess === null && probe.outcome === 'reservation-unused') + ) { + await current.deps.store.evictProvenDeadOwner({ + sessionId, + expectedFence: record.lease.runtimeFence, + probe, + now: current.now() + }) } }) } @@ -89,7 +108,6 @@ export function createStructuredAgentSessionServerLifetime(input: { const now = input.context().deps.now?.() ?? Date.now() return ( session?.child != null && - session.child.close === undefined && session.journal .snapshot() .items.some( diff --git a/src/main/orcad/orcad-structured-work-boundary.ts b/src/main/orcad/orcad-structured-work-boundary.ts index e27370a60205..2a39707696b0 100644 --- a/src/main/orcad/orcad-structured-work-boundary.ts +++ b/src/main/orcad/orcad-structured-work-boundary.ts @@ -11,7 +11,7 @@ export async function prepareOrcadStructuredWorkBoundary(runtime: { await runtime.ensureStructuredAgentSessionHost() const host = getStructuredAgentSessionHost() await host?.reconcileRestartLeases() - await host?.restoreReadableSessions() + await host?.serverRetirement.observe() } catch (error) { console.error('[orcad] structured work is unverifiable:', error) } diff --git a/src/main/orcad/orcad-structured-work-unverifiable.test.ts b/src/main/orcad/orcad-structured-work-unverifiable.test.ts new file mode 100644 index 000000000000..6543f18bcc61 --- /dev/null +++ b/src/main/orcad/orcad-structured-work-unverifiable.test.ts @@ -0,0 +1,124 @@ +import { expect, it, vi } from 'vitest' +import { createQueuedMessageTestRig } from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { + editPersistedTestAgentSessionStore, + openTestAgentSessionRecordStore +} from '../runtime/agent-session-record-store-test-harness' +import { + getStructuredAgentSessionHost, + setStructuredAgentSessionHost +} from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { prepareOrcadStructuredWorkBoundary } from './orcad-structured-work-boundary' + +it.each(['live', 'unverifiable'] as const)( + 'preserves a %s native owner during passive observation', + async (verdict) => { + const rig = await createQueuedMessageTestRig() + try { + const saved = rig.store.getRecord(HOST_TEST_SESSION) + if (!saved?.lease.ownerProcess) { + throw new Error('owner missing') + } + await rig.stop() + await rig.host.close(HOST_TEST_SESSION, 'evict') + await rig.store.transitionHandoff(HOST_TEST_SESSION, (record) => ({ + ...record, + lease: { ...saved.lease, handoffStage: 'recovering', deathEvidence: null } + })) + rig.host.deps.probeOwner = async () => + verdict === 'live' + ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + : { outcome: 'indeterminate', reason: 'probe unavailable' } + const stop = vi.fn() + rig.host.deps.stopOwnerProcess = stop + expect(rig.host.serverRetirement.read()).toBeNull() + await rig.host.serverRetirement.observe() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect({ + claimStatus: rig.store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus, + work: rig.host.serverRetirement.read(), + admitted: rig.host.serverRetirement.admitStop(() => true) + }).toEqual({ claimStatus: 'live', work: null, admitted: false }) + expect(stop).not.toHaveBeenCalled() + rig.host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) + await rig.host.serverRetirement.observe() + await vi.waitFor(() => expect(rig.host.serverRetirement.read()).toBe(0)) + expect(rig.store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ + ownerProcess: null, + deathEvidence: { kind: 'pid-absent' } + }) + } finally { + await rig.dispose() + } + } +) + +it('retains an unknown native owner across startup and readable restore until positive exit proof', async () => { + const rig = await createQueuedMessageTestRig() + const previous = getStructuredAgentSessionHost() + let restarted: StructuredAgentSessionHost | undefined + try { + const saved = rig.store.getRecord(HOST_TEST_SESSION) + if (!saved?.lease.ownerProcess) { + throw new Error('owner missing') + } + await rig.stop() + await rig.host.close(HOST_TEST_SESSION, 'evict') + await rig.store.transitionHandoff(HOST_TEST_SESSION, (record) => ({ + ...record, + lease: saved.lease + })) + const store = await openTestAgentSessionRecordStore(rig.root) + expect(store.getRecord(HOST_TEST_SESSION)?.lease.unreconciled).toBe(true) + const stop = vi.fn() + restarted = new StructuredAgentSessionHost({ + ...rig.host.deps, + store, + probeOwner: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), + stopOwnerProcess: stop + }) + setStructuredAgentSessionHost(restarted) + await prepareOrcadStructuredWorkBoundary({ ensureStructuredAgentSessionHost: async () => {} }) + await restarted.restoreReadableSessions() + expect(store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ + ownerProcess: saved.lease.ownerProcess, + handoffStage: 'recovering', + deathEvidence: null + }) + expect(restarted.serverRetirement.read()).toBeNull() + expect(restarted.serverRetirement.admitStop(() => true)).toBe(false) + expect(stop).not.toHaveBeenCalled() + restarted.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) + await restarted.serverRetirement.observe() + const retirement = restarted.serverRetirement + await vi.waitFor(() => expect(retirement.read()).toBe(0)) + } finally { + setStructuredAgentSessionHost(previous) + await restarted?.flushAllStreamedEvents() + await rig.dispose() + } +}) + +it('treats a quarantined unreadable session record as unverifiable for retirement', async () => { + const rig = await createQueuedMessageTestRig() + let restarted: StructuredAgentSessionHost | undefined + try { + await editPersistedTestAgentSessionStore(rig.root, (persisted) => { + Object.assign(persisted.records[HOST_TEST_SESSION].lease, { provenHandleLinkId: null }) + }) + const store = await openTestAgentSessionRecordStore(rig.root) + expect(store.isSessionUnreadable(HOST_TEST_SESSION)).toBe(true) + expect(store.readOnly).toBe(false) + expect(store.listRecords()).toEqual([]) + restarted = new StructuredAgentSessionHost({ ...rig.host.deps, store }) + expect({ + work: restarted.serverRetirement.read(), + admitted: restarted.serverRetirement.admitStop(() => true) + }).toEqual({ work: null, admitted: false }) + } finally { + await restarted?.flushAllStreamedEvents() + await rig.dispose() + } +}) diff --git a/src/main/orcad/orcad-unanswered-prompt-expiry-retry.test.ts b/src/main/orcad/orcad-unanswered-prompt-expiry-retry.test.ts new file mode 100644 index 000000000000..9a3e3b6987ec --- /dev/null +++ b/src/main/orcad/orcad-unanswered-prompt-expiry-retry.test.ts @@ -0,0 +1,52 @@ +import { expect, it, vi } from 'vitest' +import { + attach, + hostTestState, + seedApproval +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS as MAX_AGE } from '../native-chat/agent-session-wire/structured-agent-session-server-lifetime' + +it('retries an expired prompt when the first close could not prove provider exit', async () => { + await attach() + const { host, acquire } = hostTestState() + const events = acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('provider not attached') + } + events.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'pending', ordinal: 50 }, + { kind: 'turn', turnId: 'pending', state: 'running' }, + { turnScope: { kind: 'thread' } } + ) + await host.flushStreamedEvents(HOST_TEST_SESSION) + const approval = await seedApproval() + const requestedAt = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( + (item) => item.itemId === approval.itemId + )?.observedAt + if (requestedAt === undefined) { + throw new Error('approval missing') + } + const now = vi.spyOn(host.deps, 'now').mockReturnValue(requestedAt + MAX_AGE) + const close = vi.fn<() => Promise>().mockResolvedValueOnce(false).mockResolvedValue(true) + host.deps.adapter.closeSession = close + try { + await host.serverRetirement.expireUnansweredPrompts() + expect(close).toHaveBeenCalledTimes(1) + expect(host.serverRetirement.read()).toBeGreaterThan(0) + await host.serverRetirement.observe() + await host.serverRetirement.expireUnansweredPrompts() + expect(close).toHaveBeenCalledTimes(2) + const snapshot = await host.journalSnapshot(HOST_TEST_SESSION) + expect(snapshot.items.find((item) => item.itemId === approval.itemId)?.body).toMatchObject({ + resolution: { state: 'cancelled' } + }) + expect( + snapshot.items.some(({ body }) => body.kind === 'turn' && body.state === 'interrupted') + ).toBe(true) + await vi.waitFor(() => expect(host.serverRetirement.read()).toBe(0)) + } finally { + host.deps.adapter.closeSession = async () => true + now.mockRestore() + } +}) diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts index 551c200229e0..d11806fae40a 100644 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ b/src/main/ssh/orcad-runtime-deployment.test.ts @@ -193,7 +193,6 @@ describe('createManagedOrcadEnvironment', () => { await deploy() expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ census: { - structuredWork: 0, liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null From 392cc5384c4058eb4d2b63610591e243837281f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:25:47 -0700 Subject: [PATCH 5/9] fix: give unheld reservations and queued sends retirement exits --- .../structured-agent-session-host-types.ts | 2 + .../structured-agent-session-host.ts | 12 +- ...ed-agent-session-reservation-retirement.ts | 19 ++ ...ured-agent-session-retired-conversation.ts | 24 +++ ...tructured-agent-session-server-lifetime.ts | 73 ++++++-- .../structured-agent-session-task-queue.ts | 10 +- src/main/orcad/orcad-entry.ts | 2 + ...d-ownerless-reservation-retirement.test.ts | 162 ++++++++++++++++++ ...cad-restart-queued-send-retirement.test.ts | 60 +++++++ .../orcad/orcad-stop-request-listener.test.ts | 45 ++++- src/main/orcad/orcad-stop-request-listener.ts | 36 +++- .../orcad/orcad-structured-work-boundary.ts | 18 ++ .../agent-session-lease-transitions.ts | 31 ++++ 13 files changed, 468 insertions(+), 26 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-reservation-retirement.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-retired-conversation.ts create mode 100644 src/main/orcad/orcad-ownerless-reservation-retirement.test.ts create mode 100644 src/main/orcad/orcad-restart-queued-send-retirement.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 2230efa15c9b..c30b2698df13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -112,6 +112,8 @@ export type StructuredAgentSessionHostSession = { } export type StructuredAgentSessionHostDeps = { + /** Execution-host platform; tests may supply a different host. */ + platform?: NodeJS.Platform store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter /** The agents this runtime drives; what each declares is read here, never from the adapter. */ 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 d4dbffb73d6c..6eb64b0e1300 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 @@ -2,6 +2,7 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rew import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' import { createStructuredAgentSessionServerLifetime } from './structured-agent-session-server-lifetime' +import { restoreRetiredStructuredAgentSessionConversation } from './structured-agent-session-retired-conversation' // Structured agent-session host: where the lease, journal, and provider adapter meet. // Mutations share one durable admission path and serialize per session. A conversation is reached // only through `conversation`, which opens it at rest; an agent is started only by work that needs @@ -89,7 +90,16 @@ export class StructuredAgentSessionHost { tasks: this.tasks, deliveryActive: (id) => this.conversationDelivery.loop.isRunning(id), childWork: (id) => this.clientDelivery.readChildWork(id), - stopDelivery: () => this.stopDelivery() + stopDelivery: () => this.stopDelivery(), + restoreRetiredConversation: (id) => + restoreRetiredStructuredAgentSessionConversation( + { + deps: this.deps, + sessions: this.sessions, + adoptOpened: this.conversationDelivery.adoptOpened + }, + id + ) }) private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-retirement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-retirement.ts new file mode 100644 index 000000000000..78255d1c68fc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-retirement.ts @@ -0,0 +1,19 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { isOwnerlessAgentSessionReservation } from '../../runtime/agent-session-lease-transitions' +import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' + +// Beyond the cold-start wait plus scheduling margin, an unheld reservation cannot control work. +export const STRUCTURED_AGENT_SESSION_OWNERLESS_RESERVATION_MAX_AGE_MS = + STRUCTURED_AGENT_SESSION_START_WAIT_MS + 30_000 + +export function ownerlessReservationPastRetirementDeadline( + record: AgentSessionRecord, + platform: NodeJS.Platform, + now: number +): boolean { + return ( + (platform === 'darwin' || platform === 'win32') && + isOwnerlessAgentSessionReservation(record) && + now - record.lease.lastRenewedAt >= STRUCTURED_AGENT_SESSION_OWNERLESS_RESERVATION_MAX_AGE_MS + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-retired-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-retired-conversation.ts new file mode 100644 index 000000000000..0cfdc3a1d77f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-retired-conversation.ts @@ -0,0 +1,24 @@ +import { + resettleOpenStructuredAgentSessionConversation, + type StructuredAgentSessionConversationOpenContext +} from './structured-agent-session-conversation-open' +import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' + +/** Reuses journal settlement without entering active owner recovery or starting a provider. */ +export async function restoreRetiredStructuredAgentSessionConversation( + context: StructuredAgentSessionConversationOpenContext, + sessionId: string +): Promise { + if (context.deps.store.getRecord(sessionId)?.lease.claimStatus !== 'released') { + return + } + const session = context.sessions.get(sessionId) + if (session) { + await resettleOpenStructuredAgentSessionConversation(context.deps, sessionId, session) + return + } + const opened = await restoreStructuredAgentSessionRead(context.deps, sessionId) + if (opened) { + await context.adoptOpened(sessionId, opened) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts index 321dca5dbcfe..2597733c04f8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts @@ -8,6 +8,11 @@ import type { StructuredAgentSessionTaskQueue } from './structured-agent-session import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { structuredAgentSessionOwesWork } from './structured-agent-session-owed-work' import { isProvenDeadProbe } from '../../../shared/agent-session-lease-adjudication' +import { + abandonAgentSessionOwnerlessReservation, + isOwnerlessAgentSessionReservation +} from '../../runtime/agent-session-lease-transitions' +import { ownerlessReservationPastRetirementDeadline } from './structured-agent-session-reservation-retirement' export const STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS = 24 * 60 * 60_000 @@ -17,6 +22,7 @@ export function createStructuredAgentSessionServerLifetime(input: { deliveryActive: (id: string) => boolean childWork: (id: string) => readonly AgentChildWorkView[] | undefined stopDelivery: () => void + restoreRetiredConversation: (sessionId: string) => Promise }) { const read = (): number | null => { const deps = input.context().deps @@ -32,7 +38,15 @@ export function createStructuredAgentSessionServerLifetime(input: { for (const record of deps.store.listRecords()) { const session = input.context().sessions.get(record.sessionId) // A lease without its transport must be reconciled by its execution host. - if (!session?.child && record.lease.claimStatus !== 'released') { + if ( + !session?.child && + record.lease.claimStatus !== 'released' && + !ownerlessReservationPastRetirementDeadline( + record, + deps.platform ?? process.platform, + deps.now?.() ?? Date.now() + ) + ) { return null } const loaded = session ? null : replayJournal(deps.journalDatabase.db, record.sessionId) @@ -67,29 +81,60 @@ export function createStructuredAgentSessionServerLifetime(input: { } return { read, + abandonOwnerlessReservations: async (): Promise => { + input.tasks.closeAdmission() + input.stopDelivery() + const { deps, runtimeState } = input.context() + runtimeState.acquireAborts.abortAll('abandoned by user server stop') + await Promise.allSettled( + deps.store + .listRecords() + .filter(isOwnerlessAgentSessionReservation) + .map((record) => + deps.store + .transitionHandoff(record.sessionId, (latest) => + abandonAgentSessionOwnerlessReservation({ + record: latest, + expectedFence: record.lease.runtimeFence, + now: deps.now?.() ?? Date.now() + }) + ) + .catch((error: unknown) => + deps.logger.warn('recording user abandonment of a reservation failed', { + scope: 'server-reservation-abandon', + sessionId: record.sessionId, + error + }) + ) + ) + ) + }, observe: async (): Promise => { try { const context = input.context() - for (const { sessionId, lease } of context.deps.store.listRecords()) { - if (!context.sessions.get(sessionId)?.child && lease.claimStatus !== 'released') { + for (const { sessionId } of context.deps.store.listRecords()) { + if (!context.sessions.get(sessionId)?.child) { await input.tasks.serialize(sessionId, async () => { const current = input.context() const record = current.deps.store.getRecord(sessionId) if (!record || current.sessions.get(sessionId)?.child) { return } - const probe = await current.runtimeState.probeRecord(record) - if ( - isProvenDeadProbe(probe) || - (record.lease.ownerProcess === null && probe.outcome === 'reservation-unused') - ) { - await current.deps.store.evictProvenDeadOwner({ - sessionId, - expectedFence: record.lease.runtimeFence, - probe, - now: current.now() - }) + if (record.lease.claimStatus !== 'released') { + const probe = await current.runtimeState.probeRecord(record) + if ( + isProvenDeadProbe(probe) || + (record.lease.ownerProcess === null && probe.outcome === 'reservation-unused') + ) { + await current.deps.store.evictProvenDeadOwner({ + sessionId, + expectedFence: record.lease.runtimeFence, + probe, + now: current.now() + }) + } } + await input.restoreRetiredConversation(sessionId) }) } } 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 index f7da8299d36e..dba193e3ce28 100644 --- 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 @@ -1,4 +1,7 @@ -import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' +import { + getKeyedSerializedQueueTail, + runKeyedSerializedOperation +} from '../../cli/keyed-promise-queue' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' export class StructuredAgentSessionTaskQueue { @@ -18,7 +21,10 @@ export class StructuredAgentSessionTaskQueue { } serializeDuringShutdown(sessionId: string, task: () => Promise): Promise { - return runKeyedSerializedOperation(this.chains, sessionId, task) + const operation = runKeyedSerializedOperation(this.chains, sessionId, task) + const tail = getKeyedSerializedQueueTail(this.chains, sessionId) + // Observers finish after their own tail clears, so a probe does not count itself as work. + return operation.finally(() => tail) } hasPending = (): boolean => this.chains.size > 0 || this.attaching.size > 0 diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 957051c8c909..948d5d090f73 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -25,6 +25,7 @@ import { parseArgs } from './orcad-command-arguments' import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' import { admitOrcadAutomaticStop, + prepareOrcadUserStop, prepareOrcadStructuredWorkBoundary } from './orcad-structured-work-boundary' import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' @@ -424,6 +425,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise + platform === 'linux' ? Promise.resolve(null) : scanAgentSessionSpawnTokenProcesses(platform) + ) + rig.host.deps.platform = platform + rig.host.deps.now = () => reservedAt + rig.host.deps.probeOwner = createStructuredAgentSessionOwnerProbe( + rig.store.hostId, + undefined, + (token) => findAgentSessionSpawnTokenProcesses(token, scan) + ) + await rig.store.reserveOwner({ + sessionId: id, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: rig.root }, + expectedFence: null, + spawnToken: 'unverifiable-reservation', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: hostTestOperationId(), + fingerprint: 'create-reservation' + }, + now: reservedAt + }) + await rig.host.reconcileRestartLeases() + return { ...rig, id, reservedAt, scan } +} + +it.each(['darwin', 'win32'] as const)( + 'bounds automatic retirement on an unheld %s reservation without declaring exit', + async (platform) => { + const rig = await reservationRig(platform) + try { + rig.host.deps.now = () => rig.reservedAt + MAX_AGE - 1 + expect(await rig.host.serverRetirement.observe()).toBeNull() + expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) + rig.host.deps.now = () => rig.reservedAt + MAX_AGE + expect(await rig.host.serverRetirement.observe()).toBe(0) + expect(rig.scan).toHaveBeenCalled() + expect(rig.store.getRecord(rig.id)?.lease).toMatchObject({ + claimStatus: 'reserved', + reservedSpawnToken: 'unverifiable-reservation', + ownerProcess: null, + deathEvidence: null + }) + const held = Promise.withResolvers() + const operation = rig.host.collaboratorsForTests().serialize(rig.id, () => held.promise) + expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) + held.resolve() + await operation + expect(rig.host.serverRetirement.admitStop(() => true)).toBe(true) + } finally { + await rig.dispose() + } + } +) + +it.each(['darwin', 'win32'] as const)( + 'records deliberate abandonment of an ownerless %s reservation across restart', + async (platform) => { + const rig = await reservationRig(platform) + try { + const fence = rig.store.getRecord(rig.id)?.lease.runtimeFence + await rig.host.serverRetirement.abandonOwnerlessReservations() + const reopened = await openTestAgentSessionRecordStore(rig.root) + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'scan unavailable' }), + now: rig.reservedAt + }) + expect(reopened.getRecord(rig.id)?.lease).toMatchObject({ + claimStatus: 'released', + reservedSpawnToken: null, + ownerProcess: null, + handoffStage: null, + deathEvidence: null + }) + expect(reopened.getRecord(rig.id)?.lease.runtimeFence).toBe((fence ?? 0) + 1) + } finally { + await rig.dispose() + } + } +) + +it('keeps Linux scan proof and named owners outside the ownerless retirement deadline', async () => { + const rig = await reservationRig('linux') + try { + rig.host.deps.now = () => rig.reservedAt + MAX_AGE + expect(await rig.host.serverRetirement.observe()).toBeNull() + expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) + } finally { + await rig.dispose() + } + const owned = await createQueuedMessageTestRig() + try { + const lease = owned.store.getRecord(HOST_TEST_SESSION)?.lease + await owned.host.serverRetirement.abandonOwnerlessReservations() + expect(owned.store.getRecord(HOST_TEST_SESSION)?.lease).toEqual(lease) + } finally { + await owned.dispose() + } +}) + +it('never expires or abandons an unheld named owner on Windows', async () => { + const rig = await createQueuedMessageTestRig() + try { + const saved = rig.store.getRecord(HOST_TEST_SESSION)?.lease + if (!saved?.ownerProcess) { + throw new Error('owner missing') + } + await rig.stop() + await rig.host.close(HOST_TEST_SESSION, 'evict') + await rig.store.transitionHandoff(HOST_TEST_SESSION, (record) => ({ + ...record, + lease: { ...saved, handoffStage: 'recovering' } + })) + rig.host.deps.platform = 'win32' + rig.host.deps.now = () => saved.lastRenewedAt + MAX_AGE + rig.host.deps.probeOwner = async () => ({ + outcome: 'indeterminate', + reason: 'scan unavailable' + }) + expect(await rig.host.serverRetirement.observe()).toBeNull() + expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) + await rig.host.serverRetirement.abandonOwnerlessReservations() + expect(rig.store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ + ownerProcess: saved.ownerProcess, + claimStatus: 'live', + deathEvidence: null + }) + } finally { + await rig.dispose() + } +}) diff --git a/src/main/orcad/orcad-restart-queued-send-retirement.test.ts b/src/main/orcad/orcad-restart-queued-send-retirement.test.ts new file mode 100644 index 000000000000..73dce584cef5 --- /dev/null +++ b/src/main/orcad/orcad-restart-queued-send-retirement.test.ts @@ -0,0 +1,60 @@ +import { expect, it, vi } from 'vitest' +import { createQueuedMessageTestRig } from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import { + getStructuredAgentSessionHost, + setStructuredAgentSessionHost +} from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { rotateStructuredAgentSessionHostInstanceForTests } from '../native-chat/agent-session-wire/structured-agent-session-queued-pause' +import { prepareOrcadStructuredWorkBoundary } from './orcad-structured-work-boundary' + +it('does not let an accepted send from an exited generation pin an unattended restarted host', async () => { + const rig = await createQueuedMessageTestRig() + const previous = getStructuredAgentSessionHost() + let restarted: StructuredAgentSessionHost | undefined + try { + rig.host.stopDelivery() + const sent = rig.send('accepted just before the runtime crashed') + expect(await sent.result).toMatchObject({ ok: true }) + expect(await rig.submission(sent.id)).toMatchObject({ + dispatchState: 'pending', + handoverRecorded: true + }) + expect((await rig.submission(sent.id))?.handedOverAt).toBeUndefined() + expect(rig.dispatch).not.toHaveBeenCalled() + rotateStructuredAgentSessionHostInstanceForTests() + const store = await openTestAgentSessionRecordStore(rig.root) + restarted = new StructuredAgentSessionHost({ + ...rig.host.deps, + store, + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + setStructuredAgentSessionHost(restarted) + await prepareOrcadStructuredWorkBoundary({ ensureStructuredAgentSessionHost: async () => {} }) + expect(store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { kind: 'pid-absent' } + }) + for (let sweep = 0; sweep < 3; sweep++) { + await restarted.serverRetirement.expireUnansweredPrompts() + await restarted.serverRetirement.observe() + } + const beforeOpen = restarted.serverRetirement.read() + const commit = vi.fn(() => false) + const admitted = restarted.serverRetirement.admitStop(commit) + await restarted.restoreReadableSessions() + const afterOpen = restarted.serverRetirement.read() + expect({ beforeOpen, stopCommits: commit.mock.calls.length, admitted, afterOpen }).toEqual({ + beforeOpen: 0, + stopCommits: 1, + admitted: false, + afterOpen: 0 + }) + } finally { + setStructuredAgentSessionHost(previous) + await restarted?.flushAllStreamedEvents() + await rig.dispose() + } +}) diff --git a/src/main/orcad/orcad-stop-request-listener.test.ts b/src/main/orcad/orcad-stop-request-listener.test.ts index cd8d796be495..2241b225209e 100644 --- a/src/main/orcad/orcad-stop-request-listener.test.ts +++ b/src/main/orcad/orcad-stop-request-listener.test.ts @@ -222,7 +222,13 @@ describe('orcad stop-request listeners', () => { const managedStop = managedContext() const onRequest = vi.fn() const automatic = vi.fn(() => false) - listen(onRequest, { installRoot, managedStop, admitAutomaticStop: automatic }) + const abandon = vi.fn(async () => {}) + listen(onRequest, { + installRoot, + managedStop, + admitAutomaticStop: automatic, + beforeUserStop: abandon + }) if (kind === 'slot') { writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '{"intent":"user"}') } else { @@ -238,5 +244,42 @@ describe('orcad stop-request listeners', () => { } await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) expect(automatic).not.toHaveBeenCalled() + expect(abandon).toHaveBeenCalledOnce() }) + + it.each(['slot', 'managed'])( + 'honors a user %s stop when abandonment bookkeeping fails', + async (kind) => { + const installRoot = directory() + const managedStop = managedContext() + const onRequest = vi.fn() + const report = vi.spyOn(console, 'error').mockImplementation(() => {}) + listen(onRequest, { + installRoot, + managedStop, + admitAutomaticStop: () => false, + beforeUserStop: async () => { + throw new Error('store unreadable') + } + }) + if (kind === 'slot') { + writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '{"intent":"user"}') + } else { + writeFileSync( + orcadManagedStopRequestPath(managedStop.instance), + JSON.stringify({ + schemaVersion: 1, + transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', + ...managedStop, + intent: 'user' + }) + ) + } + await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) + expect(report).toHaveBeenCalledWith( + '[orcad] user stop preparation failed; stop continues:', + expect.any(Error) + ) + } + ) }) diff --git a/src/main/orcad/orcad-stop-request-listener.ts b/src/main/orcad/orcad-stop-request-listener.ts index 3928d054f834..d83c61fc1267 100644 --- a/src/main/orcad/orcad-stop-request-listener.ts +++ b/src/main/orcad/orcad-stop-request-listener.ts @@ -101,6 +101,7 @@ export function installOrcadStopRequestListeners( admitAutomaticStop: (commit: () => boolean) => boolean /** Runs once for a validated managed request before the stop; it cannot prevent it. */ beforeManagedStop?: (request: OrcadManagedStopRequest) => Promise + beforeUserStop?: () => Promise pollIntervalMs?: number } ): OrcadStopRequestListener { @@ -112,6 +113,13 @@ export function installOrcadStopRequestListeners( listener.close() } } + const prepareUserStop = async (): Promise => { + try { + await options.beforeUserStop?.() + } catch (error) { + console.error('[orcad] user stop preparation failed; stop continues:', error) + } + } listeners.push( listenForRequest( join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME), @@ -123,9 +131,17 @@ export function installOrcadStopRequestListeners( unlinkSync(path) return true } - return user ? consume() : options.admitAutomaticStop(consume) ? true : null + const admitted = user ? consume() : options.admitAutomaticStop(consume) + return admitted ? user : null + }, + (user) => { + close() + if (user) { + void prepareUserStop().finally(onRequest) + } else { + onRequest() + } }, - () => onRequest(), pollIntervalMs ) ) @@ -148,15 +164,19 @@ export function installOrcadStopRequestListeners( } return true } - const admitted = orcadManagedStopIsUserRequested(request) - ? commit() - : options.admitAutomaticStop(commit) - return admitted ? request : null + const user = orcadManagedStopIsUserRequested(request) + const admitted = user ? commit() : options.admitAutomaticStop(commit) + return admitted ? { request, user } : null }, - (request) => { + ({ request, user }) => { close() // Preparation is best effort: whatever it reports, the stop proceeds. - void prepare(request) + void (async () => { + if (user) { + await prepareUserStop() + } + await prepare(request) + })() .catch((error: unknown) => console.error('[orcad] managed stop preparation failed:', error) ) diff --git a/src/main/orcad/orcad-structured-work-boundary.ts b/src/main/orcad/orcad-structured-work-boundary.ts index 2a39707696b0..8cf634927094 100644 --- a/src/main/orcad/orcad-structured-work-boundary.ts +++ b/src/main/orcad/orcad-structured-work-boundary.ts @@ -1,4 +1,22 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { withTimeout } from '../../shared/promise-timeout-fallback' + +const USER_RESERVATION_ABANDON_TIMEOUT_MS = 2_000 + +export async function prepareOrcadUserStop(): Promise { + const host = getStructuredAgentSessionHost() + if (!host) { + return + } + const recorded = await withTimeout( + host.serverRetirement.abandonOwnerlessReservations().then(() => true), + USER_RESERVATION_ABANDON_TIMEOUT_MS, + false + ) + if (!recorded) { + console.error('[orcad] recording reservation abandonment timed out; user stop continues') + } +} export function admitOrcadAutomaticStop(commit: () => boolean): boolean { return getStructuredAgentSessionHost()?.serverRetirement.admitStop(commit) ?? false diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 1d21ddc13add..2654d3e0991f 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -17,6 +17,7 @@ import { type AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { agentSessionProviderHandleBelongsTo } from '../../shared/agent-session-provider-handle-encoding' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionDeathEvidence, AgentSessionLease, @@ -233,6 +234,36 @@ export function evictAgentSessionOwner(args: { return releasedAgentSessionLease(record, adjudication.nextFence, adjudication.evidence, args.now) } +export function isOwnerlessAgentSessionReservation(record: AgentSessionRecord): boolean { + return ( + record.lease.runtimeKind === 'native' && + record.lease.claimStatus !== 'released' && + record.lease.ownerProcess === null && + record.lease.reservedSpawnToken !== null + ) +} + +/** A deliberate host stop abandons a reservation without claiming its process exited. */ +export function abandonAgentSessionOwnerlessReservation(args: { + record: AgentSessionRecord + expectedFence: number + now: number +}): AgentSessionRecord { + if ( + args.record.lease.runtimeFence !== args.expectedFence || + !isOwnerlessAgentSessionReservation(args.record) + ) { + return args.record + } + const released = releasedAgentSessionLease( + args.record, + nextAgentSessionFence(args.record.lease), + null, + args.now + ) + return { ...released, lease: { ...released.lease, unreconciled: false } } +} + function releasedAgentSessionLease( record: AgentSessionRecord, runtimeFence: number, From e0938759d4062465b54da77a5121b2a6a89acbdd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:30:41 -0700 Subject: [PATCH 6/9] fix: clear empty recovery leases and preserve unverified owners in tests --- .../structured-agent-session-host.test.ts | 13 +++++++++++- .../structured-agent-session-host.ts | 11 +--------- ...tured-agent-session-recovery-resolution.ts | 4 +++- ...tructured-agent-session-server-lifetime.ts | 9 +++++++-- ...agent-session-settled-attach-retry.test.ts | 20 +++++++++++++++++-- src/main/orcad/orcad-entry.ts | 12 ++++------- ...nt-session-spawn-token-descendants.test.ts | 6 ++++-- 7 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index e92b33aac1c1..6dfddffe0063 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -699,7 +699,7 @@ describe('restart', () => { expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) }) - it('releases a session whose owner can never be probed, signalling nothing, and starts over', async () => { + it('retains an unverifiable owner until exit is proven, signalling nothing', async () => { await attach() const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const stopOwnerProcess = vi.fn() @@ -710,6 +710,17 @@ describe('restart', () => { ) acquire.mockClear() + expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: held, + ownerProcess: { pid: 4242 }, + deathEvidence: null + }) + host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) expect(await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))).toMatchObject({ ok: true }) 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 6eb64b0e1300..2009e839d543 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 @@ -2,7 +2,6 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rew import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' import { createStructuredAgentSessionServerLifetime } from './structured-agent-session-server-lifetime' -import { restoreRetiredStructuredAgentSessionConversation } from './structured-agent-session-retired-conversation' // Structured agent-session host: where the lease, journal, and provider adapter meet. // Mutations share one durable admission path and serialize per session. A conversation is reached // only through `conversation`, which opens it at rest; an agent is started only by work that needs @@ -91,15 +90,7 @@ export class StructuredAgentSessionHost { deliveryActive: (id) => this.conversationDelivery.loop.isRunning(id), childWork: (id) => this.clientDelivery.readChildWork(id), stopDelivery: () => this.stopDelivery(), - restoreRetiredConversation: (id) => - restoreRetiredStructuredAgentSessionConversation( - { - deps: this.deps, - sessions: this.sessions, - adoptOpened: this.conversationDelivery.adoptOpened - }, - id - ) + adoptOpened: (id, opened) => this.conversationDelivery.adoptOpened(id, opened) }) private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 26e6b9b10ff7..968fbd49fdd9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -62,7 +62,9 @@ export async function resolveStructuredSessionRecovery( probe = await stopOwnerAndReprobe(deps, record, owner.pid) } } - if (owner ? !isProvenDeadProbe(probe) : probe.outcome !== 'reservation-unused') { + const alreadyFree = + record.lease.claimStatus === 'released' && record.lease.reservedSpawnToken === null + if (owner ? !isProvenDeadProbe(probe) : !alreadyFree && probe.outcome !== 'reservation-unused') { return 'unresolved' } try { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts index 2597733c04f8..0b6266a474a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts @@ -13,6 +13,8 @@ import { isOwnerlessAgentSessionReservation } from '../../runtime/agent-session-lease-transitions' import { ownerlessReservationPastRetirementDeadline } from './structured-agent-session-reservation-retirement' +import { restoreRetiredStructuredAgentSessionConversation } from './structured-agent-session-retired-conversation' +import type { StructuredAgentSessionConversationOpenContext } from './structured-agent-session-conversation-open' export const STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS = 24 * 60 * 60_000 @@ -22,7 +24,7 @@ export function createStructuredAgentSessionServerLifetime(input: { deliveryActive: (id: string) => boolean childWork: (id: string) => readonly AgentChildWorkView[] | undefined stopDelivery: () => void - restoreRetiredConversation: (sessionId: string) => Promise + adoptOpened: StructuredAgentSessionConversationOpenContext['adoptOpened'] }) { const read = (): number | null => { const deps = input.context().deps @@ -134,7 +136,10 @@ export function createStructuredAgentSessionServerLifetime(input: { }) } } - await input.restoreRetiredConversation(sessionId) + await restoreRetiredStructuredAgentSessionConversation( + { deps: current.deps, sessions: current.sessions, adoptOpened: input.adoptOpened }, + sessionId + ) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index f17ee460653e..110bbf1078cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -219,7 +219,7 @@ describe('settled attach retry', () => { expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) }) - it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { + it('retains an unverified reservation until a deliberate server stop abandons it', async () => { const spawnTokens: string[] = [] acquire.mockImplementation(async ({ fence, spawnToken }) => { spawnTokens.push(spawnToken) @@ -294,7 +294,23 @@ describe('settled attach retry', () => { await host.restoreReadableSessions() expect(releaseAcquisition).toHaveBeenCalledTimes(1) - // No owner was recorded: released at restart, with no evidence, since nothing proved one. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'recovering', + runtimeFence: 1, + reservedSpawnToken: 'spawn-1', + ownerProcess: null, + deathEvidence: null + }) + await host.serverRetirement.abandonOwnerlessReservations() + await host.flushAllStreamedEvents() + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + ...host.deps, + store, + adapter: adapter() + }) + await host.restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 948d5d090f73..be8d457d44c5 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -23,11 +23,7 @@ import { } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' -import { - admitOrcadAutomaticStop, - prepareOrcadUserStop, - prepareOrcadStructuredWorkBoundary -} from './orcad-structured-work-boundary' +import * as structuredWork from './orcad-structured-work-boundary' import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' import type { OrcadManagedStopContext } from '../../shared/orcad-stop-request' @@ -342,7 +338,7 @@ async function startOrcadRuntime( const { publishHeadlessRuntimeGraph } = await import('../runtime/headless-runtime-graph') publishHeadlessRuntimeGraph(runtime) - await prepareOrcadStructuredWorkBoundary(runtime) + await structuredWork.prepareOrcadStructuredWorkBoundary(runtime) const bindHost = resolveOrcadBindHost(options.bind) const rpc = new OrcaRuntimeRpcServer({ @@ -424,8 +420,8 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise requestShutdown('stop request'), { installRoot: resolveOrcadInstallRoot(), managedStop: handle.managedStop, - admitAutomaticStop: admitOrcadAutomaticStop, - beforeUserStop: prepareOrcadUserStop, + admitAutomaticStop: structuredWork.admitOrcadAutomaticStop, + beforeUserStop: structuredWork.prepareOrcadUserStop, beforeManagedStop: prepareOrcadManagedStop }) bindOrcadIdleShutdown(requestShutdown) diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 7ebc52cd89a7..4962d2536664 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -187,9 +187,11 @@ describe('the reservation owner probe', () => { const store = await reconcileReservation() - // Released without evidence, and the process carrying the token is left alone. + // The token still names execution, even though it cannot identify a safe stop target. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', + claimStatus: 'reserved', + handoffStage: 'recovering', + reservedSpawnToken: MINTED_TOKEN, deathEvidence: null }) expect(kill.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]) From 24a8457448ed866939557df1d0772ddad66927ac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:15:05 -0700 Subject: [PATCH 7/9] Keep managed servers alive while chat providers are loaded --- .../structured-agent-session-host-types.ts | 2 - .../structured-agent-session-host.test.ts | 13 +- .../structured-agent-session-host.ts | 20 +- .../structured-agent-session-idle-sweep.ts | 43 +++- .../structured-agent-session-owed-work.ts | 39 ---- ...ructured-agent-session-provider-support.ts | 12 -- ...-agent-session-recovery-resolution.test.ts | 63 +++--- ...tured-agent-session-recovery-resolution.ts | 38 ++-- ...ed-agent-session-reservation-retirement.ts | 19 -- ...ured-agent-session-retired-conversation.ts | 24 --- ...ured-agent-session-server-lifetime.test.ts | 97 --------- ...tructured-agent-session-server-lifetime.ts | 201 ------------------ ...agent-session-settled-attach-retry.test.ts | 20 +- .../structured-agent-session-task-queue.ts | 28 +-- src/main/orcad/orcad-entry.ts | 5 - src/main/orcad/orcad-health.ts | 7 +- .../orcad/orcad-idle-exit-monitor.test.ts | 26 ++- src/main/orcad/orcad-idle-exit-monitor.ts | 9 +- ...ad-managed-idle-exit-chat-provider.test.ts | 119 +++++++++++ .../orcad/orcad-managed-idle-exit-host.ts | 22 +- ...ad-managed-idle-exit-provider-race.test.ts | 67 ++++++ ...-managed-idle-exit-structured-work.test.ts | 33 --- .../orcad/orcad-managed-idle-exit.test.ts | 5 +- src/main/orcad/orcad-managed-idle-exit.ts | 12 +- .../orcad-managed-stop-cancellation.test.ts | 1 - .../orcad/orcad-managed-stop-intent.test.ts | 62 ------ src/main/orcad/orcad-managed-stop-intent.ts | 44 ---- ...d-ownerless-reservation-retirement.test.ts | 162 -------------- ...cad-restart-queued-send-retirement.test.ts | 60 ------ .../orcad/orcad-stop-request-listener.test.ts | 133 +----------- src/main/orcad/orcad-stop-request-listener.ts | 61 +----- .../orcad/orcad-structured-work-boundary.ts | 55 ----- ...orcad-structured-work-unverifiable.test.ts | 124 ----------- ...cad-unanswered-prompt-expiry-retry.test.ts | 52 ----- .../orcad-unanswered-prompt-expiry.test.ts | 55 ----- .../agent-session-lease-transitions.ts | 35 ++- .../agent-session-record-store.test.ts | 16 +- .../rpc/methods/orcad-terminal-census.test.ts | 11 +- .../rpc/methods/orcad-terminal-census.ts | 11 +- ...nt-session-spawn-token-descendants.test.ts | 6 +- src/main/ssh/orcad-managed-remote-stop.ts | 13 +- src/main/ssh/orcad-managed-runtime-context.ts | 5 +- src/main/ssh/orcad-recovery-slot.ts | 15 +- src/main/ssh/orcad-remote-deploy.test.ts | 23 +- .../orcad-remote-lifecycle-windows.test.ts | 8 +- .../orcad-remote-process-control-windows.ts | 5 +- src/main/ssh/orcad-remote-process-control.ts | 21 +- src/main/ssh/orcad-remote-rollback.test.ts | 16 +- ...-remote-shell-commands.integration.test.ts | 53 +---- src/main/ssh/orcad-remote-stop.ts | 5 +- .../ssh/orcad-remote-windows-scripts.test.ts | 24 +-- src/main/ssh/orcad-runtime-deployment.test.ts | 6 +- .../ssh/orcad-runtime-maintenance.test.ts | 7 +- .../orcad-update-plan-structured-work.test.ts | 45 ---- src/main/ssh/orcad-update-plan.test.ts | 103 ++------- src/main/ssh/orcad-update-plan.ts | 18 -- src/main/ssh/orcad-windows-host-script.ts | 10 +- .../agent-session-lease-adjudication.test.ts | 8 +- .../agent-session-lease-adjudication.ts | 28 +-- src/shared/orcad-stop-request.ts | 6 +- src/shared/orcad-terminal-census.ts | 4 +- 61 files changed, 435 insertions(+), 1800 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-owed-work.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-reservation-retirement.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-retired-conversation.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts create mode 100644 src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts create mode 100644 src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts delete mode 100644 src/main/orcad/orcad-managed-idle-exit-structured-work.test.ts delete mode 100644 src/main/orcad/orcad-managed-stop-intent.test.ts delete mode 100644 src/main/orcad/orcad-managed-stop-intent.ts delete mode 100644 src/main/orcad/orcad-ownerless-reservation-retirement.test.ts delete mode 100644 src/main/orcad/orcad-restart-queued-send-retirement.test.ts delete mode 100644 src/main/orcad/orcad-structured-work-boundary.ts delete mode 100644 src/main/orcad/orcad-structured-work-unverifiable.test.ts delete mode 100644 src/main/orcad/orcad-unanswered-prompt-expiry-retry.test.ts delete mode 100644 src/main/orcad/orcad-unanswered-prompt-expiry.test.ts delete mode 100644 src/main/ssh/orcad-update-plan-structured-work.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index c30b2698df13..2230efa15c9b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -112,8 +112,6 @@ export type StructuredAgentSessionHostSession = { } export type StructuredAgentSessionHostDeps = { - /** Execution-host platform; tests may supply a different host. */ - platform?: NodeJS.Platform store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter /** The agents this runtime drives; what each declares is read here, never from the adapter. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 6dfddffe0063..e92b33aac1c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -699,7 +699,7 @@ describe('restart', () => { expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' }) }) - it('retains an unverifiable owner until exit is proven, signalling nothing', async () => { + it('releases a session whose owner can never be probed, signalling nothing, and starts over', async () => { await attach() const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 const stopOwnerProcess = vi.fn() @@ -710,17 +710,6 @@ describe('restart', () => { ) acquire.mockClear() - expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_ownership_unknown' } - }) - expect(acquire).not.toHaveBeenCalled() - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: held, - ownerProcess: { pid: 4242 }, - deathEvidence: null - }) - host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) expect(await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))).toMatchObject({ ok: true }) 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 2009e839d543..f918adbfb2cc 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 @@ -1,7 +1,6 @@ import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind' import { rewindStructuredAgentSession } from './structured-agent-session-rewind' import { StructuredConversationCommandController } from './structured-conversation-command-controller' -import { createStructuredAgentSessionServerLifetime } from './structured-agent-session-server-lifetime' // Structured agent-session host: where the lease, journal, and provider adapter meet. // Mutations share one durable admission path and serialize per session. A conversation is reached // only through `conversation`, which opens it at rest; an agent is started only by work that needs @@ -84,14 +83,6 @@ export class StructuredAgentSessionHost { ) private readonly subscribers = this.clientDelivery.subscribers private readonly tasks = new StructuredAgentSessionTaskQueue() - readonly serverRetirement = createStructuredAgentSessionServerLifetime({ - context: () => this.lifetimeContext(), - tasks: this.tasks, - deliveryActive: (id) => this.conversationDelivery.loop.isRunning(id), - childWork: (id) => this.clientDelivery.readChildWork(id), - stopDelivery: () => this.stopDelivery(), - adoptOpened: (id, opened) => this.conversationDelivery.adoptOpened(id, opened) - }) private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType private readonly restore: ReturnType @@ -179,6 +170,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]) => @@ -220,7 +213,12 @@ export class StructuredAgentSessionHost { agentDefinitions = () => this.deps.agents.definitions() /** Saved chats can outlive their registration; both vocabularies bound a client's audience. */ - knownAgentIds = (): readonly string[] => providerSupport.knownAgentIds(this.deps) + knownAgentIds = (): readonly string[] => [ + ...new Set([ + ...this.deps.agents.definitions().map(({ agent }) => agent), + ...this.deps.store.listRecords().map(({ provider }) => provider) + ]) + ] private readonly tabs = sessionTabs.createStructuredAgentSessionTabSurface( this, @@ -273,7 +271,7 @@ export class StructuredAgentSessionHost { idleSweep: this.lifetime, tasks: this.tasks, restartResume: this.restartResume, - serialize: (id, task) => this.tasks.serializeDuringShutdown(id, task), + serialize: this.serialize, trigger: options?.trigger ?? 'quit' }).finally(() => this.clientDelivery.closeAll()) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 08ffd1f38221..1b67f259ed95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -8,6 +8,10 @@ // // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. +import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' @@ -39,8 +43,16 @@ export type StructuredAgentSessionIdleSweepDeps = { idleMs?: number } -export { hasPendingStructuredAgentSessionPrompt } from './structured-agent-session-owed-work' -import { structuredAgentSessionOwesWork } from './structured-agent-session-owed-work' +/** A prompt the user has not answered. A subagent can raise one the lead turn cannot see. */ +export function hasPendingStructuredAgentSessionPrompt( + items: readonly AgentJournalRenderItem[] +): boolean { + return items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) +} export class StructuredAgentSessionIdleSweep { private timer: ReturnType | null = null @@ -117,15 +129,24 @@ export class StructuredAgentSessionIdleSweep { await this.deps.closeConversation(sessionId) } + private queuedOrDelivering(sessionId: string, session: StructuredAgentSessionHostSession) { + return ( + this.deps.deliveryActive(sessionId) || + session.journal.submissions().some(isQueuedAgentJournalSubmission) + ) + } + + /** Work the running child still owes. Scoped to the child: with none, nothing here can pin the + * handle, and a leftover prompt or turn row is only history. */ private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean { - return structuredAgentSessionOwesWork({ - snapshot: session.journal.snapshot(), - hasChild: session.child !== null, - stopping: session.child?.close !== undefined, - deliveryActive: this.deps.deliveryActive(sessionId), - childWork: this.deps.childWork(sessionId), - openDispatch: this.deps.hasOpenDispatch(sessionId), - providerHoldsDispatch: this.deps.providerHoldsDispatch(sessionId) - }) + const items = session.journal.snapshot().items + return ( + activeStructuredAgentSessionTurnId(items) !== null || + this.queuedOrDelivering(sessionId, session) || + agentChildWorkLiveness(this.deps.childWork(sessionId)) !== null || + this.deps.hasOpenDispatch(sessionId) || + this.deps.providerHoldsDispatch(sessionId) || + hasPendingStructuredAgentSessionPrompt(items) + ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work.ts deleted file mode 100644 index 39a950f441c9..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' - -/** Includes prompts from children, whose requests need not belong to the lead's turn. */ -export function hasPendingStructuredAgentSessionPrompt( - items: AgentJournalSnapshot['items'] -): boolean { - return items.some( - ({ body }) => - (body.kind === 'approval' || body.kind === 'question') && body.resolution.state === 'pending' - ) -} - -/** The same execution-host evidence governs child retirement and server retirement. */ -export function structuredAgentSessionOwesWork(input: { - snapshot: Pick - hasChild: boolean - deliveryActive?: boolean - stopping?: boolean - childWork?: readonly AgentChildWorkView[] - openDispatch?: boolean - providerHoldsDispatch?: boolean -}): boolean { - return ( - input.deliveryActive === true || - input.stopping === true || - input.snapshot.submissions.some(isQueuedAgentJournalSubmission) || - (input.hasChild && - (activeStructuredAgentSessionTurnId(input.snapshot.items) !== null || - input.snapshot.submissions.some(({ dispatchState }) => dispatchState === 'pending') || - agentChildWorkLiveness(input.childWork) !== null || - input.openDispatch === true || - input.providerHoldsDispatch === true || - hasPendingStructuredAgentSessionPrompt(input.snapshot.items))) - ) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index db307d6e8b6b..9215c4908445 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -65,15 +65,3 @@ export function hostCanStartRecord( agentDrivesSession(deps.agents, record) ) } - -export function knownAgentIds(deps: { - agents: Pick - store: { listRecords(): readonly AgentSessionRecord[] } -}): readonly string[] { - return [ - ...new Set([ - ...deps.agents.definitions().map(({ agent }) => agent), - ...deps.store.listRecords().map(({ provider }) => provider) - ]) - ] -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index a4c9cf39432c..d92eaddadc38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -118,7 +118,7 @@ function deps( } describe('structured session recovery resolution', () => { - it('retains an unverified reservation until a token scan proves nothing spawned', async () => { + it('releases an ownerless reservation: nothing it recorded can be holding it', async () => { const store = await openStore() await reserve(store) await latch(store) @@ -128,25 +128,13 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('unresolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'recovering', - runtimeFence: 1, - reservedSpawnToken: 'spawn-recovery' - }) - expect( - await resolveStructuredSessionRecovery( - deps(store, () => ({ outcome: 'reservation-unused' })), - SESSION - ) - ).toBe('resolved') + expect(result).toBe('resolved') expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, runtimeFence: 2, reservedSpawnToken: null, - deathEvidence: { kind: 'pid-absent' } + deathEvidence: null }) }) @@ -202,7 +190,7 @@ describe('structured session recovery resolution', () => { }) }) - it('retains an owner that survives the bounded stop ladder', async () => { + it('releases an owner that survives the stop ladder, with no death evidence', async () => { const store = await openStore() await liveOwner(store) await latch(store) @@ -215,21 +203,22 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('unresolved') + expect(result).toBe('resolved') expect(stopOwnerProcess.mock.calls).toEqual([ [4242, 'SIGTERM'], [4242, 'SIGKILL'] ]) + // Its transport died with the runtime that held it; nothing proved it gone, so no evidence. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: 'recovering', - runtimeFence: 1, - ownerProcess: { pid: 4242 }, + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + ownerProcess: null, deathEvidence: null }) }) - it('retains an owner whose identity cannot be verified, and signals nothing', async () => { + it('releases an owner whose identity cannot be verified, and signals nothing', async () => { const store = await openStore() await liveOwner(store) await latch(store) @@ -242,19 +231,19 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('unresolved') + expect(result).toBe('resolved') // The pid may have been reused by an unrelated process. expect(stopOwnerProcess).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: 'recovering', - runtimeFence: 1, - ownerProcess: { pid: 4242 }, + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + ownerProcess: null, deathEvidence: null }) }) - it('retains a Windows owner without signalling its saved pid until exit is proven', async () => { + it('releases a Windows owner the probe matches without signalling its saved pid', async () => { const store = await openStore() await liveOwner(store) await latch(store) @@ -268,13 +257,13 @@ describe('structured session recovery resolution', () => { SESSION ) - expect(result).toBe('unresolved') + expect(result).toBe('resolved') // Windows stops a tree only through a child it still holds; a saved pid is never signalled. expect(stopOwnerProcess).not.toHaveBeenCalled() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'live', - handoffStage: 'recovering', - ownerProcess: { pid: 4242 }, + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, deathEvidence: null }) }) @@ -315,7 +304,7 @@ describe('structured session recovery resolution', () => { }) }) - it('retains an older terminal reservation until the host proves it unused', async () => { + it('releases a terminal reservation an older build left naming nobody at restart', async () => { const directory = await newStoreDirectory() await reserve(await openStore(directory)) await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) @@ -324,10 +313,11 @@ describe('structured session recovery resolution', () => { probe: async () => ({ outcome: 'indeterminate', reason: 'no scan' }), now: NOW }) + // No process is recorded for a conflict to name, so there is nothing to wait out. expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeKind: 'native', - handoffStage: 'recovering', - reservedSpawnToken: 'spawn-recovery', + claimStatus: 'released', + handoffStage: null, deathEvidence: null }) expect( @@ -335,8 +325,7 @@ describe('structured session recovery resolution', () => { deps(store, () => ({ outcome: 'reservation-unused' })), SESSION ) - ).toBe('resolved') - expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + ).toBe('not-applicable') }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts index 968fbd49fdd9..257072f7c23b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -1,7 +1,13 @@ /** - * Recovery retains ownership until present-time evidence proves exit. Transport loss cannot - * prove exit. Native POSIX owners may be stopped by verified identity; Windows owners and - * terminal agents are only released on exit proof, since their saved pid is not a safe stop handle. + * Exits from the `recovering` stage. A session lands there when evidence about its owner was + * unavailable; this re-asks with present-time evidence and always concludes. A dead owner is + * evicted on proof. A live one is stopped by identity and evicted once + * proven gone. One that outlives the stop, or whose identity cannot be verified, is released + * anyway: its transport died with the runtime that held it, so nothing can drive it, and no signal + * is sent to a pid that cannot be verified as the one recorded. Windows never signals a saved pid + * at all: Orca stops a Windows tree only through a child it still holds. Only a conflicted claim, + * which is how a terminal owner an older build recorded now loads, is waited out and never + * stopped: it is the user's own agent, and its exit is its way out. */ import { @@ -11,6 +17,7 @@ import { } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../../provider-process/provider-process-supervisor' +import { releaseUnprovenAgentSessionOwner } from '../../runtime/agent-session-lease-transitions' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' export type StructuredSessionRecoveryStopSignal = 'SIGTERM' | 'SIGKILL' @@ -62,18 +69,21 @@ export async function resolveStructuredSessionRecovery( probe = await stopOwnerAndReprobe(deps, record, owner.pid) } } - const alreadyFree = - record.lease.claimStatus === 'released' && record.lease.reservedSpawnToken === null - if (owner ? !isProvenDeadProbe(probe) : !alreadyFree && probe.outcome !== 'reservation-unused') { - return 'unresolved' - } try { - await deps.store.evictProvenDeadOwner({ - sessionId, - expectedFence: record.lease.runtimeFence, - probe, - now: deps.now() - }) + await (owner && !isProvenDeadProbe(probe) + ? deps.store.transitionHandoff(sessionId, (latest) => + releaseUnprovenAgentSessionOwner({ + record: latest, + expectedFence: record.lease.runtimeFence, + now: deps.now() + }) + ) + : deps.store.evictProvenDeadOwner({ + sessionId, + expectedFence: record.lease.runtimeFence, + probe, + now: deps.now() + })) return 'resolved' } catch (error) { const code = error instanceof Error ? error.message : String(error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-retirement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-retirement.ts deleted file mode 100644 index 78255d1c68fc..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-retirement.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { isOwnerlessAgentSessionReservation } from '../../runtime/agent-session-lease-transitions' -import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from './structured-agent-session-send-settlement' - -// Beyond the cold-start wait plus scheduling margin, an unheld reservation cannot control work. -export const STRUCTURED_AGENT_SESSION_OWNERLESS_RESERVATION_MAX_AGE_MS = - STRUCTURED_AGENT_SESSION_START_WAIT_MS + 30_000 - -export function ownerlessReservationPastRetirementDeadline( - record: AgentSessionRecord, - platform: NodeJS.Platform, - now: number -): boolean { - return ( - (platform === 'darwin' || platform === 'win32') && - isOwnerlessAgentSessionReservation(record) && - now - record.lease.lastRenewedAt >= STRUCTURED_AGENT_SESSION_OWNERLESS_RESERVATION_MAX_AGE_MS - ) -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-retired-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-retired-conversation.ts deleted file mode 100644 index 0cfdc3a1d77f..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-retired-conversation.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - resettleOpenStructuredAgentSessionConversation, - type StructuredAgentSessionConversationOpenContext -} from './structured-agent-session-conversation-open' -import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' - -/** Reuses journal settlement without entering active owner recovery or starting a provider. */ -export async function restoreRetiredStructuredAgentSessionConversation( - context: StructuredAgentSessionConversationOpenContext, - sessionId: string -): Promise { - if (context.deps.store.getRecord(sessionId)?.lease.claimStatus !== 'released') { - return - } - const session = context.sessions.get(sessionId) - if (session) { - await resettleOpenStructuredAgentSessionConversation(context.deps, sessionId, session) - return - } - const opened = await restoreStructuredAgentSessionRead(context.deps, sessionId) - if (opened) { - await context.adoptOpened(sessionId, opened) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.test.ts deleted file mode 100644 index aa08434ba19e..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { createQueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' - -it('defers an automatic stop across an accepted send, provider handover, and Stop settlement', async () => { - const rig = await createQueuedMessageTestRig() - try { - const held = Promise.withResolvers() - const queued = rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, () => held.promise) - const sending = rig.send('accepted behind the lock') - const commit = vi.fn(() => true) - expect(rig.host.serverRetirement.admitStop(commit)).toBe(false) - expect(commit).not.toHaveBeenCalled() - held.resolve() - await queued - await sending.result - await vi.waitFor(() => expect(rig.dispatch).toHaveBeenCalled()) - expect(rig.host.serverRetirement.read()).toBeGreaterThan(0) - expect(rig.host.serverRetirement.admitStop(commit)).toBe(false) - const stopping = rig.stop() - expect(rig.host.serverRetirement.admitStop(commit)).toBe(false) - await stopping - } finally { - await rig.dispose() - } -}) - -it('closes admission atomically when an idle host commits an automatic stop', async () => { - const rig = await createQueuedMessageTestRig() - try { - await vi.waitFor(() => expect(rig.host.serverRetirement.read()).toBe(0)) - expect(rig.host.serverRetirement.admitStop(() => true)).toBe(true) - await expect(rig.send('raced the stop').result).rejects.toMatchObject({ - message: 'agent_session_ownership_unknown' - }) - expect(rig.dispatch).not.toHaveBeenCalled() - } finally { - await rig.dispose() - } -}) - -it('keeps admission open when a stop loses its decision to cancellation', async () => { - const rig = await createQueuedMessageTestRig() - try { - await vi.waitFor(() => expect(rig.host.serverRetirement.read()).toBe(0)) - expect(rig.host.serverRetirement.admitStop(() => false)).toBe(false) - await expect(rig.send('still usable').result).resolves.toMatchObject({ ok: true }) - } finally { - await rig.dispose() - } -}) - -it('treats an unreadable host journal as unknown and never invokes an automatic stop', async () => { - const rig = await createQueuedMessageTestRig() - try { - vi.spyOn(rig.store, 'listRecords').mockImplementation(() => { - throw new Error('unreadable') - }) - const commit = vi.fn(() => true) - expect(rig.host.serverRetirement.read()).toBeNull() - expect(rig.host.serverRetirement.admitStop(commit)).toBe(false) - expect(commit).not.toHaveBeenCalled() - } finally { - vi.restoreAllMocks() - await rig.dispose() - } -}) - -it('rechecks an unknown prior owner so proven exit ends its retirement obligation', async () => { - const rig = await createQueuedMessageTestRig() - try { - const owner = rig.store.getRecord(HOST_TEST_SESSION)?.lease.ownerProcess - expect(owner).toBeTruthy() - await rig.stop() - await rig.host.close(HOST_TEST_SESSION, 'evict') - await rig.store.transitionHandoff(HOST_TEST_SESSION, (record) => ({ - ...record, - lease: { - ...record.lease, - ownerProcess: owner ?? null, - claimStatus: 'conflicted', - handoffStage: 'recovering', - deathEvidence: null - } - })) - rig.host.deps.probeOwner = async () => ({ outcome: 'indeterminate', reason: 'unreachable' }) - expect(await rig.host.serverRetirement.observe()).toBeNull() - expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) - rig.host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - await rig.host.serverRetirement.observe() - await vi.waitFor(() => expect(rig.host.serverRetirement.read()).toBe(0)) - expect(rig.store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus).toBe('released') - expect(rig.dispatch).not.toHaveBeenCalled() - } finally { - await rig.dispose() - } -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts deleted file mode 100644 index 0b6266a474a0..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-server-lifetime.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { - stopStructuredAgentSessionAgentUnderSerialize, - type StructuredAgentSessionLifetimeContext -} from './structured-agent-session-host-lifetime' -import { replayJournal } from '../agent-session-journal/journal-open' -import { renderJournalState } from '../agent-session-journal/journal-reducer' -import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' -import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' -import { structuredAgentSessionOwesWork } from './structured-agent-session-owed-work' -import { isProvenDeadProbe } from '../../../shared/agent-session-lease-adjudication' -import { - abandonAgentSessionOwnerlessReservation, - isOwnerlessAgentSessionReservation -} from '../../runtime/agent-session-lease-transitions' -import { ownerlessReservationPastRetirementDeadline } from './structured-agent-session-reservation-retirement' -import { restoreRetiredStructuredAgentSessionConversation } from './structured-agent-session-retired-conversation' -import type { StructuredAgentSessionConversationOpenContext } from './structured-agent-session-conversation-open' - -export const STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS = 24 * 60 * 60_000 - -export function createStructuredAgentSessionServerLifetime(input: { - context: () => StructuredAgentSessionLifetimeContext - tasks: StructuredAgentSessionTaskQueue - deliveryActive: (id: string) => boolean - childWork: (id: string) => readonly AgentChildWorkView[] | undefined - stopDelivery: () => void - adoptOpened: StructuredAgentSessionConversationOpenContext['adoptOpened'] -}) { - const read = (): number | null => { - const deps = input.context().deps - try { - if ( - deps.store.readOnly || - deps.journalDatabase.readOnly || - deps.store.listHeldSessionIds().some((id) => deps.store.isSessionUnreadable(id)) - ) { - return null - } - let count = 0 - for (const record of deps.store.listRecords()) { - const session = input.context().sessions.get(record.sessionId) - // A lease without its transport must be reconciled by its execution host. - if ( - !session?.child && - record.lease.claimStatus !== 'released' && - !ownerlessReservationPastRetirementDeadline( - record, - deps.platform ?? process.platform, - deps.now?.() ?? Date.now() - ) - ) { - return null - } - const loaded = session ? null : replayJournal(deps.journalDatabase.db, record.sessionId) - if (loaded?.damage || loaded?.newer) { - return null - } - const snapshot = - session?.journal.snapshot() ?? (loaded ? renderJournalState(loaded.state) : null) - if ( - snapshot && - structuredAgentSessionOwesWork({ - snapshot, - hasChild: session?.child != null, - stopping: session?.child?.close !== undefined, - deliveryActive: input.deliveryActive(record.sessionId), - childWork: input.childWork(record.sessionId), - openDispatch: deps.hasOpenDispatch?.(record) === true, - providerHoldsDispatch: deps.adapter.holdsDispatch?.(record.sessionId) === true - }) - ) { - count++ - } - } - return count || (input.tasks.hasPending() ? 1 : 0) - } catch (error) { - deps.logger.warn('reading structured work before server retirement failed', { - scope: 'server-retirement', - error - }) - return null - } - } - return { - read, - abandonOwnerlessReservations: async (): Promise => { - input.tasks.closeAdmission() - input.stopDelivery() - const { deps, runtimeState } = input.context() - runtimeState.acquireAborts.abortAll('abandoned by user server stop') - await Promise.allSettled( - deps.store - .listRecords() - .filter(isOwnerlessAgentSessionReservation) - .map((record) => - deps.store - .transitionHandoff(record.sessionId, (latest) => - abandonAgentSessionOwnerlessReservation({ - record: latest, - expectedFence: record.lease.runtimeFence, - now: deps.now?.() ?? Date.now() - }) - ) - .catch((error: unknown) => - deps.logger.warn('recording user abandonment of a reservation failed', { - scope: 'server-reservation-abandon', - sessionId: record.sessionId, - error - }) - ) - ) - ) - }, - observe: async (): Promise => { - try { - const context = input.context() - for (const { sessionId } of context.deps.store.listRecords()) { - if (!context.sessions.get(sessionId)?.child) { - await input.tasks.serialize(sessionId, async () => { - const current = input.context() - const record = current.deps.store.getRecord(sessionId) - if (!record || current.sessions.get(sessionId)?.child) { - return - } - if (record.lease.claimStatus !== 'released') { - const probe = await current.runtimeState.probeRecord(record) - if ( - isProvenDeadProbe(probe) || - (record.lease.ownerProcess === null && probe.outcome === 'reservation-unused') - ) { - await current.deps.store.evictProvenDeadOwner({ - sessionId, - expectedFence: record.lease.runtimeFence, - probe, - now: current.now() - }) - } - } - await restoreRetiredStructuredAgentSessionConversation( - { deps: current.deps, sessions: current.sessions, adoptOpened: input.adoptOpened }, - sessionId - ) - }) - } - } - return read() - } catch (error) { - input.context().deps.logger.warn('rechecking structured work recovery failed', { - scope: 'server-retirement', - error - }) - return null - } - }, - expireUnansweredPrompts: async (): Promise => { - const expired = (id: string): boolean => { - const session = input.context().sessions.get(id) - const now = input.context().deps.now?.() ?? Date.now() - return ( - session?.child != null && - session.journal - .snapshot() - .items.some( - ({ body, observedAt }) => - (body.kind === 'approval' || body.kind === 'question') && - body.resolution.state === 'pending' && - now - observedAt >= STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS - ) - ) - } - await Promise.allSettled( - [...input.context().sessions.keys()].filter(expired).map((id) => - input.tasks - .serialize(id, async () => { - if (expired(id)) { - await stopStructuredAgentSessionAgentUnderSerialize(input.context(), id, { - cause: 'evict' - }) - } - }) - .catch((error: unknown) => - input.context().deps.logger.warn('expiring an unanswered prompt failed', { - scope: 'prompt-expiry', - sessionId: id, - error - }) - ) - ) - ) - }, - /** No await between evidence, the request's commit, and closing admission. */ - admitStop: (commit: () => boolean): boolean => { - if (read() !== 0 || !commit()) { - return false - } - input.tasks.closeAdmission() - input.stopDelivery() - return true - } - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 110bbf1078cc..f17ee460653e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -219,7 +219,7 @@ describe('settled attach retry', () => { expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) }) - it('retains an unverified reservation until a deliberate server stop abandons it', async () => { + it('releases a reservation a crash left ownerless at restart, so the next start goes ahead', async () => { const spawnTokens: string[] = [] acquire.mockImplementation(async ({ fence, spawnToken }) => { spawnTokens.push(spawnToken) @@ -294,23 +294,7 @@ describe('settled attach retry', () => { await host.restoreReadableSessions() expect(releaseAcquisition).toHaveBeenCalledTimes(1) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'recovering', - runtimeFence: 1, - reservedSpawnToken: 'spawn-1', - ownerProcess: null, - deathEvidence: null - }) - await host.serverRetirement.abandonOwnerlessReservations() - await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - ...host.deps, - store, - adapter: adapter() - }) - await host.restoreReadableSessions() + // No owner was recorded: released at restart, with no evidence, since nothing proved one. expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, 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 index dba193e3ce28..f0237835c756 100644 --- 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 @@ -1,35 +1,11 @@ -import { - getKeyedSerializedQueueTail, - runKeyedSerializedOperation -} from '../../cli/keyed-promise-queue' -import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' export class StructuredAgentSessionTaskQueue { private readonly chains = new Map>() private readonly attaching = new Set>() - private admissionClosed = false serialize(sessionId: string, task: () => Promise): Promise { - if (this.admissionClosed) { - return Promise.reject( - agentSessionRefusalError('agent_session_ownership_unknown', { - reason: 'sessionNotAttached' - }) - ) - } - return this.serializeDuringShutdown(sessionId, task) - } - - serializeDuringShutdown(sessionId: string, task: () => Promise): Promise { - const operation = runKeyedSerializedOperation(this.chains, sessionId, task) - const tail = getKeyedSerializedQueueTail(this.chains, sessionId) - // Observers finish after their own tail clears, so a probe does not count itself as work. - return operation.finally(() => tail) - } - - hasPending = (): boolean => this.chains.size > 0 || this.attaching.size > 0 - closeAdmission = (): void => { - this.admissionClosed = true + return runKeyedSerializedOperation(this.chains, sessionId, task) } trackAttach(operation: Promise): Promise { diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index be8d457d44c5..b89c813774fc 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -23,7 +23,6 @@ import { } from './orcad-lifecycle' import { parseArgs } from './orcad-command-arguments' import type { OrcadRuntimeCleanup } from './orcad-runtime-lifetime' -import * as structuredWork from './orcad-structured-work-boundary' import { installOrcadStopRequestListeners } from './orcad-stop-request-listener' import { prepareOrcadManagedStop } from './orcad-managed-stop-admission' import type { OrcadManagedStopContext } from '../../shared/orcad-stop-request' @@ -338,8 +337,6 @@ async function startOrcadRuntime( const { publishHeadlessRuntimeGraph } = await import('../runtime/headless-runtime-graph') publishHeadlessRuntimeGraph(runtime) - await structuredWork.prepareOrcadStructuredWorkBoundary(runtime) - const bindHost = resolveOrcadBindHost(options.bind) const rpc = new OrcaRuntimeRpcServer({ runtime, @@ -420,8 +417,6 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise requestShutdown('stop request'), { installRoot: resolveOrcadInstallRoot(), managedStop: handle.managedStop, - admitAutomaticStop: structuredWork.admitOrcadAutomaticStop, - beforeUserStop: structuredWork.prepareOrcadUserStop, beforeManagedStop: prepareOrcadManagedStop }) bindOrcadIdleShutdown(requestShutdown) diff --git a/src/main/orcad/orcad-health.ts b/src/main/orcad/orcad-health.ts index abf52edbc83c..d8cf2f705a28 100644 --- a/src/main/orcad/orcad-health.ts +++ b/src/main/orcad/orcad-health.ts @@ -18,10 +18,7 @@ import { readDaemonPidRecord } from '../daemon/daemon-init' import type { OrcadProfileStateAuthoritySelection } from './orcad-profile-state-telemetry' -import { - ORCAD_STOP_REQUESTS_CAPABILITY, - ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY -} from '../../shared/orcad-stop-request' +import { ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' import type { OrcadIdleStopRecord } from '../../shared/orcad-idle-exit' /** @@ -77,7 +74,6 @@ export type OrcadHealth = { * Absent on older builds, which a client must keep stopping with SIGTERM. */ stopRequests?: typeof ORCAD_STOP_REQUESTS_CAPABILITY - structuredWorkProtection?: typeof ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY /** * Managed launches only: how the previous run ended if it stopped for idleness, else null * (a crash, a signal, or a first start). Absent on user-started and older builds. @@ -187,7 +183,6 @@ export async function collectOrcadHealth( terminalDaemon: await collectTerminalDaemonHealth(), ...(profileStateAuthority ? { profileStateAuthority } : {}), stopRequests: ORCAD_STOP_REQUESTS_CAPABILITY, - structuredWorkProtection: ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY, ...(previousIdleStop !== undefined ? { previousIdleStop } : {}) } } diff --git a/src/main/orcad/orcad-idle-exit-monitor.test.ts b/src/main/orcad/orcad-idle-exit-monitor.test.ts index 29bb8e83b541..87ea58eefcf0 100644 --- a/src/main/orcad/orcad-idle-exit-monitor.test.ts +++ b/src/main/orcad/orcad-idle-exit-monitor.test.ts @@ -91,25 +91,29 @@ describe('OrcadIdleExitMonitor', () => { expect(await h.monitor.check()).toBe(true) }) - it('restarts the quiet window when the final host boundary defers a racing send', async () => { + it('does not fire after it was stopped', async () => { const h = harness() - h.onIdle.mockReturnValueOnce(false) await h.monitor.check() - h.advance(100) - expect(await h.monitor.check()).toBe(false) + h.monitor.stop() + h.advance(1_000) expect(await h.monitor.check()).toBe(false) - h.advance(100) - expect(await h.monitor.check()).toBe(true) - expect(h.onIdle).toHaveBeenCalledTimes(2) + expect(h.onIdle).not.toHaveBeenCalled() }) - it('does not fire after it was stopped', async () => { + it('restarts the quiet period when the final idle stop declines after an asynchronous check', async () => { const h = harness() await h.monitor.check() - h.monitor.stop() - h.advance(1_000) + 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) - expect(h.onIdle).not.toHaveBeenCalled() + 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', () => { diff --git a/src/main/orcad/orcad-idle-exit-monitor.ts b/src/main/orcad/orcad-idle-exit-monitor.ts index 75cc5165f2ae..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 | false + onIdle: (evidence: OrcadIdleExitEvidence) => void | boolean | Promise now?: () => number pollMs?: number log?: (line: string) => void @@ -81,8 +81,11 @@ export class OrcadIdleExitMonitor { return false } if ( - this.options.onIdle({ quietSince, stoppedAt: now, timeoutMs: this.options.timeoutMs }) === - false + (await this.options.onIdle({ + quietSince, + stoppedAt: now, + timeoutMs: this.options.timeoutMs + })) === false ) { this.quietSince = null return false 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 bec7da002850..2ecab96124cd 100644 --- a/src/main/orcad/orcad-managed-idle-exit-host.ts +++ b/src/main/orcad/orcad-managed-idle-exit-host.ts @@ -13,11 +13,8 @@ import { writeOrcadIdleStopRecord } from './orcad-idle-stop-record' import type { OrcadShutdownTrigger } from './orcad-lifecycle' -import { - expireOrcadUnansweredPrompts, - admitOrcadAutomaticStop -} from './orcad-structured-work-boundary' 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 @@ -70,6 +67,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: { @@ -78,19 +77,20 @@ async function startOrcadManagedIdleExit( countDaemonSessions: countLiveOrcadDaemonSessions, hasDaemon: () => getDaemonEndpointFacts() !== null, agentStates: input.agentStates, - readStructuredWork: expireOrcadUnansweredPrompts, + hasChatProviders, hasStagedMigration: input.hasStagedMigration, automationsBusy: input.automationsBusy, activationFenceExists }, - stop: (evidence) => { - if (!admitOrcadAutomaticStop(() => true)) { + stop: async (evidence) => { + await stopForIdle(input, evidence, retireOrcadDaemonIfIdle) + // Provider startup may complete while daemon retirement is awaiting its reply. + if (hasChatProviders()) { + discardOrcadIdleStopRecord(input.userDataPath) return false } - void stopForIdle(input, evidence, retireOrcadDaemonIfIdle).finally(() => - idleRecord.requestShutdown(requestIdleShutdown) - ) - return undefined + 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..7f04ea41c920 --- /dev/null +++ b/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts @@ -0,0 +1,67 @@ +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('rechecks providers after daemon retirement before requesting an idle shutdown', async () => { + 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: () => ({ openConnections: 0, requestsInFlight: 0, lastRequestAt: 0 }) + }, + 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(true) + 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-structured-work.test.ts b/src/main/orcad/orcad-managed-idle-exit-structured-work.test.ts deleted file mode 100644 index f9a4a8c470f6..000000000000 --- a/src/main/orcad/orcad-managed-idle-exit-structured-work.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, it } from 'vitest' -import { createOrcadIdleProbes } from './orcad-managed-idle-exit' -import { - attach, - hostTestState, - seedApproval -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' -import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' - -it('U2: managed idle exit protects with an attached provider and pending approval', async () => { - await attach() - const pending = await seedApproval() - const snapshot = await hostTestState().host.journalSnapshot(HOST_TEST_SESSION) - expect(snapshot.items.find((item) => item.itemId === pending.itemId)?.body).toMatchObject({ - kind: 'approval', - resolution: { state: 'pending' } - }) - const probes = createOrcadIdleProbes( - { timeoutMs: 900_000, activationRoot: 'test-fence' }, - { - readClientActivity: () => ({ openConnections: 0, requestsInFlight: 0, lastRequestAt: 0 }), - listTerminals: async () => [], - countDaemonSessions: async () => 0, - hasDaemon: () => true, - readStructuredWork: () => hostTestState().host.serverRetirement.read(), - agentStates: () => [{ state: 'blocked' }], - hasStagedMigration: () => false, - automationsBusy: () => false, - activationFenceExists: async () => false - } - ) - expect(await probes.find((probe) => probe.name === 'structured-work')?.read()).toBe('busy') -}) diff --git a/src/main/orcad/orcad-managed-idle-exit.test.ts b/src/main/orcad/orcad-managed-idle-exit.test.ts index 66b741d77579..3092f2c13119 100644 --- a/src/main/orcad/orcad-managed-idle-exit.test.ts +++ b/src/main/orcad/orcad-managed-idle-exit.test.ts @@ -23,7 +23,7 @@ function idlePorts(): OrcadManagedIdleExitPorts { countDaemonSessions: async () => 0, hasDaemon: () => true, agentStates: () => [{ state: 'done' }], - readStructuredWork: () => 0, + hasChatProviders: () => false, hasStagedMigration: () => false, automationsBusy: () => false, activationFenceExists: async () => false @@ -74,7 +74,7 @@ describe('createOrcadIdleProbes', () => { clients: 'idle', terminals: 'idle', agents: 'idle', - 'structured-work': 'idle', + 'chat-providers': 'idle', migration: 'idle', automations: 'idle', activation: 'idle' @@ -99,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 9c932bc20af7..0963e0ad44ae 100644 --- a/src/main/orcad/orcad-managed-idle-exit.ts +++ b/src/main/orcad/orcad-managed-idle-exit.ts @@ -46,7 +46,7 @@ export type OrcadManagedIdleExitPorts = { countDaemonSessions: () => Promise hasDaemon: () => boolean agentStates: () => readonly { state: string }[] - readStructuredWork: () => number | null | Promise + hasChatProviders: () => boolean hasStagedMigration: () => boolean /** An enabled schedule or an unsettled run; nothing would fire either once the host exits. */ automationsBusy: () => boolean @@ -60,13 +60,6 @@ export function createOrcadIdleProbes( ): OrcadIdleProbe[] { const verdict = (busy: boolean): OrcadIdleVerdict => (busy ? 'busy' : 'idle') return [ - { - name: 'structured-work', - read: async () => { - const work = await ports.readStructuredWork() - return work === null ? 'unverifiable' : verdict(work > 0) - } - }, { name: 'clients', read: () => { @@ -92,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()) }, { @@ -118,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 | false + stop: (evidence: OrcadIdleExitEvidence) => void | boolean | Promise }): () => void { const monitor = new OrcadIdleExitMonitor({ timeoutMs: input.config.timeoutMs, diff --git a/src/main/orcad/orcad-managed-stop-cancellation.test.ts b/src/main/orcad/orcad-managed-stop-cancellation.test.ts index 40a84a56b398..d4c3c2291086 100644 --- a/src/main/orcad/orcad-managed-stop-cancellation.test.ts +++ b/src/main/orcad/orcad-managed-stop-cancellation.test.ts @@ -93,7 +93,6 @@ describe('cancelling a managed stop', () => { const root = mkdtempSync(join(tmpdir(), 'orcad-stop-cancel-slot-')) roots.push(root) const listener = installOrcadStopRequestListeners(onRequest, { - admitAutomaticStop: () => false, installRoot: root, managedStop: { version: request.version, diff --git a/src/main/orcad/orcad-managed-stop-intent.test.ts b/src/main/orcad/orcad-managed-stop-intent.test.ts deleted file mode 100644 index 02df7cf973f6..000000000000 --- a/src/main/orcad/orcad-managed-stop-intent.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { expect, it } from 'vitest' -import { - createOrcadDecommissionTransaction, - withOrcadDecommissionStopDispatched -} from '../ssh/orcad-decommission-transaction' -import { emptyOrcadActivationRecord } from '../ssh/orcad-activation-record' -import { ORCAD_ACTIVATION_TRANSACTION_FILENAME } from '../ssh/orcad-activation-transaction' -import { orcadManagedStopIsUserRequested } from './orcad-managed-stop-intent' - -const request = { - schemaVersion: 1 as const, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - version: '1.0.0', - runtimeId: 'runtime-1', - instance: { pid: 42, startedAtMs: 1, nonce: 'nonce', lockPath: '/data/lock' } -} - -it('recognizes an older desktop Settings stop from its matching existing transaction', () => { - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-intent-')) - try { - const transaction = withOrcadDecommissionStopDispatched( - createOrcadDecommissionTransaction({ - transactionId: request.transactionId, - recordBefore: { ...emptyOrcadActivationRecord(), active: request.version }, - now: new Date(1) - }), - request, - new Date(2) - ) - writeFileSync(join(root, ORCAD_ACTIVATION_TRANSACTION_FILENAME), JSON.stringify(transaction)) - expect(orcadManagedStopIsUserRequested(request, root)).toBe(true) - expect(orcadManagedStopIsUserRequested({ ...request, runtimeId: 'other' }, root)).toBe(false) - expect( - orcadManagedStopIsUserRequested( - { ...request, instance: { ...request.instance, nonce: 'other' } }, - root - ) - ).toBe(false) - expect( - orcadManagedStopIsUserRequested( - { ...request, transactionId: 'f11f43dc-e3d9-497c-8c6e-d86ae7e54f7c' }, - root - ) - ).toBe(false) - } finally { - rmSync(root, { recursive: true, force: true }) - } -}) - -it('honors explicit user intent even when the activation journal is unreadable', () => { - const root = mkdtempSync(join(tmpdir(), 'orcad-stop-intent-')) - try { - writeFileSync(join(root, ORCAD_ACTIVATION_TRANSACTION_FILENAME), '{') - expect(orcadManagedStopIsUserRequested(request, root)).toBe(false) - expect(orcadManagedStopIsUserRequested({ ...request, intent: 'user' }, root)).toBe(true) - } finally { - rmSync(root, { recursive: true, force: true }) - } -}) diff --git a/src/main/orcad/orcad-managed-stop-intent.ts b/src/main/orcad/orcad-managed-stop-intent.ts deleted file mode 100644 index 359c1f4701c0..000000000000 --- a/src/main/orcad/orcad-managed-stop-intent.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { join } from 'node:path' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' -import { ORCAD_MANAGED_ACTIVATION_ROOT_ENV } from '../../shared/orcad-idle-exit' -import type { OrcadManagedStopRequest } from '../../shared/orcad-stop-request' -import { - ORCAD_ACTIVATION_TRANSACTION_FILENAME, - parseOrcadActivationTransaction -} from '../ssh/orcad-activation-transaction' -import { sameOrcadManagedStopInstance } from './orcad-managed-stop-request' - -/** Older Settings stops carry their intent in the existing decommission transaction. */ -export function orcadManagedStopIsUserRequested( - request: OrcadManagedStopRequest, - activationRoot = process.env[ORCAD_MANAGED_ACTIVATION_ROOT_ENV] -): boolean { - if (request.intent === 'user') { - return true - } - if (!activationRoot) { - return false - } - try { - const { buffer } = readNodeFileSyncWithinLimit( - join(activationRoot, ORCAD_ACTIVATION_TRANSACTION_FILENAME), - 64 * 1024 - ) - const parsed = parseOrcadActivationTransaction(buffer.toString('utf8')) - if (parsed.state !== 'ok') { - return false - } - const transaction = parsed.transaction - return ( - transaction.operation === 'decommission' && - transaction.phase === 'stop-dispatched' && - transaction.transactionId === request.transactionId && - transaction.request !== null && - transaction.request.runtimeId === request.runtimeId && - transaction.request.version === request.version && - sameOrcadManagedStopInstance(transaction.request.instance, request.instance) - ) - } catch { - return false - } -} diff --git a/src/main/orcad/orcad-ownerless-reservation-retirement.test.ts b/src/main/orcad/orcad-ownerless-reservation-retirement.test.ts deleted file mode 100644 index 85fce7adcd1a..000000000000 --- a/src/main/orcad/orcad-ownerless-reservation-retirement.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { createQueuedMessageTestRig } from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' -import { - HOST_TEST_SESSION, - HOST_TEST_NOW, - hostTestOperationId -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { STRUCTURED_AGENT_SESSION_OWNERLESS_RESERVATION_MAX_AGE_MS as MAX_AGE } from '../native-chat/agent-session-wire/structured-agent-session-reservation-retirement' -import { createStructuredAgentSessionOwnerProbe } from '../runtime/structured-agent-session-owner-probe' -import { - findAgentSessionSpawnTokenProcesses, - scanAgentSessionSpawnTokenProcesses -} from '../runtime/agent-session-spawn-token-process-scan' -import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' - -async function reservationRig(platform: NodeJS.Platform) { - const rig = await createQueuedMessageTestRig() - await rig.stop() - await rig.host.close(HOST_TEST_SESSION, 'evict') - const id = 'ownerless-reservation' - const reservedAt = HOST_TEST_NOW - const scan = vi.fn(() => - platform === 'linux' ? Promise.resolve(null) : scanAgentSessionSpawnTokenProcesses(platform) - ) - rig.host.deps.platform = platform - rig.host.deps.now = () => reservedAt - rig.host.deps.probeOwner = createStructuredAgentSessionOwnerProbe( - rig.store.hostId, - undefined, - (token) => findAgentSessionSpawnTokenProcesses(token, scan) - ) - await rig.store.reserveOwner({ - sessionId: id, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'folder-1', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: rig.root }, - expectedFence: null, - spawnToken: 'unverifiable-reservation', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'test', - operationId: hostTestOperationId(), - fingerprint: 'create-reservation' - }, - now: reservedAt - }) - await rig.host.reconcileRestartLeases() - return { ...rig, id, reservedAt, scan } -} - -it.each(['darwin', 'win32'] as const)( - 'bounds automatic retirement on an unheld %s reservation without declaring exit', - async (platform) => { - const rig = await reservationRig(platform) - try { - rig.host.deps.now = () => rig.reservedAt + MAX_AGE - 1 - expect(await rig.host.serverRetirement.observe()).toBeNull() - expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) - rig.host.deps.now = () => rig.reservedAt + MAX_AGE - expect(await rig.host.serverRetirement.observe()).toBe(0) - expect(rig.scan).toHaveBeenCalled() - expect(rig.store.getRecord(rig.id)?.lease).toMatchObject({ - claimStatus: 'reserved', - reservedSpawnToken: 'unverifiable-reservation', - ownerProcess: null, - deathEvidence: null - }) - const held = Promise.withResolvers() - const operation = rig.host.collaboratorsForTests().serialize(rig.id, () => held.promise) - expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) - held.resolve() - await operation - expect(rig.host.serverRetirement.admitStop(() => true)).toBe(true) - } finally { - await rig.dispose() - } - } -) - -it.each(['darwin', 'win32'] as const)( - 'records deliberate abandonment of an ownerless %s reservation across restart', - async (platform) => { - const rig = await reservationRig(platform) - try { - const fence = rig.store.getRecord(rig.id)?.lease.runtimeFence - await rig.host.serverRetirement.abandonOwnerlessReservations() - const reopened = await openTestAgentSessionRecordStore(rig.root) - await reopened.reconcileOnRestart({ - probe: async () => ({ outcome: 'indeterminate', reason: 'scan unavailable' }), - now: rig.reservedAt - }) - expect(reopened.getRecord(rig.id)?.lease).toMatchObject({ - claimStatus: 'released', - reservedSpawnToken: null, - ownerProcess: null, - handoffStage: null, - deathEvidence: null - }) - expect(reopened.getRecord(rig.id)?.lease.runtimeFence).toBe((fence ?? 0) + 1) - } finally { - await rig.dispose() - } - } -) - -it('keeps Linux scan proof and named owners outside the ownerless retirement deadline', async () => { - const rig = await reservationRig('linux') - try { - rig.host.deps.now = () => rig.reservedAt + MAX_AGE - expect(await rig.host.serverRetirement.observe()).toBeNull() - expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) - } finally { - await rig.dispose() - } - const owned = await createQueuedMessageTestRig() - try { - const lease = owned.store.getRecord(HOST_TEST_SESSION)?.lease - await owned.host.serverRetirement.abandonOwnerlessReservations() - expect(owned.store.getRecord(HOST_TEST_SESSION)?.lease).toEqual(lease) - } finally { - await owned.dispose() - } -}) - -it('never expires or abandons an unheld named owner on Windows', async () => { - const rig = await createQueuedMessageTestRig() - try { - const saved = rig.store.getRecord(HOST_TEST_SESSION)?.lease - if (!saved?.ownerProcess) { - throw new Error('owner missing') - } - await rig.stop() - await rig.host.close(HOST_TEST_SESSION, 'evict') - await rig.store.transitionHandoff(HOST_TEST_SESSION, (record) => ({ - ...record, - lease: { ...saved, handoffStage: 'recovering' } - })) - rig.host.deps.platform = 'win32' - rig.host.deps.now = () => saved.lastRenewedAt + MAX_AGE - rig.host.deps.probeOwner = async () => ({ - outcome: 'indeterminate', - reason: 'scan unavailable' - }) - expect(await rig.host.serverRetirement.observe()).toBeNull() - expect(rig.host.serverRetirement.admitStop(() => true)).toBe(false) - await rig.host.serverRetirement.abandonOwnerlessReservations() - expect(rig.store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ - ownerProcess: saved.ownerProcess, - claimStatus: 'live', - deathEvidence: null - }) - } finally { - await rig.dispose() - } -}) diff --git a/src/main/orcad/orcad-restart-queued-send-retirement.test.ts b/src/main/orcad/orcad-restart-queued-send-retirement.test.ts deleted file mode 100644 index 73dce584cef5..000000000000 --- a/src/main/orcad/orcad-restart-queued-send-retirement.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { createQueuedMessageTestRig } from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' -import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' -import { - getStructuredAgentSessionHost, - setStructuredAgentSessionHost -} from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { rotateStructuredAgentSessionHostInstanceForTests } from '../native-chat/agent-session-wire/structured-agent-session-queued-pause' -import { prepareOrcadStructuredWorkBoundary } from './orcad-structured-work-boundary' - -it('does not let an accepted send from an exited generation pin an unattended restarted host', async () => { - const rig = await createQueuedMessageTestRig() - const previous = getStructuredAgentSessionHost() - let restarted: StructuredAgentSessionHost | undefined - try { - rig.host.stopDelivery() - const sent = rig.send('accepted just before the runtime crashed') - expect(await sent.result).toMatchObject({ ok: true }) - expect(await rig.submission(sent.id)).toMatchObject({ - dispatchState: 'pending', - handoverRecorded: true - }) - expect((await rig.submission(sent.id))?.handedOverAt).toBeUndefined() - expect(rig.dispatch).not.toHaveBeenCalled() - rotateStructuredAgentSessionHostInstanceForTests() - const store = await openTestAgentSessionRecordStore(rig.root) - restarted = new StructuredAgentSessionHost({ - ...rig.host.deps, - store, - probeOwner: async () => ({ outcome: 'pid-absent' }) - }) - setStructuredAgentSessionHost(restarted) - await prepareOrcadStructuredWorkBoundary({ ensureStructuredAgentSessionHost: async () => {} }) - expect(store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: { kind: 'pid-absent' } - }) - for (let sweep = 0; sweep < 3; sweep++) { - await restarted.serverRetirement.expireUnansweredPrompts() - await restarted.serverRetirement.observe() - } - const beforeOpen = restarted.serverRetirement.read() - const commit = vi.fn(() => false) - const admitted = restarted.serverRetirement.admitStop(commit) - await restarted.restoreReadableSessions() - const afterOpen = restarted.serverRetirement.read() - expect({ beforeOpen, stopCommits: commit.mock.calls.length, admitted, afterOpen }).toEqual({ - beforeOpen: 0, - stopCommits: 1, - admitted: false, - afterOpen: 0 - }) - } finally { - setStructuredAgentSessionHost(previous) - await restarted?.flushAllStreamedEvents() - await rig.dispose() - } -}) diff --git a/src/main/orcad/orcad-stop-request-listener.test.ts b/src/main/orcad/orcad-stop-request-listener.test.ts index 2241b225209e..dc98966f6c45 100644 --- a/src/main/orcad/orcad-stop-request-listener.test.ts +++ b/src/main/orcad/orcad-stop-request-listener.test.ts @@ -52,7 +52,7 @@ describe('orcad stop-request listeners', () => { const installRoot = directory() writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') const onRequest = vi.fn() - listen(onRequest, { installRoot, admitAutomaticStop: (commit) => commit() }) + listen(onRequest, { installRoot }) expect(onRequest).toHaveBeenCalledOnce() expect(existsSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) }) @@ -60,7 +60,7 @@ describe('orcad stop-request listeners', () => { it('picks up a slot request written later, once', async () => { const installRoot = directory() const onRequest = vi.fn() - listen(onRequest, { installRoot, admitAutomaticStop: (commit) => commit() }) + listen(onRequest, { installRoot }) writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') @@ -73,7 +73,6 @@ describe('orcad stop-request listeners', () => { const onRequest = vi.fn() const report = vi.spyOn(console, 'error').mockImplementation(() => {}) listen(onRequest, { - admitAutomaticStop: (commit) => commit(), installRoot: directory(), managedStop, beforeManagedStop: async () => { @@ -99,11 +98,7 @@ describe('orcad stop-request listeners', () => { it('stops on a valid managed request and keeps it as evidence', async () => { const managedStop = managedContext() const onRequest = vi.fn() - listen(onRequest, { - admitAutomaticStop: (commit) => commit(), - installRoot: directory(), - managedStop - }) + listen(onRequest, { installRoot: directory(), managedStop }) const path = orcadManagedStopRequestPath(managedStop.instance) writeFileSync( path, @@ -130,12 +125,7 @@ describe('orcad stop-request listeners', () => { const onRequest = vi.fn() const prepare = vi.fn(async () => {}) const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { - admitAutomaticStop: (commit) => commit(), - installRoot: directory(), - managedStop, - beforeManagedStop: prepare - }) + listen(onRequest, { installRoot: directory(), managedStop, beforeManagedStop: prepare }) await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) await new Promise((resolve) => setTimeout(resolve, 50)) expect(prepare).toHaveBeenCalledOnce() @@ -147,11 +137,7 @@ describe('orcad stop-request listeners', () => { const managedStop = managedContext() const onRequest = vi.fn() const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { - admitAutomaticStop: (commit) => commit(), - installRoot: directory(), - managedStop - }) + listen(onRequest, { installRoot: directory(), managedStop }) writeFileSync( orcadManagedStopRequestPath(managedStop.instance), JSON.stringify({ @@ -170,116 +156,9 @@ describe('orcad stop-request listeners', () => { it('stops watching once closed', async () => { const installRoot = directory() const onRequest = vi.fn() - listen(onRequest, { installRoot, admitAutomaticStop: (commit) => commit() }).close() + listen(onRequest, { installRoot }).close() writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '') await new Promise((resolve) => setTimeout(resolve, 50)) expect(onRequest).not.toHaveBeenCalled() }) - - it.each([1, null])('defers an older desktop slot request while work is %s', async (work) => { - const installRoot = directory() - const path = join(installRoot, ORCAD_STOP_REQUEST_FILENAME) - let observed = work - const onRequest = vi.fn() - listen(onRequest, { - installRoot, - admitAutomaticStop: (commit) => observed === 0 && commit() - }) - writeFileSync(path, '') - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).not.toHaveBeenCalled() - expect(existsSync(path)).toBe(true) - observed = 0 - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - }) - - it('defers a generation-bound request without claiming its decision', async () => { - const managedStop = managedContext() - const onRequest = vi.fn() - let owingWork = true - listen(onRequest, { - installRoot: directory(), - managedStop, - admitAutomaticStop: (commit) => !owingWork && commit() - }) - const path = orcadManagedStopRequestPath(managedStop.instance) - writeFileSync( - path, - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop - }) - ) - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(onRequest).not.toHaveBeenCalled() - owingWork = false - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - }) - - it.each(['slot', 'managed'])('always honors an explicit user stop through %s', async (kind) => { - const installRoot = directory() - const managedStop = managedContext() - const onRequest = vi.fn() - const automatic = vi.fn(() => false) - const abandon = vi.fn(async () => {}) - listen(onRequest, { - installRoot, - managedStop, - admitAutomaticStop: automatic, - beforeUserStop: abandon - }) - if (kind === 'slot') { - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '{"intent":"user"}') - } else { - writeFileSync( - orcadManagedStopRequestPath(managedStop.instance), - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop, - intent: 'user' - }) - ) - } - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - expect(automatic).not.toHaveBeenCalled() - expect(abandon).toHaveBeenCalledOnce() - }) - - it.each(['slot', 'managed'])( - 'honors a user %s stop when abandonment bookkeeping fails', - async (kind) => { - const installRoot = directory() - const managedStop = managedContext() - const onRequest = vi.fn() - const report = vi.spyOn(console, 'error').mockImplementation(() => {}) - listen(onRequest, { - installRoot, - managedStop, - admitAutomaticStop: () => false, - beforeUserStop: async () => { - throw new Error('store unreadable') - } - }) - if (kind === 'slot') { - writeFileSync(join(installRoot, ORCAD_STOP_REQUEST_FILENAME), '{"intent":"user"}') - } else { - writeFileSync( - orcadManagedStopRequestPath(managedStop.instance), - JSON.stringify({ - schemaVersion: 1, - transactionId: '0b9f6a3e-9e2c-4c8e-8f58-4c0f6b1d2e3a', - ...managedStop, - intent: 'user' - }) - ) - } - await vi.waitFor(() => expect(onRequest).toHaveBeenCalledOnce()) - expect(report).toHaveBeenCalledWith( - '[orcad] user stop preparation failed; stop continues:', - expect.any(Error) - ) - } - ) }) diff --git a/src/main/orcad/orcad-stop-request-listener.ts b/src/main/orcad/orcad-stop-request-listener.ts index d83c61fc1267..1bde6a3a65aa 100644 --- a/src/main/orcad/orcad-stop-request-listener.ts +++ b/src/main/orcad/orcad-stop-request-listener.ts @@ -6,7 +6,6 @@ * root, which is validated and kept as evidence until the completion command proves exit. */ import { unlinkSync, watch, type FSWatcher } from 'node:fs' -import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' import { basename, dirname, join } from 'node:path' import { ORCAD_STOP_REQUEST_FILENAME, @@ -17,13 +16,9 @@ import { orcadManagedStopRequestPath, validateOrcadManagedStopRequest } from './orcad-managed-stop-request' -import { - claimOrcadManagedStopDecision, - readOrcadManagedStopDecision -} from './orcad-managed-stop-decision' +import { claimOrcadManagedStopDecision } from './orcad-managed-stop-decision' import { withdrawOrcadManagedStopRequest } from './orcad-managed-stop-cancellation' import { hasErrorCode } from '../daemon/daemon-process-inspection' -import { orcadManagedStopIsUserRequested } from './orcad-managed-stop-intent' export type OrcadStopRequestListener = { close(): void } @@ -97,11 +92,8 @@ export function installOrcadStopRequestListeners( options: { installRoot: string managedStop?: OrcadManagedStopContext - /** Commits only after a host observation proves no work, then closes chat admission. */ - admitAutomaticStop: (commit: () => boolean) => boolean /** Runs once for a validated managed request before the stop; it cannot prevent it. */ beforeManagedStop?: (request: OrcadManagedStopRequest) => Promise - beforeUserStop?: () => Promise pollIntervalMs?: number } ): OrcadStopRequestListener { @@ -113,35 +105,14 @@ export function installOrcadStopRequestListeners( listener.close() } } - const prepareUserStop = async (): Promise => { - try { - await options.beforeUserStop?.() - } catch (error) { - console.error('[orcad] user stop preparation failed; stop continues:', error) - } - } listeners.push( listenForRequest( join(options.installRoot, ORCAD_STOP_REQUEST_FILENAME), (path) => { - const user = - readNodeFileSyncWithinLimit(path, 1024).buffer.toString('utf8').trim() === - '{"intent":"user"}' - const consume = (): boolean => { - unlinkSync(path) - return true - } - const admitted = user ? consume() : options.admitAutomaticStop(consume) - return admitted ? user : null - }, - (user) => { - close() - if (user) { - void prepareUserStop().finally(onRequest) - } else { - onRequest() - } + unlinkSync(path) + return true }, + () => onRequest(), pollIntervalMs ) ) @@ -153,30 +124,18 @@ export function installOrcadStopRequestListeners( orcadManagedStopRequestPath(managedStop.instance), (path) => { const request = validateOrcadManagedStopRequest(managedStop, path) - if (readOrcadManagedStopDecision(request) === 'canceled') { + // A cancelled request is never acted on; keep listening for the next transaction. + if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') { + // A cancelled request left behind would block every later transaction's request. withdrawOrcadManagedStopRequest(request) throw new Error('orcad_managed_stop_canceled') } - const commit = (): boolean => { - if (claimOrcadManagedStopDecision(request, 'dispatched') === 'canceled') { - withdrawOrcadManagedStopRequest(request) - throw new Error('orcad_managed_stop_canceled') - } - return true - } - const user = orcadManagedStopIsUserRequested(request) - const admitted = user ? commit() : options.admitAutomaticStop(commit) - return admitted ? { request, user } : null + return request }, - ({ request, user }) => { + (request) => { close() // Preparation is best effort: whatever it reports, the stop proceeds. - void (async () => { - if (user) { - await prepareUserStop() - } - await prepare(request) - })() + void prepare(request) .catch((error: unknown) => console.error('[orcad] managed stop preparation failed:', error) ) diff --git a/src/main/orcad/orcad-structured-work-boundary.ts b/src/main/orcad/orcad-structured-work-boundary.ts deleted file mode 100644 index 8cf634927094..000000000000 --- a/src/main/orcad/orcad-structured-work-boundary.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { withTimeout } from '../../shared/promise-timeout-fallback' - -const USER_RESERVATION_ABANDON_TIMEOUT_MS = 2_000 - -export async function prepareOrcadUserStop(): Promise { - const host = getStructuredAgentSessionHost() - if (!host) { - return - } - const recorded = await withTimeout( - host.serverRetirement.abandonOwnerlessReservations().then(() => true), - USER_RESERVATION_ABANDON_TIMEOUT_MS, - false - ) - if (!recorded) { - console.error('[orcad] recording reservation abandonment timed out; user stop continues') - } -} - -export function admitOrcadAutomaticStop(commit: () => boolean): boolean { - return getStructuredAgentSessionHost()?.serverRetirement.admitStop(commit) ?? false -} - -export async function prepareOrcadStructuredWorkBoundary(runtime: { - ensureStructuredAgentSessionHost: () => Promise -}): Promise { - try { - await runtime.ensureStructuredAgentSessionHost() - const host = getStructuredAgentSessionHost() - await host?.reconcileRestartLeases() - await host?.serverRetirement.observe() - } catch (error) { - console.error('[orcad] structured work is unverifiable:', error) - } -} - -/** An unknown prior owner is probed again; its saved lease never becomes an idle-exit latch. */ -export async function observeOrcadStructuredWork(): Promise { - const host = getStructuredAgentSessionHost() - if (!host) { - return null - } - await host.reconcileRestartLeases() - return host.serverRetirement.observe() -} - -export async function expireOrcadUnansweredPrompts(): Promise { - const host = getStructuredAgentSessionHost() - if (!host) { - return null - } - await host.serverRetirement.expireUnansweredPrompts() - return observeOrcadStructuredWork() -} diff --git a/src/main/orcad/orcad-structured-work-unverifiable.test.ts b/src/main/orcad/orcad-structured-work-unverifiable.test.ts deleted file mode 100644 index 6543f18bcc61..000000000000 --- a/src/main/orcad/orcad-structured-work-unverifiable.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { createQueuedMessageTestRig } from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' -import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' -import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { - editPersistedTestAgentSessionStore, - openTestAgentSessionRecordStore -} from '../runtime/agent-session-record-store-test-harness' -import { - getStructuredAgentSessionHost, - setStructuredAgentSessionHost -} from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { prepareOrcadStructuredWorkBoundary } from './orcad-structured-work-boundary' - -it.each(['live', 'unverifiable'] as const)( - 'preserves a %s native owner during passive observation', - async (verdict) => { - const rig = await createQueuedMessageTestRig() - try { - const saved = rig.store.getRecord(HOST_TEST_SESSION) - if (!saved?.lease.ownerProcess) { - throw new Error('owner missing') - } - await rig.stop() - await rig.host.close(HOST_TEST_SESSION, 'evict') - await rig.store.transitionHandoff(HOST_TEST_SESSION, (record) => ({ - ...record, - lease: { ...saved.lease, handoffStage: 'recovering', deathEvidence: null } - })) - rig.host.deps.probeOwner = async () => - verdict === 'live' - ? { outcome: 'identity-matched', matchedOn: ['spawn-token'] } - : { outcome: 'indeterminate', reason: 'probe unavailable' } - const stop = vi.fn() - rig.host.deps.stopOwnerProcess = stop - expect(rig.host.serverRetirement.read()).toBeNull() - await rig.host.serverRetirement.observe() - await new Promise((resolve) => setTimeout(resolve, 0)) - expect({ - claimStatus: rig.store.getRecord(HOST_TEST_SESSION)?.lease.claimStatus, - work: rig.host.serverRetirement.read(), - admitted: rig.host.serverRetirement.admitStop(() => true) - }).toEqual({ claimStatus: 'live', work: null, admitted: false }) - expect(stop).not.toHaveBeenCalled() - rig.host.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - await rig.host.serverRetirement.observe() - await vi.waitFor(() => expect(rig.host.serverRetirement.read()).toBe(0)) - expect(rig.store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ - ownerProcess: null, - deathEvidence: { kind: 'pid-absent' } - }) - } finally { - await rig.dispose() - } - } -) - -it('retains an unknown native owner across startup and readable restore until positive exit proof', async () => { - const rig = await createQueuedMessageTestRig() - const previous = getStructuredAgentSessionHost() - let restarted: StructuredAgentSessionHost | undefined - try { - const saved = rig.store.getRecord(HOST_TEST_SESSION) - if (!saved?.lease.ownerProcess) { - throw new Error('owner missing') - } - await rig.stop() - await rig.host.close(HOST_TEST_SESSION, 'evict') - await rig.store.transitionHandoff(HOST_TEST_SESSION, (record) => ({ - ...record, - lease: saved.lease - })) - const store = await openTestAgentSessionRecordStore(rig.root) - expect(store.getRecord(HOST_TEST_SESSION)?.lease.unreconciled).toBe(true) - const stop = vi.fn() - restarted = new StructuredAgentSessionHost({ - ...rig.host.deps, - store, - probeOwner: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), - stopOwnerProcess: stop - }) - setStructuredAgentSessionHost(restarted) - await prepareOrcadStructuredWorkBoundary({ ensureStructuredAgentSessionHost: async () => {} }) - await restarted.restoreReadableSessions() - expect(store.getRecord(HOST_TEST_SESSION)?.lease).toMatchObject({ - ownerProcess: saved.lease.ownerProcess, - handoffStage: 'recovering', - deathEvidence: null - }) - expect(restarted.serverRetirement.read()).toBeNull() - expect(restarted.serverRetirement.admitStop(() => true)).toBe(false) - expect(stop).not.toHaveBeenCalled() - restarted.deps.probeOwner = async () => ({ outcome: 'pid-absent' }) - await restarted.serverRetirement.observe() - const retirement = restarted.serverRetirement - await vi.waitFor(() => expect(retirement.read()).toBe(0)) - } finally { - setStructuredAgentSessionHost(previous) - await restarted?.flushAllStreamedEvents() - await rig.dispose() - } -}) - -it('treats a quarantined unreadable session record as unverifiable for retirement', async () => { - const rig = await createQueuedMessageTestRig() - let restarted: StructuredAgentSessionHost | undefined - try { - await editPersistedTestAgentSessionStore(rig.root, (persisted) => { - Object.assign(persisted.records[HOST_TEST_SESSION].lease, { provenHandleLinkId: null }) - }) - const store = await openTestAgentSessionRecordStore(rig.root) - expect(store.isSessionUnreadable(HOST_TEST_SESSION)).toBe(true) - expect(store.readOnly).toBe(false) - expect(store.listRecords()).toEqual([]) - restarted = new StructuredAgentSessionHost({ ...rig.host.deps, store }) - expect({ - work: restarted.serverRetirement.read(), - admitted: restarted.serverRetirement.admitStop(() => true) - }).toEqual({ work: null, admitted: false }) - } finally { - await restarted?.flushAllStreamedEvents() - await rig.dispose() - } -}) diff --git a/src/main/orcad/orcad-unanswered-prompt-expiry-retry.test.ts b/src/main/orcad/orcad-unanswered-prompt-expiry-retry.test.ts deleted file mode 100644 index 9a3e3b6987ec..000000000000 --- a/src/main/orcad/orcad-unanswered-prompt-expiry-retry.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { - attach, - hostTestState, - seedApproval -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' -import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS as MAX_AGE } from '../native-chat/agent-session-wire/structured-agent-session-server-lifetime' - -it('retries an expired prompt when the first close could not prove provider exit', async () => { - await attach() - const { host, acquire } = hostTestState() - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('provider not attached') - } - events.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'pending', ordinal: 50 }, - { kind: 'turn', turnId: 'pending', state: 'running' }, - { turnScope: { kind: 'thread' } } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - const approval = await seedApproval() - const requestedAt = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( - (item) => item.itemId === approval.itemId - )?.observedAt - if (requestedAt === undefined) { - throw new Error('approval missing') - } - const now = vi.spyOn(host.deps, 'now').mockReturnValue(requestedAt + MAX_AGE) - const close = vi.fn<() => Promise>().mockResolvedValueOnce(false).mockResolvedValue(true) - host.deps.adapter.closeSession = close - try { - await host.serverRetirement.expireUnansweredPrompts() - expect(close).toHaveBeenCalledTimes(1) - expect(host.serverRetirement.read()).toBeGreaterThan(0) - await host.serverRetirement.observe() - await host.serverRetirement.expireUnansweredPrompts() - expect(close).toHaveBeenCalledTimes(2) - const snapshot = await host.journalSnapshot(HOST_TEST_SESSION) - expect(snapshot.items.find((item) => item.itemId === approval.itemId)?.body).toMatchObject({ - resolution: { state: 'cancelled' } - }) - expect( - snapshot.items.some(({ body }) => body.kind === 'turn' && body.state === 'interrupted') - ).toBe(true) - await vi.waitFor(() => expect(host.serverRetirement.read()).toBe(0)) - } finally { - host.deps.adapter.closeSession = async () => true - now.mockRestore() - } -}) diff --git a/src/main/orcad/orcad-unanswered-prompt-expiry.test.ts b/src/main/orcad/orcad-unanswered-prompt-expiry.test.ts deleted file mode 100644 index 1268b7132abc..000000000000 --- a/src/main/orcad/orcad-unanswered-prompt-expiry.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { - attach, - hostTestState, - seedApproval -} from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness' -import { HOST_TEST_SESSION } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { STRUCTURED_AGENT_SESSION_UNANSWERED_PROMPT_MAX_AGE_MS as MAX_AGE } from '../native-chat/agent-session-wire/structured-agent-session-server-lifetime' - -it('keeps an unanswered approval alive for 24 hours, then cancels it and releases the child', async () => { - await attach() - const { host, acquire } = hostTestState() - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('provider not attached') - } - events.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'pending', ordinal: 50 }, - { kind: 'turn', turnId: 'pending', state: 'running' }, - { turnScope: { kind: 'thread' } } - ) - await host.flushStreamedEvents(HOST_TEST_SESSION) - const approval = await seedApproval() - const requestedAt = (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( - (item) => item.itemId === approval.itemId - )?.observedAt - if (requestedAt === undefined) { - throw new Error('approval missing') - } - const now = vi.spyOn(host.deps, 'now').mockReturnValue(requestedAt + MAX_AGE - 1) - try { - await host.serverRetirement.expireUnansweredPrompts() - expect(host.serverRetirement.read()).toBeGreaterThan(0) - expect( - (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( - (item) => item.itemId === approval.itemId - )?.body - ).toMatchObject({ resolution: { state: 'pending' } }) - now.mockReturnValue(requestedAt + MAX_AGE) - await host.serverRetirement.expireUnansweredPrompts() - expect( - (await host.journalSnapshot(HOST_TEST_SESSION)).items.find( - (item) => item.itemId === approval.itemId - )?.body - ).toMatchObject({ resolution: { state: 'cancelled' } }) - expect( - (await host.journalSnapshot(HOST_TEST_SESSION)).items.some( - ({ body }) => body.kind === 'turn' && body.state === 'interrupted' - ) - ).toBe(true) - await vi.waitFor(() => expect(host.serverRetirement.read()).toBe(0)) - } finally { - now.mockRestore() - } -}) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 2654d3e0991f..950e05a8e78e 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -234,34 +234,23 @@ export function evictAgentSessionOwner(args: { return releasedAgentSessionLease(record, adjudication.nextFence, adjudication.evidence, args.now) } -export function isOwnerlessAgentSessionReservation(record: AgentSessionRecord): boolean { - return ( - record.lease.runtimeKind === 'native' && - record.lease.claimStatus !== 'released' && - record.lease.ownerProcess === null && - record.lease.reservedSpawnToken !== null - ) -} - -/** A deliberate host stop abandons a reservation without claiming its process exited. */ -export function abandonAgentSessionOwnerlessReservation(args: { +/** + * Recovery's conclusion when proof never came: a recorded owner whose identity cannot be verified, + * or that survived the stop ladder. Its transport died with the runtime that held it, so nothing + * can drive it, and a verdict that never arrives must not hold the conversation. Nothing proved it + * gone, so no death evidence is written. + */ +export function releaseUnprovenAgentSessionOwner(args: { record: AgentSessionRecord expectedFence: number now: number }): AgentSessionRecord { - if ( - args.record.lease.runtimeFence !== args.expectedFence || - !isOwnerlessAgentSessionReservation(args.record) - ) { - return args.record + const { record } = args + assertFence(record.lease, args.expectedFence) + if (record.lease.handoffStage !== 'recovering') { + throw new Error('agent_session_ownership_unknown') } - const released = releasedAgentSessionLease( - args.record, - nextAgentSessionFence(args.record.lease), - null, - args.now - ) - return { ...released, lease: { ...released.lease, unreconciled: false } } + return releasedAgentSessionLease(record, nextAgentSessionFence(record.lease), null, args.now) } function releasedAgentSessionLease( diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index bac6075c02b0..cc0a83cc55f2 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -584,14 +584,14 @@ describe('restart reconciliation', () => { probe: async () => ({ outcome: 'indeterminate', reason: 'no answer' }), now: NOW }) - // Unverifiable execution retains its identity until a later probe can prove exit. + // An unverifiable owner goes to recovery like any other; resolution concludes about it. expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ handoffStage: 'recovering', ownerProcess: { pid: expect.any(Number) } }) }) - it('retains an ownerless older conflict until its reservation is proven unused', async () => { + it('releases a claim an older record marked conflicted that names no process', async () => { const first = await open() await markLegacyConflicted(first, { ownerProcess: null }) @@ -601,19 +601,9 @@ describe('restart reconciliation', () => { now: NOW }) expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ - claimStatus: 'conflicted', - handoffStage: 'recovering', - deathEvidence: null - }) - const verified = await open() - await verified.reconcileOnRestart({ - probe: async () => ({ outcome: 'reservation-unused' }), - now: NOW + 1 - }) - expect(verified.getRecord('session-alpha')?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - deathEvidence: { kind: 'pid-absent' } + deathEvidence: null }) }) diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.test.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.test.ts index 092e8786276d..5759f9f8de12 100644 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.test.ts +++ b/src/main/runtime/rpc/methods/orcad-terminal-census.test.ts @@ -1,21 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -vi.mock('../../../orcad/orcad-structured-work-boundary', () => ({ - observeOrcadStructuredWork: async () => 0 -})) - const collect = vi.hoisted(() => vi.fn()) vi.mock('../../../orcad/orcad-terminal-census', () => ({ collectOrcadTerminalCensus: collect })) import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad-terminal-census' const handler = ORCAD_TERMINAL_CENSUS_METHODS[0]!.handler -const idle = { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 7 -} +const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 } function call(params: Record, released: number) { const runtime = { releaseFinishedAutomationRunTerminals: vi.fn(async () => released) } diff --git a/src/main/runtime/rpc/methods/orcad-terminal-census.ts b/src/main/runtime/rpc/methods/orcad-terminal-census.ts index 5598c78148da..f48c553156d7 100644 --- a/src/main/runtime/rpc/methods/orcad-terminal-census.ts +++ b/src/main/runtime/rpc/methods/orcad-terminal-census.ts @@ -1,4 +1,3 @@ -import { observeOrcadStructuredWork } from '../../../orcad/orcad-structured-work-boundary' import { defineMethod } from '../core' import { ORCAD_TERMINAL_CENSUS_METHOD, @@ -14,17 +13,13 @@ export const ORCAD_TERMINAL_CENSUS_METHODS = [ // Why lazy: the census reaches the daemon modules, whose xterm polyfill defines a global // `window`; importing them statically would load it into every process with the dispatcher. const { collectOrcadTerminalCensus } = await import('../../../orcad/orcad-terminal-census') - const collect = async () => ({ - ...(await collectOrcadTerminalCensus(params.activatedAt)), - structuredWork: await observeOrcadStructuredWork() - }) if (!params.releaseFinishedAutomationTerminals) { - return collect() + return collectOrcadTerminalCensus(params.activatedAt) } - const before = await collect() + const before = await collectOrcadTerminalCensus(params.activatedAt) const released = await runtime.releaseFinishedAutomationRunTerminals() return settledCensus( - collect, + () => collectOrcadTerminalCensus(params.activatedAt), before.liveSessions === null ? null : before.liveSessions - released ) } diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 4962d2536664..7ebc52cd89a7 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -187,11 +187,9 @@ describe('the reservation owner probe', () => { const store = await reconcileReservation() - // The token still names execution, even though it cannot identify a safe stop target. + // Released without evidence, and the process carrying the token is left alone. expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'reserved', - handoffStage: 'recovering', - reservedSpawnToken: MINTED_TOKEN, + claimStatus: 'released', deathEvidence: null }) expect(kill.mock.calls.filter(([, signal]) => signal !== 0)).toEqual([]) diff --git a/src/main/ssh/orcad-managed-remote-stop.ts b/src/main/ssh/orcad-managed-remote-stop.ts index 675a066d79bf..0c76dfc405a2 100644 --- a/src/main/ssh/orcad-managed-remote-stop.ts +++ b/src/main/ssh/orcad-managed-remote-stop.ts @@ -30,7 +30,6 @@ import { ORCAD_COMPLETE_MANAGED_STOP_FLAG, ORCAD_MANAGED_STOP_REQUEST_FILE_FLAG, ORCAD_STOP_REQUESTS_CAPABILITY, - ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY, OrcadManagedStopCancellationSchema, OrcadManagedStopCompletionSchema, type OrcadManagedStopCancellation, @@ -52,8 +51,7 @@ function refused(code: string, reason: string): OrcadManagedStopTarget { /** Names the running instance of `version`, or says why the host could not prove which it is. */ export async function readRemoteOrcadManagedStopTarget( options: OrcadSlotOptions, - version: string, - intent?: 'user' + version: string ): Promise { const slotDir = orcadSlotDir(options, version) const readiness = parseOrcadReadinessWaitOutput( @@ -73,15 +71,6 @@ export async function readRemoteOrcadManagedStopTarget( `orcad ${version} predates managed stop requests; it can only be stopped by signal.` ) } - if ( - intent !== 'user' && - health.structuredWorkProtection !== ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY - ) { - return refused( - 'orcad_structured_work_protection_unavailable', - 'This server cannot protect chat work during an automatic stop. Use an explicit update or stop instead.' - ) - } const lockPath = joinRemotePath(options.host, options.userDataDir, ORCAD_LOCK_FILE_NAME) const lockRead = await readBoundedOrcadRemoteRecord(options, lockPath, LOCK_RECORD_MAX_BYTES) const lock = lockRead.state === 'present' ? parseOrcadInstanceLockRecord(lockRead.raw) : null diff --git a/src/main/ssh/orcad-managed-runtime-context.ts b/src/main/ssh/orcad-managed-runtime-context.ts index 2cb8e509654f..6195028ab75a 100644 --- a/src/main/ssh/orcad-managed-runtime-context.ts +++ b/src/main/ssh/orcad-managed-runtime-context.ts @@ -106,7 +106,8 @@ export function probeManagedOrcadReadiness( ) } -// Unknown terminal protocol still cannot be forced; explicit chat interruption needs no protocol. +// Why only this code: deploy never has a session count to force past, so forcing an unknown +// census lands on the daemon-protocol deferral, which force cannot clear. export function isForceableOrcadDeferral(code: string): boolean { - return code === 'orcad_update_terminals_running' || code === 'orcad_update_structured_work' + return code === 'orcad_update_terminals_running' } diff --git a/src/main/ssh/orcad-recovery-slot.ts b/src/main/ssh/orcad-recovery-slot.ts index de752ca5dda9..29b1f6b607ee 100644 --- a/src/main/ssh/orcad-recovery-slot.ts +++ b/src/main/ssh/orcad-recovery-slot.ts @@ -36,8 +36,6 @@ export type OrcadSlotOptions = OrcadRemoteExecTarget & { userDataDir: string bindHost: string port: number - /** Only an explicit update-now request may interrupt structured work. */ - force?: boolean readinessTimeoutMs?: number sleep?: (ms: number) => Promise } @@ -117,17 +115,8 @@ export async function stopOrcadSlot( options.host, remoteDir, justLaunched - ? { - waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, - justLaunched: true, - nodePath: options.nodePath, - user: options.force === true - } - : { - waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, - nodePath: options.nodePath, - user: options.force === true - } + ? { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, justLaunched: true } + : { waitSeconds: ORCAD_SLOT_STOP_WAIT_SECONDS, nodePath: options.nodePath } ) ) ) diff --git a/src/main/ssh/orcad-remote-deploy.test.ts b/src/main/ssh/orcad-remote-deploy.test.ts index eae92f768a29..6742623a91ab 100644 --- a/src/main/ssh/orcad-remote-deploy.test.ts +++ b/src/main/ssh/orcad-remote-deploy.test.ts @@ -190,12 +190,7 @@ function options(overrides: Partial = {}): OrcadDeployOption userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, readinessTimeoutMs: 50, sleep: async () => {}, now: () => new Date('2026-02-02T00:00:00.000Z'), @@ -340,12 +335,7 @@ describe('deployOrcad', () => { options({ host: getRemoteHostPlatform(platform), target, - census: { - structuredWork: 0, - liveSessions: 1, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - } + census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 } }) ) const chmod = mockExec.mock.calls.findIndex(([, command]) => @@ -561,14 +551,7 @@ describe('deployOrcad', () => { } scriptHost(script) const result = await deployOrcad( - options({ - census: { - structuredWork: 0, - liveSessions: 2, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - } - }) + options({ census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 } }) ) expect(result).toMatchObject({ outcome: 'installed-not-activated', diff --git a/src/main/ssh/orcad-remote-lifecycle-windows.test.ts b/src/main/ssh/orcad-remote-lifecycle-windows.test.ts index d63cbffb3b80..23f6624b8311 100644 --- a/src/main/ssh/orcad-remote-lifecycle-windows.test.ts +++ b/src/main/ssh/orcad-remote-lifecycle-windows.test.ts @@ -84,7 +84,6 @@ function readyLine(version = VERSION): string { arch: 'x64', pid: 4242, stopRequests: 1, - structuredWorkProtection: 1, terminalDaemon: { state: 'live', ownsFreshSessions: true, @@ -169,12 +168,7 @@ function options(): OrcadDeployOptions { userDataDir: 'C:/Users/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, readinessTimeoutMs: 1_000, sleep: async () => {}, now: () => new Date('2026-10-02T00:00:00.000Z') diff --git a/src/main/ssh/orcad-remote-process-control-windows.ts b/src/main/ssh/orcad-remote-process-control-windows.ts index dc5136c543bd..5898a65fe013 100644 --- a/src/main/ssh/orcad-remote-process-control-windows.ts +++ b/src/main/ssh/orcad-remote-process-control-windows.ts @@ -12,12 +12,11 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' export function windowsStopOrcadCommand( host: RemoteHostPlatform, remoteInstallDir: string, - options: { waitSeconds: number; justLaunched: boolean; user?: boolean } + options: { waitSeconds: number; justLaunched: boolean } ): string { return orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'stop', [ remoteInstallDir, String(options.waitSeconds), - options.justLaunched ? '1' : '0', - options.user ? '1' : '0' + options.justLaunched ? '1' : '0' ]) } diff --git a/src/main/ssh/orcad-remote-process-control.ts b/src/main/ssh/orcad-remote-process-control.ts index bc732baf13ca..3650d5a6a71a 100644 --- a/src/main/ssh/orcad-remote-process-control.ts +++ b/src/main/ssh/orcad-remote-process-control.ts @@ -9,8 +9,7 @@ import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ import { ORCAD_READINESS_FILENAME } from './orcad-remote-launch' import { ORCAD_STOP_REQUEST_FILENAME, - ORCAD_STOP_REQUESTS_CAPABILITY, - ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY + ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' import { ORCAD_PID_FILENAME, posixProcessAliveShellFunction } from './orcad-remote-host-support' @@ -28,16 +27,15 @@ import { windowsStopOrcadCommand } from './orcad-remote-process-control-windows' export function stopOrcadCommand( host: RemoteHostPlatform, remoteInstallDir: string, - options: { waitSeconds: number; user?: boolean } & ( - | { justLaunched: true; nodePath?: string } + options: { waitSeconds: number } & ( + | { justLaunched: true } | { justLaunched?: false; nodePath: string } ) ): string { if (isWindowsRemoteHost(host)) { return windowsStopOrcadCommand(host, remoteInstallDir, { waitSeconds: options.waitSeconds, - justLaunched: options.justLaunched === true, - user: options.user + justLaunched: options.justLaunched === true }) } const pidFile = shellEscape(joinRemotePath(host, remoteInstallDir, ORCAD_PID_FILENAME)) @@ -49,11 +47,6 @@ export function stopOrcadCommand( `const r = JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8'));`, `const pid = r?.type === 'orca_server_ready' ? r.health?.pid : null;`, `if (!Number.isSafeInteger(pid) || pid <= 1) process.exit(1);`, - ...(options.user - ? [] - : [ - `if (r.health?.structuredWorkProtection !== ${ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY} || r.health?.stopRequests !== ${ORCAD_STOP_REQUESTS_CAPABILITY}) process.exit(1);` - ]), `const mode = r.health?.stopRequests === ${ORCAD_STOP_REQUESTS_CAPABILITY} ? 'request' : 'signal';`, `process.stdout.write(String(pid) + ':' + mode);` ].join(' ') @@ -63,17 +56,17 @@ export function stopOrcadCommand( 'case "$pid" in "" | *[!0-9]* ) echo NO_PID; exit 0;; esac;', 'stop_mode=signal;', // Older launchers recorded a waiting shell, whose exit does not prove runtime exit. - ...(options.justLaunched && options.user + ...(options.justLaunched ? [] : [ - `runtime_answer=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath ?? 'node')}; ` + + `runtime_answer=$(${selectOrcadSlotRuntimeCommand(host, remoteInstallDir, options.nodePath)}; ` + `"$orcad_runtime" -e ${shellEscape(readRuntimePid)} ${readiness} 2>/dev/null) || { echo UNKNOWN; exit 0; };`, '[ "$pid" = "${runtime_answer%%:*}" ] || { echo UNKNOWN; exit 0; };', 'stop_mode=${runtime_answer#*:};' ]), 'orcad_alive "$pid" || { echo ALREADY_EXITED; exit 0; };', 'if [ "$stop_mode" = request ]; then', - `( umask 077; printf %s ${shellEscape(options.user ? '{"intent":"user"}' : '')} > ${requestFile} ) 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };`, + `( umask 077; : > ${requestFile} ) 2>/dev/null || { echo SIGNAL_FAILED; exit 0; };`, 'else kill -TERM "$pid" 2>/dev/null || { echo SIGNAL_FAILED; exit 0; }; fi;', // Past here the stop may be under way, so a lost or unknown answer must keep the fence. 'echo SIGNALED;', diff --git a/src/main/ssh/orcad-remote-rollback.test.ts b/src/main/ssh/orcad-remote-rollback.test.ts index 2d9c5ea91ab4..59baa86881a7 100644 --- a/src/main/ssh/orcad-remote-rollback.test.ts +++ b/src/main/ssh/orcad-remote-rollback.test.ts @@ -185,12 +185,7 @@ function options(overrides: Partial = {}): OrcadRollbackOp userDataDir: '/home/u/.orca', bindHost: '127.0.0.1', port: 7777, - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, targetBuildHash: BUILD_HASH, targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, readinessTimeoutMs: 50, @@ -241,14 +236,7 @@ describe('rollbackOrcad', () => { const log: string[] = [] scriptHost(log) const result = await rollbackOrcad( - options({ - census: { - structuredWork: 0, - liveSessions: 3, - startedSinceActivation: 2, - daemonProtocolVersion: 3 - } - }) + options({ census: { liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 3 } }) ) expect(result).toMatchObject({ outcome: 'refused', diff --git a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts index ec3276fab5c6..fffede27a02a 100644 --- a/src/main/ssh/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/ssh/orcad-remote-shell-commands.integration.test.ts @@ -127,7 +127,7 @@ async function launchTestRuntime( ] : []), `console.log(JSON.stringify({type: 'orca_server_ready', health: {pid: process.pid${ - stopRequests ? ', stopRequests: 1, structuredWorkProtection: 1' : '' + stopRequests ? ', stopRequests: 1' : '' }}}));`, `setTimeout(() => process.exit(1), 10_000);` ].join('\n') @@ -166,10 +166,7 @@ async function launchTestRuntime( return { runtimePid, recordedPid, terminatedFile } } -function stopTestRuntime( - justLaunched = false, - user = true -): ReturnType { +function stopTestRuntime(justLaunched = false): ReturnType { return parseOrcadStopOutcome( execFileSync( '/bin/sh', @@ -177,7 +174,6 @@ function stopTestRuntime( '-c', stopOrcadCommand(host, versionDir, { waitSeconds: 3, - user, ...(justLaunched ? { justLaunched: true as const } : { nodePath: process.execPath }) }) ], @@ -412,36 +408,6 @@ describe('liveness and stop commands, run for real', () => { expect(existsSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) }) - it('refuses an automatic stop of an older server with no structured protection', async () => { - const { runtimePid, terminatedFile } = await launchTestRuntime() - expect(stopTestRuntime(false, false)).toBe('unknown') - expect(() => process.kill(runtimePid, 0)).not.toThrow() - expect(existsSync(terminatedFile)).toBe(false) - expect(existsSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME))).toBe(false) - }) - - it('never signals an automatic candidate cleanup before readiness proves protection', async () => { - const { runtimePid, terminatedFile } = await launchTestRuntime() - rmSync(join(versionDir, ORCAD_READINESS_FILENAME)) - expect(stopTestRuntime(true, false)).toBe('unknown') - expect(() => process.kill(runtimePid, 0)).not.toThrow() - expect(existsSync(terminatedFile)).toBe(false) - }) - - it('never falls back to a signal when automatic readiness lacks stop-request support', async () => { - const { runtimePid, terminatedFile } = await launchTestRuntime() - writeFileSync( - join(versionDir, ORCAD_READINESS_FILENAME), - JSON.stringify({ - type: 'orca_server_ready', - health: { pid: runtimePid, structuredWorkProtection: 1 } - }) - ) - expect(stopTestRuntime(false, false)).toBe('unknown') - expect(() => process.kill(runtimePid, 0)).not.toThrow() - expect(existsSync(terminatedFile)).toBe(false) - }) - it('clears a stop request the previous process never consumed before launching', async () => { writeFileSync(join(versionDir, ORCAD_STOP_REQUEST_FILENAME), '') const { runtimePid } = await launchTestRuntime(false, true) @@ -486,7 +452,7 @@ describe('liveness and stop commands, run for real', () => { } ) - it('can explicitly stop a candidate just launched with exec without readiness', async () => { + it('can stop a candidate just launched with exec without readiness', async () => { const { runtimePid, recordedPid, terminatedFile } = await launchTestRuntime() expect(recordedPid).toBe(runtimePid) rmSync(join(versionDir, ORCAD_READINESS_FILENAME)) @@ -508,7 +474,6 @@ describe('liveness and stop commands, run for real', () => { sh( `${deniedProbe} ${stopOrcadCommand(host, versionDir, { waitSeconds: 1, - user: true, nodePath: process.execPath })}` ) @@ -584,13 +549,7 @@ describe('liveness and stop commands, run for real', () => { ) expect( parseOrcadStopOutcome( - sh( - stopOrcadCommand(host, versionDir, { - waitSeconds: 10, - justLaunched: true, - user: true - }) - ) + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10, justLaunched: true })) ) ).toBe('stopped') expect(await exited).toBe('SIGTERM') @@ -615,7 +574,7 @@ describe('liveness and stop commands, run for real', () => { expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') expect( parseOrcadStopOutcome( - sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true, user: true })) + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) ) ).toBe('already-exited') } finally { @@ -628,7 +587,7 @@ describe('liveness and stop commands, run for real', () => { writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(exited)) expect( parseOrcadStopOutcome( - sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true, user: true })) + sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) ) ).toBe('already-exited') }) diff --git a/src/main/ssh/orcad-remote-stop.ts b/src/main/ssh/orcad-remote-stop.ts index a615891844bb..345926b0d727 100644 --- a/src/main/ssh/orcad-remote-stop.ts +++ b/src/main/ssh/orcad-remote-stop.ts @@ -89,7 +89,7 @@ export async function decommissionRemoteOrcad( if (census) { return census } - const target = await readRemoteOrcadManagedStopTarget(options, activeVersion, 'user') + const target = await readRemoteOrcadManagedStopTarget(options, activeVersion) if (target.state === 'refused') { return refuse(target.verdict, target.code, target.reason) } @@ -106,8 +106,7 @@ export async function decommissionRemoteOrcad( transactionId: transaction.transactionId, ...target.context, // Best effort: an idle daemon goes with orcad, a busy one keeps its terminals. - retireIdleDaemon: true as const, - intent: 'user' as const + retireIdleDaemon: true as const } // Durable before it can reach the host, so recovery can settle exactly this request. transaction = withOrcadDecommissionStopDispatched(transaction, request, now()) diff --git a/src/main/ssh/orcad-remote-windows-scripts.test.ts b/src/main/ssh/orcad-remote-windows-scripts.test.ts index c2c4686c21f9..d1699ac18d31 100644 --- a/src/main/ssh/orcad-remote-windows-scripts.test.ts +++ b/src/main/ssh/orcad-remote-windows-scripts.test.ts @@ -141,10 +141,8 @@ describe('Windows liveness script', () => { describe('Windows stop script', () => { const requestFile = () => join(dir, ORCAD_STOP_REQUEST_FILENAME) - const stop = async (justLaunched: boolean, waitSeconds = 5, user = true) => - ( - await runOp('stop', [dir, String(waitSeconds), justLaunched ? '1' : '0', user ? '1' : '0']) - ).stdout + const stop = async (justLaunched: boolean, waitSeconds = 5) => + (await runOp('stop', [dir, String(waitSeconds), justLaunched ? '1' : '0'])).stdout .trim() .split(/\r?\n/u) .at(-1) @@ -170,11 +168,8 @@ describe('Windows stop script', () => { const pid = fakeOrcad() stageAddon({ [pid]: 1000 }) recordProcess(pid, 1000) - writeFileSync( - join(dir, ORCAD_READINESS_FILENAME), - readyLine({ pid, stopRequests: 1, structuredWorkProtection: 1 }) - ) - expect(await stop(false, 5, false)).toBe('STOPPED') + writeFileSync(join(dir, ORCAD_READINESS_FILENAME), readyLine({ pid, stopRequests: 1 })) + expect(await stop(false)).toBe('STOPPED') }) it('writes the request for a just-launched candidate that has not published readiness', async () => { @@ -184,17 +179,6 @@ describe('Windows stop script', () => { expect(await stop(true)).toBe('STOPPED') }) - it('refuses an automatic stop when an older server cannot protect chats', async () => { - stageAddon({ [process.pid]: 1000 }) - recordProcess(process.pid, 1000) - writeFileSync( - join(dir, ORCAD_READINESS_FILENAME), - readyLine({ pid: process.pid, stopRequests: 1 }) - ) - expect(await stop(false, 0, false)).toBe('UNKNOWN') - expect(existsSync(requestFile())).toBe(false) - }) - it('refuses a build that cannot be asked, without writing anything', async () => { stageAddon({ [process.pid]: 1000 }) recordProcess(process.pid, 1000) diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts index d11806fae40a..f0f5be86e484 100644 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ b/src/main/ssh/orcad-runtime-deployment.test.ts @@ -192,11 +192,7 @@ describe('createManagedOrcadEnvironment', () => { it('deploys an empty host with a zero census and an unknown one over an active slot', async () => { await deploy() expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({ - census: { - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: null - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }, nodePath: '' }) }) diff --git a/src/main/ssh/orcad-runtime-maintenance.test.ts b/src/main/ssh/orcad-runtime-maintenance.test.ts index f992ca6f919b..33506c397ca1 100644 --- a/src/main/ssh/orcad-runtime-maintenance.test.ts +++ b/src/main/ssh/orcad-runtime-maintenance.test.ts @@ -103,12 +103,10 @@ describe('updateManagedOrcadEnvironment', () => { mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-not-activated', fullVersion: '0.3.0+new', - code: 'orcad_update_structured_work', + code: 'orcad_update_terminals_running', reason: 'busy' }) - await expect( - updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) - ).resolves.toMatchObject({ outcome: 'deferred', forceable: true }) + await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-and-activated', fullVersion: '0.3.0+new' @@ -118,7 +116,6 @@ describe('updateManagedOrcadEnvironment', () => { force: true }) expect(result).toMatchObject({ outcome: 'updated', activeVersion: '0.3.0+new' }) - expect(mocks.deploy.mock.calls.at(-1)?.[0]).toMatchObject({ force: true }) expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), { buildHash: 'local-hash', fullVersion: '0.3.0+new' diff --git a/src/main/ssh/orcad-update-plan-structured-work.test.ts b/src/main/ssh/orcad-update-plan-structured-work.test.ts deleted file mode 100644 index 21d086893d42..000000000000 --- a/src/main/ssh/orcad-update-plan-structured-work.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { expect, it } from 'vitest' -import { planOrcadUpdate } from './orcad-update-plan' -import { emptyOrcadActivationRecord } from './orcad-activation-record' -import { createQueuedMessageTestRig } from '../native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture' - -it('U1: automatic update defers with zero PTYs while native provider work is in flight', async () => { - const rig = await createQueuedMessageTestRig() - try { - const sent = await rig.workingSend() - expect(await rig.submission(sent)).toMatchObject({ - dispatchState: 'pending', - handedOverAt: expect.any(Number) - }) - expect(rig.closeSession).not.toHaveBeenCalled() - expect( - planOrcadUpdate({ - candidateDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, - record: { ...emptyOrcadActivationRecord(), active: '0.2.0+aa01' }, - candidateVersion: '0.3.0+bb01', - census: { - structuredWork: rig.host.serverRetirement.read(), - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - } - }) - ).toMatchObject({ action: 'defer', code: 'orcad_update_structured_work' }) - } finally { - await rig.dispose() - } -}) - -it('defers automatic updates of an older host without a structured work observation', () => { - const input = { - candidateDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }, - record: { ...emptyOrcadActivationRecord(), active: '0.2.0+aa01' }, - candidateVersion: '0.3.0+bb01', - census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } - } - expect(planOrcadUpdate(input)).toMatchObject({ - action: 'defer', - code: 'orcad_update_structured_work' - }) - expect(planOrcadUpdate({ ...input, force: true })).toMatchObject({ action: 'proceed' }) -}) diff --git a/src/main/ssh/orcad-update-plan.test.ts b/src/main/ssh/orcad-update-plan.test.ts index e32bd91f6b37..daa2014a016f 100644 --- a/src/main/ssh/orcad-update-plan.test.ts +++ b/src/main/ssh/orcad-update-plan.test.ts @@ -35,12 +35,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.2.0+bb01', - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - } + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } }) expect(plan).toMatchObject({ action: 'noop' }) }) @@ -50,12 +45,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { - structuredWork: 0, - liveSessions: 3, - startedSinceActivation: 1, - daemonProtocolVersion: 3 - } + census: { liveSessions: 3, startedSinceActivation: 1, daemonProtocolVersion: 3 } }) expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_terminals_running' }) expect(plan.action === 'defer' && plan.reason).toContain('would not kill them') @@ -66,12 +56,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { - structuredWork: 0, - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: 3 - } + census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 } }) expect(plan).toMatchObject({ action: 'defer', @@ -84,12 +69,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { - structuredWork: 0, - liveSessions: null, - startedSinceActivation: null, - daemonProtocolVersion: 3 - }, + census: { liveSessions: null, startedSinceActivation: null, daemonProtocolVersion: 3 }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -100,12 +80,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { - structuredWork: 0, - liveSessions: 2, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 2, startedSinceActivation: 0, daemonProtocolVersion: 3 }, force: true }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: true }) @@ -131,7 +106,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { ...census, structuredWork: 0 }, + census, force }) expect(plan).toMatchObject({ @@ -152,7 +127,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { ...census, structuredWork: 0 }, + census, force: true }) expect(plan).toMatchObject({ action: 'defer', code: 'orcad_update_ends_in_process_terminals' }) @@ -163,12 +138,7 @@ describe('planOrcadUpdate', () => { candidateDaemonProtocol: PROTOCOL, record: record(), candidateVersion: '0.3.0+cc01', - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - } + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 } }) expect(plan).toMatchObject({ action: 'proceed', preservesLiveDaemon: false }) }) @@ -180,12 +150,7 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'clean', target: '0.1.0+aa01' }) @@ -196,12 +161,7 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { - structuredWork: 0, - liveSessions: 1, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 1, startedSinceActivation: 0, daemonProtocolVersion: 3 }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ safety: 'lossy', target: '0.1.0+aa01' }) @@ -213,12 +173,7 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, stateWritesSinceActivation: null }) expect(safety).toMatchObject({ safety: 'lossy' }) @@ -230,12 +185,7 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { - structuredWork: 0, - liveSessions: 4, - startedSinceActivation: 1, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 4, startedSinceActivation: 1, daemonProtocolVersion: 3 }, stateWritesSinceActivation: true }) expect(safety).toMatchObject({ @@ -250,12 +200,7 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: false, - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -267,12 +212,7 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record({ snapshot: null }), snapshotPresent: true, - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_snapshot_missing' }) @@ -283,12 +223,7 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record(), snapshotPresent: true, - census: { - structuredWork: 0, - liveSessions: 2, - startedSinceActivation: null, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 2, startedSinceActivation: null, daemonProtocolVersion: 3 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_census_unavailable' }) @@ -299,12 +234,7 @@ describe('assessOrcadRollback', () => { targetDaemonProtocol: PROTOCOL, record: record({ previous: null }), snapshotPresent: true, - census: { - structuredWork: 0, - liveSessions: 0, - startedSinceActivation: 0, - daemonProtocolVersion: 3 - }, + census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 }, stateWritesSinceActivation: false }) expect(safety).toMatchObject({ safety: 'unsafe', code: 'orcad_rollback_no_target' }) @@ -313,7 +243,6 @@ describe('assessOrcadRollback', () => { describe('D7 daemon protocol crossing', () => { const census = (daemonProtocolVersion: number | null, liveSessions: number | null = 2) => ({ - structuredWork: 0, liveSessions, startedSinceActivation: 0, daemonProtocolVersion diff --git a/src/main/ssh/orcad-update-plan.ts b/src/main/ssh/orcad-update-plan.ts index a15d6232e9ec..f634e7395b5f 100644 --- a/src/main/ssh/orcad-update-plan.ts +++ b/src/main/ssh/orcad-update-plan.ts @@ -38,7 +38,6 @@ export type OrcadUpdateDecision = | { action: 'defer'; code: OrcadUpdateDeferCode; reason: string } export type OrcadUpdateDeferCode = - | 'orcad_update_structured_work' | 'orcad_update_terminals_running' | 'orcad_update_terminal_census_unavailable' | 'orcad_update_strands_live_terminals' @@ -67,14 +66,6 @@ export function planOrcadUpdate(input: { reason: `${input.candidateVersion} is already the active version; nothing to restart.` } } - if (input.record.active && !input.force && input.census.structuredWork !== 0) { - return { - action: 'defer', - code: 'orcad_update_structured_work', - reason: - 'Chat work is still running or could not be verified. Update when it finishes, or choose Update now to interrupt it.' - } - } const inProcess = input.census.inProcessSessions ?? 0 // First, and force cannot override it: these run inside orcad, so no daemon carries them // across, and an empty daemon list would otherwise read as an unreported protocol. @@ -176,7 +167,6 @@ export type OrcadRollbackSafety = | { safety: 'unsafe'; code: OrcadRollbackUnsafeCode; reason: string } export type OrcadRollbackUnsafeCode = - | 'orcad_rollback_structured_work' | 'orcad_rollback_no_target' | 'orcad_rollback_snapshot_missing' | 'orcad_rollback_orphans_live_terminals' @@ -216,14 +206,6 @@ export function assessOrcadRollback(input: { */ stateWritesSinceActivation: boolean | null }): OrcadRollbackSafety { - if (input.census.structuredWork !== 0) { - return { - safety: 'unsafe', - code: 'orcad_rollback_structured_work', - reason: - 'Chat work is still running or could not be verified. Nothing was stopped or restored.' - } - } const target = input.record.previous if (!target) { return { diff --git a/src/main/ssh/orcad-windows-host-script.ts b/src/main/ssh/orcad-windows-host-script.ts index 48f9e2b581cf..c731244c6ffe 100644 --- a/src/main/ssh/orcad-windows-host-script.ts +++ b/src/main/ssh/orcad-windows-host-script.ts @@ -23,8 +23,7 @@ import { } from '../../shared/orcad-artifacts' import { ORCAD_STOP_REQUEST_FILENAME, - ORCAD_STOP_REQUESTS_CAPABILITY, - ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY + ORCAD_STOP_REQUESTS_CAPABILITY } from '../../shared/orcad-stop-request' import { ORCAD_STDIO_BRIDGE_FUNCTION } from './orcad-stdio-bridge-script' import { @@ -145,7 +144,7 @@ const ops = { }, // Never a signal: on Windows that is TerminateProcess, which skips the durable shutdown. - stop(dir, waitArg, launchedArg, userArg) { + stop(dir, waitArg, launchedArg) { const record = orcadRecord(dir) if (!record) return answer('NO_PID') const ready = readiness(dir) @@ -154,15 +153,14 @@ const ops = { // The readiness PID must corroborate the launcher's, so a reused PID is never addressed. if (health.pid !== record.pid) return answer('UNKNOWN') if (health.stopRequests !== ${ORCAD_STOP_REQUESTS_CAPABILITY}) return answer('UNSUPPORTED') - if (userArg !== '1' && health.structuredWorkProtection !== ${ORCAD_STRUCTURED_WORK_PROTECTION_CAPABILITY}) return answer('UNKNOWN') - } else if (launchedArg !== '1' || userArg !== '1') { + } else if (launchedArg !== '1') { return answer('UNKNOWN') } // A just-launched candidate's listener consumes a request written before it started. const first = orcadState(dir, record) if (first === 'dead') return answer('ALREADY_EXITED') if (first !== 'alive') return answer('UNKNOWN') - try { fs.writeFileSync(path.join(dir, ${text(ORCAD_STOP_REQUEST_FILENAME)}), userArg === '1' ? '{"intent":"user"}' : '', { mode: 0o600 }) } catch { return answer('SIGNAL_FAILED') } + try { fs.writeFileSync(path.join(dir, ${text(ORCAD_STOP_REQUEST_FILENAME)}), '', { mode: 0o600 }) } catch { return answer('SIGNAL_FAILED') } process.stdout.write('SIGNALED\\n') const end = Date.now() + Number(waitArg) * 1000 const tick = () => { diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts index 453abd5d733a..35e794efd0d2 100644 --- a/src/shared/agent-session-lease-adjudication.test.ts +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -268,7 +268,7 @@ describe('restart reconciliation', () => { probe: INDETERMINATE, observedAt: 9_000 }) - ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) + ).toEqual({ disposition: 'evicted', nextFence: 8, evidence: null }) }) it('frees a conflict whose named owner is proven gone', () => { @@ -311,12 +311,14 @@ describe('restart reconciliation', () => { }) it.each([null, 'recovering'] as const)( - 'retains an ownerless reservation at stage %s until a scan proves nothing spawned', + 'releases an ownerless reservation at stage %s, with evidence only when a scan proved nothing spawned', (handoffStage) => { + // A child spawned before its identity was recorded lost its stdio with the runtime that + // crashed, and a token scan is the only proof there can be. const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage }) expect( adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) - ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) + ).toEqual({ disposition: 'evicted', nextFence: 8, evidence: null }) expect( adjudicateAgentSessionRestart({ lease: reserved, diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts index c4de33297db1..75de4a74c40d 100644 --- a/src/shared/agent-session-lease-adjudication.ts +++ b/src/shared/agent-session-lease-adjudication.ts @@ -242,23 +242,23 @@ export function adjudicateAgentSessionRestart(args: { // treating it as an unproven reservation is what re-latches every released record on restart. return { disposition: 'free', reason: 'lease has no owner and no reservation' } } - // A spawn-token scan must prove the reservation unused; missing transport is not exit proof. - if (probe.outcome !== 'reservation-unused') { - return { - disposition: 'recovering', - stage: 'recovering', - reason: 'reservation process could not be verified' - } - } + // Why: a child spawned before its identity was recorded lost its stdio with the runtime that + // crashed, so nothing can drive it. A token scan that proves no child is the only evidence there + // can be; without it the lease is released anyway. A live child still carrying the token is + // never signalled: the token is inherited by every descendant, so it cannot prove which one is + // the provider child. return { disposition: 'evicted', nextFence: nextAgentSessionFence(lease), - evidence: { - kind: 'pid-absent', - detail: 'reservation never spawned', - observedAt, - ownerFence: lease.runtimeFence - } + evidence: + probe.outcome === 'reservation-unused' + ? { + kind: 'pid-absent', + detail: 'reservation never spawned', + observedAt, + ownerFence: lease.runtimeFence + } + : null } } if (isProvenAliveProbe(probe)) { diff --git a/src/shared/orcad-stop-request.ts b/src/shared/orcad-stop-request.ts index f88f86f68029..f6e4dc243d43 100644 --- a/src/shared/orcad-stop-request.ts +++ b/src/shared/orcad-stop-request.ts @@ -35,9 +35,7 @@ export const OrcadManagedStopRequestSchema = z.object({ runtimeId: z.string().min(1).max(255), instance: OrcadManagedStopInstanceSchema, /** Best effort: also retire the terminal daemon if it is provably idle. Never blocks the stop. */ - retireIdleDaemon: z.literal(true).optional(), - /** Missing intent is automatic, including requests from older desktops. */ - intent: z.literal('user').optional() + retireIdleDaemon: z.literal(true).optional() }) export type OrcadManagedStopInstance = z.infer @@ -97,8 +95,6 @@ export type OrcadDaemonRetirementRecord = z.infer From da06daa534b0887a0a2e6c6dd49170b9bbb2cdb2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:24:39 -0700 Subject: [PATCH 8/9] Recheck returning clients before managed idle shutdown --- .../orcad/orcad-managed-idle-exit-host.ts | 10 ++- ...ad-managed-idle-exit-provider-race.test.ts | 65 ++++++++++--------- 2 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/main/orcad/orcad-managed-idle-exit-host.ts b/src/main/orcad/orcad-managed-idle-exit-host.ts index 2ecab96124cd..ac98a79b5b6e 100644 --- a/src/main/orcad/orcad-managed-idle-exit-host.ts +++ b/src/main/orcad/orcad-managed-idle-exit-host.ts @@ -84,8 +84,14 @@ async function startOrcadManagedIdleExit( }, stop: async (evidence) => { await stopForIdle(input, evidence, retireOrcadDaemonIfIdle) - // Provider startup may complete while daemon retirement is awaiting its reply. - if (hasChatProviders()) { + // 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 } 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 index 7f04ea41c920..a5ac52b1612e 100644 --- a/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts +++ b/src/main/orcad/orcad-managed-idle-exit-provider-race.test.ts @@ -35,33 +35,40 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()) -it('rechecks providers after daemon retirement before requesting an idle shutdown', async () => { - 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: () => ({ openConnections: 0, requestsInFlight: 0, lastRequestAt: 0 }) - }, - 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(true) - retire.resolve({ retirement: 'retired', reason: null }) +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') -}) + expect(await stopping).toBe(false) + expect(shutdown).not.toHaveBeenCalled() + expect(discardOrcadIdleStopRecord).toHaveBeenCalledWith('test-profile') + } +) From 6d3c504df69f5846f7dd769179585fe59a459508 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:34:52 -0700 Subject: [PATCH 9/9] test: run the managed idle exit chat provider test in the Node runtime It builds on the native chat rest test rig, which opens a real SQLite database. --- config/scripts/vitest-sqlite-runtime-files.mjs | 1 + 1 file changed, 1 insertion(+) 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',