diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index cfad5c453105..8c62c52b4822 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -250,13 +250,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte input ) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge) - backgroundTaskStops: NonNullable = ( - sessionId - ) => acpChildStopCapabilities(this.sessions.get(sessionId)) + backgroundTaskStops = (id: string) => acpChildStopCapabilities(this.sessions.get(id)) closeSession = (sessionId: string): Promise => this.close(sessionId) disposeSession = (sessionId: string): Promise => this.close(sessionId) releaseAcquisition = (input: { sessionId: string }) => this.close(input.sessionId) + hasOwnedAcquisition = (sessionId: string): boolean => + this.sessions.has(sessionId) || this.starts.failedSessionIds().includes(sessionId) /** After a sink failure: the exit is recovered as unexpected. */ forceCloseSession = (sessionId: string): Promise => this.stop(sessionId, false) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 749bc8258497..095689ecfd60 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -286,9 +286,9 @@ export async function acquireClaudeSession({ sessions, attempt, translator, - prompts + prompts, + releaseAcquisition: () => acquisitions.deleteIfCurrent(sessionId, attempt) }) - acquisitions.deleteIfCurrent(sessionId, attempt) throw acquisitionError } finally { attempt.finish() diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 84b4804cd696..d375ce188fe5 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -262,6 +262,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda releaseAcquisition = (input: { sessionId: string }): Promise => this.afterClose(input.sessionId, () => this.releaseProviderSession(input.sessionId)) + hasOwnedAcquisition = (sessionId: string): boolean => + this.acquisitions.get(sessionId) !== undefined || + this.sessions.has(sessionId) || + this.exits.has(sessionId) /** A close clears the session's tasks outside `emit`; its ending still reaches the host. */ private async afterClose(sessionId: string, close: () => Promise): Promise { diff --git a/src/main/claude/claude-structured-session-close.ts b/src/main/claude/claude-structured-session-close.ts index 316d90b0a509..947b68fa847d 100644 --- a/src/main/claude/claude-structured-session-close.ts +++ b/src/main/claude/claude-structured-session-close.ts @@ -43,6 +43,7 @@ export function claudeAcquisitionCleanupError( : new AgentSessionAcquisitionExitUnprovenError(cause) } +/** An unproven exit keeps the acquisition so a later stop can still prove its root gone. */ export async function resolveClaudeAcquisitionError(input: { error: unknown sessionId: string @@ -50,6 +51,7 @@ export async function resolveClaudeAcquisitionError(input: { attempt: ClaudeAcquisitionAttempt translator: ClaudeJournalTranslator | null prompts: ClaudePromptRegistry + releaseAcquisition: () => void }): Promise { let acquisitionError = input.error if (input.sessions.get(input.sessionId)?.connection !== input.attempt.connection) { @@ -64,6 +66,9 @@ export async function resolveClaudeAcquisitionError(input: { acquisitionError = claudeAcquisitionCleanupError(input.attempt.connection, input.error) } } + if (!(acquisitionError instanceof AgentSessionAcquisitionExitUnprovenError)) { + input.releaseAcquisition() + } return acquisitionError } @@ -283,6 +288,10 @@ export async function closeClaudeSession(input: { ) // Why: cancellation must preserve the same actionable verdict as published-session close. if (!(cleanupError instanceof AgentSessionAcquisitionExitUnprovenError)) { + // A proved-dead attempt cannot remain the owner blocking the next start. + if (attempt) { + input.acquisitions.deleteIfCurrent(input.sessionId, attempt) + } throw cleanupError } return false diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts index 3f90182acd41..5090001308c0 100644 --- a/src/main/codex/codex-app-server-connection.test.ts +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -796,18 +796,14 @@ describe('openCodexAppServerConnection', () => { }, spawnImpl ) - child.kill.mockImplementation(() => { - child.emit('exit', null, 'SIGKILL') - return true - }) - const inFlight = rejection(connection.request('turn/start')) child.stdout.write(`${JSON.stringify(frame)}\n`) expect((await inFlight).message).toContain('structured sink failed') - expect(exits).toEqual([expect.stringContaining('structured sink failed')]) + expect(exits).toHaveLength(0) expect(connection.closed).toBe(true) - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + child.emit('exit', null, 'SIGKILL') + expect(exits).toEqual([expect.stringContaining('structured sink failed')]) await connection.close() }) @@ -869,20 +865,16 @@ describe('openCodexAppServerConnection', () => { { onExit: (error) => exits.push(error.message) }, spawnImpl ) - child.kill.mockImplementation(() => { - child.emit('exit', null, 'SIGKILL') - return true - }) - const inFlight = rejection(connection.request('turn/start')) child.stdin.emit('error', new Error('write EPIPE')) expect((await inFlight).message).toContain('EPIPE') - expect(exits).toHaveLength(1) + expect(exits).toHaveLength(0) // A child nobody can write to is not a live session: the owner must see the // connection as gone rather than keep issuing calls that can only time out. expect(connection.closed).toBe(true) - await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + child.emit('exit', null, 'SIGKILL') + expect(exits).toHaveLength(1) expect((await rejection(connection.request('turn/start'))).message).toContain('EPIPE') await connection.close() }) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts index 2d46889027ce..17ad6e85795a 100644 --- a/src/main/codex/codex-app-server-connection.ts +++ b/src/main/codex/codex-app-server-connection.ts @@ -51,7 +51,7 @@ export async function openCodexAppServerConnection( site: 'codex-app-server-teardown', ...(handlers.onOutput ? { onOutput: handlers.onOutput } : {}) }) - const { child, terminateTree: terminateProcessTree } = managed + const { child } = managed let nextRequestId = 1 let closing = false @@ -71,7 +71,7 @@ export async function openCodexAppServerConnection( writeResponse, onProtocolFailure: (error) => { handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() } }) @@ -108,7 +108,7 @@ export async function openCodexAppServerConnection( return } handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() }) const recordReader = createProviderRecordReader({ @@ -129,7 +129,7 @@ export async function openCodexAppServerConnection( }, onFatal: (error) => { handleUnexpectedEnd(error) - void terminateProcessTree() + void managed.close() } }) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 50cb90a45f9f..c3f199ba12a9 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -334,6 +334,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) + hasOwnedAcquisition = (sessionId: string): boolean => + this.acquisitions.get(sessionId) !== undefined || this.sessions.has(sessionId) private session(sessionId: string): CodexSession { return requireLiveCodexSession(this.sessions, sessionId) diff --git a/src/main/grok/grok-transcript-import.ts b/src/main/grok/grok-transcript-import.ts index dab984da73f9..5a40aa6744b7 100644 --- a/src/main/grok/grok-transcript-import.ts +++ b/src/main/grok/grok-transcript-import.ts @@ -11,6 +11,10 @@ export const GROK_TRANSCRIPT_IMPORT = directoryTranscriptImport({ resolveGrokHomeDir() ], findTranscript: async ({ providerSessionId, store, workspaceCwd, strictErrors }) => - (await findGrokChatHistoryByCwd({ sessionId: providerSessionId, cwd: workspaceCwd, sessionsDir: store })) ?? + (await findGrokChatHistoryByCwd({ + sessionId: providerSessionId, + cwd: workspaceCwd, + sessionsDir: store + })) ?? resolveSessionFilePath('grok', providerSessionId, { grokSessionsDir: store, strictErrors }) }) diff --git a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts index 7cdedc6c12e1..af66dcce2df9 100644 --- a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts +++ b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts @@ -36,7 +36,9 @@ export function adoptedTranscriptSourceObserver(input: { maxSourceBytes: number }) { let sessionId: string | null = - input.agent === 'claude' || input.agent === 'codex' ? sessionIdFromFileName(input.filePath) : null + input.agent === 'claude' || input.agent === 'codex' + ? sessionIdFromFileName(input.filePath) + : null let cwd: string | null = null let sawCodexMetadata = false return { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 80ad7ab6bfd1..232db1502832 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -10,7 +10,11 @@ // never be spliced into a sequence space that a structured session is also // writing; a later structured resume rolls the epoch again and rebuilds. -import { adoptedTranscriptSourceObserver, validateAdoptedOpenCodeSource, type AdoptedTranscriptSource } from './adopted-transcript-source' +import { + adoptedTranscriptSourceObserver, + validateAdoptedOpenCodeSource, + type AdoptedTranscriptSource +} from './adopted-transcript-source' import { createReadStream } from 'node:fs' import type { AgentType } from '../../../shared/agent-status-types' import type { @@ -215,10 +219,14 @@ async function decodeReplacementItems(input: { source?: AdoptedTranscriptSource limits: JournalPayloadLimits }): Promise<{ items: JournalReplacementItem[]; consumedBytes: number }> { - const source = input.source ? adoptedTranscriptSourceObserver({ - agent: input.agent, filePath: input.filePath, source: input.source, - maxSourceBytes: MAX_LEGACY_IMPORT_RECORD_BYTES - }) : null + const source = input.source + ? adoptedTranscriptSourceObserver({ + agent: input.agent, + filePath: input.filePath, + source: input.source, + maxSourceBytes: MAX_LEGACY_IMPORT_RECORD_BYTES + }) + : null const tracker = createLegacyIdentityTracker({ transcriptAgent: input.transcriptAgent, agent: input.agent, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts new file mode 100644 index 000000000000..8099ad8093a0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts @@ -0,0 +1,114 @@ +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' +import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' +import type { StructuredAgentRegistration } from './structured-agent-registry' +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' + +export type SessionRoute = { + registration: StructuredAgentRegistration + state: 'acquiring' | 'live' | 'stopped' + acquisition?: StructuredAgentSessionAcquisitionReceipt +} + +/** The route retains the actual startup identity until its exit is durably acknowledged. */ +export async function acquireSessionRoute( + input: StructuredAgentSessionAcquireInput, + registration: StructuredAgentRegistration, + routes: Map, + isClosed: () => boolean +) { + const route: SessionRoute = { + registration, + state: 'acquiring', + ...(input.attemptId && input.launch + ? { + acquisition: { + attemptId: input.attemptId, + fence: input.fence, + spawnToken: input.spawnToken, + location: input.launch.location + } + } + : {}) + } + routes.set(input.identity.sessionId, route) + try { + const acquired = await registration.adapter.acquire({ + ...input, + onSpawned: async (process) => { + if (route.acquisition) { + route.acquisition.process = process + } + await input.onSpawned?.(process) + } + }) + if (route.acquisition) { + route.acquisition.process = acquired.process + } + if (isClosed()) { + throw new Error('structured session adapter router is closed') + } + route.state = 'live' + return acquired + } catch (error) { + if ( + routes.get(input.identity.sessionId) === route && + !registration.adapter.hasOwnedAcquisition?.(input.identity.sessionId) + ) { + routes.delete(input.identity.sessionId) + } + throw error + } +} + +/** Releases only the actual routed acquisition, retaining its observed exit until acknowledged. */ +export async function releaseSessionRoute( + input: { sessionId: string }, + routes: Map, + adapters: () => StructuredAgentSessionAdapter[] +): Promise { + const route = routes.get(input.sessionId) + if (route) { + if (route.state === 'stopped') { + return true + } + if (!route.registration.adapter.hasOwnedAcquisition?.(input.sessionId)) { + if (routes.get(input.sessionId) === route) { + routes.delete(input.sessionId) + } + return false + } + try { + let exitReport: Error | undefined + const stopped = await stopAgentSessionProviderRoot( + () => route.registration.adapter.releaseAcquisition?.(input) ?? Promise.resolve(false), + (error) => { + exitReport = error + } + ) + if (stopped && routes.get(input.sessionId) === route) { + route.state = 'stopped' + } + if (exitReport) { + throw exitReport + } + return stopped + } catch (error) { + if ( + routes.get(input.sessionId) === route && + route.state !== 'stopped' && + !route.registration.adapter.hasOwnedAcquisition?.(input.sessionId) + ) { + routes.delete(input.sessionId) + } + throw error + } + } + let released = false + for (const candidate of adapters()) { + released = (await candidate.releaseAcquisition?.(input)) === true || released + } + return released +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts index 27c4a323506d..8545e6009a9c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts @@ -39,6 +39,7 @@ function adapterOf( return { acquire: vi.fn(async ({ fence, spawnToken }) => acquisition(fence, spawnToken)), releaseAcquisition, + hasOwnedAcquisition: () => true, dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), @@ -47,7 +48,7 @@ function adapterOf( } describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { - it('drops the owner even when its release reports a typed failure', async () => { + it('retains the actual owner after an unproved release for a scoped retry', async () => { const failure = new Error('root exited') const claude = adapterOf(vi.fn().mockRejectedValueOnce(failure).mockResolvedValue(false)) const codex = adapterOf(vi.fn(async () => false)) @@ -56,10 +57,9 @@ describe('StructuredAgentSessionAdapterRouter.releaseAcquisition', () => { await router.acquire({ identity, fence: 1, spawnToken: 'spawn-1' }) await expect(router.releaseAcquisition({ sessionId: 'session-1' })).rejects.toBe(failure) - // With no owner left, a later release asks every adapter instead of the stale one. await expect(router.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(false) expect(claude.releaseAcquisition).toHaveBeenCalledTimes(2) - expect(codex.releaseAcquisition).toHaveBeenCalledTimes(1) + expect(codex.releaseAcquisition).not.toHaveBeenCalled() }) }) @@ -359,10 +359,9 @@ describe('StructuredAgentSessionAdapterRouter.closeAll', () => { await router.closeAll() resolveAcquire(acquisition(2, 'spawn-2')) - // The route is NOT published behind a closed adapter, so nothing routes back out to it — and - // with no route the router has nothing to stop and no stop to report. + // Shutdown preserves the stop receipt for an acquisition that resolves behind it. await expect(acquiring).rejects.toThrow('router is closed') - await expect(router.closeSession('session-1')).resolves.toBe(false) + await expect(router.closeSession('session-1')).resolves.toBe(true) expect(closeSession).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index f0c22c24f18b..46c447319456 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -5,16 +5,18 @@ import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionAcquisitionReceipt } from './structured-agent-session-startup-attempt-contract' import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { StructuredAgentRegistration, StructuredAgentRegistry } from './structured-agent-registry' -type SessionRoute = { - registration: StructuredAgentRegistration - state: 'live' | 'stopped' -} +import { + acquireSessionRoute, + releaseSessionRoute, + type SessionRoute +} from './structured-agent-session-acquisition-route' /** Routes each session to the adapter its agent is registered with. Adding an agent is one more * registration; nothing here names one. */ @@ -44,28 +46,11 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi throw new Error('structured session adapter router is closed') } const registration = this.requireAgent(input.identity) - const acquired = await registration.adapter.acquire(input) - if (this.allAdaptersClosed) { - throw new Error('structured session adapter router is closed') - } - this.routes.set(input.identity.sessionId, { registration, state: 'live' }) - return acquired + return acquireSessionRoute(input, registration, this.routes, () => this.allAdaptersClosed) } async releaseAcquisition(input: { sessionId: string }): Promise { - const route = this.routes.get(input.sessionId) - if (route) { - try { - return (await route.registration.adapter.releaseAcquisition?.(input)) === true - } finally { - this.routes.delete(input.sessionId) - } - } - let released = false - for (const candidate of this.adapters()) { - released = (await candidate.releaseAcquisition?.(input)) === true || released - } - return released + return releaseSessionRoute(input, this.routes, () => this.adapters()) } open: NonNullable = (input) => { @@ -242,6 +227,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi if (route.state === 'stopped') { return true } + if (route.state === 'acquiring') { + return this.releaseAcquisition({ sessionId }) + } const { adapter } = route.registration const stop = selectStop(adapter) const stopped = await stop?.call(adapter, sessionId) @@ -252,6 +240,21 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return false } + hasOwnedAcquisition = (sessionId: string): boolean => { + const route = this.routes.get(sessionId) + return ( + (route?.state === 'stopped' && !!route.acquisition) || + route?.registration.adapter.hasOwnedAcquisition?.(sessionId) === true + ) + } + + stoppedAcquisition = ( + sessionId: string + ): StructuredAgentSessionAcquisitionReceipt | undefined => { + const route = this.routes.get(sessionId) + return route?.state === 'stopped' ? route.acquisition : undefined + } + async closeAll(): Promise { if (this.allAdaptersClosed) { return @@ -278,8 +281,13 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi } /** Drops a per-session stop receipt after the host releases its durable owner. */ - acknowledgeSessionRelease = (sessionId: string): void => { - this.routes.delete(sessionId) + acknowledgeSessionRelease = ( + sessionId: string, + acquisition?: StructuredAgentSessionAcquisitionReceipt + ): void => { + if (!acquisition || this.stoppedAcquisition(sessionId) === acquisition) { + this.routes.delete(sessionId) + } } private owner(sessionId: string): StructuredAgentSessionAdapter { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index d19106525ed5..3eaf8c236d6c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -53,7 +53,10 @@ export type { import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import type { AgentSessionPromptResponse } from '../../../shared/agent-session-question-answer' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import type { StructuredAgentSessionStartupAttempt } from './structured-agent-session-startup-attempt-contract' +import type { + StructuredAgentSessionStartupAttempt, + StructuredAgentSessionAcquisitionReceipt +} from './structured-agent-session-startup-attempt-contract' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' import type { AgentModelCatalogLiveListing } from '../agent-model-catalog/agent-model-catalog-entry' import type { StructuredAgentSessionProviderOpen } from './structured-agent-session-provider-open-contract' @@ -284,6 +287,9 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & * `AgentSessionAcquisitionRootExitObservedError` when the provider root's own * exit was observed first-hand but its descendants were not proven gone. */ releaseAcquisition?(input: { sessionId: string }): Promise + /** Whether this adapter owns a child, failed acquisition, or unacknowledged acquisition receipt. */ + hasOwnedAcquisition?(sessionId: string): boolean + stoppedAcquisition?(sessionId: string): StructuredAgentSessionAcquisitionReceipt | undefined dispatch(input: { sessionId: string clientMessageId: string @@ -439,5 +445,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & /** Stops a provider child for teardown without requiring a future-resume cursor. */ disposeSession?(sessionId: string): Promise /** Host acknowledgement that the proven-dead child, lease and journal owner are released. */ - acknowledgeSessionRelease?(sessionId: string): void + acknowledgeSessionRelease?( + sessionId: string, + acquisition?: StructuredAgentSessionAcquisitionReceipt + ): void } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index a641a5b285f1..193c764dcd05 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -69,7 +69,10 @@ export function attachStructuredAgentSessionUnderSerialize( params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions = {} ): Promise> { - return context.tasks.trackAttach(runAttach(context, callerKey, params, options)) + return context.tasks.trackAttach(runAttach(context, callerKey, params, options), { + sessionId: params.envelope.sessionId, + location: params.location + }) } export function attachStructuredAgentSession( @@ -85,7 +88,8 @@ export function attachStructuredAgentSession( context.tasks.trackAttach( context.serialize(sessionId, () => runAttach(context, callerKey, params, { ...options, recordPhase }) - ) + ), + { sessionId, location: params.location } ) if (params.envelope.expectedRuntimeFence !== null) { return run() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts index 0fa4b7c77099..60dda17a1573 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-exit.ts @@ -8,6 +8,7 @@ import { type SubmissionRejectionFact } from '../../../shared/agent-session-failure' import { PROVIDER_EXIT_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { StructuredAgentSessionEndedEvent } from './structured-agent-session-adapter' import type { @@ -185,11 +186,19 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< } context.publishStatus?.(sessionId) } + const wakeQueuedDelivery = (): void => { + if ( + !session.journal.submissions || + session.journal.submissions().some(isQueuedAgentJournalSubmission) + ) { + context.wakeDelivery?.(sessionId) + } + } const record = context.store.getRecord(sessionId) if (!record || record.lease.handoffStage !== null) { // An acquisition or recovery already owns this lease's transition. endChild() - context.wakeDelivery?.(sessionId) + wakeQueuedDelivery() return } let settlement: StructuredAgentSessionExitSettlement | null = null @@ -293,7 +302,7 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize< // Stop's own) issued at the fence before. Either way every reader learns the generation ended // and the queued-card drain runs, whether or not the release or the settlement was written. context.generationEnded(sessionId, { restate: released && !expected }) - context.wakeDelivery?.(sessionId) + wakeQueuedDelivery() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 8c066130d213..da63a25dee2c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -28,6 +28,8 @@ import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { deferredStructuredAgentSessionLogger } from './structured-agent-session-logger' import { sameProviderChild } from './structured-agent-session-provider-child' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { stopWorkspaceSleepChat } from './structured-agent-session-workspace-sleep-stop' import type { StructuredAgentSessionExpiredStartup } from './structured-agent-session-startup-attempt' import { providerStartupFailureFact, @@ -89,6 +91,81 @@ export function createStructuredAgentSessionConversationLifetime(host: { const providerHoldsDispatch = (sessionId: string): boolean => deps().adapter.holdsDispatch?.(sessionId) === true + const holdStopClose = async ( + sessionId: string, + stop: () => Promise, + onCloseError?: (error: unknown) => void + ): Promise => { + const session = sessions.get(sessionId) + if (session) { + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) + } + const result = await stop() + try { + await closeConversation(sessionId) + } catch (error) { + if (!onCloseError) { + throw error + } + onCloseError(error) + } + return result + } + + const closeForWorkspaceSleep = ( + sessionId: string, + workspaceId: string, + executionHostId: ExecutionHostId + ): Promise<{ fence: number; stopped: boolean } | null> => { + const matches = (): boolean => { + const location = deps().store.getRecord(sessionId)?.location + return location?.workspaceId === workspaceId && location.executionHostId === executionHostId + } + // Pending creates do not yet have a record; their queued close waits for founding to finish. + if (matches()) { + host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') + } + return serialize(sessionId, async () => { + if (!matches()) { + return null + } + readRefusals.forget(sessionId) + const record = deps().store.getRecord(sessionId) + if (!record) { + return null + } + const fence = record.lease.runtimeFence + const session = sessions.get(sessionId) + const child = session?.child + const owned = deps().adapter.hasOwnedAcquisition?.(sessionId) === true + const stopped = await holdStopClose( + sessionId, + () => + stopWorkspaceSleepChat({ + deps: deps(), + sessions, + stopAgent, + sessionId, + fence, + child, + owned, + now: () => host.context().now() + }), + (error) => + deps().logger.warn('closing a stopped workspace chat handle failed', { + scope: 'workspace-chat-close', + sessionId, + error + }) + ) + // Lease release advances the fence; preserve the value after this close for the caller's + // final identity check against a later restart. + return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } + }) + } + const idleSweep = new StructuredAgentSessionIdleSweep({ sessions, serialize, @@ -137,6 +214,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { ) } return { + closeForWorkspaceSleep, idleSweep, stopAgent, /** A host stop of the child the expired attempt published, if it is still that one and still @@ -242,16 +320,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') return serialize(sessionId, async () => { readRefusals.forget(sessionId) - const session = sessions.get(sessionId) - if (session) { - // Settled and marked before the stop, so no start delivers it, and nothing it closed with - // sends by itself, even if the handle stays open. - await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { - mark: 'always' - }) - } - await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) - await closeConversation(sessionId) + await holdStopClose(sessionId, () => stopAgent(sessionId, { cause })) }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 45ad97d2ec15..aa05c4ac120d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -55,7 +55,8 @@ export function createStructuredAgentSession( } throw error } - }) + }), + { sessionId: params.envelope.sessionId, location: params.location } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 61fef167631f..974fa2111ac6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -189,7 +189,9 @@ export function createStructuredAgentSessionConversationDelivery(input: { // A chat open before its owner's death was proven revises what its open settled. Queued, never // awaited: the writer can hold this session's serialize (an attach recovering its lease). const stopResettling = deps.store.onDeathEvidence((sessionId) => { - if (sessions.has(sessionId)) { + const session = sessions.get(sessionId) + // A user close already settles and retires this handle; its next open re-derives any proof. + if (session && session.child?.close?.cause !== 'user-close') { void input .trackStart( input.serialize(sessionId, () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts new file mode 100644 index 000000000000..881a05ef78a3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts @@ -0,0 +1,53 @@ +import * as agentStart from './structured-agent-session-agent-start' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import type { StructuredAgentSessionClientDelivery } from './structured-agent-session-client-delivery' +import type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type * as conversation from './structured-agent-session-host-delivery' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' + +type Inputs = { + deps: StructuredAgentSessionHostDeps + sessions: StructuredAgentSessionConversations + subscribers: StructuredAgentSessionClientDelivery['subscribers'] + lifetime: StructuredAgentSessionConversationLifetime + clientDelivery: StructuredAgentSessionClientDelivery + conversationDelivery: conversation.StructuredAgentSessionConversationDelivery + queued: ReturnType + runtimeState: StructuredAgentSessionHostRuntimeState + attachContext: () => StructuredAgentSessionAttachContext + serialize: StructuredAgentSessionMutationContext['serialize'] + now: () => number +} + +export function createStructuredAgentSessionHostMutationContext( + input: Inputs +): StructuredAgentSessionMutationContext { + return { + deps: input.deps, + sessions: input.sessions, + publish: (sessionId, journal) => input.subscribers.publish(sessionId, journal), + conversation: input.lifetime.conversation, + readChildWork: input.clientDelivery.readChildWork, + publishChildWork: input.clientDelivery.publishChildWork, + captureChildWorkStops: input.clientDelivery.captureChildWorkStops, + childWorkOpen: input.clientDelivery.childWorkOpen, + childWorkIncomplete: input.clientDelivery.childWorkIncomplete, + serialize: input.serialize, + openConversation: input.conversationDelivery.open, + ensureAgent: (sessionId) => + agentStart.ensureStructuredAgentSessionAgentForOperation(input.attachContext(), sessionId), + startupAttempts: input.runtimeState.startupAttempts, + deliveryOrder: input.runtimeState.deliveryOrder, + joinChildClose: (sessionId) => + agentStart.joinClosingStructuredAgentSessionChild(input.attachContext(), sessionId), + wakeDelivery: (sessionId) => input.conversationDelivery.loop.wake(sessionId), + stopAgent: (sessionId, ending) => input.lifetime.stopAgent(sessionId, ending), + wakeQueuedDrain: (sessionId) => input.queued.drain.schedule(sessionId), + acquireAborts: input.runtimeState.acquireAborts, + now: input.now + } +} 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 0e9889fa0432..1bba744c55e5 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 @@ -7,7 +7,6 @@ import { StructuredConversationCommandController } from './structured-conversati // it, and the idle sweep is the one thing that puts it to rest. import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type * as SessionWire from '../../../shared/agent-session-wire' import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' @@ -18,6 +17,7 @@ import { StructuredAgentSessionHostRuntimeState } from './structured-agent-sessi import { structuredAgentSessionEntryDelegates } from './structured-agent-session-create' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import * as agentStart from './structured-agent-session-agent-start' +import { createStructuredAgentSessionHostMutationContext } from './structured-agent-session-host-mutation-wiring' import { createStructuredAgentSessionConversationLifetime, type StructuredAgentSessionConversationLifetime @@ -42,6 +42,7 @@ import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent- import * as conversation from './structured-agent-session-host-delivery' import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring' import * as sessionLogger from './structured-agent-session-logger' +import { sleepStructuredAgentSessionWorkspace } from './structured-agent-session-workspace-sleep' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -211,6 +212,20 @@ export class StructuredAgentSessionHost { close: StructuredAgentSessionConversationLifetime['close'] = (sessionId, cause) => this.lifetime.close(sessionId, cause) + /** One bounded host-owned snapshot; a tab need not be visible for its child to be stopped. */ + sleepWorkspace = (workspaceId: string): Promise<{ stoppedSessionIds: string[] }> => + sleepStructuredAgentSessionWorkspace( + { + deps: this.deps, + tasks: this.tasks, + lifetime: this.lifetime, + sessions: this.sessions, + deliveryRunning: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), + childWorkIncomplete: this.clientDelivery.childWorkIncomplete + }, + workspaceId + ) + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) @@ -268,32 +283,20 @@ export class StructuredAgentSessionHost { }).finally(() => this.clientDelivery.closeAll()) } - private mutationContext(): StructuredAgentSessionMutationContext { - return { + private mutationContext = (): StructuredAgentSessionMutationContext => + createStructuredAgentSessionHostMutationContext({ deps: this.deps, sessions: this.sessions, - publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), - conversation: this.lifetime.conversation, - readChildWork: this.clientDelivery.readChildWork, - publishChildWork: this.clientDelivery.publishChildWork, - captureChildWorkStops: this.clientDelivery.captureChildWorkStops, - childWorkOpen: this.clientDelivery.childWorkOpen, - childWorkIncomplete: this.clientDelivery.childWorkIncomplete, + subscribers: this.subscribers, + lifetime: this.lifetime, + clientDelivery: this.clientDelivery, + conversationDelivery: this.conversationDelivery, + queued: this.queued, + runtimeState: this.runtimeState, + attachContext: () => this.attachContext(), serialize: (sessionId, task) => this.serialize(sessionId, task), - openConversation: this.conversationDelivery.open, - ensureAgent: (sessionId) => - agentStart.ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId), - startupAttempts: this.runtimeState.startupAttempts, - deliveryOrder: this.runtimeState.deliveryOrder, - joinChildClose: (sessionId) => - agentStart.joinClosingStructuredAgentSessionChild(this.attachContext(), sessionId), - wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId), - stopAgent: (sessionId, ending) => this.lifetime.stopAgent(sessionId, ending), - wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId), - acquireAborts: this.runtimeState.acquireAborts, now: () => this.now() - } - } + }) send = this.conversationCommands.send @@ -320,8 +323,7 @@ export class StructuredAgentSessionHost { readCommands = (sessionId: string) => ({ commands: this.clientDelivery.readCommands(sessionId) }) /** From the record store, never the session map: an idle-released chat has no map entry. */ - handoffStatus = (sessionId: string): SessionWire.AgentSessionHandoffStatus => - structuredAgentSessionOwnerStatus(this.deps, sessionId) + handoffStatus = (sessionId: string) => structuredAgentSessionOwnerStatus(this.deps, sessionId) history: StructuredAgentSessionBackgroundTaskChannel['history'] = (request, scope) => this.backgroundTasks.history(request, scope) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts index e0eb83e0a0b5..539b1eb3cd06 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts @@ -19,7 +19,7 @@ export function queuedSendAnswer( ? journal.submission(draft.consumedAs) : undefined return consumed - ? { clientMessageId, submission: consumed } + ? { clientMessageId, submission: { ...consumed } } : { clientMessageId, queued: { messageId: draft.messageId, position: draft.position, state: draft.state } @@ -38,5 +38,5 @@ export function queuedSendAnswer( queued: { messageId: clientMessageId, position: 0, state: 'withdrawn' } } } - return handoff ? { clientMessageId, submission: handoff } : null + return handoff ? { clientMessageId, submission: { ...handoff } } : null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts index 14767b058d1f..4d3c509f98f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract.ts @@ -2,7 +2,11 @@ // module, so it must not pull in the host or Node-only process code. import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionRecord, + AgentSessionExecutionLocation, + AgentSessionProcessIdentity +} from '../../../shared/agent-session-record' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' @@ -42,3 +46,11 @@ export type StructuredAgentSessionStartupAttempt = { /** Any stdout frame or stderr line from the child: proof the start is still moving. */ readonly onOutput?: () => void } + +export type StructuredAgentSessionAcquisitionReceipt = Pick< + StructuredAgentSessionStartupAttempt, + 'attemptId' | 'fence' | 'spawnToken' +> & { + location: AgentSessionExecutionLocation + process?: AgentSessionProcessIdentity +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 3dd914f647fa..0d3144dd76fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -45,6 +45,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { startsWhenPublished } from './structured-agent-session-instant-start.test-support' +const REQUESTED_CLOSE_END = { type: 'ended', reason: 'closed', cause: 'requested-close' } as const const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -238,7 +239,19 @@ beforeEach(async () => { } } }) - closeSession = vi.fn(async () => true) + // Like real adapters, the close reports its own end onto the chat's lane before it returns. + closeSession = vi.fn(async (sessionId: string) => { + const child = host['sessions'].get(sessionId)?.child + if (child?.generation) { + void host.handleAdapterEvent({ + ...REQUESTED_CLOSE_END, + sessionId, + fence: child.fence, + acquisitionGeneration: child.generation + }) + } + return true + }) dispatch = vi.fn(async () => ({ state: 'rejected' as const, ...agentSessionFailureWords(agentSessionFailureFact('providerRejected'), { @@ -255,6 +268,45 @@ afterEach(async () => { }) describe('a chat that closes', () => { + it('returns the receipt for a stopped child whose provider reports its own end', async () => { + await attach() + await host['tasks'].drainAttaches() + const reported = vi.spyOn(host, 'handleAdapterEvent') + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + expect(reported).toHaveBeenCalledWith(expect.objectContaining({ cause: 'requested-close' })) + // History stays saved while the conversation handle closes. + expect([store.getRecord(SESSION) !== null, host.hasSession(SESSION)]).toEqual([true, false]) + expect(await host.sleepWorkspace('other-workspace')).toEqual({ stoppedSessionIds: [] }) + }) + + it('omits an unproven workspace child from the stop receipt', async () => { + await attach() + closeSession.mockResolvedValueOnce(false) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: [], + failures: [{ sessionId: SESSION, reason: 'stop-unverifiable' }] + }) + expect(store.getRecord(SESSION)).not.toBeNull() + }) + + it('does not count a chat whose send was accepted after the stop as stopped', async () => { + await attach() + await vi.waitFor(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) + const close = host['lifetime'].closeForWorkspaceSleep + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + const result = await close(...args) + const body = hostTestMessage('newer send') + // The later work exists once the send is accepted, not merely once it is queued. + const sent = host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + expect(await sent).toMatchObject({ ok: true }) + return result + }) + + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) + }) + it('stops the provider child it started', async () => { await attach() @@ -372,7 +424,7 @@ describe('a chat that closes', () => { }) describe('a session with a turn in flight', () => { - it('is not stopped while the turn runs, and is once it ends and idles', async () => { + it('keeps a running turn and sleeps only proven ended chats after idle eviction or restart', async () => { await attach() emitTurnLifecycle('running', 1) await host.flushStreamedEvents(SESSION) @@ -388,6 +440,17 @@ describe('a session with a turn in flight', () => { clock += IDLE_MS await waitForEviction() + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + await abandonStructuredAgentSessionHost(host) + await reboot() + await host.restoreReadableSessions() + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] }) + const getRecord = store.getRecord.bind(store) + const withoutDeath = (record: ReturnType) => + record && { ...record, lease: { ...record.lease, deathEvidence: null } } + vi.spyOn(store, 'getRecord').mockImplementation((id) => withoutDeath(getRecord(id))) + const receipt = await host.sleepWorkspace('workspace-1') + expect(receipt).toMatchObject({ failures: [{ reason: 'stop-unverifiable' }] }) }) // Codex settles an admitted send only on its echo, which may never come; the stop retires it. 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..79c6234a47f3 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,22 +1,55 @@ import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' export class StructuredAgentSessionTaskQueue { private readonly chains = new Map>() private readonly attaching = new Set>() + private readonly pendingLocations = new Map< + Promise, + { + sessionId: string + location: AgentSessionExecutionLocation + } + >() serialize(sessionId: string, task: () => Promise): Promise { return runKeyedSerializedOperation(this.chains, sessionId, task) } - trackAttach(operation: Promise): Promise { + trackAttach( + operation: Promise, + pending?: { sessionId: string; location: AgentSessionExecutionLocation } + ): Promise { this.attaching.add(operation) + if (pending) { + this.pendingLocations.set(operation, pending) + } void operation.then( - () => this.attaching.delete(operation), - () => this.attaching.delete(operation) + () => this.finishAttach(operation), + () => this.finishAttach(operation) ) return operation } + private finishAttach(operation: Promise): void { + this.attaching.delete(operation) + this.pendingLocations.delete(operation) + } + + pendingMembers(workspaceId: string, executionHostId: string): string[] { + return [...this.pendingLocations.values()] + .filter( + ({ location }) => + location.workspaceId === workspaceId && location.executionHostId === executionHostId + ) + .map(({ sessionId }) => sessionId) + } + + /** A create or attach for this session that has not settled yet. */ + hasPendingAttach(sessionId: string): boolean { + return [...this.pendingLocations.values()].some((pending) => pending.sessionId === sessionId) + } + async drainAttaches(): Promise { while (this.attaching.size > 0) { await Promise.allSettled(this.attaching) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index f5258f0e5a87..ac9dd8baf42c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -209,7 +209,7 @@ export async function performSend( ok: true, value: { clientMessageId: input.clientMessageId, - submission: requireSubmission(ctx, input.clientMessageId) + submission: { ...requireSubmission(ctx, input.clientMessageId) } } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts new file mode 100644 index 000000000000..5b2d81155225 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -0,0 +1,165 @@ +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + adapterAtPublishFor, + fakeClaude, + PROVIDER_SESSION_ID +} from '../../claude/claude-structured-session-test-support' +import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support' +import { StructuredAgentSessionAdapterRouter } from './structured-agent-session-adapter-router' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { expect, it, vi } from 'vitest' +import { hostTestMessage } from './structured-agent-session-host-test-data' +import { + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState, + attachParams +} from './structured-agent-session-host-test-harness' + +it('omits an unverified stop superseded by a newer send while retaining a sibling receipt', async () => { + const { host, store, acquire } = hostTestState() + const record = await attach() + if (!record) { + throw new Error('attach did not produce a record') + } + const sibling = 'session-beta' + const siblingRecord = { ...record, sessionId: sibling } + const getRecord = store.getRecord.bind(store) + vi.spyOn(store, 'listRecords').mockReturnValue([record, siblingRecord]) + vi.spyOn(store, 'getRecord').mockImplementation((id) => + id === sibling ? siblingRecord : getRecord(id) + ) + host.deps.adapter.closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) + + let failedStopDone: () => void = () => undefined + const failedStop = new Promise((resolve) => { + failedStopDone = resolve + }) + let releaseSibling: () => void = () => undefined + const slowSibling = new Promise((resolve) => { + releaseSibling = resolve + }) + const close = host['lifetime'].closeForWorkspaceSleep + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + if (args[0] === sibling) { + await slowSibling + return { fence: siblingRecord.lease.runtimeFence, stopped: true } + } + const result = await close(...args) + failedStopDone() + return result + }) + + const sleeping = host.sleepWorkspace('workspace-1') + try { + await failedStop + const body = hostTestMessage('resume after unverified stop') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(2)) + } finally { + releaseSibling() + } + expect(await sleeping).toEqual({ stoppedSessionIds: [sibling] }) +}) + +it.each(['persist', 'root-exit', 'retry', 'successor', 'root-successor'] as const)( + 'retains failed-acquisition exit proof across %s', + async (mode) => { + const { host: original, store, root } = hostTestState() + const claude = fakeClaude({ + unprovenCloseVerdict: { root: 'unverifiable', tree: 'unverifiable' } + }) + const open = claude.openConnection + claude.openConnection = async (...args) => { + const connection = await open(...args) + claude.connections[0].closed = true + return connection + } + const adapter = adapterAtPublishFor(claude) + const agents = claudeAndCodexAgents(adapter) + const router = new StructuredAgentSessionAdapterRouter(agents, async () => {}) + const host = new StructuredAgentSessionHost({ ...original.deps, agents, adapter: router }) + replaceHostTestState({ host, store }) + await expect( + host.attach( + CALLER, + attachParams({ + agent: 'claude', + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/accounts/claude' }, + providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } + }) + ) + ).rejects.toThrow('agent_session_acquisition_exit_unproven') + expect(router.hasOwnedAcquisition('session-alpha')).toBe(true) + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + const connection = claude.connections[0] + connection.close = vi.fn(async () => mode !== 'root-exit' && mode !== 'root-successor') + if (mode === 'root-exit' || mode === 'root-successor') { + connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } + } + if (mode === 'retry' || mode === 'successor') { + vi.spyOn(store, 'transitionHandoff').mockRejectedValueOnce(new Error('evidence write failed')) + } + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + if (mode === 'retry' || mode === 'successor') { + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + expect(router.stoppedAcquisition('session-alpha')).toBeDefined() + } + if (mode === 'successor' || mode === 'root-successor') { + const body = hostTestMessage('send after failed exit bookkeeping') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await vi + .waitFor(() => expect(claude.connections).toHaveLength(2)) + .finally(() => { + for (const opened of claude.connections) { + opened.close = async () => true + } + }) + claude.connections[1].close = async () => true + expect(router.stoppedAcquisition('session-alpha')).toBeUndefined() + expect(store.getRecord('session-alpha')?.lease.deathEvidence).toBeNull() + return + } + expect(await host.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + expect(connection.close).toHaveBeenCalledOnce() + expect(router.stoppedAcquisition('session-alpha')).toBeUndefined() + const reopened = await openTestAgentSessionRecordStore(root) + const restarted = new StructuredAgentSessionHost({ ...host.deps, store: reopened }) + expect(reopened.getRecord('session-alpha')?.lease.deathEvidence).toMatchObject({ + kind: 'exit-observed', + ownerFence: 1 + }) + await restarted.restoreReadableSessions() + expect(await restarted.sleepWorkspace('workspace-1')).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + } +) + +it('keeps a send admitted after the host Sleep close alive', async () => { + const { host, acquire, dispatch } = hostTestState() + await attach() + const close = vi.fn(async () => true) + host.deps.adapter.closeSession = close + const sleeping = host.sleepWorkspace('workspace-1') + const body = hostTestMessage('phone send after Sleep admission') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + await sleeping + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledTimes(2) + expect(close).toHaveBeenCalledOnce() + expect(host.hasSession('session-alpha')).toBe(true) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts new file mode 100644 index 000000000000..cb7b9a43f54d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts @@ -0,0 +1,83 @@ +import { recordAgentSessionAcquisitionExit } from '../../runtime/agent-session-acquisition-exit-transition' +import { + agentSessionLeaseIsReleased, + agentSessionLeaseOwnerVerdict +} from '../../../shared/agent-session-lease-adjudication' +import { structuredSessionNeverStarted } from '../../runtime/agent-session-record-founding' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionProviderChild +} from './structured-agent-session-host-types' +import { sameProviderChild } from './structured-agent-session-provider-child' +import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' + +/** Stops one chat captured by a workspace Sleep and reports whether the stop is proven. */ +export async function stopWorkspaceSleepChat(input: { + deps: Pick + sessions: StructuredAgentSessionConversations + stopAgent: (sessionId: string, ending: StructuredAgentSessionStopEnding) => Promise + sessionId: string + fence: number + child: StructuredAgentSessionProviderChild | null | undefined + owned: boolean + now: () => number +}): Promise { + const { deps, sessions, sessionId, child } = input + let ownedStopped = false + try { + if (child) { + await input.stopAgent(sessionId, { cause: 'user-close' }) + } else if (input.owned) { + ownedStopped = await stopAgentSessionProviderRoot( + () => deps.adapter.releaseAcquisition?.({ sessionId }) ?? Promise.resolve(false) + ) + const receipt = deps.adapter.stoppedAcquisition?.(sessionId) + if (ownedStopped && receipt) { + try { + await deps.store.transitionHandoff(sessionId, (record) => { + if ( + deps.adapter.stoppedAcquisition?.(sessionId) !== receipt || + sessions.get(sessionId)?.child + ) { + throw new Error('agent_session_ownership_unknown') + } + return recordAgentSessionAcquisitionExit(record, receipt, input.fence, input.now()) + }) + deps.adapter.acknowledgeSessionRelease?.(sessionId, receipt) + } catch (error) { + deps.logger.warn('saving failed acquisition exit evidence failed', { + scope: 'workspace-chat-stop', + sessionId, + error + }) + } + } + } + } catch (error) { + deps.logger.warn('workspace chat stop was not proven', { + scope: 'workspace-chat-stop', + sessionId, + error + }) + } + const ended = sessions.get(sessionId)?.lastEndedChild + const after = deps.store.getRecord(sessionId) + const childStopped = + !!child && + !sessions.get(sessionId)?.child && + ended?.rootGone === true && + sameProviderChild(ended, child) + // A released reservation may still have an unproven root after restart; only + // exit evidence or a never-started record proves an unheld chat is stopped. + const alreadyStopped = + !!after && + after.lease.runtimeFence === input.fence && + agentSessionLeaseIsReleased(after.lease) && + (agentSessionLeaseOwnerVerdict(after.lease) === 'exited' || + structuredSessionNeverStarted(after)) && + !sessions.get(sessionId)?.child && + deps.adapter.hasOwnedAcquisition?.(sessionId) !== true + return childStopped || ownedStopped || alreadyStopped +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts new file mode 100644 index 000000000000..5ebbe337fcf3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -0,0 +1,90 @@ +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionWorkspaceSleepResult } from '../../../shared/agent-session-workspace-sleep' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import type { StructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +type WorkspaceSleepHost = { + deps: Pick + tasks: StructuredAgentSessionTaskQueue + lifetime: Pick + sessions: StructuredAgentSessionConversations + deliveryRunning: (sessionId: string) => boolean + childWorkIncomplete: (sessionId: string) => boolean +} + +/** Snapshot host-owned members and return only same-call, current positive stop receipts. */ +export async function sleepStructuredAgentSessionWorkspace( + host: WorkspaceSleepHost, + workspaceId: string +): Promise { + let records: ReturnType + try { + records = host.deps.store.listRecords() + } catch (error) { + host.deps.logger.warn('workspace chat inventory failed', { + scope: 'workspace-chat-inventory', + workspaceId, + error + }) + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } + } + const belongs = (location: AgentSessionExecutionLocation): boolean => + location.workspaceId === workspaceId && location.executionHostId === LOCAL_EXECUTION_HOST_ID + const candidates = new Set([ + ...records.filter((record) => belongs(record.location)).map((record) => record.sessionId), + ...host.tasks.pendingMembers(workspaceId, LOCAL_EXECUTION_HOST_ID) + ]) + const sessionIds = [...candidates] + // Admits every close synchronously; `async` turns a synchronous throw into a rejection. + const stops = await Promise.allSettled( + sessionIds.map(async (sessionId) => + host.lifetime.closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) + ) + ) + const stoppedSessionIds: string[] = [] + const failures: NonNullable = [] + for (const [index, stop] of stops.entries()) { + const sessionId = sessionIds[index] + if (stop.status === 'rejected') { + failures.push({ sessionId, reason: 'stop-unverifiable' }) + continue + } + if (!stop.value) { + continue + } + try { + const record = host.deps.store.getRecord(sessionId) + if (!record || !belongs(record.location) || record.lease.runtimeFence !== stop.value.fence) { + continue + } + if (!stop.value.stopped) { + failures.push({ sessionId, reason: 'stop-unverifiable' }) + continue + } + // Judge later work by facts, not lane occupancy: the stop queues its own end report. + const session = host.sessions.get(sessionId) + if ( + session?.child || + host.tasks.hasPendingAttach(sessionId) || + host.deliveryRunning(sessionId) || + host.childWorkIncomplete(sessionId) || + session?.journal.pendingSubmissions().length || + host.deps.hasOpenDispatch?.(record) + ) { + continue + } + stoppedSessionIds.push(sessionId) + } catch (error) { + host.deps.logger.warn('workspace chat stop receipt could not be checked', { + scope: 'workspace-chat-receipt', + sessionId, + error + }) + failures.push({ sessionId, reason: 'stop-unverifiable' }) + } + } + return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } +} diff --git a/src/main/native-chat/codex-adoption-preparation.ts b/src/main/native-chat/codex-adoption-preparation.ts index 0e86904abdd9..8ce69292a0f4 100644 --- a/src/main/native-chat/codex-adoption-preparation.ts +++ b/src/main/native-chat/codex-adoption-preparation.ts @@ -21,13 +21,16 @@ export async function prepareCodexAdoption( return source } const nativeSystemHomePath = getSystemCodexHomePath() - const result = await prepare({ - agent: 'codex', - sessionId: providerSessionId, - filePath: source.transcriptPath, - codexHome: source.accountHome.path, - executionHostId: 'local' - }, nativeSystemHomePath) + const result = await prepare( + { + agent: 'codex', + sessionId: providerSessionId, + filePath: source.transcriptPath, + codexHome: source.accountHome.path, + executionHostId: 'local' + }, + nativeSystemHomePath + ) const home = result.useRealCodexHome ? nativeSystemHomePath : result.substituteCodexHome if ( !home || @@ -41,7 +44,8 @@ export async function prepareCodexAdoption( !recognizedHomes.some( (candidate) => isLegacyAgentSessionAccountHome(candidate) && - normalizeRuntimePathForComparison(candidate.path) === normalizeRuntimePathForComparison(home) + normalizeRuntimePathForComparison(candidate.path) === + normalizeRuntimePathForComparison(home) ) ) { throw new Error('The prepared conversation account is not recognized on this host.') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 49d4ab5869e6..435e8bf0fe00 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -224,7 +224,12 @@ async function resolveTiered( agent: SessionIdLookupAgent, sessionId: string, hostDirs: readonly string[], - options: { wslTier: boolean; accountDirs?: () => readonly string[]; signal?: AbortSignal; strictErrors?: boolean } + options: { + wslTier: boolean + accountDirs?: () => readonly string[] + signal?: AbortSignal + strictErrors?: boolean + } ): Promise { const { signal } = options const hit = await findFirstInDirs(agent, sessionId, hostDirs, signal, options.strictErrors) @@ -232,7 +237,13 @@ async function resolveTiered( return hit } const accountDirs = (options.accountDirs?.() ?? []).filter((dir) => !hostDirs.includes(dir)) - const accountHit = await findFirstInDirs(agent, sessionId, accountDirs, signal, options.strictErrors) + const accountHit = await findFirstInDirs( + agent, + sessionId, + accountDirs, + signal, + options.strictErrors + ) if (accountHit || !options.wslTier) { return accountHit } diff --git a/src/main/native-chat/structured-agent-cli-conversations.ts b/src/main/native-chat/structured-agent-cli-conversations.ts index c56e77add999..864d52d87e73 100644 --- a/src/main/native-chat/structured-agent-cli-conversations.ts +++ b/src/main/native-chat/structured-agent-cli-conversations.ts @@ -53,7 +53,10 @@ export function absoluteConversationStore(path: string | null | undefined): stri } export function directoryTranscriptImport(directories: { - conversationStore: (input: { accountHomePath: string; launchEnv: NodeJS.ProcessEnv }) => string | null + conversationStore: (input: { + accountHomePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null accountHomeCandidates: (input: { settings: StructuredAgentTranscriptImportSettings selectedAccountHomePath: string diff --git a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts index 79d05fa69140..988ecc7818fa 100644 --- a/src/main/native-chat/transcript-opencode-sqlite-query.test.ts +++ b/src/main/native-chat/transcript-opencode-sqlite-query.test.ts @@ -503,7 +503,9 @@ describe('readOpenCodeTranscriptSignal', () => { insertPart(db, { id: 'prt-1', messageId: 'msg-1', time: 10, data: { type: 'text', text: 'a' } }) insertPart(db, { id: 'prt-2', messageId: 'msg-2', time: 20, data: { type: 'text', text: 'b' } }) - db.exec("ALTER TABLE session ADD COLUMN directory TEXT; UPDATE session SET directory = '/folder'") + db.exec( + "ALTER TABLE session ADD COLUMN directory TEXT; UPDATE session SET directory = '/folder'" + ) const signal = readOpenCodeTranscriptSignal(path, 'ses-1') expect(signal).toEqual({ cwd: '/folder', diff --git a/src/main/omp/omp-session-cwd-lookup.test.ts b/src/main/omp/omp-session-cwd-lookup.test.ts index 691275f09fcb..b2fbbdbcb96c 100644 --- a/src/main/omp/omp-session-cwd-lookup.test.ts +++ b/src/main/omp/omp-session-cwd-lookup.test.ts @@ -52,7 +52,12 @@ async function lookup(cwd = '/repo/orca', homeDir = join(root, 'home'), temp = j launchEnv }) return store - ? OMP_TRANSCRIPT_IMPORT.findTranscript({ providerSessionId: ID, store, workspaceCwd: cwd, launchEnv }) + ? OMP_TRANSCRIPT_IMPORT.findTranscript({ + providerSessionId: ID, + store, + workspaceCwd: cwd, + launchEnv + }) : null } diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 139268fe0a78..11452053b28b 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -263,6 +263,9 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { releaseAcquisition(input: { sessionId: string }): Promise { return this.closeSession(input.sessionId) } + hasOwnedAcquisition(sessionId: string): boolean { + return this.sessions.has(sessionId) + } forceCloseSession(id: string): Promise { this.sessions.get(id)?.fail(new Error('Pi event sink failed')) return this.closeSession(id, false) diff --git a/src/main/provider-process/provider-process-teardown.test.ts b/src/main/provider-process/provider-process-teardown.test.ts index 41ad22387d7c..249a553df092 100644 --- a/src/main/provider-process/provider-process-teardown.test.ts +++ b/src/main/provider-process/provider-process-teardown.test.ts @@ -183,18 +183,35 @@ describe('terminateProviderProcessTree', () => { ['live', 'live'], ['unverifiable', 'unverifiable'] ] as const)( - 'reports a %s descendant snapshot as-is and leaves the stopped root resumable', + 'reports a %s descendant snapshot after killing anchored child groups and the held root', async (observed, verdict) => { const target = child() + const signalProcessGroup = vi.fn() + const signalProcess = vi.fn() await expect( terminateProviderProcessTree(target, { site: 'codex-app-server-teardown', platform: 'darwin', - captureDescendants: async () => ({ rootPgid: 1234, descendants: [], capturedAtMs: 1 }), - terminateDescendants: async () => observed + captureDescendants: async () => ({ + rootPgid: 1234, + descendants: [ + { pid: 2000, ppid: 1234, pgid: 2000, startedAt: 'now' }, + { pid: 3000, ppid: 2000, pgid: 2000, startedAt: 'now' } + ], + capturedAtMs: 1 + }), + terminateDescendants: async () => observed, + signalProcessGroup, + signalProcess }) ).resolves.toBe(verdict) - expect(target.kill).toHaveBeenLastCalledWith('SIGCONT') + expect(signalProcessGroup).toHaveBeenCalledWith(2000, 'SIGKILL') + expect(signalProcess).toHaveBeenCalledWith(2000, 'SIGKILL') + expect(signalProcess).not.toHaveBeenCalledWith(3000, 'SIGKILL') + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + expect(signalProcess.mock.invocationCallOrder[0]).toBeLessThan( + target.kill.mock.invocationCallOrder.at(-1)! + ) } ) diff --git a/src/main/provider-process/provider-process-teardown.ts b/src/main/provider-process/provider-process-teardown.ts index 32820726feef..c177fda2ac52 100644 --- a/src/main/provider-process/provider-process-teardown.ts +++ b/src/main/provider-process/provider-process-teardown.ts @@ -23,6 +23,7 @@ export type ProviderProcessTeardownDeps = { /** Resolves true only when taskkill reports the whole tree terminated. */ terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void + signalProcess?: (pid: number, signal: NodeJS.Signals) => void } function terminateDedicatedPosixGroup( @@ -66,41 +67,48 @@ async function terminatePosixTree( const terminate = deps.terminateDescendants ?? ((captured: DescendantSnapshot) => terminateDescendantSnapshotWithVerdict(captured)) - const verdict = await terminate(snapshot) - const descendantsExited = verdict === 'exited' - // A detached POSIX launch is the leader of its own process group. Group - // signalling reaches grandchildren even after they daemonise/reparent, - // while the stopped root and captured pgid make the ownership proof exact. - // The identity-gated descendant sweep remains the fallback for older hosts - // or launches that could not establish a dedicated group. - if (descendantsExited && snapshot.rootPgid === rootPid) { + let verdict: ProviderProcessTeardownVerdict = 'unverifiable' + try { + verdict = await terminate(snapshot) + } finally { + const signal = deps.signalProcess ?? process.kill const signalGroup = deps.signalProcessGroup ?? - ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) - let groupSignalled = false - try { - signalGroup(snapshot.rootPgid, 'SIGKILL') - groupSignalled = true - } catch { - // Already-gone is still the desired outcome, but nothing here killed it, - // and a crumb for a kill we never landed is a false render-process-gone suspect. + ((pgid: number, toSend: NodeJS.Signals) => process.kill(-pgid, toSend)) + // The stopped root cannot reap or release its direct children's PIDs. Its snapshot pins + // their identity until after this root is killed, even if a later table read fails. + for (const descendant of snapshot.descendants) { + if (descendant.ppid !== rootPid) { + continue + } + if (descendant.pgid === descendant.pid) { + try { + signalGroup(descendant.pid, 'SIGKILL') + } catch { + // An already exited group is safe to ignore. + } + } + try { + signal(descendant.pid, 'SIGKILL') + } catch { + // The root still has to be stopped when a child rejects a signal. + } } - if (groupSignalled) { - // Outside the try, as in terminateDedicatedPosixGroup: that catch is the - // already-gone contract, not a breadcrumb handler. - recordSelfInitiatedTreeKill({ - pid: snapshot.rootPgid, - site: deps.site, - scope: 'posix-process-group' - }) + if (snapshot.rootPgid === rootPid) { + try { + signalGroup(rootPid, 'SIGKILL') + recordSelfInitiatedTreeKill({ + pid: rootPid, + site: deps.site, + scope: 'posix-process-group' + }) + } catch { + // The held root is still addressed through its exact child handle below. + } } + child.kill('SIGKILL') } - if (!descendantsExited) { - child.kill('SIGCONT') - return verdict - } - child.kill('SIGKILL') - return 'exited' + return verdict } /** Stops every process owned by one provider launch before releasing its wrapper. */ diff --git a/src/main/runtime/agent-session-acquisition-exit-transition.ts b/src/main/runtime/agent-session-acquisition-exit-transition.ts new file mode 100644 index 000000000000..a88ead3026c9 --- /dev/null +++ b/src/main/runtime/agent-session-acquisition-exit-transition.ts @@ -0,0 +1,55 @@ +import { isDeepStrictEqual } from 'node:util' +import { + agentSessionExecutionLocationsEqual, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { agentSessionLeaseIsReleased } from '../../shared/agent-session-lease-adjudication' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { StructuredAgentSessionAcquisitionReceipt } from '../native-chat/agent-session-wire/structured-agent-session-startup-attempt-contract' +import { assertFence, withLease } from './agent-session-lease-transitions' + +/** An unpublished start's observed exit belongs to its reservation, never a later owner. */ +export function recordAgentSessionAcquisitionExit( + record: AgentSessionRecord, + receipt: StructuredAgentSessionAcquisitionReceipt, + expectedFence: number, + now: number +): AgentSessionRecord { + assertFence(record.lease, expectedFence) + const lease = record.lease + const released = + agentSessionLeaseIsReleased(lease) && + lease.ownerProcess === null && + lease.runtimeFence === nextAgentSessionFence({ ...lease, runtimeFence: receipt.fence }) + const owner = lease.ownerProcess + const recovering = + lease.handoffStage === 'recovering' && + lease.handoffOperationId === null && + lease.runtimeFence === receipt.fence && + lease.reservedSpawnToken === receipt.spawnToken && + owner !== null && + receipt.process !== undefined && + isDeepStrictEqual({ ...owner, runtime: undefined }, { ...receipt.process, runtime: undefined }) + if ( + !agentSessionExecutionLocationsEqual(record.location, receipt.location) || + (!released && !recovering) + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...lease, + runtimeFence: released ? lease.runtimeFence : nextAgentSessionFence(lease), + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + lastRenewedAt: now, + deathEvidence: { + kind: 'exit-observed', + detail: 'the failed acquisition provider root exited', + ownerFence: receipt.fence, + observedAt: now + } + }) +} diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index 7c750ca3dc12..c79265f088e5 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -42,6 +42,20 @@ export function agentSessionRecordIdentityFields( /** The fence a founded conversation starts at; every reservation moves it. */ export const AGENT_SESSION_FOUNDING_FENCE = 1 +/** A founded row with no reservation, owner, or provider handle has never started an agent. */ +export function structuredSessionNeverStarted(record: AgentSessionRecord): boolean { + const { lease } = record + return ( + lease.claimStatus === 'released' && + lease.runtimeFence === AGENT_SESSION_FOUNDING_FENCE && + lease.minimumNextFence === undefined && + lease.ownerProcess === null && + lease.reservedSpawnToken === null && + lease.handoffStage === null && + record.providerHandleChain.length === 0 + ) +} + /** * A conversation no agent has run yet, at rest: its first send starts one. The empty handle chain * is what makes that start a fresh conversation rather than a resume. diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index ecbb599de7ff..1f721b8c0661 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -64,8 +64,10 @@ function createParams(operationId = OPERATION, sessionId = SESSION) { const fields = { worktree: `id:${WORKSPACE}`, agent: 'codex' as const, - resumeFrom: { providerSessionId: THREAD, - cliSource: { workspaceId: WORKSPACE, paneKey: 'source-pane' } } + resumeFrom: { + providerSessionId: THREAD, + cliSource: { workspaceId: WORKSPACE, paneKey: 'source-pane' } + } } return { envelope: { @@ -156,12 +158,20 @@ describe('committed adopting create RPC replay', () => { { prepareCodexStructuredLaunch: selectAccountHome } ) const sourceRow = { - paneKey: 'source-pane', worktreeId: WORKSPACE, agentType: 'codex', - connectionId: null, receivedAt: 1, stateStartedAt: 1, state: 'waiting' as const, prompt: '', + paneKey: 'source-pane', + worktreeId: WORKSPACE, + agentType: 'codex', + connectionId: null, + receivedAt: 1, + stateStartedAt: 1, + state: 'waiting' as const, + prompt: '', providerSession: { key: 'session_id' as const, id: THREAD, transcriptPath } } let rows: AgentStatusIpcPayload[] = [sourceRow] - const sourceRows = vi.spyOn(runtime, 'getAgentProviderSessionRows').mockImplementation(() => rows) + const sourceRows = vi + .spyOn(runtime, 'getAgentProviderSessionRows') + .mockImplementation(() => rows) // The structured surface is settings-gated for every caller, not just mobile; this test // probes durable-identity replay, which only runs once the gate admits the call. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The RPC gate reads only this setting from the test double. @@ -214,11 +224,15 @@ describe('committed adopting create RPC replay', () => { }) const params = createParams() - rows = unary ? [] : [{ ...sourceRow, providerSession: { ...sourceRow.providerSession, id: 'replaced' } }] - const route = vi.spyOn(runtime, 'routeClientHostedBrowserRpc').mockImplementationOnce(async () => { - rows = [sourceRow] - return { handled: false } - }) + rows = unary + ? [] + : [{ ...sourceRow, providerSession: { ...sourceRow.providerSession, id: 'replaced' } }] + const route = vi + .spyOn(runtime, 'routeClientHostedBrowserRpc') + .mockImplementationOnce(async () => { + rows = [sourceRow] + return { handled: false } + }) expect(await call(dispatcher, params, CLIENT, unary)).toMatchObject({ ok: true, result: { ok: false, refusal: { details: { reason: 'transcriptUnreadable' } } } @@ -286,9 +300,15 @@ describe('committed adopting create RPC replay', () => { expect(selectAccountHome).toHaveBeenCalledTimes(1) expect(await call(dispatcher, createParams(OPERATION, 'another-chat'))).toMatchObject({ ok: true, - result: { ok: false, refusal: { details: { - reason: 'conversationHeldElsewhere', owner: { sessionId: SESSION, workspaceId: WORKSPACE } - } } } + result: { + ok: false, + refusal: { + details: { + reason: 'conversationHeldElsewhere', + owner: { sessionId: SESSION, workspaceId: WORKSPACE } + } + } + } }) const otherOperation = createParams(`${Date.now()}-00000000000000000000000000000002`) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 12813a8f66ea..f0e68d1ca2f1 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -16,6 +16,11 @@ export const CLEANUP_METHODS = [ params: { sessionId: SESSION }, hostCall: 'close' }, + { + method: 'agentSession.sleepWorkspace', + params: { workspaceId: 'workspace-1' }, + hostCall: 'sleepWorkspace' + }, { method: 'agentSession.cancel', params: { envelope: envelope(), turnId: 'turn-1' }, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 5d42665d0f06..739d470cc04d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -176,6 +176,7 @@ export function hostStub(): StructuredAgentSessionHost { waitForSendSettlement: vi.fn(), cancel: vi.fn(async () => ({ ok: true, replayed: false })), close: vi.fn(async () => undefined), + sleepWorkspace: vi.fn(async () => ({ stoppedSessionIds: [SESSION] })), revealSession: vi.fn(async () => ({ sessionId: SESSION, workspaceId: 'workspace-1', @@ -324,6 +325,4 @@ export function installStructuredHostStub(): void { setStructuredAgentSessionHost(hostStub()) } -export function clearStructuredHostStub(): void { - setStructuredAgentSessionHost(null) -} +export const clearStructuredHostStub = (): void => setStructuredAgentSessionHost(null) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index 76ce9c1b8249..7098c46ea3cd 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -26,6 +26,7 @@ export { RestartResumeParams, RewindParams, SendParams, + SleepWorkspaceParams, SessionId, SetOptionParams, SubscribeParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index ae089150db33..535817b6a63f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -175,7 +175,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(37) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(38) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -336,7 +336,7 @@ describe('capability gating', () => { } ) - it.each(CLEANUP_METHODS)( + it.each(CLEANUP_METHODS.filter(({ method }) => method !== 'agentSession.sleepWorkspace'))( 'does not install a host for cleanup-only method $method', async ({ method, params }) => { const ensureHost = vi.fn() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index cef46e353a75..1fe96d81d0b9 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -62,10 +62,14 @@ import { OptionsParams, RewindParams, SendParams, + SleepWorkspaceParams, SubscribeParams, UnsubscribeParams } from './structured-agent-session-schemas' import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { structuredSessionNeverStarted } from '../../agent-session-record-founding' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { AGENT_SESSION_SNAPSHOT_CATCH_UP_CLIENT_CAPABILITY } from '../../../../shared/protocol-version' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { @@ -163,6 +167,52 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ return { ok: true as const } } }), + defineMethod({ + name: 'agentSession.sleepWorkspace', + permission: 'workspace', + params: SleepWorkspaceParams, + handler: async (params, ctx) => { + requireStructuredCapability(ctx) + const installed = getStructuredAgentSessionHost() + if (installed) { + return installed.sleepWorkspace(params.workspaceId) + } + let store: Awaited> + try { + store = await ctx.runtime.openAgentSessionRecordStore() + } catch (error) { + const appeared = getStructuredAgentSessionHost() + if (appeared) { + return appeared.sleepWorkspace(params.workspaceId) + } + console.warn('[agent-session] workspace sleep record inventory unavailable', error) + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } + } + const appeared = getStructuredAgentSessionHost() + if (appeared) { + return appeared.sleepWorkspace(params.workspaceId) + } + try { + const records = store + .listRecords() + .filter( + (record) => + record.location.workspaceId === params.workspaceId && + record.location.executionHostId === LOCAL_EXECUTION_HOST_ID + ) + const stoppedSessionIds = records + .filter(structuredSessionNeverStarted) + .map((record) => record.sessionId) + const failures = records + .filter((record) => !structuredSessionNeverStarted(record)) + .map((record) => ({ sessionId: record.sessionId, reason: 'stop-unverifiable' as const })) + return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } + } catch (error) { + console.warn('[agent-session] workspace sleep record inventory failed', error) + return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } + } + } + }), ...STRUCTURED_AGENT_SESSION_PROMPT_RESPONSE_METHODS, defineMethod({ name: 'agentSession.history', diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 0b0ccff3b7ef..9deac08a9ba8 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -31,7 +31,7 @@ import { structuredSessionTabRetired, structuredWorkerAddressable } from './structured-worker-custody' -import { AGENT_SESSION_FOUNDING_FENCE } from './agent-session-record-founding' +import { structuredSessionNeverStarted } from './agent-session-record-founding' import { isTuiAgent } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' import { isStructuredWorkerHandle } from '../../shared/structured-worker-handle' @@ -284,20 +284,6 @@ export function observeStructuredWorker( * owner, spawn, handoff or provider handle. A released reservation whose exit was never proven has * moved the fence, so it stays unverifiable. */ -function structuredSessionNeverStarted(record: AgentSessionRecord): boolean { - const { lease } = record - return ( - lease.claimStatus === 'released' && - lease.runtimeFence === AGENT_SESSION_FOUNDING_FENCE && - // A fence floor marks a copy restored from backup, which may hide a reservation it lost. - lease.minimumNextFence === undefined && - lease.ownerProcess === null && - lease.reservedSpawnToken === null && - lease.handoffStage === null && - record.providerHandleChain.length === 0 - ) -} - /** One session's own liveness, for callers that act on that session rather than on a worker. */ export function observeStructuredSession(sessionId: string): StructuredWorkerObservation { const host = getStructuredAgentSessionHost() diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 97d257ad0b53..7a01d0a4918d 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -7,6 +7,7 @@ import type { AgentSessionStatusSummary } from '../../../../shared/agent-session-wire' import { buildSubagentChildRows } from '../sidebar/worktree-subagent-child-rows' +import { hasSleepableWorkspaceActivity } from '../sidebar/workspace-lineage-menu-actions' import { resolveAttention } from '../../../../shared/sidebar-list/smart-attention' import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' @@ -70,6 +71,7 @@ import { useStructuredAgentSessionHostStopping } from './StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { useAppStore } from '@/store' const structuredTab = { id: 'structured-tab-1', @@ -651,6 +653,33 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).toHaveBeenCalledTimes(calls) }) + it('keeps the host-fed chat row through Sleep and stops offering Sleep once the host lets go', async () => { + const state = (): AppState => useAppStore.getState() + const sleepable = (): boolean => hasSleepableWorkspaceActivity('wt-1', state()) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) + const paneKey = statuses()[0].paneKey + mocks.store?.setState({ acknowledgedAgentsByPaneKey: { [paneKey]: 3 } }) + expect(sleepable()).toBe(true) + + // Sleep's terminal teardown drops the worktree's rows before the host's revoke reaches the feed. + act(() => state().dropAgentStatusByWorktree('wt-1', { shutdownReason: 'manual-sleep' })) + expect(state().agentStatusByPaneKey[paneKey]).toEqual( + expect.objectContaining({ state: 'done', structuredHostOwned: true }) + ) + act(() => + feed().emit({ + type: 'status', + session: summary({ status: 'idle', updatedAt: 2, hostExecutionOwned: undefined }) + }) + ) + expect(statuses()).toEqual([expect.objectContaining({ state: 'done' })]) + expect(statuses()[0].structuredHostOwned).toBeUndefined() + expect(state().acknowledgedAgentsByPaneKey).toEqual({ [paneKey]: 3 }) + expect(sleepable()).toBe(false) + }) + it('drops the status and the feed when the last structured tab closes', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx index fbca904c4dc5..220585d567f7 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.copy-worktree-name.test.tsx @@ -12,6 +12,7 @@ import WorktreeContextMenu from './WorktreeContextMenu' globalThis.IS_REACT_ACT_ENVIRONMENT = true const state = { + unifiedTabsByWorktree: {}, updateWorktreeMeta: vi.fn(), setWorktreesPinnedAndReveal: vi.fn(), workspaceStatuses: [], diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx index 8bdac0ca1437..80b11f02c9b8 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.delete-shortcut.test.tsx @@ -84,6 +84,7 @@ vi.mock('@/i18n/i18n', () => ({ })) const defaultStoreState = { + unifiedTabsByWorktree: {}, updateWorktreeMeta: vi.fn(), setWorktreesPinnedAndReveal: vi.fn(), workspaceStatuses: [], diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx index da48eb26ae0b..141304c66c13 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.react185-bystander.test.tsx @@ -22,6 +22,7 @@ import WorktreeContextMenu from './WorktreeContextMenu' globalThis.IS_REACT_ACT_ENVIRONMENT = true const state = { + unifiedTabsByWorktree: {}, updateWorktreeMeta: vi.fn(), setWorktreesPinnedAndReveal: vi.fn(), workspaceStatuses: [ diff --git a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx index 4eed6feea33e..55bee52aa0b5 100644 --- a/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeDeveloperMenuReveal.test.tsx @@ -12,6 +12,7 @@ import type { Worktree } from '../../../../shared/worktree/types' vi.mock('@/store', () => { const state = { + unifiedTabsByWorktree: {}, tabsByWorktree: {}, ptyIdsByTabId: {}, browserTabsByWorktree: {}, diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts b/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts new file mode 100644 index 000000000000..e2fb53e477aa --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts @@ -0,0 +1,84 @@ +import type { AgentSessionWorkspaceSleepResult } from '../../../../shared/agent-session-workspace-sleep' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { authorityKey, SELF_LOCAL_AUTHORITY } from '../../../../shared/host-authority' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { resolveOwner } from '@/lib/resolve-owner' +import { runtimeRpcTargetForAuthorityKey } from '@/runtime/runtime-client-target' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { workspacePublishingHosts } from '@/store/slices/worktrees/teardown/host-twin-worktree-purge' +import type { AppState } from '@/store/types' + +export type WorkspaceChatStopReceipt = { + executionHostId: ExecutionHostId + stoppedSessionIds: string[] +} + +export type WorkspaceChatStop = { receipts: WorkspaceChatStopReceipt[]; failed: boolean } + +/** One runtime per copy of the workspace; with no listing row, the workspace's route. */ +function chatHostTargets( + state: AppState, + workspaceId: string +): Map { + const hosts = workspacePublishingHosts(state, workspaceId) + const targets = new Map() + for (const hostId of hosts.length > 0 ? hosts : [undefined]) { + const match = resolveOwner(state, hostId ? { workspaceId, hostId } : { workspaceId }) + const owner = + match.kind === 'resolved' + ? match.owner + : match.kind === 'missing' + ? SELF_LOCAL_AUTHORITY + : null + // Why null for a directly dialed SSH place: no native chat host runs there. + const target = owner ? runtimeRpcTargetForAuthorityKey(authorityKey(owner)) : null + if (target) { + const key = + target.kind === 'local' + ? LOCAL_EXECUTION_HOST_ID + : toRuntimeExecutionHostId(target.environmentId) + targets.set(key, target) + } + } + return targets +} + +/** Each host stops only the chats it runs, so every copy's host is asked. Never rejects. */ +export async function stopWorkspaceChats( + state: AppState, + workspaceId: string +): Promise { + const settled = await Promise.all( + [...chatHostTargets(state, workspaceId)].map(([executionHostId, target]) => + callStructuredAgentSession( + target, + 'agentSession.sleepWorkspace', + { workspaceId } + ).then( + (result) => ({ executionHostId, result, error: undefined }), + (error: unknown) => ({ executionHostId, result: null, error }) + ) + ) + ) + const receipts: WorkspaceChatStopReceipt[] = [] + let failed = false + for (const { executionHostId, result, error } of settled) { + if (!result || result.failures?.length) { + failed = true + console.error('[sleep-worktree] native chat stop incomplete', { + workspaceId, + executionHostId, + failures: result?.failures, + error + }) + } + if (result) { + receipts.push({ executionHostId, stoppedSessionIds: result.stoppedSessionIds }) + } + } + return { receipts, failed } +} diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index 5f3b588f93c2..d5f0a8d6d56e 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => { state.ptyIdsByTabId[tab.id] = [] } }), + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, tabsByWorktree: {} as Record, ptyIdsByTabId: {} as Record } @@ -39,6 +41,9 @@ vi.mock('@/lib/worktree-activation', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: async () => ({ stoppedSessionIds: [] }) +})) import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { runSleepWorktrees } from './sleep-worktree-flow' diff --git a/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts new file mode 100644 index 000000000000..d6f28753d448 --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { + createTestStore, + makeRuntimeOwnedWorktree, + makeTab, + makeWorktree, + seedStore +} from '@/store/slices/store-test-helpers' +import { + clearWorktreeSleepIntent, + forgetWorktreeSleepIntent, + hasWorktreeSleepIntent +} from '@/lib/worktree-sleep-intent' + +const mocks = vi.hoisted(() => ({ + chat: vi.fn(), + terminal: vi.fn(), + suspend: vi.fn(), + toast: vi.fn() +})) +let store: ReturnType +vi.mock('@/store', () => ({ useAppStore: { getState: () => store.getState() } })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.chat +})) +vi.mock('@/runtime/remote-worktree-sleep', () => ({ requestRemoteWorktreeSleep: mocks.terminal })) +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + unregisterPtyDataHandlers: () => [], + restorePtyDataHandlersAfterFailedShutdown: vi.fn() +})) +vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) +import { runSleepWorktrees } from './sleep-worktree-flow' + +const ids = ['repo1::/one', 'repo1::/two'] +beforeEach(() => { + vi.clearAllMocks() + ids.forEach(forgetWorktreeSleepIntent) + store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: ids.map((id) => + makeRuntimeOwnedWorktree({ id, repoId: 'repo1', hostId: 'runtime:env-a' }, 'env-a') + ) + }, + tabsByWorktree: Object.fromEntries(ids.map((id) => [id, [makeTab({ id, worktreeId: id })]])), + ptyIdsByTabId: Object.fromEntries(ids.map((id) => [id, [`remote:env-a@@${id}`]])) + }) + mocks.chat.mockResolvedValue({ stoppedSessionIds: ['chat'] }) + mocks.terminal.mockResolvedValue(undefined) + mocks.suspend.mockResolvedValue(null) + vi.stubGlobal('window', { + api: { ephemeralVm: { suspendWorkspace: mocks.suspend }, pty: { kill: vi.fn() } } + }) +}) + +it('admits both real terminal actions and chat stops before a held first terminal, then honors wake', async () => { + const browser = store.getState().shutdownWorktreeBrowsers + store.setState({ + shutdownWorktreeBrowsers: (id) => { + browser(id) + if (id === ids[1]) { + store.setState({ + ptyIdsByTabId: { + ...store.getState().ptyIdsByTabId, + [id]: [`remote:env-a@@replacement-${id}`] + } + }) + } + } + }) + const first = Promise.withResolvers() + mocks.terminal.mockImplementation(({ worktreeId }: { worktreeId: string }) => + worktreeId === ids[0] ? first.promise : Promise.resolve() + ) + const sleeping = runSleepWorktrees(ids) + try { + expect(mocks.terminal).toHaveBeenCalledTimes(2) + expect(mocks.terminal).toHaveBeenLastCalledWith({ worktreeId: ids[1], environmentId: 'env-a' }) + expect(mocks.chat.mock.calls.map(([, , params]) => params.workspaceId)).toEqual(ids) + await vi.waitFor(() => expect(store.getState().ptyIdsByTabId[ids[1]]).toEqual([])) + expect(store.getState().suppressedPtyExitIds[`remote:env-a@@replacement-${ids[1]}`]).toBe(true) + clearWorktreeSleepIntent(ids[1]) + expect(hasWorktreeSleepIntent(ids[1])).toBe(false) + expect(mocks.suspend).not.toHaveBeenCalled() + } finally { + first.resolve() + } + const results = await sleeping + expect(mocks.chat).toHaveBeenCalledTimes(2) + expect(mocks.suspend).toHaveBeenCalledExactlyOnceWith({ workspaceId: ids[0] }) + expect(results.find((result) => result.workspaceId === ids[1])?.completed).toBe(false) +}) + +it('joins every admitted chat before VM suspend even when one terminal fails', async () => { + const chat = Promise.withResolvers<{ stoppedSessionIds: string[] }>() + mocks.chat.mockImplementation((_target, _method, { workspaceId }) => + workspaceId === ids[0] + ? chat.promise + : Promise.resolve({ + stoppedSessionIds: ['second'], + failures: [{ reason: 'stop-unverifiable' }] + }) + ) + mocks.terminal.mockRejectedValueOnce(new Error('terminal_failed')) + const sleeping = runSleepWorktrees(ids) + try { + await vi.waitFor(() => expect(mocks.terminal).toHaveBeenCalledTimes(2)) + expect(mocks.chat).toHaveBeenCalledTimes(2) + expect(mocks.suspend).not.toHaveBeenCalled() + } finally { + chat.resolve({ stoppedSessionIds: ['first'] }) + } + const results = await sleeping + expect( + results.map(({ stoppedSessionIds, completed }) => ({ stoppedSessionIds, completed })) + ).toEqual([ + { stoppedSessionIds: ['first'], completed: false }, + { stoppedSessionIds: ['second'], completed: false } + ]) + expect(mocks.suspend).toHaveBeenCalledExactlyOnceWith({ workspaceId: ids[1] }) + expect(mocks.toast).toHaveBeenCalledOnce() +}) + +it('keeps the real chat-only twin refusal after synchronous browser cleanup', async () => { + const browser = store.getState().shutdownWorktreeBrowsers + store.setState({ + shutdownWorktreeBrowsers: (id) => { + browser(id) + seedTwin() + } + }) + const seedTwin = () => + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ id: ids[0], repoId: 'repo1', hostId: 'local' }), + makeRuntimeOwnedWorktree( + { id: ids[0], repoId: 'repo1', hostId: 'runtime:env-a' }, + 'env-a' + ) + ] + }, + tabsByWorktree: {}, + ptyIdsByTabId: {} + }) + store.setState({ browserTabsByWorktree: { [ids[0]]: [] } }) + await runSleepWorktrees([ids[0]]) + expect(store.getState().browserTabsByWorktree[ids[0]]).toBeUndefined() + expect(mocks.chat).not.toHaveBeenCalled() + expect(mocks.terminal).not.toHaveBeenCalled() + expect(mocks.toast).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index ae0aba247970..865541279fe3 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' const mocks = vi.hoisted(() => { const state: { @@ -10,23 +11,29 @@ const mocks = vi.hoisted(() => { consumeSuppressedPtyExit: ReturnType tabsByWorktree: Record ptyIdsByTabId: Record + worktreesByRepo: Record + detectedWorktreesByRepo: Record } = { activeWorktreeId: null, setActiveWorktree: vi.fn((worktreeId: string | null) => { state.activeWorktreeId = worktreeId }), - shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), + shutdownWorktreeBrowsers: vi.fn(), shutdownWorktreeTerminals: vi.fn().mockResolvedValue(undefined), suppressPtyExit: vi.fn(), consumeSuppressedPtyExit: vi.fn(), - tabsByWorktree: {} as Record, - ptyIdsByTabId: {} as Record + tabsByWorktree: {}, + ptyIdsByTabId: {}, + worktreesByRepo: {}, + detectedWorktreesByRepo: {} } const suspendWorkspace = vi.fn().mockResolvedValue(null) const toastError = vi.fn() const markWorktreeSleepIntent = vi.fn() const clearWorktreeSleepIntent = vi.fn() + const callChat = vi.fn() return { + callChat, clearWorktreeSleepIntent, markWorktreeSleepIntent, state, @@ -45,9 +52,14 @@ vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/lib/worktree-sleep-intent', () => ({ clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, markWorktreeSleepIntent: mocks.markWorktreeSleepIntent, + hasWorktreeSleepIntent: () => true, withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.callChat +})) +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { runSleepWorktree, runSleepWorktrees, @@ -66,8 +78,14 @@ describe('runSleepWorktree', () => { requestAnimationFrame: vi.fn() }) mocks.state.setActiveWorktree.mockClear() - mocks.state.shutdownWorktreeBrowsers.mockClear().mockResolvedValue(undefined) - mocks.state.shutdownWorktreeTerminals.mockClear().mockResolvedValue(undefined) + mocks.state.shutdownWorktreeBrowsers.mockReset() + mocks.state.shutdownWorktreeTerminals + .mockReset() + .mockImplementation( + async (_id: string, opts: Parameters[1]) => { + opts?.onAdmitted?.() + } + ) mocks.state.suppressPtyExit.mockClear() mocks.state.consumeSuppressedPtyExit.mockClear() mocks.suspendWorkspace.mockClear().mockResolvedValue(null) @@ -77,6 +95,8 @@ describe('runSleepWorktree', () => { mocks.state.activeWorktreeId = null mocks.state.tabsByWorktree = {} mocks.state.ptyIdsByTabId = {} + mocks.state.worktreesByRepo = {} + mocks.callChat.mockReset().mockResolvedValue({ stoppedSessionIds: [] }) }) it('tears down browsers before terminals on the sleep path', async () => { @@ -90,7 +110,8 @@ describe('runSleepWorktree', () => { // browserTabsByWorktree delete, browsers would no-op and leak webviews. expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-1') expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-1', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.suspendWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-1' }) const browsersCallOrder = mocks.state.shutdownWorktreeBrowsers.mock.invocationCallOrder[0] @@ -206,50 +227,11 @@ describe('runSleepWorktree', () => { expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() }) - it('leaves a worktree the user activated mid-batch awake', async () => { - let releaseFirst: () => void = () => {} - mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseFirst = resolve - }) - ) - - const run = runSleepWorktrees(['wt-1', 'wt-2']) - await Promise.resolve() - // Why: the user clicked wt-2 while wt-1 was tearing down; sleeping it anyway - // must not leave the active workspace marked with no clear pending. - mocks.state.activeWorktreeId = 'wt-2' - releaseFirst() - await run - - expect(mocks.clearWorktreeSleepIntent).toHaveBeenLastCalledWith('wt-2') - }) - - it('marks each worktree only when its own teardown starts', async () => { - let releaseFirst: () => void = () => {} - mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseFirst = resolve - }) - ) - - const run = runSleepWorktrees(['wt-1', 'wt-2']) - await Promise.resolve() - - // Why: wt-2 is still awake while wt-1 tears down; marking it early would - // hold its panes cold and swallow its activity. - expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-1') - expect(mocks.markWorktreeSleepIntent).not.toHaveBeenCalledWith('wt-2') - releaseFirst() - await run - expect(mocks.markWorktreeSleepIntent).toHaveBeenCalledWith('wt-2') - }) - it('surfaces a toast and skips terminals when browsers throws', async () => { mocks.state.activeWorktreeId = 'wt-1' - mocks.state.shutdownWorktreeBrowsers.mockRejectedValueOnce(new Error('boom')) + mocks.state.shutdownWorktreeBrowsers.mockImplementationOnce(() => { + throw new Error('boom') + }) mocks.state.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } mocks.state.ptyIdsByTabId = { 'tab-1': ['pty-1'] } @@ -290,19 +272,21 @@ describe('runSleepWorktree', () => { it('continues sleeping later worktrees when one selected worktree fails', async () => { mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => { if (worktreeId === 'wt-1') { - return Promise.reject(new Error('first failed')) + throw new Error('first failed') } - return Promise.resolve() + return undefined }) await runSleepWorktrees(['wt-1', 'wt-2']) expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalledWith('wt-1', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt-2') expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalledWith('wt-2', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.suspendWorkspace).toHaveBeenCalledWith({ workspaceId: 'wt-2' }) expect(mocks.toastError).toHaveBeenCalledWith( @@ -315,9 +299,11 @@ describe('runSleepWorktree', () => { }) it('reports failed worktrees to a caller that answers for them, without a toast', async () => { - mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => - worktreeId === 'wt-1' ? Promise.reject(new Error('first failed')) : Promise.resolve() - ) + mocks.state.shutdownWorktreeBrowsers.mockImplementation((worktreeId: string) => { + if (worktreeId === 'wt-1') { + throw new Error('first failed') + } + }) const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2']) @@ -335,16 +321,75 @@ describe('runSleepWorktree', () => { expect(mocks.state.setActiveWorktree).toHaveBeenCalledWith(null) expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenNthCalledWith(1, 'wt-1') expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenNthCalledWith(1, 'wt-1', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenNthCalledWith(2, 'wt-2') expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenNthCalledWith(2, 'wt-2', { - keepIdentifiers: true + keepIdentifiers: true, + onAdmitted: expect.any(Function) }) expect(mocks.suspendWorkspace).toHaveBeenNthCalledWith(1, { workspaceId: 'wt-1' }) expect(mocks.suspendWorkspace).toHaveBeenNthCalledWith(2, { workspaceId: 'wt-2' }) }) + const twinId = 'repo-1::/wt' + const twinRows = { + 'repo-1': [ + { id: twinId, repoId: 'repo-1', hostId: 'local' }, + { id: twinId, repoId: 'repo-1', hostId: 'runtime:env-a' } + ] + } + + it("stops a workspace's chats on every host that publishes a copy", async () => { + mocks.state.worktreesByRepo = twinRows + mocks.state.tabsByWorktree = { [twinId]: [{ id: 'tab-1' }] } + mocks.state.ptyIdsByTabId = { 'tab-1': [toRemoteRuntimePtyId('pty-1', 'env-a')] } + mocks.callChat.mockImplementation(async (target: { kind: string }) => ({ + stoppedSessionIds: [`chat-${target.kind}`] + })) + + const results = await runSleepWorktrees([twinId]) + + expect(results).toEqual([ + expect.objectContaining({ executionHostId: 'local', stoppedSessionIds: ['chat-local'] }), + expect.objectContaining({ + executionHostId: 'runtime:env-a', + stoppedSessionIds: ['chat-environment'], + completed: true + }) + ]) + }) + + it('stops no chat when it cannot tell which copy runs the terminals', async () => { + mocks.state.worktreesByRepo = twinRows + mocks.state.shutdownWorktreeTerminals.mockRejectedValueOnce( + new Error('ambiguous_terminal_owner') + ) + + await runSleepWorktree(twinId) + + expect(mocks.callChat).not.toHaveBeenCalled() + expect(mocks.toastError).toHaveBeenCalledOnce() + }) + + it('reports an unconfirmed chat stop in one toast and keeps stopped terminals asleep', async () => { + mocks.state.activeWorktreeId = 'wt-1' + mocks.callChat.mockRejectedValue(new Error('host offline')) + + const results = await runSleepWorktree('wt-1') + + expect(mocks.state.shutdownWorktreeTerminals).toHaveBeenCalled() + expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() + expect(mocks.state.setActiveWorktree.mock.calls).toEqual([[null]]) + expect(results).toEqual([]) + expect(mocks.toastError).toHaveBeenCalledOnce() + expect(mocks.toastError).toHaveBeenCalledWith('Failed to sleep workspace', { + description: + 'The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again.' + }) + }) + it('sleeps a workspace named by both copies of a twin once', async () => { await runSleepWorktrees(['wt-1', 'wt-1']) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index e554c5d4234c..3959cbc1039a 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -2,11 +2,23 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { clearWorktreeSleepIntent, + hasWorktreeSleepIntent, markWorktreeSleepIntent, withWorktreeSleepTeardown } from '@/lib/worktree-sleep-intent' -import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' import { translate } from '@/i18n/i18n' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { preserveSidebarWorktreePosition } from './sleep-worktree-sidebar-position' +import { stopWorkspaceChats, type WorkspaceChatStop } from './sleep-workspace-chat-stop' + +/** One per host asked to stop the workspace's chats. */ +export type WorkspaceSleepResult = { + workspaceId: string + executionHostId: ExecutionHostId + stoppedSessionIds: string[] + /** The whole workspace slept and is still asleep. */ + completed: boolean +} /** * Shared "sleep worktree" flow (close all panels to free memory / CPU) @@ -21,93 +33,8 @@ import { translate } from '@/i18n/i18n' * Centralizing the sequence here keeps that safety invariant in one place so * a new caller can't accidentally skip it. */ -export async function runSleepWorktree(worktreeId: string): Promise { - await runSleepWorktrees([worktreeId]) -} - -function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] { - return Array.from(document.querySelectorAll('[data-worktree-id]')).filter( - (element) => element.dataset.worktreeId === worktreeId - ) -} - -function isPinnedSidebarWorktreeOption(element: HTMLElement): boolean { - // Why: duplicated pinned rows share a worktree id; the row-key prefix is the - // stable row-scoped signal that distinguishes the pinned copy from natural rows. - return element.dataset.worktreeRowKey?.startsWith('pinned:') === true -} - -function findPrimarySidebarWorktreeOption(worktreeId: string): HTMLElement | null { - const options = getSidebarWorktreeOptions(worktreeId) - return ( - options.find((element) => - element.querySelector('[data-worktree-card-active="primary"]') - ) ?? - options.find((element) => !isPinnedSidebarWorktreeOption(element)) ?? - options[0] ?? - null - ) -} - -function findSidebarWorktreeRow(worktreeId: string, rowKey?: string): HTMLElement | null { - const options = getSidebarWorktreeOptions(worktreeId) - const option = rowKey - ? (options.find((element) => element.dataset.worktreeRowKey === rowKey) ?? null) - : (findPrimarySidebarWorktreeOption(worktreeId) ?? null) - return option?.closest('[data-worktree-virtual-row]') ?? null -} - -function preserveSidebarWorktreePosition(worktreeId: string): () => void { - if (typeof document === 'undefined') { - return () => {} - } - const getScroller = (): HTMLElement | null => - document.querySelector('[data-worktree-sidebar]') - const scroller = getScroller() - const activeOption = findPrimarySidebarWorktreeOption(worktreeId) - const activeRowKey = activeOption?.dataset.worktreeRowKey - const row = activeOption?.closest('[data-worktree-virtual-row]') ?? null - if (!scroller || !row) { - return () => {} - } - scroller.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) - const previousScrollTop = scroller.scrollTop - const previousScrollHeight = scroller.scrollHeight - const previousTop = row.getBoundingClientRect().top - - return () => { - let attempts = 0 - const restore = (): void => { - const currentScroller = getScroller() - if (!currentScroller) { - attempts += 1 - if (attempts < 12) { - window.requestAnimationFrame(restore) - } - return - } - const nextRow = findSidebarWorktreeRow(worktreeId, activeRowKey) - if (!nextRow) { - // Why: a remount can first render the wrong virtual window. Put the - // scroller near the same content after height changes so the row - // mounts, then retry and correct by actual DOM position. - currentScroller.scrollTop = Math.max( - 0, - previousScrollTop + currentScroller.scrollHeight - previousScrollHeight - ) - } else { - const delta = nextRow.getBoundingClientRect().top - previousTop - if (Math.abs(delta) > 1) { - currentScroller.scrollTop += delta - } - } - attempts += 1 - if (attempts < 12) { - window.requestAnimationFrame(restore) - } - } - window.requestAnimationFrame(restore) - } +export async function runSleepWorktree(worktreeId: string): Promise { + return runSleepWorktrees([worktreeId]) } function describeSleepFailure(error: unknown): string { @@ -142,8 +69,17 @@ function describeSleepFailure(error: unknown): string { ) } -export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise { - const { errors } = await sleepWorktreesReportingFailures(worktreeIds) +function describeChatStopFailure(): string { + return translate( + 'auto.components.sidebar.sleep.worktree.flow.chat.partial', + 'The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again.' + ) +} + +export async function runSleepWorktrees( + worktreeIds: readonly string[] +): Promise { + const { errors, results } = await sleepWorktreesReportingFailures(worktreeIds) if (errors.length > 0) { // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. toast.error( @@ -161,9 +97,15 @@ export async function runSleepWorktrees(worktreeIds: readonly string[]): Promise } ) } + return results } -export type WorktreeSleepOutcome = { failedWorktreeIds: string[]; errors: string[] } +export type WorktreeSleepOutcome = { + /** Every workspace not verifiably slept, including one whose chat stop went unconfirmed. */ + failedWorktreeIds: string[] + errors: string[] + results: WorkspaceSleepResult[] +} /** The sleep without the toast, for callers that report failures themselves. */ export async function sleepWorktreesReportingFailures( @@ -172,7 +114,7 @@ export async function sleepWorktreesReportingFailures( // Why unique: both copies of a twin share one id, and sleeping it sleeps the whole workspace. const worktreeIds = [...new Set(requestedWorktreeIds)] if (worktreeIds.length === 0) { - return { failedWorktreeIds: [], errors: [] } + return { failedWorktreeIds: [], errors: [], results: [] } } const { activeWorktreeId, @@ -194,51 +136,66 @@ export async function sleepWorktreesReportingFailures( } const errors: string[] = [] const failedWorktreeIds = new Set() - try { - for (const worktreeId of worktreeIds) { - // Why: the marker outlives teardown so the panes left mounted stay cold - // until an explicit wake (#10205); mark per workspace so an earlier - // slow teardown never leaves a later, still-awake one marked. - markWorktreeSleepIntent(worktreeId) - try { - // Why: sleep mirrors removeWorktree's shutdown sequence — browsers first - // so destroyPersistentWebview unregisters the Chromium guests before any - // other teardown runs, terminals second so the PTY kill uses the same - // ordering on both paths. Without the browser thunk here, sleep leaks - // browserPagesByWorkspace entries and live webviews for the slept worktree. - await withWorktreeSleepTeardown(worktreeId, () => shutdownWorktreeBrowsers(worktreeId)) - } catch (err) { - console.error('[sleep-worktree] browser shutdown failed', { worktreeId, error: err }) - failedWorktreeIds.add(worktreeId) - errors.push(describeSleepFailure(err)) - continue - } - try { - // Why: sleep is reversible — the tab record stays in tabsByWorktree, the - // layout stays in terminalLayoutsByTabId, only the live PTY processes are - // released. keepIdentifiers preserves tab.ptyId / ptyIdsByLeafId / - // lastKnownRelayPtyIdByTabId so wake re-spawns against the same on-disk - // history dir (local) or relay session id (SSH); it also captures - // serializer buffers into buffersByLeafId for SSH wake to reseed - // scrollback. See DESIGN_DOC_TERMINAL_HISTORY_FIX_V2.md §3.3.c. - await withWorktreeSleepTeardown(worktreeId, async () => { - await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true }) - if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { - await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) - } - }) - // Why: a workspace the user activated during the batch is awake by their choice. + // Why apart from failedWorktreeIds: stopped terminals stay stopped when only a chat stop fails. + const chatFailedIds = new Set() + const attempts = worktreeIds.map((worktreeId) => { + markWorktreeSleepIntent(worktreeId) + let chats: Promise = Promise.resolve({ receipts: [], failed: false }) + // Browser cleanup and the authoritative terminal admission run before any batch wait. + const panels = withWorktreeSleepTeardown(worktreeId, async () => { + shutdownWorktreeBrowsers(worktreeId) + await shutdownWorktreeTerminals(worktreeId, { + keepIdentifiers: true, + onAdmitted: () => { + chats = stopWorkspaceChats(useAppStore.getState(), worktreeId).catch((error: unknown) => { + console.error('[sleep-worktree] native chat stop failed', { worktreeId, error }) + return { receipts: [], failed: true } + }) + } + }) + }).then( + () => { if (useAppStore.getState().activeWorktreeId === worktreeId) { clearWorktreeSleepIntent(worktreeId) } - } catch (err) { - console.error('[sleep-worktree] terminal or host suspension failed', { - worktreeId, - error: err - }) + }, + (error: unknown) => { + console.error('[sleep-worktree] panel shutdown failed', { worktreeId, error }) failedWorktreeIds.add(worktreeId) - errors.push(describeSleepFailure(err)) + errors.push(describeSleepFailure(error)) + } + ) + return { worktreeId, settled: panels.then(() => chats) } + }) + const results: WorkspaceSleepResult[] = [] + try { + // A VM may carry another workspace's host: settle the entire batch before suspending any. + const chatStops = await Promise.all(attempts.map(({ settled }) => settled)) + for (const [index, { worktreeId }] of attempts.entries()) { + const { receipts, failed } = chatStops[index] + if (failed) { + chatFailedIds.add(worktreeId) + errors.push(describeChatStopFailure()) } + if (!failedWorktreeIds.has(worktreeId) && hasWorktreeSleepIntent(worktreeId)) { + try { + await withWorktreeSleepTeardown(worktreeId, async () => { + if (typeof window !== 'undefined' && window.api?.ephemeralVm?.suspendWorkspace) { + await window.api.ephemeralVm.suspendWorkspace({ workspaceId: worktreeId }) + } + }) + if (useAppStore.getState().activeWorktreeId === worktreeId) { + clearWorktreeSleepIntent(worktreeId) + } + } catch (error) { + console.error('[sleep-worktree] host suspension failed', { worktreeId, error }) + failedWorktreeIds.add(worktreeId) + errors.push(describeSleepFailure(error)) + } + } + results.push( + ...receipts.map((receipt) => ({ workspaceId: worktreeId, ...receipt, completed: false })) + ) } } finally { // Why: a failed sleep leaves the workspace awake and retryable. @@ -249,5 +206,15 @@ export async function sleepWorktreesReportingFailures( setActiveWorktree(sleptActiveWorktreeId) } } - return { failedWorktreeIds: [...failedWorktreeIds], errors } + return { + failedWorktreeIds: [...new Set([...failedWorktreeIds, ...chatFailedIds])], + errors: [...new Set(errors)], + results: results.map((result) => ({ + ...result, + completed: + !failedWorktreeIds.has(result.workspaceId) && + !chatFailedIds.has(result.workspaceId) && + hasWorktreeSleepIntent(result.workspaceId) + })) + } } diff --git a/src/renderer/src/components/sidebar/sleep-worktree-sidebar-position.ts b/src/renderer/src/components/sidebar/sleep-worktree-sidebar-position.ts new file mode 100644 index 000000000000..f36e98c67321 --- /dev/null +++ b/src/renderer/src/components/sidebar/sleep-worktree-sidebar-position.ts @@ -0,0 +1,86 @@ +import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' + +function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] { + return Array.from(document.querySelectorAll('[data-worktree-id]')).filter( + (element) => element.dataset.worktreeId === worktreeId + ) +} + +function isPinnedSidebarWorktreeOption(element: HTMLElement): boolean { + // Why: duplicated pinned rows share a worktree id; the row-key prefix is the + // stable row-scoped signal that distinguishes the pinned copy from natural rows. + return element.dataset.worktreeRowKey?.startsWith('pinned:') === true +} + +function findPrimarySidebarWorktreeOption(worktreeId: string): HTMLElement | null { + const options = getSidebarWorktreeOptions(worktreeId) + return ( + options.find((element) => + element.querySelector('[data-worktree-card-active="primary"]') + ) ?? + options.find((element) => !isPinnedSidebarWorktreeOption(element)) ?? + options[0] ?? + null + ) +} + +function findSidebarWorktreeRow(worktreeId: string, rowKey?: string): HTMLElement | null { + const options = getSidebarWorktreeOptions(worktreeId) + const option = rowKey + ? (options.find((element) => element.dataset.worktreeRowKey === rowKey) ?? null) + : (findPrimarySidebarWorktreeOption(worktreeId) ?? null) + return option?.closest('[data-worktree-virtual-row]') ?? null +} + +export function preserveSidebarWorktreePosition(worktreeId: string): () => void { + if (typeof document === 'undefined') { + return () => {} + } + const getScroller = (): HTMLElement | null => + document.querySelector('[data-worktree-sidebar]') + const scroller = getScroller() + const activeOption = findPrimarySidebarWorktreeOption(worktreeId) + const activeRowKey = activeOption?.dataset.worktreeRowKey + const row = activeOption?.closest('[data-worktree-virtual-row]') ?? null + if (!scroller || !row) { + return () => {} + } + scroller.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) + const previousScrollTop = scroller.scrollTop + const previousScrollHeight = scroller.scrollHeight + const previousTop = row.getBoundingClientRect().top + + return () => { + let attempts = 0 + const restore = (): void => { + const currentScroller = getScroller() + if (!currentScroller) { + attempts += 1 + if (attempts < 12) { + window.requestAnimationFrame(restore) + } + return + } + const nextRow = findSidebarWorktreeRow(worktreeId, activeRowKey) + if (!nextRow) { + // Why: a remount can first render the wrong virtual window. Put the + // scroller near the same content after height changes so the row + // mounts, then retry and correct by actual DOM position. + currentScroller.scrollTop = Math.max( + 0, + previousScrollTop + currentScroller.scrollHeight - previousScrollHeight + ) + } else { + const delta = nextRow.getBoundingClientRect().top - previousTop + if (Math.abs(delta) > 1) { + currentScroller.scrollTop += delta + } + } + attempts += 1 + if (attempts < 12) { + window.requestAnimationFrame(restore) + } + } + window.requestAnimationFrame(restore) + } +} diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index eb9675c9f254..d7f4849ba314 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -15,11 +15,13 @@ import { import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' import { + EMPTY_AGENT_STATUS_BY_PANE_KEY, EMPTY_BROWSER_TABS_BY_WORKTREE, EMPTY_CYCLIC_LINEAGE_IDS, EMPTY_DELETE_STATE_BY_WORKTREE_ID, EMPTY_PTY_IDS_BY_TAB_ID, EMPTY_TABS_BY_WORKTREE, + EMPTY_UNIFIED_TABS_BY_WORKTREE, EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY, EMPTY_WORKTREE_LINEAGE_BY_ID, hasWorktreeParentLink, @@ -121,6 +123,12 @@ export function useWorktreeContextMenuModel({ const browserTabsByWorktree = useAppStore((s) => selectMenuScopedMap(menuOpen, s.browserTabsByWorktree, EMPTY_BROWSER_TABS_BY_WORKTREE) ) + const unifiedTabsByWorktree = useAppStore((s) => + selectMenuScopedMap(menuOpen, s.unifiedTabsByWorktree, EMPTY_UNIFIED_TABS_BY_WORKTREE) + ) + const agentStatusByPaneKey = useAppStore((s) => + selectMenuScopedMap(menuOpen, s.agentStatusByPaneKey, EMPTY_AGENT_STATUS_BY_PANE_KEY) + ) const deleteStateByWorktreeId = useAppStore((s) => selectMenuScopedMap(menuOpen, s.deleteStateByWorktreeId, EMPTY_DELETE_STATE_BY_WORKTREE_ID) ) @@ -137,17 +145,32 @@ export function useWorktreeContextMenuModel({ hasSleepableWorkspaceActivity(item.id, { tabsByWorktree, ptyIdsByTabId, - browserTabsByWorktree + browserTabsByWorktree, + unifiedTabsByWorktree, + agentStatusByPaneKey }) ), - [activeContextWorktrees, browserTabsByWorktree, ptyIdsByTabId, tabsByWorktree] + [ + activeContextWorktrees, + agentStatusByPaneKey, + browserTabsByWorktree, + ptyIdsByTabId, + tabsByWorktree, + unifiedTabsByWorktree + ] ) const lineageMenuActions = useWorkspaceLineageMenuActions({ enabled: !isMultiContext, parent: worktree, worktrees: allWorktrees, lineageById: worktreeLineageById, - activity: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } + activity: { + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree, + agentStatusByPaneKey + } }) const lineageDescendantCount = lineageMenuActions.descendants.length const subtreeSleepableWorktrees = lineageMenuActions.sleepableTargets diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index f4a06ccfada0..319880a34b82 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { getWorkspaceLineageMenuActions, hasSleepableWorkspaceActivity @@ -66,7 +68,9 @@ describe('workspace lineage menu actions', () => { }, browserTabsByWorktree: { [grandchild.id]: [{ id: 'grandchild-browser' }] - } + }, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {} } }) @@ -91,7 +95,9 @@ describe('workspace lineage menu actions', () => { activity: { tabsByWorktree: { [child.id]: [{ id: 'child-tab' }] }, ptyIdsByTabId: { 'child-tab': ['child-pty'] }, - browserTabsByWorktree: {} + browserTabsByWorktree: {}, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {} } }) @@ -100,13 +106,19 @@ describe('workspace lineage menu actions', () => { }) }) +function hostHeld(tab: Tab): Record { + return { [structuredAgentSessionPaneKey(tab.id, tab.entityId)]: { structuredHostOwned: true } } +} + describe('hasSleepableWorkspaceActivity', () => { it('treats preserved empty PTY arrays as slept, not live', () => { expect( hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, ptyIdsByTabId: { 'tab-1': [] }, - browserTabsByWorktree: {} + browserTabsByWorktree: {}, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {} }) ).toBe(false) }) @@ -116,15 +128,45 @@ describe('hasSleepableWorkspaceActivity', () => { hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, ptyIdsByTabId: { 'tab-1': ['pty-1'] }, - browserTabsByWorktree: {} + browserTabsByWorktree: {}, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {} }) ).toBe(true) expect( hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: {}, ptyIdsByTabId: {}, - browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] } + browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] }, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {} }) ).toBe(true) }) + + it('offers Sleep for a chat-only workspace while its host holds the chat', () => { + const chat: Tab = { + id: 'chat-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + const activity = (agentStatusByPaneKey: Record) => + hasSleepableWorkspaceActivity('wt-1', { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-1': [chat] }, + agentStatusByPaneKey + }) + expect(activity(hostHeld(chat))).toBe(true) + expect(activity({})).toBe(false) + }) }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index b5e2dccca649..975c3b0338ab 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -2,12 +2,17 @@ import { useMemo } from 'react' import { tabHasLivePty } from '../../../../shared/tab-has-live-pty' import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' +import type { Tab } from '../../../../shared/tab-types' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' type WorkspaceActivityMaps = { tabsByWorktree: Record ptyIdsByTabId: Record browserTabsByWorktree: Record + unifiedTabsByWorktree: Record + agentStatusByPaneKey: Record } export type WorkspaceLineageMenuActions = { @@ -18,12 +23,19 @@ export type WorkspaceLineageMenuActions = { export function hasSleepableWorkspaceActivity( worktreeId: string, - { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree }: WorkspaceActivityMaps + activity: WorkspaceActivityMaps ): boolean { - const tabs = tabsByWorktree[worktreeId] ?? [] + const tabs = activity.tabsByWorktree[worktreeId] ?? [] return ( - tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id)) || - (browserTabsByWorktree[worktreeId] ?? []).length > 0 + tabs.some((tab) => tabHasLivePty(activity.ptyIdsByTabId, tab.id)) || + (activity.browserTabsByWorktree[worktreeId] ?? []).length > 0 || + // A chat counts only while its host holds its process; a stopped one has nothing to sleep. + (activity.unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => + isStructuredTab(tab) && + activity.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] + ?.structuredHostOwned === true + ) ) } @@ -54,7 +66,13 @@ export function useWorkspaceLineageMenuActions( args: Parameters[0] & { enabled: boolean } ): WorkspaceLineageMenuActions { const { enabled, parent, worktrees, lineageById, activity } = args - const { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } = activity + const { + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree, + agentStatusByPaneKey + } = activity return useMemo( () => enabled @@ -62,9 +80,25 @@ export function useWorkspaceLineageMenuActions( parent, worktrees, lineageById, - activity: { tabsByWorktree, ptyIdsByTabId, browserTabsByWorktree } + activity: { + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree, + agentStatusByPaneKey + } }) : EMPTY_LINEAGE_MENU_ACTIONS, - [browserTabsByWorktree, enabled, lineageById, parent, ptyIdsByTabId, tabsByWorktree, worktrees] + [ + agentStatusByPaneKey, + browserTabsByWorktree, + enabled, + lineageById, + parent, + ptyIdsByTabId, + tabsByWorktree, + unifiedTabsByWorktree, + worktrees + ] ) } diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts index 9bd317910576..e3a24afd41e1 100644 --- a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts @@ -27,6 +27,8 @@ export const PARENT_PICKER_EXIT_ANIMATION_MS = 200 export const EMPTY_TABS_BY_WORKTREE: AppState['tabsByWorktree'] = {} export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} +export const EMPTY_UNIFIED_TABS_BY_WORKTREE: AppState['unifiedTabsByWorktree'] = {} +export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} export const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {} export const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {} export const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8bf09ce31dc5..7d3246dbfbee 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6353,7 +6353,10 @@ "host": { "unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again." }, - "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection." + "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection.", + "chat": { + "partial": "The host could not confirm the chat stopped. Other panels may have closed; check the connection and try again." + } } } }, diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index 06b4d6ff9f8d..7ad8cff68ac2 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -107,7 +107,8 @@ const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap = ['agentSession.rewind', 195_000], // A waiting catalog read lasts as long as the host's listing: Claude's is 60 s, after up to 15 s // for an account switch to settle and 5 s of login-shell environment. - ['agentSession.modelCatalog', 90_000] + ['agentSession.modelCatalog', 90_000], + ['agentSession.sleepWorkspace', 120_000] ]) export async function callStructuredAgentSession( diff --git a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts index 81d6082d0181..11d1acf0a7d1 100644 --- a/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts +++ b/src/renderer/src/store/folder-workspaces/folder-workspace-mutations.ts @@ -293,7 +293,7 @@ export function createFolderWorkspaceMutationActions( if (!get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { // Folder workspaces use the same browser registry key as worktrees; // tear down Chromium guests before purging the remaining renderer state. - await get().shutdownWorktreeBrowsers(workspaceKey) + await Promise.resolve(get().shutdownWorktreeBrowsers(workspaceKey)) get().purgeWorktreeTerminalState([workspaceKey]) deleteWorkspaceChatDrafts(chatDraftKeys) } diff --git a/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts index f25478b6025b..e6074b694821 100644 --- a/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts +++ b/src/renderer/src/store/slices/agent-status-pane-key-tab-binding.ts @@ -66,6 +66,22 @@ export function getLeafIdFromPaneKey(paneKey: string): string | null { return leafId.length > 0 ? leafId : null } +/** Open non-terminal tabs (chats) whose rows their own projection writes and removes, so terminal + * cleanup must leave them alone. Terminal owners must still exist in the terminal inventory + * instead; unified metadata can lag a close. */ +export function openNonTerminalTabIds( + state: Pick, + worktreeId: string +): Set { + const tabIds = new Set() + for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) { + if (tab.contentType !== 'terminal') { + tabIds.add(tab.id) + } + } + return tabIds +} + export function findCompletedOrphanPaneKeysForTabClose( state: AgentStatusTabPrefixDropState, worktreeId: string | undefined, @@ -74,12 +90,9 @@ export function findCompletedOrphanPaneKeysForTabClose( if (!worktreeId) { return [] } - const openTabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) - // Terminal owners must still exist in the terminal inventory; unified metadata can lag a close. - for (const tab of state.unifiedTabsByWorktree[worktreeId] ?? []) { - if (tab.contentType !== 'terminal') { - openTabIds.add(tab.id) - } + const openTabIds = openNonTerminalTabIds(state, worktreeId) + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + openTabIds.add(tab.id) } const paneKeys: string[] = [] for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { diff --git a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts index 71a373ecae9b..e7aa681a767f 100644 --- a/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-worktree-drop-actions.ts @@ -3,7 +3,9 @@ import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries' import { + getTabIdFromPaneKey, normalizePaneKeySet, + openNonTerminalTabIds, paneKeyMatchesAnyTabPrefix, retainedAgentEntryFromLive, shouldReplaceRetainedWithLive @@ -20,9 +22,13 @@ export function createAgentStatusWorktreeDropActions( let hadLive = false set((s) => { const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const liveEntries = Object.entries(s.agentStatusByPaneKey).filter( - ([paneKey, entry]) => - entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + // An open chat's row is its host's: the chat tab's projection removes it on close. + const chatTabIds = openNonTerminalTabIds(s, worktreeId) + const ownedByWorktree = (paneKey: string, entryWorktreeId: string | undefined): boolean => + !chatTabIds.has(getTabIdFromPaneKey(paneKey) ?? '') && + (entryWorktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes)) + const liveEntries = Object.entries(s.agentStatusByPaneKey).filter(([paneKey, entry]) => + ownedByWorktree(paneKey, entry.worktreeId) ) const liveKeys = liveEntries.map(([paneKey]) => paneKey) const liveKeySet = new Set(liveKeys) @@ -30,10 +36,7 @@ export function createAgentStatusWorktreeDropActions( (paneKey) => paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) || liveKeySet.has(paneKey) ) const retainedKeys = Object.entries(s.retainedAgentsByPaneKey) - .filter( - ([paneKey, retained]) => - retained.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) - ) + .filter(([paneKey, retained]) => ownedByWorktree(paneKey, retained.worktreeId)) .map(([paneKey]) => paneKey) const retainedKeySet = new Set(retainedKeys) const migrationUnsupported = pruneMigrationUnsupportedEntries( diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts index 016a217f2ace..49096e3a5a25 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -237,7 +237,7 @@ export function createBrowserCloseActions( } }, - shutdownWorktreeBrowsers: async (worktreeId) => { + shutdownWorktreeBrowsers: (worktreeId) => { const workspaces = get().browserTabsByWorktree[worktreeId] ?? [] // Why: snapshot before the loop — closeBrowserTab empties the array, so set() below couldn't recompute hadBrowserTabs. const hadBrowserTabs = workspaces.length > 0 diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index 40175ef8a32f..3ace125b1429 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -149,7 +149,7 @@ export type BrowserSlice = { profileId: string | null ) => Promise closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void - shutdownWorktreeBrowsers: (worktreeId: string) => Promise + shutdownWorktreeBrowsers: (worktreeId: string) => void reopenClosedBrowserTab: (worktreeId: string) => BrowserWorkspace | null /** Activation uses the tab's workspace unless a target is supplied; global selection * moves only when that workspace is active. */ diff --git a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts index c2efa2330efd..4e271dd0b217 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/host-twin-worktree-purge.ts @@ -95,6 +95,11 @@ function resolvePurgedPublishers( } } +/** Every host whose listing publishes this id; empty when no row names it. */ +export function workspacePublishingHosts(s: AppState, workspaceId: string): ExecutionHostId[] { + return resolvePurgedPublishers(s, workspaceId, new Set()).survivors +} + /** Per attributed host, its twin ids with the hosts that still publish them. */ export type HostTwinPurgeScopes = Map> diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index 4a4c0a4a2e6f..02f1bb57f296 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -58,7 +58,7 @@ export async function tearDownRemovedWorktreeRendererState(args: { } // Why: renderer state follows the successful backend result, so blocked dirty deletes keep their terminals intact. // Why browsers first: unregister Chromium guests before other teardown can intercept them (avoids a browser-state race). - await get().shutdownWorktreeBrowsers(worktreeId) + await Promise.resolve(get().shutdownWorktreeBrowsers(worktreeId)) await get().shutdownWorktreeTerminals(worktreeId, { shutdownReason: 'remove-worktree', // The backend removal above already killed the workspace's PTYs. diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 5ebd99bbd597..43c63c9883a6 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -148,6 +148,7 @@ export type TerminalActions = { sleepingPaneKeys?: string[] expectedRuntimePtyIds?: string[] backendOwnsPtyTeardown?: boolean + onAdmitted?: () => void } ) => Promise /** Captures resumable pane evidence and publishes suppression before exact physical teardown. */ diff --git a/src/renderer/src/store/terminals/terminal-shutdown.ts b/src/renderer/src/store/terminals/terminal-shutdown.ts index 7ce850f344de..926170fe5db0 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown.ts @@ -101,6 +101,7 @@ export function createTerminalShutdownActions( guards.markShutdownPending() guards.unregisterHandlers() try { + opts?.onAdmitted?.() // Backend ownership is opt-in; the default owner RPC prevents remote PTY leaks. if (runtimeEnvironmentId && !backendOwnsPtyTeardown) { await (shutdownReason === 'manual-sleep' diff --git a/src/shared/agent-session-workspace-sleep.ts b/src/shared/agent-session-workspace-sleep.ts new file mode 100644 index 000000000000..ac275d90c069 --- /dev/null +++ b/src/shared/agent-session-workspace-sleep.ts @@ -0,0 +1,7 @@ +export type AgentSessionWorkspaceSleepResult = { + stoppedSessionIds: string[] + failures?: { + sessionId?: string + reason: 'inventory-unavailable' | 'stop-unverifiable' + }[] +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index d573d4225aaf..c5ec2ca40045 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -552,6 +552,7 @@ import { RewindParams, SendParams, SetOptionParams, + SleepWorkspaceParams, SubscribeParams, SubscribeTurnCompletionsParams, ThreadGoalParams, @@ -711,6 +712,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.rewind': RewindParams, 'agentSession.send': SendParams, 'agentSession.setOption': SetOptionParams, + 'agentSession.sleepWorkspace': SleepWorkspaceParams, 'agentSession.subscribe': SubscribeParams, 'agentSession.subscribeStatus': null, 'agentSession.subscribeTurnCompletions': SubscribeTurnCompletionsParams, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index da29e7ab640c..cf99d8c0900e 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -277,6 +277,7 @@ export const SetOptionParams = z .strict() export const OptionsParams = z.object({ sessionId: SessionId }).strict() +export const SleepWorkspaceParams = z.object({ workspaceId: z.string().min(1) }).strict() export const AcknowledgeAttentionParams = OptionsParams.extend({ observedCursor: JournalCursor }) /** `agentSession.agents` takes nothing: the list is the host's, whichever client asks. */ diff --git a/src/shared/structured-agent-session-create.test.ts b/src/shared/structured-agent-session-create.test.ts index b4aa2ca38963..f78c3b48f1ed 100644 --- a/src/shared/structured-agent-session-create.test.ts +++ b/src/shared/structured-agent-session-create.test.ts @@ -163,15 +163,32 @@ describe('the tab id a create reserves', () => { }) it('negotiates a strict pane identity and includes it in the create fingerprint without accepting paths', () => { - const source = { providerSessionId: 'thread-abc', cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } } + const source = { + providerSessionId: 'thread-abc', + cliSource: { workspaceId: 'folder', paneKey: 'tab:leaf' } + } expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY) expect(ResumeSource.parse(source)).toEqual(source) expect(ResumeSource.safeParse({ ...source, transcriptPath: '/client/path' }).success).toBe(false) - expect(ResumeSource.safeParse({ ...source, cliSource: { ...source.cliSource, accountHome: '/client/home' } }).success).toBe(false) + expect( + ResumeSource.safeParse({ + ...source, + cliSource: { ...source.cliSource, accountHome: '/client/home' } + }).success + ).toBe(false) const first = createParams({ resumeFrom: source }) - expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint) - expect(first.envelope.payloadFingerprint).toBe(createParams({ resumeFrom: source }).envelope.payloadFingerprint) - expect(first.envelope.payloadFingerprint).not.toBe(createParams({ resumeFrom: { - ...source, cliSource: { ...source.cliSource, paneKey: 'other:leaf' } - } }).envelope.payloadFingerprint) + expect(first.envelope.payloadFingerprint).not.toBe( + createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint + ) + expect(first.envelope.payloadFingerprint).toBe( + createParams({ resumeFrom: source }).envelope.payloadFingerprint + ) + expect(first.envelope.payloadFingerprint).not.toBe( + createParams({ + resumeFrom: { + ...source, + cliSource: { ...source.cliSource, paneKey: 'other:leaf' } + } + }).envelope.payloadFingerprint + ) }) diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts index 6a0237ce272e..0546d7137ef0 100644 --- a/src/shared/structured-agent-session-mutation.ts +++ b/src/shared/structured-agent-session-mutation.ts @@ -1,5 +1,8 @@ import { sha256 } from './sha256' -import type { StructuredAgentSessionFirstMessage, StructuredAgentSessionResumeSource } from './structured-agent-session-create' +import type { + StructuredAgentSessionFirstMessage, + StructuredAgentSessionResumeSource +} from './structured-agent-session-create' import type { StructuredAgentId } from './agent-session-provider-handle' function canonicalize(value: unknown): string { diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 89353b5ec6d3..18b834508b16 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -76,6 +76,7 @@ export function structuredHostStub( value: { itemId: 'item-1', epoch: 'rewound-epoch' } })), close: vi.fn(async () => undefined), + sleepWorkspace: vi.fn(async () => ({ stoppedSessionIds: [] })), revealSession: vi.fn(async () => ({ sessionId, workspaceId, diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts index 5c04b8c30ef2..39ded4f372d5 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-calls.ts @@ -79,6 +79,11 @@ export const STRUCTURED_CALLS: { result: { ok: true, replayed: false, value: { itemId: 'item-1', epoch: 'rewound-epoch' } } }, { method: 'agentSession.close', hostMethod: 'close', result: { ok: true } }, + { + method: 'agentSession.sleepWorkspace', + hostMethod: 'sleepWorkspace', + result: { stoppedSessionIds: [] } + }, { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt', diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 1cf4c790b435..f623f485e6c2 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -178,6 +178,8 @@ export function paramsFor(method: string): unknown { } case 'agentSession.history': return { sessionId: SESSION, direction: 'tail' } + case 'agentSession.sleepWorkspace': + return { workspaceId: WORKSPACE } case 'agentSession.currentRequests': return { sessionId: SESSION, version: '00000000-0000-4000-8000-000000000001' } case 'agentSession.acknowledgeAttention':