mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+114
@@ -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
|
||||
}
|
||||
+5
-6
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+33
-25
@@ -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
|
||||
}
|
||||
|
||||
+6
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+79
-10
@@ -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, () =>
|
||||
|
||||
+53
@@ -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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
+13
-1
@@ -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
|
||||
}
|
||||
|
||||
+65
-2
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+165
@@ -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)
|
||||
})
|
||||
+83
@@ -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.')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
+5
@@ -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'] = {}
|
||||
|
||||
@@ -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[]>>
|
||||
|
||||
|
||||
+1
-1
@@ -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'
|
||||
}[]
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user