diff --git a/src/main/persistence/window-save-divergence-values.ts b/src/main/persistence/window-save-divergence-values.ts index 00467df2d6c..64089857674 100644 --- a/src/main/persistence/window-save-divergence-values.ts +++ b/src/main/persistence/window-save-divergence-values.ts @@ -28,7 +28,7 @@ const ID_KEYS = new Set([ * launch env and transcript path; editor views carry git snapshots): only their size is logged. */ const OPAQUE_RECORDS = new Set(['file', 'editor view']) -const OPAQUE_FIELDS = new Set(['sleeping', 'value']) +const OPAQUE_FIELDS = new Set(['sleeping', 'resume', 'value']) const MAX_SHOWN_ID_LENGTH = 80 /** A daemon terminal id embeds its worktree id, which may be a long path. */ const MAX_SHOWN_PTY_ID_LENGTH = 256 diff --git a/src/main/persistence/window-save-divergence.ts b/src/main/persistence/window-save-divergence.ts index 5835293ed74..1b57385a955 100644 --- a/src/main/persistence/window-save-divergence.ts +++ b/src/main/persistence/window-save-divergence.ts @@ -30,7 +30,7 @@ import { compactFieldChanges, type FieldChange } from './window-save-divergence- const LOG_PATH_ENV = 'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG' const MAX_PATHS = 12 /** A pane's terminal binding and resume record: main writes them on every spawn and capture. */ -const BINDING_FIELDS = new Set(['ptyId', 'incarnationId', 'sleeping']) +const BINDING_FIELDS = new Set(['ptyId', 'incarnationId', 'sleeping', 'resume']) /** `values`: main's and the save's values of each changed field, content left out. */ export type WindowSaveDivergence = { workspace: string; paths: string[]; values?: FieldChange[] } diff --git a/src/main/persistence/window-save-reconnect-facts.test.ts b/src/main/persistence/window-save-reconnect-facts.test.ts index 06048e48091..703827560b2 100644 --- a/src/main/persistence/window-save-reconnect-facts.test.ts +++ b/src/main/persistence/window-save-reconnect-facts.test.ts @@ -113,6 +113,31 @@ describe('what the next startup reconnects comes from main, not the window’s s expect(saved.activeWorktreeIdsOnShutdown).toEqual([]) }) + it.each(['live', 'quit'] as const)( + 'a running agent’s %s resume record is not a sleep: its relay session reconnects', + (origin) => { + const stored = sshPartition() + stored.sleepingAgentSessionsByPaneKey = { + [`tab-s:${leaf(3)}`]: { + paneKey: `tab-s:${leaf(3)}`, + worktreeId: SSH_KEY, + agent: 'codex', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: '', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin + } + } + + const saved = windowViewSaveForMain(stored, stored, SSH_HOST, factsFor([], [])) + + expect(saved.remoteSessionIdsByTabId).toEqual({ 'tab-s': RELAY }) + expect(saved.activeWorktreeIdsOnShutdown).toEqual([SSH_KEY]) + } + ) + it('a target the user left offline is not redialed; a connected one is', () => { const stored = sshPartition() diff --git a/src/main/runtime/terminal/recovery/shutdown-reconnect-facts.ts b/src/main/runtime/terminal/recovery/shutdown-reconnect-facts.ts index d44e0b312ba..2e1da5d8bba 100644 --- a/src/main/runtime/terminal/recovery/shutdown-reconnect-facts.ts +++ b/src/main/runtime/terminal/recovery/shutdown-reconnect-facts.ts @@ -2,6 +2,7 @@ // terminal records and its SSH connections, never taken from a window's save. The rules are the // window's (buildTerminalSessionData, buildActiveConnectionIdsAtShutdown), read from main's state. +import { isPaneSleepRecord } from '../../../../shared/agent-session-resume' import { isOwnedSshTargetId } from '../../../../shared/execution-host' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { SshConnectionStatus } from '../../../../shared/ssh-types' @@ -28,14 +29,19 @@ function tabPtyIds(session: WorkspaceSessionState, tabId: string, tabPtyId: stri const layout = session.terminalLayoutsByTabId[tabId] const byLeaf = layout?.ptyIdsByLeafId ?? {} const leafIds = collectLayoutLeafIdsInOrder(layout?.root ?? null) - const sleeping = session.sleepingAgentSessionsByPaneKey ?? {} + const records = session.sleepingAgentSessionsByPaneKey ?? {} + // A running agent's live or quit resume record is not a sleep. + const slept = (leafId: string | undefined): boolean => { + const record = leafId ? records[`${tabId}:${leafId}`] : undefined + return record !== undefined && isPaneSleepRecord(record) + } const panes = leafIds.flatMap((leafId) => { const ptyId = byLeaf[leafId] - return ptyId ? [{ ptyId, sleeping: Boolean(sleeping[`${tabId}:${leafId}`]) }] : [] + return ptyId ? [{ ptyId, sleeping: slept(leafId) }] : [] }) // A tab-level id is its single pane's: slept when that pane is. if (tabPtyId && !panes.some((pane) => pane.ptyId === tabPtyId)) { - const solePaneSleeping = leafIds.length === 1 && Boolean(sleeping[`${tabId}:${leafIds[0]}`]) + const solePaneSleeping = leafIds.length === 1 && slept(leafIds[0]) panes.push({ ptyId: tabPtyId, sleeping: solePaneSleeping }) } return panes diff --git a/src/main/runtime/terminal/surface/terminal-surface-verdict-main.test.ts b/src/main/runtime/terminal/surface/terminal-surface-verdict-main.test.ts index 4f0720650e2..1893785acc5 100644 --- a/src/main/runtime/terminal/surface/terminal-surface-verdict-main.test.ts +++ b/src/main/runtime/terminal/surface/terminal-surface-verdict-main.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { HEADLESS_LEAF_ID, TEST_WORKTREE_ID } from '../../orca-runtime-test-fixtures.spec' import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { PaneStartStatuses } from '../../pane-start-statuses' import { SURFACE_TAB_ID, surfaceRuntime } from './terminal-surface-replies.test-fixture' @@ -8,9 +9,10 @@ vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_ const PANE_KEY = makePaneKey(SURFACE_TAB_ID, HEADLESS_LEAF_ID) -/** The scenario with its first pane holding a sleeping agent record (a pane with a status). */ +/** The scenario with its first pane holding an agent resume record (a sleep record by default). */ function sleepingPaneRuntime( - hostLists?: () => readonly string[] + hostLists?: () => readonly string[], + origin?: SleepingAgentSessionRecord['origin'] ): ReturnType { const harness = surfaceRuntime({ hostLists }) const session = harness.store.getSession() @@ -26,7 +28,8 @@ function sleepingPaneRuntime( prompt: '', state: 'done', capturedAt: 1, - updatedAt: 1 + updatedAt: 1, + ...(origin ? { origin } : {}) } } }) @@ -68,6 +71,30 @@ describe("main's pane verdicts (switch on)", () => { } ) + // A running agent's resume record ('live', or 'quit' on a warm reattach) is not a sleep. + it.each(['live', 'quit'] as const)( + "a pane bound to a running terminal with a '%s' resume record is connected and live", + async (origin) => { + const { runtime } = sleepingPaneRuntime(undefined, origin) + + expect(await firstPaneRow(runtime)).toEqual({ + connected: true, + writable: true, + verdict: 'live' + }) + } + ) + + it("a pane with a 'worktree-sleep' record still reads as sleeping", async () => { + const { runtime } = sleepingPaneRuntime(undefined, 'worktree-sleep') + + expect(await firstPaneRow(runtime)).toEqual({ + connected: false, + writable: false, + verdict: 'unverifiable' + }) + }) + it('a sleeping pane whose terminal the host reports absent is exited', async () => { let hostLists = ['pty-a', 'pty-b'] const { runtime } = sleepingPaneRuntime(() => hostLists) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.ts b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.ts index c19b5e5f09e..d1f12a95cb8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/empty-reattach-retire-evidence.ts @@ -1,12 +1,13 @@ import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime-pty-id' -import type { SleepingAgentSessionRecord } from '../../../../../shared/agent-session-resume' +import { + isPaneSleepRecord, + type SleepingAgentSessionRecord +} from '../../../../../shared/agent-session-resume' import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' // Why not activationTreatsNoteAsFinished: a live+done note is a finished turn's idle anchor for a running pane, not sleep. export function isHibernationDoneRecord(record: SleepingAgentSessionRecord): boolean { - return ( - (record.origin === undefined || record.origin === 'worktree-sleep') && record.state === 'done' - ) + return isPaneSleepRecord(record) && record.state === 'done' } // Why: never remote, where disconnect() only closes this viewer's stream, so the retry re-lands on the same live PTY and loops. diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 515108693af..d9fb75fa5cc 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -85,6 +85,11 @@ export type SleepingAgentSessionRecord = { restoreOnTabOpenOnly?: boolean } +/** A record of a slept pane (worktree sleep, or a legacy record); 'quit'/'live' resume a running agent. */ +export function isPaneSleepRecord(record: Pick): boolean { + return record.origin === undefined || record.origin === 'worktree-sleep' +} + const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) const PROVIDER_SESSION_ID_MAX_LENGTH = 512 diff --git a/src/shared/workspace-layout/workspace-layout-command-persistence.test.ts b/src/shared/workspace-layout/workspace-layout-command-persistence.test.ts index 74740306b99..2675106ec4e 100644 --- a/src/shared/workspace-layout/workspace-layout-command-persistence.test.ts +++ b/src/shared/workspace-layout/workspace-layout-command-persistence.test.ts @@ -91,11 +91,11 @@ describe('a terminal tab whose tab-bar id and terminal id differ', () => { ) const newLeaf = split.results[0]!.leafId! expect(split.results[0]!.paneKey).toBe(`term-agent:${newLeaf}`) - const record = loaded.layout.workspaces[GIT_KEY]!.leaves![leaf(3)]!.sleeping! + const record = loaded.layout.workspaces[GIT_KEY]!.leaves![leaf(3)]!.resume! const slept = build( context, [ - { type: 'wake', workspace: GIT_KEY, paneKeys: [`term-agent:${leaf(3)}`] }, + { type: 'wake', workspace: GIT_KEY, paneKeys: [`term-agent:${leaf(3)}`], recordOnly: true }, { type: 'sleep', workspace: GIT_KEY, diff --git a/src/shared/workspace-layout/workspace-layout-commands.test.ts b/src/shared/workspace-layout/workspace-layout-commands.test.ts index 697dfd947db..6e44c49f006 100644 --- a/src/shared/workspace-layout/workspace-layout-commands.test.ts +++ b/src/shared/workspace-layout/workspace-layout-commands.test.ts @@ -333,9 +333,10 @@ describe('layout command refusals', () => { const recorded = applyLayoutCommand(bound, { ...sleep, recordOnly: true }, context) expect(recorded).toMatchObject({ ok: true, effects: { stopPtyIds: [] } }) const recordedModel = recorded.ok ? recorded.model : bound + // A running agent's capture is its resume record, not a sleep. expect(recordedModel.workspaces[WS]!.leaves![leafB]).toEqual({ ptyId: 'pty-b', - sleeping: { + resume: { agent: 'codex', providerSession: { key: 'session_id', id: 's' }, prompt: 'p', diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index 085d2ef8302..860671875c6 100644 --- a/src/shared/workspace-layout/workspace-layout-disk-fields.ts +++ b/src/shared/workspace-layout/workspace-layout-disk-fields.ts @@ -69,8 +69,9 @@ type PartSources = { | Prefixed<'browserTab', keyof LayoutBrowserTab> // `$key`: the map key the entry is stored under. // `ptyGeneration` is in memory only: a load starts every pane's count afresh. - | Prefixed<'leaf', Exclude | '$key'> + | Prefixed<'leaf', Exclude | '$key'> | Prefixed<'leaf.sleeping', keyof LayoutSleepingRecord> + | Prefixed<'leaf.resume', keyof LayoutSleepingRecord> | Prefixed<'closedTab', keyof LayoutClosedTab> desktopView: | Prefixed<'view', Exclude> @@ -93,9 +94,12 @@ export type LayoutSource = { [Part in keyof LoadedWorkspaceLayout]: PartSources[Part] }[keyof LoadedWorkspaceLayout] -/** `from`: the one source. `via`: sources that only select or validate (a fallback, a filter). */ +/** + * `from`: the one source. `via`: sources that only select or validate (a fallback, a filter). + * `orFrom`: a second holder of the same fact; each record is held by exactly one of the two. + */ export type DiskFieldWritten = - | { from: LayoutSource; via?: readonly LayoutSource[] } + | { from: LayoutSource; orFrom?: LayoutSource; via?: readonly LayoutSource[] } | 'unwritten' /** A map of per-workspace lists: only which keys it holds is compared. */ | 'container' @@ -227,25 +231,28 @@ const BROWSER = { const PANE_KEY = { from: 'leaf.$key', via: ['tab.entityId', 'tab.panes.root'] } as const const paneRecord = (from: Source) => ({ from, via: ['tab.panes.root'] }) as const +/** A resume record field: from a slept pane's record or a running agent's, never both. */ +const resumeRecord = (field: Field) => + ({ ...paneRecord(`leaf.sleeping.${field}`), orFrom: `leaf.resume.${field}` }) as const const SLEEPING = { paneKey: PANE_KEY, tabId: PANE_KEY, worktreeId: { from: 'workspace.worktreeId', via: ['tab.panes.root'] }, - agent: paneRecord('leaf.sleeping.agent'), - providerSession: paneRecord('leaf.sleeping.providerSession'), - prompt: paneRecord('leaf.sleeping.prompt'), - state: paneRecord('leaf.sleeping.state'), - capturedAt: paneRecord('leaf.sleeping.capturedAt'), - updatedAt: paneRecord('leaf.sleeping.updatedAt'), - terminalTitle: paneRecord('leaf.sleeping.terminalTitle'), - lastAssistantMessage: paneRecord('leaf.sleeping.lastAssistantMessage'), - interrupted: paneRecord('leaf.sleeping.interrupted'), - mainAgent: paneRecord('leaf.sleeping.mainAgent'), - connectionId: paneRecord('leaf.sleeping.connectionId'), - launchConfig: paneRecord('leaf.sleeping.launchConfig'), - origin: paneRecord('leaf.sleeping.origin'), - restoreOnTabOpenOnly: paneRecord('leaf.sleeping.restoreOnTabOpenOnly') + agent: resumeRecord('agent'), + providerSession: resumeRecord('providerSession'), + prompt: resumeRecord('prompt'), + state: resumeRecord('state'), + capturedAt: resumeRecord('capturedAt'), + updatedAt: resumeRecord('updatedAt'), + terminalTitle: resumeRecord('terminalTitle'), + lastAssistantMessage: resumeRecord('lastAssistantMessage'), + interrupted: resumeRecord('interrupted'), + mainAgent: resumeRecord('mainAgent'), + connectionId: resumeRecord('connectionId'), + launchConfig: resumeRecord('launchConfig'), + origin: resumeRecord('origin'), + restoreOnTabOpenOnly: resumeRecord('restoreOnTabOpenOnly') } as const satisfies Record /** One entry of today's incarnation map, by pane key. */ @@ -322,7 +329,9 @@ type TableEntry = { Table in Exclude ]: (typeof DISK_TABLES)[Table][keyof (typeof DISK_TABLES)[Table]] }[Exclude] -type FromOf = Entry extends { from: infer From } ? From : never +type FromOf = Entry extends { from: infer From } + ? From | (Entry extends { orFrom: infer OrFrom } ? OrFrom : never) + : never /** Sources no disk field is written from; must stay `never`. */ export type UnwrittenLayoutSource = Exclude< diff --git a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts index 5d13ab6b40e..a6bf85ffc11 100644 --- a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts +++ b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts @@ -86,7 +86,9 @@ const writtenOf = (field: string): DiskFieldWritten => : WRITTEN_BY_FIELD.get(field)! const sourcesOf = (written: DiskFieldWritten): LayoutSource[] => - typeof written === 'string' ? [] : [written.from, ...(written.via ?? [])] + typeof written === 'string' + ? [] + : [written.from, ...(written.orFrom ? [written.orFrom] : []), ...(written.via ?? [])] /** The richest session the fixtures build, with every optional source populated. */ function richLoaded(): LoadedWorkspaceLayout { @@ -159,15 +161,21 @@ function populate(loaded: LoadedWorkspaceLayout): LoadedWorkspaceLayout { fill(loaded, 'carried.pairedTerminalTabCloseIntentsByEnvironment', { env: ['host-tab-1', 'host-tab-2'] }) - const sleeping = (field: keyof LayoutSleepingRecord, value: unknown) => - fill(loaded, `leaf.sleeping.${field}` as const, value) - sleeping('terminalTitle', 'claude') - sleeping('lastAssistantMessage', 'done') - sleeping('interrupted', true) - sleeping('mainAgent', { state: 'done' }) - sleeping('connectionId', 'conn-1') - sleeping('launchConfig', { agentArgs: '', agentEnv: {} }) - sleeping('restoreOnTabOpenOnly', true) + // The fixture's record is a quit capture (leaf.resume); a second pane holds a real sleep. + holdersOf(loaded, 'leaf').find((holder) => holder.incarnationId === 'inc-1')!.sleeping = { + ...SLEPT_PANE_RECORD + } + for (const holder of ['leaf.sleeping', 'leaf.resume'] as const) { + const record = (field: keyof LayoutSleepingRecord, value: unknown) => + fill(loaded, `${holder}.${field}` as const, value) + record('terminalTitle', 'claude') + record('lastAssistantMessage', 'done') + record('interrupted', true) + record('mainAgent', { state: 'done' }) + record('connectionId', 'conn-1') + record('launchConfig', { agentArgs: '', agentEnv: {} }) + record('restoreOnTabOpenOnly', true) + } fill(loaded, 'carried.unplacedSleepingRecords', { 'tab-elsewhere:x': { paneKey: 'tab-elsewhere:x', worktreeId: 'repo-9::/gone', prompt: 'p' } }) @@ -187,6 +195,16 @@ function populate(loaded: LoadedWorkspaceLayout): LoadedWorkspaceLayout { type Holder = Record +const SLEPT_PANE_RECORD: LayoutSleepingRecord = { + agent: 'codex', + providerSession: { key: 'session_id', id: 'session-slept' }, + prompt: 'slept prompt', + state: 'done', + capturedAt: 1_700_000_600_000, + updatedAt: 1_700_000_600_000, + origin: 'worktree-sleep' +} + type PrefixOf = Path extends `${infer Head}.${infer Rest}` ? Rest extends `${string}.${string}` ? `${Head}.${PrefixOf}` @@ -214,6 +232,7 @@ function holdersOf(loaded: LoadedWorkspaceLayout, prefix: string): Holder[] { browserTab: workspaces.flatMap((workspace) => workspace.browserTabs ?? []), leaf: leaves, 'leaf.sleeping': leaves.flatMap((entry) => entry.sleeping ?? []), + 'leaf.resume': leaves.flatMap((entry) => entry.resume ?? []), closedTab: workspaces.flatMap((workspace) => Object.values(workspace.closedTerminalTabs ?? {})), view: [view], viewGroup: nested(view.groups), @@ -300,6 +319,41 @@ describe('each on-disk field has one source', () => { expect(empty).toEqual([]) }) + it('holds each placed pane record in exactly one of its two holders', () => { + const loaded = richLoaded() + const leaves = Object.values(loaded.layout.workspaces).flatMap((workspace) => + Object.values(workspace.leaves ?? {}) + ) + const holders = leaves.map((entry) => [entry.sleeping, entry.resume].filter(Boolean).length) + expect( + holders.filter((count) => count > 1), + 'a pane record in both holders' + ).toEqual([]) + const placed = Object.keys(saveWorkspaceLayout(loaded).sleepingAgentSessionsByPaneKey ?? {}) + const unplaced = Object.keys(loaded.carried.unplacedSleepingRecords ?? {}) + // Every saved record a pane holds comes from one holder, and every holder is saved. + expect(placed.length - unplaced.length).toBe(holders.filter((count) => count === 1).length) + for (const origin of [undefined, 'worktree-sleep', 'quit', 'live'] as const) { + const session = localDesktopSession() + const paneKey = Object.keys(session.sleepingAgentSessionsByPaneKey!)[0]! + const stored = session.sleepingAgentSessionsByPaneKey![paneKey]! + const { origin: _origin, ...rest } = stored + session.sleepingAgentSessionsByPaneKey![paneKey] = origin ? { ...rest, origin } : rest + const reloaded = loadWorkspaceLayout(LOCAL_EXECUTION_HOST_ID, session, { + mintId: () => 'minted', + mintLeafId: () => leaf(9) + }) + const held = Object.values(reloaded.layout.workspaces).flatMap((workspace) => + Object.values(workspace.leaves ?? {}).flatMap((entry) => [ + ...(entry.sleeping ? ['sleeping'] : []), + ...(entry.resume ? ['resume'] : []) + ]) + ) + const slept = origin === undefined || origin === 'worktree-sleep' + expect(held, String(origin)).toEqual([slept ? 'sleeping' : 'resume']) + } + }) + it('changes on disk exactly the fields each source is declared for', () => { const base = richLoaded() const before = diskValues(saveWorkspaceLayout(base)) @@ -324,7 +378,7 @@ describe('each on-disk field has one source', () => { if ( written && typeof written !== 'string' && - written.from === source && + (written.from === source || written.orFrom === source) && !changed.includes(name) ) { ineffective.push(`${source} -/-> ${name}`) diff --git a/src/shared/workspace-layout/workspace-layout-load-bindings.ts b/src/shared/workspace-layout/workspace-layout-load-bindings.ts index 3751045bfdb..547d8245f73 100644 --- a/src/shared/workspace-layout/workspace-layout-load-bindings.ts +++ b/src/shared/workspace-layout/workspace-layout-load-bindings.ts @@ -2,7 +2,7 @@ // key (records); the model keys it by leaf id alone, so a leaf id repeated in the partition gets a // new id first and every record lands on the pane it named. -import type { SleepingAgentSessionRecord } from '../agent-session-resume' +import { isPaneSleepRecord, type SleepingAgentSessionRecord } from '../agent-session-resume' import type { TerminalLayoutSnapshot } from '../terminal-tab-types' import type { WorkspaceSessionState } from '../workspace-session-state-types' import { omitStoredFields } from './stored-record-fields' @@ -75,7 +75,8 @@ function loadLeaf( paneKey: string, records: PaneRecords ) { - const sleeping = records.sleeping[paneKey] + const saved = records.sleeping[paneKey] + const record = saved && omitStoredFields(saved, ['paneKey', 'tabId', 'worktreeId']) const leaf: LayoutLeaf = { ...(stored.ptyIdsByLeafId?.[leafId] !== undefined ? { ptyId: stored.ptyIdsByLeafId[leafId] } @@ -89,9 +90,7 @@ function loadLeaf( ...(stored.lastGridsByLeafId?.[leafId] !== undefined ? { lastGrid: stored.lastGridsByLeafId[leafId] } : {}), - ...(sleeping - ? { sleeping: omitStoredFields(sleeping, ['paneKey', 'tabId', 'worktreeId']) } - : {}) + ...(record && (isPaneSleepRecord(record) ? { sleeping: record } : { resume: record })) } delete records.sleeping[paneKey] delete records.incarnations[paneKey] diff --git a/src/shared/workspace-layout/workspace-layout-model-rules.test.ts b/src/shared/workspace-layout/workspace-layout-model-rules.test.ts index 3431f73b054..11c33929dcb 100644 --- a/src/shared/workspace-layout/workspace-layout-model-rules.test.ts +++ b/src/shared/workspace-layout/workspace-layout-model-rules.test.ts @@ -76,6 +76,14 @@ describe('checkWorkspaceLayoutModelRules: the model itself, not what it saves to ) }) + it('flags a pane holding both a sleep record and a resume record', () => { + const broken = withGitWorkspace((workspace) => { + const leafData = workspace.leaves![leaf(3)]! + leafData.sleeping = { ...leafData.resume!, origin: 'worktree-sleep' } + }) + expect(rules(broken)).toEqual(['leaf_sleeping_and_resume']) + }) + it('flags one leaf id in two tabs, since pane data is keyed by leaf alone', () => { const broken = withGitWorkspace((workspace) => { const unbound = workspace.tabs.find((tab) => tab.id === 'tab-unbound')! diff --git a/src/shared/workspace-layout/workspace-layout-model-rules.ts b/src/shared/workspace-layout/workspace-layout-model-rules.ts index 3b93904cfa0..2b7bdda1207 100644 --- a/src/shared/workspace-layout/workspace-layout-model-rules.ts +++ b/src/shared/workspace-layout/workspace-layout-model-rules.ts @@ -102,6 +102,13 @@ function checkWorkspace( if (strays.length > 0) { breach('leaf_without_pane', strays, `pane data for panes no tab holds`) } + // One disk entry holds a pane's record, so a leaf with both would lose one on save. + const doubled = Object.entries(workspace.leaves ?? {}).flatMap(([leafId, leaf]) => + leaf.sleeping && leaf.resume ? [leafId] : [] + ) + if (doubled.length > 0) { + breach('leaf_sleeping_and_resume', doubled, `panes with both a sleep and a resume record`) + } return violations } diff --git a/src/shared/workspace-layout/workspace-layout-model.ts b/src/shared/workspace-layout/workspace-layout-model.ts index c98e707cc57..48c19432124 100644 --- a/src/shared/workspace-layout/workspace-layout-model.ts +++ b/src/shared/workspace-layout/workspace-layout-model.ts @@ -86,7 +86,10 @@ export type LayoutLeaf = { title?: string /** The pane's last grid: the size its terminal starts at. */ lastGrid?: TerminalGrid + /** A slept pane's record (isPaneSleepRecord). Never set with `resume`. */ sleeping?: LayoutSleepingRecord + /** A running agent's resume record (origin live/quit), not a sleep. Never set with `sleeping`. */ + resume?: LayoutSleepingRecord } export type WorkspaceLayout = { diff --git a/src/shared/workspace-layout/workspace-layout-process-commands.ts b/src/shared/workspace-layout/workspace-layout-process-commands.ts index 19ee14fe01d..b9df59dfbcd 100644 --- a/src/shared/workspace-layout/workspace-layout-process-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-process-commands.ts @@ -1,6 +1,7 @@ // Commands that start, restart, sleep or wake terminals. Their layout effect is small (bindings // and sleeping records); the runtime runs the returned effects after replying. +import { isPaneSleepRecord } from '../agent-session-resume' import { omitStoredFields } from './stored-record-fields' import { applied, @@ -13,6 +14,7 @@ import { import type { CommandOf, LayoutContext } from './workspace-layout-command-types' import { paneKeyOf, + type LayoutLeaf, type WorkspaceLayout, type WorkspaceLayoutModel } from './workspace-layout-model' @@ -27,6 +29,11 @@ function workspacePanes(workspace: WorkspaceLayout): { paneKey: string; leafId: ) } +/** A slept pane's record or a running agent's resume record: either says how its agent resumes. */ +function hasResumeRecord(leaf: LayoutLeaf | undefined): boolean { + return leaf?.sleeping !== undefined || leaf?.resume !== undefined +} + /** * Idempotent: starts or restores a pane that has no live terminal. A bound pane, or one whose * start is in flight, is left alone; only restartPane replaces a terminal. A pane with no @@ -47,7 +54,7 @@ export function startPane( if (leaf?.ptyId || context.isStarting?.(pane.leafId)) { return applied(model) } - if (leaf?.sleeping) { + if (hasResumeRecord(leaf)) { return refuse('pane_sleeping') } return applied(model, {}, { startPaneKeys: [command.paneKey] }, { [pane.leafId]: null }) @@ -89,8 +96,11 @@ export function sleep(model: WorkspaceLayoutModel, command: CommandOf<'sleep'>): for (const { paneKey, leafId } of targets) { const record = command.records.find((entry) => entry.paneKey === paneKey) if (record) { - const sleeping = omitStoredFields(record, ['paneKey', 'tabId', 'worktreeId']) - workspace = setLeaf(workspace, leafId, { ...workspace.leaves?.[leafId], sleeping }) + const kept = omitStoredFields(workspace.leaves?.[leafId] ?? {}, ['sleeping', 'resume']) + const stored = omitStoredFields(record, ['paneKey', 'tabId', 'worktreeId']) + // A recordOnly capture of a running agent ('live'/'quit') is a resume record, not a sleep. + const next = isPaneSleepRecord(stored) ? { sleeping: stored } : { resume: stored } + workspace = setLeaf(workspace, leafId, { ...kept, ...next }) } } return applied( @@ -102,16 +112,19 @@ export function sleep(model: WorkspaceLayoutModel, command: CommandOf<'sleep'>): export function wake(model: WorkspaceLayoutModel, command: CommandOf<'wake'>): Applied { let workspace = model.workspaces[command.workspace]! - const woken = workspacePanes(workspace).filter( - (pane) => + // A recordOnly wake drops either record (how a live one is cleared); a start wakes only slept panes. + const woken = workspacePanes(workspace).filter((pane) => { + const leaf = workspace.leaves?.[pane.leafId] + return ( (!command.paneKeys || command.paneKeys.includes(pane.paneKey)) && - workspace.leaves?.[pane.leafId]?.sleeping - ) + (command.recordOnly ? hasResumeRecord(leaf) : leaf?.sleeping !== undefined) + ) + }) for (const { leafId } of woken) { workspace = setLeaf( workspace, leafId, - omitStoredFields(workspace.leaves![leafId]!, ['sleeping']) + omitStoredFields(workspace.leaves![leafId]!, ['sleeping', 'resume']) ) } const paneKeys = woken.map((pane) => pane.paneKey) diff --git a/src/shared/workspace-layout/workspace-layout-resume-record.test.ts b/src/shared/workspace-layout/workspace-layout-resume-record.test.ts new file mode 100644 index 00000000000..ccb6831f171 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-resume-record.test.ts @@ -0,0 +1,228 @@ +// A running agent's resume record (origin 'live' or 'quit') loads to `leaf.resume`, a slept pane's +// to `leaf.sleeping`; both save to the one sleepingAgentSessionsByPaneKey entry, unchanged. + +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../agent-session-resume' +import { LOCAL_EXECUTION_HOST_ID } from '../execution-host' +import { applyLayoutCommand } from './workspace-layout-commands' +import { build, testContext } from './workspace-layout-command.test-fixture' +import { loadWorkspaceLayout } from './workspace-layout-load' +import type { LoadedWorkspaceLayout } from './workspace-layout-load-types' +import type { LayoutLeaf, WorkspaceLayoutModel } from './workspace-layout-model' +import { localDesktopSession, sleepingRecord } from './workspace-layout-profile.test-fixture' +import { publishWorkspaceLayout } from './workspace-layout-published' +import { saveWorkspaceLayout } from './workspace-layout-save' +import { GIT_KEY, leaf } from './workspace-layout-session.test-fixture' +import type { WorkspaceSessionState } from '../workspace-session-state-types' + +type Origin = SleepingAgentSessionRecord['origin'] +const ORIGINS: readonly Origin[] = ['live', 'quit', 'worktree-sleep', undefined] + +function withOrigin(record: SleepingAgentSessionRecord, origin: Origin) { + const { origin: _stored, ...rest } = record + return origin ? { ...rest, origin } : rest +} + +/** The desktop partition with its agent pane's record captured as `origin`. */ +function sessionWith(origin: Origin): WorkspaceSessionState { + const session = localDesktopSession() + const paneKey = `term-agent:${leaf(3)}` + session.sleepingAgentSessionsByPaneKey = { + [paneKey]: withOrigin(session.sleepingAgentSessionsByPaneKey![paneKey]!, origin) + } + return session +} + +function load(session: WorkspaceSessionState): LoadedWorkspaceLayout { + let next = 0 + return loadWorkspaceLayout(LOCAL_EXECUTION_HOST_ID, session, { + mintId: () => `minted-${++next}`, + mintLeafId: () => leaf(9) + }) +} + +const leafOf = (model: WorkspaceLayoutModel, leafId: string): LayoutLeaf | undefined => + model.workspaces[GIT_KEY]!.leaves?.[leafId] + +const isSleep = (origin: Origin) => origin === undefined || origin === 'worktree-sleep' + +describe("a pane's resume record through load and save", () => { + it.each(ORIGINS)('loads a %s record to the one holder its origin names', (origin) => { + const agent = leafOf(load(sessionWith(origin)).layout, leaf(3))! + expect(agent.sleeping !== undefined).toBe(isSleep(origin)) + expect(agent.resume !== undefined).toBe(!isSleep(origin)) + }) + + it.each(ORIGINS)('saves a %s record byte for byte as stored', (origin) => { + const session = sessionWith(origin) + const saved = saveWorkspaceLayout(load(session)) + expect(JSON.stringify(saved.sleepingAgentSessionsByPaneKey)).toBe( + JSON.stringify(session.sleepingAgentSessionsByPaneKey) + ) + }) + + it('never publishes either record to a layout frame', () => { + const { layout } = load(sessionWith('live')) + const frame = JSON.stringify( + publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, LOCAL_EXECUTION_HOST_ID) + ) + expect(leafOf(layout, leaf(3))!.resume).toBeDefined() + expect(frame).not.toContain('"resume"') + expect(frame).not.toContain('fix the flaky test') + }) +}) + +describe('the sleep, wake and start commands on a resume record', () => { + const shellPane = `tab-shell:${leaf(1)}` + const record = (origin: Origin) => + withOrigin(sleepingRecord(GIT_KEY, 'tab-shell', leaf(1)), origin) + + it('keeps a recordOnly live capture as a resume record and a real sleep as a sleep', () => { + const context = testContext() + const { layout } = load(localDesktopSession()) + const sleep = (origin: Origin, recordOnly?: true) => ({ + type: 'sleep' as const, + workspace: GIT_KEY, + paneKeys: [shellPane], + records: [record(origin)], + ...(recordOnly ? { recordOnly } : {}) + }) + const live = build(context, [sleep('live', true)], layout).model + expect(leafOf(live, leaf(1))).toMatchObject({ resume: { origin: 'live' } }) + expect(leafOf(live, leaf(1))!.sleeping).toBeUndefined() + const slept = build(context, [sleep('worktree-sleep')], live).model + expect(leafOf(slept, leaf(1))).toMatchObject({ sleeping: { origin: 'worktree-sleep' } }) + expect(leafOf(slept, leaf(1))!.resume).toBeUndefined() + const recaptured = build(context, [sleep('quit', true)], slept).model + expect(leafOf(recaptured, leaf(1))).toMatchObject({ resume: { origin: 'quit' } }) + expect(leafOf(recaptured, leaf(1))!.sleeping).toBeUndefined() + // A recordOnly wake is how a running agent's record is dropped. + const cleared = build( + context, + [{ type: 'wake', workspace: GIT_KEY, paneKeys: [shellPane], recordOnly: true }], + recaptured + ) + expect(cleared.results[0]).toEqual({ woken: [shellPane] }) + expect(leafOf(cleared.model, leaf(1))!.resume).toBeUndefined() + }) + + it('a waking start leaves a running agent alone: it keeps its resume record and is not started', () => { + const context = testContext() + const { layout } = load(localDesktopSession()) + const live = build( + context, + [ + { + type: 'sleep', + workspace: GIT_KEY, + paneKeys: [shellPane], + records: [record('live')], + recordOnly: true + } + ], + layout + ).model + const woken = applyLayoutCommand(live, { type: 'wake', workspace: GIT_KEY }, context) + expect(woken).toMatchObject({ ok: true, result: { woken: [] } }) + expect(woken.ok && woken.effects?.startPaneKeys).toEqual([]) + expect(woken.ok && leafOf(woken.model, leaf(1))).toMatchObject({ resume: { origin: 'live' } }) + }) + + it('refuses a plain start of an unbound pane holding a resume record', () => { + const context = testContext() + const unboundPane = `tab-unbound:${leaf(4)}` + const { layout } = load(localDesktopSession()) + const held = build( + context, + [ + { + type: 'sleep', + workspace: GIT_KEY, + paneKeys: [unboundPane], + records: [withOrigin(sleepingRecord(GIT_KEY, 'tab-unbound', leaf(4)), 'quit')], + recordOnly: true + } + ], + layout + ).model + expect(leafOf(held, leaf(4))!.resume).toBeDefined() + const start = { type: 'startPane' as const, workspace: GIT_KEY, paneKey: unboundPane } + expect(applyLayoutCommand(held, start, context)).toMatchObject({ + ok: false, + code: 'pane_sleeping' + }) + // A bound pane's resume record does not stop it: its terminal runs. + const bound = { type: 'startPane' as const, workspace: GIT_KEY, paneKey: shellPane } + const live = build( + context, + [ + { + type: 'sleep', + workspace: GIT_KEY, + paneKeys: [shellPane], + records: [record('live')], + recordOnly: true + } + ], + layout + ).model + expect(applyLayoutCommand(live, bound, context)).toMatchObject({ ok: true }) + }) +}) + +describe('a resume record moves and closes with its pane', () => { + it('follows a pane dragged to a new tab, and goes when the pane closes', () => { + const context = testContext() + const loaded = load(localDesktopSession()) + const live = build( + context, + [ + { + type: 'sleep', + workspace: GIT_KEY, + paneKeys: [`tab-shell:${leaf(2)}`], + records: [withOrigin(sleepingRecord(GIT_KEY, 'tab-shell', leaf(2)), 'live')], + recordOnly: true + } + ], + loaded.layout + ).model + const moved = build( + context, + [{ type: 'movePaneToNewTab', workspace: GIT_KEY, tabId: 'tab-shell', leafId: leaf(2) }], + live + ) + const newTab = moved.results[0]!.tabId! + expect(leafOf(moved.model, leaf(2))).toMatchObject({ resume: { origin: 'live' } }) + const saved = saveWorkspaceLayout({ ...loaded, layout: moved.model }) + expect(saved.sleepingAgentSessionsByPaneKey![`${newTab}:${leaf(2)}`]).toMatchObject({ + paneKey: `${newTab}:${leaf(2)}`, + tabId: newTab, + origin: 'live' + }) + expect(saved.sleepingAgentSessionsByPaneKey![`tab-shell:${leaf(2)}`]).toBeUndefined() + // After a reload the record is still that pane's resume record. + expect(leafOf(load(saved).layout, leaf(2))).toMatchObject({ resume: { origin: 'live' } }) + + const paneClosed = build( + context, + [{ type: 'closePane', workspace: GIT_KEY, tabId: 'tab-shell', leafId: leaf(2) }], + live + ).model + expect(leafOf(paneClosed, leaf(2))).toBeUndefined() + expect( + saveWorkspaceLayout({ ...loaded, layout: paneClosed }).sleepingAgentSessionsByPaneKey?.[ + `tab-shell:${leaf(2)}` + ] + ).toBeUndefined() + + const closed = build( + context, + [{ type: 'closeTabs', workspace: GIT_KEY, tabIds: [newTab] }], + moved.model + ).model + expect(leafOf(closed, leaf(2))).toBeUndefined() + const afterClose = saveWorkspaceLayout({ ...loaded, layout: closed }) + expect(afterClose.sleepingAgentSessionsByPaneKey![`${newTab}:${leaf(2)}`]).toBeUndefined() + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-round-trip.test.ts b/src/shared/workspace-layout/workspace-layout-round-trip.test.ts index 96567a9fe64..daab37e912a 100644 --- a/src/shared/workspace-layout/workspace-layout-round-trip.test.ts +++ b/src/shared/workspace-layout/workspace-layout-round-trip.test.ts @@ -94,7 +94,8 @@ describe('workspace layout Loader and Serializer', () => { // round trip above saved its pane records under the terminal id. expect(workspace.leaves?.[leaf(3)]).toMatchObject({ incarnationId: 'inc-3', - sleeping: { agent: 'claude' } + // The fixture's record is a quit capture: a running agent's resume record, not a sleep. + resume: { agent: 'claude' } }) expect(workspace.groups.map((group) => group.tabOrder)).toEqual([ ['tab-shell', 'tab-agent', 'editor:src/app.ts'], diff --git a/src/shared/workspace-layout/workspace-layout-rule-types.ts b/src/shared/workspace-layout/workspace-layout-rule-types.ts index 5b74a38d5c7..f8d9697a879 100644 --- a/src/shared/workspace-layout/workspace-layout-rule-types.ts +++ b/src/shared/workspace-layout/workspace-layout-rule-types.ts @@ -22,6 +22,7 @@ export type WorkspaceLayoutRule = | 'group_empty' | 'group_tree_mismatch' | 'leaf_without_pane' + | 'leaf_sleeping_and_resume' | 'tab_in_two_groups' | 'tab_group_mismatch' | 'group_lists_missing_tab' diff --git a/src/shared/workspace-layout/workspace-layout-save.ts b/src/shared/workspace-layout/workspace-layout-save.ts index 48df4f06af0..10a27d41006 100644 --- a/src/shared/workspace-layout/workspace-layout-save.ts +++ b/src/shared/workspace-layout/workspace-layout-save.ts @@ -139,8 +139,10 @@ function saveWorkspaceRecords({ if (leaf?.incarnationId !== undefined) { incarnations[paneKey] = leaf.incarnationId } - if (leaf?.sleeping) { - sleeping[paneKey] = { paneKey, tabId: tab.entityId, worktreeId, ...leaf.sleeping } + // One disk map holds both: a leaf has at most one of them. + const record = leaf?.sleeping ?? leaf?.resume + if (record) { + sleeping[paneKey] = { paneKey, tabId: tab.entityId, worktreeId, ...record } } } }