A running agent's resume note is not a sleep, so switch-on builds report it connected (dark, layout-owner build only) (#31485)

* fix(layout): a running agent's resume record is not a sleep in switch-on builds

The shared layout load put every pane resume record into leaf.sleeping, so main's
terminal index read a live or quit capture as a slept pane and terminal.list
reported running agents as disconnected and unverifiable. Records of slept panes
(isPaneSleepRecord) now load to leaf.sleeping and running agents' records to
leaf.resume; both save to the same sleepingAgentSessionsByPaneKey entry, so the
disk format is unchanged. The disk-field census names both holders, and a model
rule refuses a pane holding both.

* fix(layout): a waking start wakes only slept panes; a recordOnly wake drops either record

* fix(layout): main's shutdown reconnect facts read a running agent's resume record as awake
This commit is contained in:
Jinwoo Hong
2026-10-21 05:03:22 -04:00
committed by GitHub
parent 29a1446962
commit bbc8303b47
20 changed files with 450 additions and 60 deletions
@@ -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
@@ -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[] }
@@ -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()
@@ -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
@@ -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<typeof surfaceRuntime> {
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)
@@ -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.
+5
View File
@@ -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<SleepingAgentSessionRecord, 'origin'>): boolean {
return record.origin === undefined || record.origin === 'worktree-sleep'
}
const RESUMABLE_TUI_AGENT_SET: ReadonlySet<string> = new Set(RESUMABLE_TUI_AGENTS)
const PROVIDER_SESSION_ID_MAX_LENGTH = 512
@@ -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,
@@ -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',
@@ -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<keyof LayoutLeaf, 'sleeping' | 'ptyGeneration'> | '$key'>
| Prefixed<'leaf', Exclude<keyof LayoutLeaf, 'sleeping' | 'resume' | 'ptyGeneration'> | '$key'>
| Prefixed<'leaf.sleeping', keyof LayoutSleepingRecord>
| Prefixed<'leaf.resume', keyof LayoutSleepingRecord>
| Prefixed<'closedTab', keyof LayoutClosedTab>
desktopView:
| Prefixed<'view', Exclude<keyof DesktopLayoutView, 'groups' | 'panes' | 'editorDrafts'>>
@@ -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 = <Source extends `leaf.${string}`>(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 extends keyof LayoutSleepingRecord>(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<keyof SleepingAgentSessionRecord, DiskFieldWritten>
/** One entry of today's incarnation map, by pane key. */
@@ -322,7 +329,9 @@ type TableEntry = {
Table in Exclude<DiskTable, 'session'>
]: (typeof DISK_TABLES)[Table][keyof (typeof DISK_TABLES)[Table]]
}[Exclude<DiskTable, 'session'>]
type FromOf<Entry> = Entry extends { from: infer From } ? From : never
type FromOf<Entry> = 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<
@@ -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<string, unknown>
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> = Path extends `${infer Head}.${infer Rest}`
? Rest extends `${string}.${string}`
? `${Head}.${PrefixOf<Rest>}`
@@ -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}`)
@@ -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]
@@ -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')!
@@ -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
}
@@ -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 = {
@@ -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)
@@ -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()
})
})
@@ -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'],
@@ -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'
@@ -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 }
}
}
}