Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
21513fa
fix(sidebar): Sleep stops a workspace's native chat agents
brennanb2025 Oct 13, 2026
0d8222b
Fix native chat Sleep ownership and delayed teardown
brennanb2025 Oct 14, 2026
6dfded8
Stop native chat sessions when sleeping their workspace
brennanb2025 Oct 14, 2026
3ce1b30
Fix sleep teardown wake and host-qualified receipt races
brennanb2025 Oct 14, 2026
402f595
Verify mobile Sleep sends the selected desktop host
brennanb2025 Oct 14, 2026
855fefb
Keep worktree Sleep RPC within method file limit
brennanb2025 Oct 14, 2026
7ed4cad
Use sleep intent as attempt identity
brennanb2025 Oct 14, 2026
3f68a04
Gate workspace sleep on structured client support
brennanb2025 Oct 14, 2026
38ffa70
fix(native-chat): preserve sleep stop receipts and send timing
brennanb2025 Oct 14, 2026
d6adfbb
fix(native-chat): keep superseded workspace sleeps quiet
brennanb2025 Oct 14, 2026
6ce1bfd
fix(native-chat): ignore superseded failed sleep stops
brennanb2025 Oct 14, 2026
0636db2
Fix sleep lifetime test resolver types
brennanb2025 Oct 14, 2026
099f406
Accept workspace Sleep for already ended chats
brennanb2025 Oct 14, 2026
9a6e4cf
Sleep native chats through the shared no-toast sleep core
brennanb2025 Oct 15, 2026
481becb
Import isStructuredTab from its shared module
brennanb2025 Oct 15, 2026
93d3ca0
fix(native-chat): leave chat rows to their host through Sleep
brennanb2025 Oct 15, 2026
883c174
fix(native-chat): judge the Sleep receipt by facts, not lane occupancy
brennanb2025 Oct 15, 2026
ba9385a
test(native-chat): report a fake close's end only for an acquired child
brennanb2025 Oct 15, 2026
34b4aa9
Preserve canonical workspace identity when sleeping
brennanb2025 Oct 15, 2026
bb19e09
Propagate optional sleep host through file command contract
brennanb2025 Oct 15, 2026
17abfdf
Move the deliberate-sleep PTY tests to main's spec file
brennanb2025 Oct 16, 2026
56a4d22
Merge remote-tracking branch 'origin/main' into brennanb2025/sleep-st…
brennanb2025 Oct 16, 2026
7a13e60
Reapply Sleep's teardown waits to the moved PTY spec; keep acquisitio…
brennanb2025 Oct 16, 2026
c9861c2
Merge remote-tracking branch 'origin/main' into brennanb2025/sleep-st…
brennanb2025 Oct 16, 2026
07fff32
test: keep the Sleep receipt checks on the closed handle and kept record
brennanb2025 Oct 16, 2026
a76c41b
fix(sidebar): a single-workspace Sleep failure says workspace, not wo…
brennanb2025 Oct 17, 2026
135d9b6
Merge remote-tracking branch 'origin/main' into brennanb2025/sleep-st…
brennanb2025 Oct 17, 2026
c4be871
Merge remote-tracking branch 'origin/main' into brennanb2025/sleep-st…
brennanb2025 Oct 18, 2026
769d3fe
fix(sidebar): Sleep stops a workspace's chats on every host that has …
brennanb2025 Oct 18, 2026
01a6eab
test: drop redundant casts from the Sleep flow test state
brennanb2025 Oct 18, 2026
06ccbe2
fix(sidebar): stop a workspace's chats after its terminals, leaving m…
brennanb2025 Oct 18, 2026
6f1266b
fix: admit workspace chat stops before teardown waits
brennanb2025 Oct 19, 2026
ca38582
fix: retain browser teardown caller sequencing
brennanb2025 Oct 19, 2026
4af4f66
fix: retire failed acquisitions after observed root exit
brennanb2025 Oct 19, 2026
14e261b
Merge remote-tracking branch 'origin/main' into brennanb2025/sleep-st…
brennanb2025 Oct 19, 2026
5b829e9
Merge remote-tracking branch 'origin/main' into brennanb2025/sleep-st…
brennanb2025 Oct 19, 2026
9439a9e
Merge remote-tracking branch 'origin/main' into brennanb2025/sleep-st…
brennanb2025 Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/main/acp/acp-structured-session-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions src/main/claude/claude-structured-session-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions src/main/claude/claude-structured-session-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
9 changes: 9 additions & 0 deletions src/main/claude/claude-structured-session-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ 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
sessions: Map<string, ClaudeSession>
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) {
Expand All @@ -64,6 +66,9 @@ export async function resolveClaudeAcquisitionError(input: {
acquisitionError = claudeAcquisitionCleanupError(input.attempt.connection, input.error)
}
}
if (!(acquisitionError instanceof AgentSessionAcquisitionExitUnprovenError)) {
input.releaseAcquisition()
}
return acquisitionError
}

Expand Down Expand Up @@ -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
Expand Down
20 changes: 6 additions & 14 deletions src/main/codex/codex-app-server-connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down Expand Up @@ -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()
})
Expand Down
8 changes: 4 additions & 4 deletions src/main/codex/codex-app-server-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -71,7 +71,7 @@ export async function openCodexAppServerConnection(
writeResponse,
onProtocolFailure: (error) => {
handleUnexpectedEnd(error)
void terminateProcessTree()
void managed.close()
}
})

Expand Down Expand Up @@ -108,7 +108,7 @@ export async function openCodexAppServerConnection(
return
}
handleUnexpectedEnd(error)
void terminateProcessTree()
void managed.close()
})

const recordReader = createProviderRecordReader({
Expand All @@ -129,7 +129,7 @@ export async function openCodexAppServerConnection(
},
onFatal: (error) => {
handleUnexpectedEnd(error)
void terminateProcessTree()
void managed.close()
}
})

Expand Down
2 changes: 2 additions & 0 deletions src/main/codex/codex-structured-session-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,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)
Expand Down
6 changes: 5 additions & 1 deletion src/main/grok/grok-transcript-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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))
Expand All @@ -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()
})
})

Expand Down Expand Up @@ -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()
})
})
Loading
Loading