mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
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:
@@ -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)
|
||||
|
||||
+5
-4
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user