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
This commit is contained in:
Brennan Benson
2026-10-19 00:33:01 -07:00
committed by GitHub
parent 978b207cc1
commit 7dcb7d4439
80 changed files with 1883 additions and 425 deletions
@@ -250,13 +250,13 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte
input
) => stopAcpChildren(this.live(input.sessionId), input.fence, input.taskIds, input.acknowledge)
backgroundTaskStops: NonNullable<StructuredAgentSessionAdapter['backgroundTaskStops']> = (
sessionId
) => acpChildStopCapabilities(this.sessions.get(sessionId))
backgroundTaskStops = (id: string) => acpChildStopCapabilities(this.sessions.get(id))
closeSession = (sessionId: string): Promise<boolean> => this.close(sessionId)
disposeSession = (sessionId: string): Promise<boolean> => 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<boolean> => this.stop(sessionId, false)
@@ -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()
@@ -262,6 +262,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
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<boolean>): Promise<boolean> {
@@ -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<unknown> {
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
@@ -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()
})
@@ -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()
}
})
@@ -331,6 +331,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
closeAll = (): Promise<void> => this.teardown.closeAll()
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
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)
+5 -1
View File
@@ -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 })
})
@@ -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 {
@@ -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,
@@ -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<string, SessionRoute>,
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<string, SessionRoute>,
adapters: () => StructuredAgentSessionAdapter[]
): Promise<boolean> {
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
}
@@ -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()
})
})
@@ -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<boolean> {
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<StructuredAgentSessionAdapter['open']> = (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<void> {
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 {
@@ -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<boolean>
/** 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<boolean>
/** Host acknowledgement that the proven-dead child, lease and journal owner are released. */
acknowledgeSessionRelease?(sessionId: string): void
acknowledgeSessionRelease?(
sessionId: string,
acquisition?: StructuredAgentSessionAcquisitionReceipt
): void
}
@@ -69,7 +69,10 @@ export function attachStructuredAgentSessionUnderSerialize(
params: AgentSessionAttachParams,
options: StructuredAgentSessionAttachOptions = {}
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
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()
@@ -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()
}
}
@@ -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 <T>(
sessionId: string,
stop: () => Promise<T>,
onCloseError?: (error: unknown) => void
): Promise<T> => {
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 }))
})
}
}
@@ -55,7 +55,8 @@ export function createStructuredAgentSession(
}
throw error
}
})
}),
{ sessionId: params.envelope.sessionId, location: params.location }
)
}
@@ -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, () =>
@@ -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<typeof wireStructuredAgentSessionQueuedMessages>
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
}
}
@@ -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)
@@ -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
}
@@ -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
}
@@ -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<typeof getRecord>) =>
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.
@@ -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<string, Promise<void>>()
private readonly attaching = new Set<Promise<unknown>>()
private readonly pendingLocations = new Map<
Promise<unknown>,
{
sessionId: string
location: AgentSessionExecutionLocation
}
>()
serialize<T>(sessionId: string, task: () => Promise<T>): Promise<T> {
return runKeyedSerializedOperation(this.chains, sessionId, task)
}
trackAttach<T>(operation: Promise<T>): Promise<T> {
trackAttach<T>(
operation: Promise<T>,
pending?: { sessionId: string; location: AgentSessionExecutionLocation }
): Promise<T> {
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<unknown>): 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<void> {
while (this.attaching.size > 0) {
await Promise.allSettled(this.attaching)
@@ -209,7 +209,7 @@ export async function performSend(
ok: true,
value: {
clientMessageId: input.clientMessageId,
submission: requireSubmission(ctx, input.clientMessageId)
submission: { ...requireSubmission(ctx, input.clientMessageId) }
}
}
}
@@ -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<void>((resolve) => {
failedStopDone = resolve
})
let releaseSibling: () => void = () => undefined
const slowSibling = new Promise<void>((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)
})
@@ -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<StructuredAgentSessionHostDeps, 'store' | 'adapter' | 'logger'>
sessions: StructuredAgentSessionConversations
stopAgent: (sessionId: string, ending: StructuredAgentSessionStopEnding) => Promise<void>
sessionId: string
fence: number
child: StructuredAgentSessionProviderChild | null | undefined
owned: boolean
now: () => number
}): Promise<boolean> {
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
}
@@ -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<StructuredAgentSessionHostDeps, 'store' | 'logger' | 'hasOpenDispatch'>
tasks: StructuredAgentSessionTaskQueue
lifetime: Pick<StructuredAgentSessionConversationLifetime, 'closeForWorkspaceSleep'>
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<AgentSessionWorkspaceSleepResult> {
let records: ReturnType<StructuredAgentSessionHostDeps['store']['listRecords']>
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<AgentSessionWorkspaceSleepResult['failures']> = []
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 }
}
@@ -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.')
+13 -2
View File
@@ -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<string | null> {
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
}
@@ -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
@@ -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',
+6 -1
View File
@@ -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
}
+3
View File
@@ -263,6 +263,9 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter {
releaseAcquisition(input: { sessionId: string }): Promise<boolean> {
return this.closeSession(input.sessionId)
}
hasOwnedAcquisition(sessionId: string): boolean {
return this.sessions.has(sessionId)
}
forceCloseSession(id: string): Promise<boolean> {
this.sessions.get(id)?.fail(new Error('Pi event sink failed'))
return this.closeSession(id, false)
@@ -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)!
)
}
)
@@ -23,6 +23,7 @@ export type ProviderProcessTeardownDeps = {
/** Resolves true only when taskkill reports the whole tree terminated. */
terminateWindowsTree?: (rootPid: number, deps?: { site?: string }) => Promise<boolean>
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. */
@@ -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
}
})
}
@@ -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.
@@ -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`)
@@ -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' },
@@ -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)
@@ -27,6 +27,7 @@ export {
RestartResumeParams,
RewindParams,
SendParams,
SleepWorkspaceParams,
SessionId,
SetOptionParams,
SubscribeParams,
@@ -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()
@@ -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<ReturnType<typeof ctx.runtime.openAgentSessionRecordStore>>
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',
@@ -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()
@@ -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(<StructuredAgentSessionStatusBridge />)
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(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
@@ -12,6 +12,7 @@ import WorktreeContextMenu from './WorktreeContextMenu'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const state = {
unifiedTabsByWorktree: {},
updateWorktreeMeta: vi.fn(),
setWorktreesPinnedAndReveal: vi.fn(),
workspaceStatuses: [],
@@ -84,6 +84,7 @@ vi.mock('@/i18n/i18n', () => ({
}))
const defaultStoreState = {
unifiedTabsByWorktree: {},
updateWorktreeMeta: vi.fn(),
setWorktreesPinnedAndReveal: vi.fn(),
workspaceStatuses: [],
@@ -22,6 +22,7 @@ import WorktreeContextMenu from './WorktreeContextMenu'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const state = {
unifiedTabsByWorktree: {},
updateWorktreeMeta: vi.fn(),
setWorktreesPinnedAndReveal: vi.fn(),
workspaceStatuses: [
@@ -12,6 +12,7 @@ import type { Worktree } from '../../../../shared/worktree/types'
vi.mock('@/store', () => {
const state = {
unifiedTabsByWorktree: {},
tabsByWorktree: {},
ptyIdsByTabId: {},
browserTabsByWorktree: {},
@@ -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<ExecutionHostId, RuntimeClientTarget> {
const hosts = workspacePublishingHosts(state, workspaceId)
const targets = new Map<ExecutionHostId, RuntimeClientTarget>()
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<WorkspaceChatStop> {
const settled = await Promise.all(
[...chatHostTargets(state, workspaceId)].map(([executionHostId, target]) =>
callStructuredAgentSession<AgentSessionWorkspaceSleepResult>(
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 }
}
@@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => {
state.ptyIdsByTabId[tab.id] = []
}
}),
worktreesByRepo: {},
detectedWorktreesByRepo: {},
tabsByWorktree: {} as Record<string, { id: string }[]>,
ptyIdsByTabId: {} as Record<string, string[]>
}
@@ -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'
@@ -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<typeof createTestStore>
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<void>()
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()
})
@@ -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<typeof vi.fn>
tabsByWorktree: Record<string, { id: string }[]>
ptyIdsByTabId: Record<string, string[]>
worktreesByRepo: Record<string, { id: string; repoId: string; hostId?: string }[]>
detectedWorktreesByRepo: Record<string, never>
} = {
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<string, { id: string }[]>,
ptyIdsByTabId: {} as Record<string, string[]>
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<unknown>) => 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<AppState['shutdownWorktreeTerminals']>[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<void>((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<void>((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'])
@@ -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<void> {
await runSleepWorktrees([worktreeId])
}
function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-worktree-virtual-row]') ?? null
}
function preserveSidebarWorktreePosition(worktreeId: string): () => void {
if (typeof document === 'undefined') {
return () => {}
}
const getScroller = (): HTMLElement | null =>
document.querySelector<HTMLElement>('[data-worktree-sidebar]')
const scroller = getScroller()
const activeOption = findPrimarySidebarWorktreeOption(worktreeId)
const activeRowKey = activeOption?.dataset.worktreeRowKey
const row = activeOption?.closest<HTMLElement>('[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<WorkspaceSleepResult[]> {
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<void> {
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<WorkspaceSleepResult[]> {
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<string>()
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<string>()
const attempts = worktreeIds.map((worktreeId) => {
markWorktreeSleepIntent(worktreeId)
let chats: Promise<WorkspaceChatStop> = 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)
}))
}
}
@@ -0,0 +1,86 @@
import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor'
function getSidebarWorktreeOptions(worktreeId: string): HTMLElement[] {
return Array.from(document.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-worktree-virtual-row]') ?? null
}
export function preserveSidebarWorktreePosition(worktreeId: string): () => void {
if (typeof document === 'undefined') {
return () => {}
}
const getScroller = (): HTMLElement | null =>
document.querySelector<HTMLElement>('[data-worktree-sidebar]')
const scroller = getScroller()
const activeOption = findPrimarySidebarWorktreeOption(worktreeId)
const activeRowKey = activeOption?.dataset.worktreeRowKey
const row = activeOption?.closest<HTMLElement>('[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)
}
}
@@ -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
@@ -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<string, { structuredHostOwned: true }> {
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<string, { structuredHostOwned?: true }>) =>
hasSleepableWorkspaceActivity('wt-1', {
tabsByWorktree: {},
ptyIdsByTabId: {},
browserTabsByWorktree: {},
unifiedTabsByWorktree: { 'wt-1': [chat] },
agentStatusByPaneKey
})
expect(activity(hostHeld(chat))).toBe(true)
expect(activity({})).toBe(false)
})
})
@@ -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<string, { id: string }[]>
ptyIdsByTabId: Record<string, string[]>
browserTabsByWorktree: Record<string, { id: string }[]>
unifiedTabsByWorktree: Record<string, Tab[]>
agentStatusByPaneKey: Record<string, { structuredHostOwned?: true }>
}
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<typeof getWorkspaceLineageMenuActions>[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
]
)
}
@@ -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'] = {}
+4 -1
View File
@@ -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."
}
}
}
},
@@ -107,7 +107,8 @@ const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap<string, number> =
['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<TResult>(
@@ -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)
}
@@ -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<AppState, 'unifiedTabsByWorktree'>,
worktreeId: string
): Set<string> {
const tabIds = new Set<string>()
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)) {
@@ -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(
@@ -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
@@ -149,7 +149,7 @@ export type BrowserSlice = {
profileId: string | null
) => Promise<boolean>
closeBrowserTab: (tabId: string, options?: { reason?: 'cleanup' }) => void
shutdownWorktreeBrowsers: (worktreeId: string) => Promise<void>
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. */
@@ -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<ExecutionHostId, Map<string, ExecutionHostId[]>>
@@ -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.
@@ -148,6 +148,7 @@ export type TerminalActions = {
sleepingPaneKeys?: string[]
expectedRuntimePtyIds?: string[]
backendOwnsPtyTeardown?: boolean
onAdmitted?: () => void
}
) => Promise<void>
/** Captures resumable pane evidence and publishes suppression before exact physical teardown. */
@@ -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'
@@ -0,0 +1,7 @@
export type AgentSessionWorkspaceSleepResult = {
stoppedSessionIds: string[]
failures?: {
sessionId?: string
reason: 'inventory-unavailable' | 'stop-unverifiable'
}[]
}
+2
View File
@@ -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,
@@ -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. */
@@ -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
)
})
@@ -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 {
@@ -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,
@@ -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',
@@ -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':