From 7dcb7d4439e6aeefec8a769a1c427f69786c288f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:33:01 -0700 Subject: [PATCH] Sleep stops a workspace's native chat agents (#28445) * fix(sidebar): Sleep stops a workspace's native chat agents Sleeping a workspace stopped terminals and browsers but never its structured (native) chats, so their claude/codex process kept running, and Sleep was disabled for a workspace whose only content was a chat. Sleep now stops each chat's agent through a new agentSession.sleep (the host's close, keeping the tab), routed to the host recorded on the chat's tab; an older host without the capability fails Sleep with a clear message. The Sleep menu counts a chat. * Fix native chat Sleep ownership and delayed teardown * Stop native chat sessions when sleeping their workspace * Fix sleep teardown wake and host-qualified receipt races * Verify mobile Sleep sends the selected desktop host * Keep worktree Sleep RPC within method file limit * Use sleep intent as attempt identity * Gate workspace sleep on structured client support * fix(native-chat): preserve sleep stop receipts and send timing * fix(native-chat): keep superseded workspace sleeps quiet * fix(native-chat): ignore superseded failed sleep stops * Fix sleep lifetime test resolver types * Accept workspace Sleep for already ended chats * Sleep native chats through the shared no-toast sleep core Main split runSleepWorktrees into sleepWorktreesReportingFailures (no toast) and a toasting wrapper. Move the native chat stop, host routing and typed failures into the shared core so the WSL move's Sleep request also stops chats, and report every workspace not verifiably slept (an unconfirmed chat stop included) in failedWorktreeIds. Fold one ACP adapter field onto a line so main's file plus this PR's ownership check stays within the file length limit. * Import isStructuredTab from its shared module * fix(native-chat): leave chat rows to their host through Sleep Sleep's worktree status drop deleted the chat's row, which the host feed then rewrote, so the outcome depended on which write landed last. Open chat tabs own their rows (same rule as terminal-tab close), and a chat only makes a workspace sleepable while its host holds the agent's process. * fix(native-chat): judge the Sleep receipt by facts, not lane occupancy A running chat's stop queues the provider's own requested-close end report onto the chat's lane, so the lane-occupancy check read every proven stop as superseded and dropped its receipt. Later work now counts only once it exists: a moved fence, a child, delivery, child work, a pending submission, a pending create or attach, or an open dispatch. * test(native-chat): report a fake close's end only for an acquired child * Preserve canonical workspace identity when sleeping * Propagate optional sleep host through file command contract * Move the deliberate-sleep PTY tests to main's spec file Main grouped the PTY connection suites (#29193) and moved this file to a .spec.ts registered from pty-connection-spawn-and-startup.test.ts. Taking main's copy here keeps the following main merge free of a modify/delete conflict; this PR's test changes are reapplied after the merge. * Reapply Sleep's teardown waits to the moved PTY spec; keep acquisition under its line limit The deliberate-sleep spec moved in #29193; this carries the PR's changes into it: a pane mounted after wake waits for issued teardown, an unqualified pane waits for every same-id host's teardown, and a queued startup waits too. The merge left claude-structured-session-acquisition.ts over 300 lines. resolveClaudeAcquisitionError, which already builds the exit-unproven error, now decides whether to release the acquisition. * test: keep the Sleep receipt checks on the closed handle and kept record * fix(sidebar): a single-workspace Sleep failure says workspace, not workspaces * fix(sidebar): Sleep stops a workspace's chats on every host that has a copy * test: drop redundant casts from the Sleep flow test state * fix(sidebar): stop a workspace's chats after its terminals, leaving main's teardown owner check as is * fix: admit workspace chat stops before teardown waits * fix: retain browser teardown caller sequencing * fix: retire failed acquisitions after observed root exit --- .../acp/acp-structured-session-adapter.ts | 6 +- .../claude-structured-session-acquisition.ts | 4 +- .../claude-structured-session-adapter.ts | 4 + .../claude/claude-structured-session-close.ts | 9 + .../codex/codex-app-server-connection.test.ts | 20 +- src/main/codex/codex-app-server-connection.ts | 8 +- .../codex/codex-structured-session-adapter.ts | 2 + src/main/grok/grok-transcript-import.ts | 6 +- .../adopted-transcript-source.ts | 4 +- .../journal-legacy-import.ts | 18 +- ...uctured-agent-session-acquisition-route.ts | 114 +++++++++ ...tured-agent-session-adapter-router.test.ts | 11 +- ...structured-agent-session-adapter-router.ts | 58 +++-- .../structured-agent-session-adapter.ts | 13 +- ...ured-agent-session-attach-orchestration.ts | 8 +- .../structured-agent-session-child-exit.ts | 13 +- ...red-agent-session-conversation-lifetime.ts | 89 ++++++- .../structured-agent-session-create.ts | 3 +- .../structured-agent-session-host-delivery.ts | 4 +- ...ured-agent-session-host-mutation-wiring.ts | 53 ++++ .../structured-agent-session-host.ts | 52 ++-- ...ctured-agent-session-queued-send-answer.ts | 4 +- ...-agent-session-startup-attempt-contract.ts | 14 +- ...red-agent-session-surface-lifetime.test.ts | 67 ++++- .../structured-agent-session-task-queue.ts | 39 ++- .../structured-agent-session-turns.ts | 2 +- ...t-session-workspace-sleep-lifetime.test.ts | 165 +++++++++++++ ...ured-agent-session-workspace-sleep-stop.ts | 83 +++++++ ...tructured-agent-session-workspace-sleep.ts | 90 +++++++ .../native-chat/codex-adoption-preparation.ts | 20 +- src/main/native-chat/session-file-resolver.ts | 15 +- .../structured-agent-cli-conversations.ts | 5 +- .../transcript-opencode-sqlite-query.test.ts | 4 +- src/main/omp/omp-session-cwd-lookup.test.ts | 7 +- src/main/pi/rpc-session-adapter.ts | 3 + .../provider-process-teardown.test.ts | 25 +- .../provider-process-teardown.ts | 68 ++--- ...ent-session-acquisition-exit-transition.ts | 55 +++++ .../runtime/agent-session-record-founding.ts | 14 ++ ...ured-agent-session-adoption-replay.test.ts | 46 +++- ...ession-gate-classification.test-fixture.ts | 5 + ...ructured-agent-session-rpc.test-fixture.ts | 5 +- .../structured-agent-session-schemas.ts | 1 + .../methods/structured-agent-session.test.ts | 4 +- .../rpc/methods/structured-agent-session.ts | 50 ++++ .../runtime/structured-worker-authority.ts | 16 +- ...tructuredAgentSessionStatusBridge.test.tsx | 29 +++ ...reeContextMenu.copy-worktree-name.test.tsx | 1 + ...rktreeContextMenu.delete-shortcut.test.tsx | 1 + ...reeContextMenu.react185-bystander.test.tsx | 1 + .../WorktreeDeveloperMenuReveal.test.tsx | 1 + .../sidebar/sleep-workspace-chat-stop.ts | 84 +++++++ .../sleep-worktree-activation-race.test.ts | 5 + .../sidebar/sleep-worktree-admission.test.ts | 152 ++++++++++++ .../sidebar/sleep-worktree-flow.test.ts | 159 +++++++----- .../components/sidebar/sleep-worktree-flow.ts | 233 ++++++++---------- .../sleep-worktree-sidebar-position.ts | 86 +++++++ .../use-worktree-context-menu-model.tsx | 29 ++- .../workspace-lineage-menu-actions.test.ts | 52 +++- .../sidebar/workspace-lineage-menu-actions.ts | 48 +++- .../sidebar/worktree-context-menu-policy.ts | 2 + src/renderer/src/i18n/locales/en.json | 5 +- .../structured-agent-session-client.ts | 3 +- .../folder-workspace-mutations.ts | 2 +- .../agent-status-pane-key-tab-binding.ts | 25 +- .../agent-status-worktree-drop-actions.ts | 17 +- .../slices/browser/browser-close-actions.ts | 2 +- .../slices/browser/browser-slice-contract.ts | 2 +- .../teardown/host-twin-worktree-purge.ts | 5 + .../removed-worktree-renderer-teardown.ts | 2 +- .../src/store/terminals/terminal-actions.ts | 1 + .../src/store/terminals/terminal-shutdown.ts | 1 + src/shared/agent-session-workspace-sleep.ts | 7 + .../rpc-params-catalog.generated.ts | 2 + .../structured-agent-session-params.ts | 1 + .../structured-agent-session-create.test.ts | 31 ++- .../structured-agent-session-mutation.ts | 5 +- .../structured-agent-session-host-fixture.ts | 1 + .../structured-agent-session-surface-calls.ts | 5 + ...ructured-agent-session-surface-manifest.ts | 2 + 80 files changed, 1883 insertions(+), 425 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-route.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-mutation-wiring.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-stop.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts create mode 100644 src/main/runtime/agent-session-acquisition-exit-transition.ts create mode 100644 src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts create mode 100644 src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts create mode 100644 src/renderer/src/components/sidebar/sleep-worktree-sidebar-position.ts create mode 100644 src/shared/agent-session-workspace-sleep.ts diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index cfad5c45310..8c62c52b482 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 749bc825849..095689ecfd6 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 84b4804cd69..d375ce188fe 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 316d90b0a50..947b68fa847 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 3f90182acd4..5090001308c 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 2d46889027c..17ad6e85795 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 806d8ff4023..5f72bb5da38 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -331,6 +331,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 dab984da73f..5a40aa6744b 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 7cdedc6c12e..af66dcce2df 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 80ad7ab6bfd..232db150283 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 00000000000..8099ad8093a --- /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 27c4a323506..8545e6009a9 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 f0c22c24f18..46c44731945 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 d19106525ed..3eaf8c236d6 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 a641a5b285f..193c764dcd0 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 0fa4b7c7709..60dda17a157 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 8c066130d21..da63a25dee2 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 45ad97d2ec1..aa05c4ac120 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 61fef167631..974fa2111ac 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 00000000000..881a05ef78a --- /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 0e9889fa043..1bba744c55e 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 e0eb83e0a0b..539b1eb3cd0 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 14767b058d1..4d3c509f98f 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 3dd914f647f..0d3144dd76f 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 f0237835c75..79c6234a47f 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 f5258f0e5a8..ac9dd8baf42 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 00000000000..5b2d8115522 --- /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 00000000000..cb7b9a43f54 --- /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 00000000000..5ebbe337fcf --- /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 0e86904abdd..8ce69292a0f 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 49d4ab5869e..435e8bf0fe0 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 c56e77add99..864d52d87e7 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 79d05fa6914..988ecc7818f 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 691275f09fc..b2fbbdbcb96 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 139268fe0a7..11452053b28 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 41ad22387d7..249a553df09 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 32820726fee..c177fda2ac5 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 00000000000..a88ead3026c --- /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 7c750ca3dc1..c79265f088e 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 ecbb599de7f..1f721b8c066 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 12813a8f66e..f0e68d1ca2f 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 5d42665d0f0..739d470cc04 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 825455be01a..70c26c65b81 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -27,6 +27,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 ae089150db3..535817b6a63 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 cef46e353a7..1fe96d81d0b 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 0b0ccff3b7e..9deac08a9ba 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 06113cae6b1..7b135e0fce1 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' @@ -68,6 +69,7 @@ import { useStructuredAgentSessionHostStopping } from './StructuredAgentSessionStatusBridge' import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { useAppStore } from '@/store' const structuredTab = { id: 'structured-tab-1', @@ -649,6 +651,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 fbca904c4dc..220585d567f 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 8bdac0ca143..80b11f02c9b 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 da48eb26ae0..141304c66c1 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 4eed6feea33..55bee52aa0b 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 00000000000..e2fb53e477a --- /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 5f3b588f93c..d5f0a8d6d56 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 00000000000..d6f28753d44 --- /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 ae0aba24797..865541279fe 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 e554c5d4234..3959cbc1039 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 00000000000..f36e98c6732 --- /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 eb9675c9f25..d7f4849ba31 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 f4a06ccfada..319880a34b8 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 b5e2dccca64..975c3b0338a 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 9bd31791057..e3a24afd41e 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 fd4c2a642aa..3df79d47427 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6363,7 +6363,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 06b4d6ff9f8..7ad8cff68ac 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 81d6082d018..11d1acf0a7d 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 f25478b6025..e6074b69482 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 71a373ecae9..e7aa681a767 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 016a217f2ac..49096e3a5a2 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 40175ef8a32..3ace125b142 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 c2efa2330ef..4e271dd0b21 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 4a4c0a4a2e6..02f1bb57f29 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 5ebd99bbd59..43c63c9883a 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 7ce850f344d..926170fe5db 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 00000000000..ac275d90c06 --- /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 4b5eb1b58ad..4d01c725624 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -553,6 +553,7 @@ import { RewindParams, SendParams, SetOptionParams, + SleepWorkspaceParams, SubscribeParams, SubscribeTurnCompletionsParams, ThreadGoalParams, @@ -712,6 +713,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.rewind': RewindParams, 'agentSession.send': SendParams, 'agentSession.setOption': SetOptionParams, + 'agentSession.sleepWorkspace': SleepWorkspaceParams, 'agentSession.subscribe': SubscribeParams, 'agentSession.subscribeAvailability': AvailabilityParams, 'agentSession.subscribeStatus': null, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 80b365ed7f2..aece5f4ac65 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 b4aa2ca3896..f78c3b48f1e 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 6a0237ce272..0546d7137ef 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 89353b5ec6d..18b834508b1 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 36c52629a4f..d9b1352c7b5 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 e1d366372c7..5ad14f950f1 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':