Prevent stale sleeping agents from resuming (#6018)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-21 22:39:09 -07:00
committed by GitHub
co-authored by Orca
parent fc254deef5
commit 88da1808f7
11 changed files with 480 additions and 27 deletions
@@ -109,6 +109,10 @@ describe('agent sleep planner', () => {
const e = entry({ state })
expect(plannedWorktrees(snapshot({ agentStatusByPaneKey: { [e.paneKey]: e } }))).toEqual([])
}
const interrupted = entry({ interrupted: true })
expect(
plannedWorktrees(snapshot({ agentStatusByPaneKey: { [interrupted.paneKey]: interrupted } }))
).toEqual([])
const noSession = entry({ providerSession: undefined })
expect(
plannedWorktrees(snapshot({ agentStatusByPaneKey: { [noSession.paneKey]: noSession } }))
@@ -133,7 +133,11 @@ function getEligiblePane(args: {
lastTerminalInputAtByPaneKey,
mobileLockedPtyIds
} = args
if (entry.state !== 'done' || sleepingAgentSessionsByPaneKey[entry.paneKey]) {
if (
entry.state !== 'done' ||
entry.interrupted === true ||
sleepingAgentSessionsByPaneKey[entry.paneKey]
) {
return null
}
if (
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { parseWorkspaceSession } from '../../../shared/workspace-session-schema'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
@@ -322,6 +323,94 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('resumes intentional completed worktree-sleep records', () => {
const record = makeRecord({ origin: 'worktree-sleep', state: 'done' })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(1)
expect(useAppStore.getState().tabsByWorktree['wt-1']?.[0]?.launchAgent).toBe('claude')
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('clears stale manual records without launching a tab', () => {
const record = makeRecord({ capturedAt: 3_000_000, updatedAt: 1 })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(0)
expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([])
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('clears interrupted manual records without launching a tab', () => {
const record = makeRecord({ origin: 'worktree-sleep', interrupted: true })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(0)
expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([])
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('clears hydrated interrupted worktree-sleep records without launching a tab', () => {
const parsed = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {
'tab-1:leaf-1': makeRecord({
state: 'done',
origin: 'worktree-sleep',
interrupted: true
})
}
})
expect(parsed.ok).toBe(true)
if (!parsed.ok) {
throw new Error(parsed.error)
}
const record = parsed.value.sleepingAgentSessionsByPaneKey!['tab-1:leaf-1']!
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(0)
expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([])
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('clears legacy completed live records without launching a tab', () => {
const record = makeRecord({ state: 'done' })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(0)
expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([])
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
})
it('uses WSL resume quoting for Windows-path projects forced to WSL', () => {
const record = makeRecord({
providerSession: { key: 'session_id', id: "sess-1's" },
@@ -14,6 +14,7 @@ import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-r
import type { TerminalTab } from '../../../shared/types'
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
import { translate } from '@/i18n/i18n'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../shared/agent-status-types'
function getResumeLaunchPlatform(worktreeId: string): NodeJS.Platform {
const state = useAppStore.getState()
@@ -187,6 +188,18 @@ function recordPaneIsOwnedByPreservedPane(
)
}
function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean {
if (record.interrupted === true) {
return true
}
if (!record.origin && record.state === 'done') {
return true
}
return (
record.state !== 'done' && record.capturedAt - record.updatedAt > AGENT_STATUS_STALE_AFTER_MS
)
}
export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number {
const state = useAppStore.getState()
const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey)
@@ -207,6 +220,10 @@ export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): numb
let launched = 0
for (const record of records) {
const claimKey = getProviderSessionClaimKey(record)
if (isInvalidWorktreeActivationRecord(record)) {
state.clearSleepingAgentSession(record.paneKey)
continue
}
if (paneOwnedClaimKeys.has(claimKey)) {
if (!recordPaneIsOwnedByPreservedPane(record, state)) {
state.clearSleepingAgentSession(record.paneKey)
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
import type { AppState } from '../types'
import { createTestStore, makeTab } from './store-test-helpers'
const NOW = 1_800_000_000_000
afterEach(() => {
vi.useRealTimers()
})
function makeAgentEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
const paneKey = overrides.paneKey ?? 'tab-1:leaf-1'
return {
state: 'working',
prompt: 'finish the task',
updatedAt: NOW,
stateStartedAt: NOW,
stateHistory: [],
agentType: 'codex',
paneKey,
tabId: paneKey.split(':')[0],
worktreeId: 'wt-1',
providerSession: { key: 'session_id', id: `session-${paneKey}` },
...overrides
}
}
function seedTabs(store: ReturnType<typeof createTestStore>): void {
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
}
function makeSleepingRecord(
overrides: Partial<SleepingAgentSessionRecord> = {}
): SleepingAgentSessionRecord {
const paneKey = overrides.paneKey ?? 'tab-1:leaf-1'
return {
paneKey,
tabId: paneKey.split(':')[0],
worktreeId: 'wt-1',
agent: 'codex',
providerSession: { key: 'session_id', id: `sleeping-${paneKey}` },
prompt: 'old prompt',
state: 'working',
capturedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1,
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1,
origin: 'live',
...overrides
}
}
describe('manual sleep agent session capture', () => {
it('captures only fresh active live rows as worktree-sleep records', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const store = createTestStore()
seedTabs(store)
store.setState({
agentStatusByPaneKey: {
'tab-1:fresh': makeAgentEntry({ paneKey: 'tab-1:fresh' }),
'tab-1:stale': makeAgentEntry({
paneKey: 'tab-1:stale',
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1
}),
'tab-1:done': makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }),
'tab-1:interrupted': makeAgentEntry({
paneKey: 'tab-1:interrupted',
state: 'done',
interrupted: true
}),
'tab-1:post-input': makeAgentEntry({
paneKey: 'tab-1:post-input',
updatedAt: NOW - 1_000
})
},
lastTerminalInputAtByPaneKey: { 'tab-1:post-input': NOW }
} as Partial<AppState>)
store.getState().captureSleepingAgentSessionsByWorktree('wt-1')
const records = store.getState().sleepingAgentSessionsByPaneKey
expect(Object.keys(records).sort()).toEqual(['tab-1:fresh'])
expect(records['tab-1:fresh']).toMatchObject({
origin: 'worktree-sleep',
state: 'working',
providerSession: { key: 'session_id', id: 'session-tab-1:fresh' }
})
})
it('preserves retained completed sessions as intentional sleep records', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const store = createTestStore()
seedTabs(store)
const entry = makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' })
const tab = makeTab({ id: 'tab-1', worktreeId: 'wt-1' })
store.setState({
retainedAgentsByPaneKey: {
'tab-1:done': {
entry,
tab,
worktreeId: 'wt-1',
agentType: 'codex',
startedAt: entry.stateStartedAt
}
}
} as Partial<AppState>)
store.getState().captureSleepingAgentSessionsByWorktree('wt-1')
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:done']).toMatchObject({
origin: 'worktree-sleep',
state: 'done',
providerSession: { key: 'session_id', id: 'session-tab-1:done' }
})
})
it('clears pre-existing records for rows skipped by manual capture', () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const store = createTestStore()
seedTabs(store)
store.setState({
agentStatusByPaneKey: {
'tab-1:stale': makeAgentEntry({
paneKey: 'tab-1:stale',
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1
})
},
sleepingAgentSessionsByPaneKey: {
'tab-1:stale': makeSleepingRecord({ paneKey: 'tab-1:stale' })
}
} as Partial<AppState>)
store.getState().captureSleepingAgentSessionsByWorktree('wt-1')
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toBeUndefined()
})
it('uses manual sleep filtering when terminal shutdown captures sleeping records', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const store = createTestStore()
seedTabs(store)
store.setState({
agentStatusByPaneKey: {
'tab-1:fresh': makeAgentEntry({ paneKey: 'tab-1:fresh' }),
'tab-1:done': makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' })
}
} as Partial<AppState>)
await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true })
const records = store.getState().sleepingAgentSessionsByPaneKey
expect(Object.keys(records)).toEqual(['tab-1:fresh'])
expect(records['tab-1:fresh']).toMatchObject({
origin: 'worktree-sleep',
state: 'working'
})
})
it('clears pre-existing records for rows skipped during terminal shutdown capture', async () => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
const store = createTestStore()
seedTabs(store)
store.setState({
ptyIdsByTabId: { 'tab-1': [] },
agentStatusByPaneKey: {
'tab-1:stale': makeAgentEntry({
paneKey: 'tab-1:stale',
updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1
})
},
sleepingAgentSessionsByPaneKey: {
'tab-1:stale': makeSleepingRecord({ paneKey: 'tab-1:stale' })
}
} as Partial<AppState>)
await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true })
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toBeUndefined()
})
})
+96 -9
View File
@@ -344,21 +344,94 @@ function sleepingRecordFromEntry(args: {
? { lastAssistantMessage: args.entry.lastAssistantMessage }
: {}),
...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}),
...(args.entry.interrupted ? { interrupted: true } : {}),
...(args.origin ? { origin: args.origin } : {})
}
}
type CollectSleepingAgentSessionRecordsOptions = {
paneKeys?: readonly string[]
captureMode?: 'manual-worktree-sleep' | 'completed-agent-hibernation'
}
function normalizeSleepingAgentSessionCollectOptions(
options: readonly string[] | CollectSleepingAgentSessionRecordsOptions | undefined
): CollectSleepingAgentSessionRecordsOptions {
if (!options) {
return {}
}
return Array.isArray(options)
? { paneKeys: options }
: (options as CollectSleepingAgentSessionRecordsOptions)
}
function isValidManualSleepLiveAgentEntry(
state: AppState,
entry: AgentStatusEntry,
capturedAt: number
): boolean {
if (entry.interrupted === true || entry.state === 'done') {
return false
}
const lastInputAt = state.lastTerminalInputAtByPaneKey[entry.paneKey]
if (
typeof lastInputAt === 'number' &&
Number.isFinite(lastInputAt) &&
lastInputAt > entry.updatedAt
) {
return false
}
return isExplicitAgentStatusFresh(entry, capturedAt, AGENT_STATUS_STALE_AFTER_MS)
}
function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean {
return entry.state === 'done' && entry.interrupted !== true
}
export function removeSleepingRecordsReplacedByManualWorktreeSleep(
records: Record<string, SleepingAgentSessionRecord>,
worktreeId: string,
paneKeys?: readonly string[]
): { records: Record<string, SleepingAgentSessionRecord>; changed: boolean } {
const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null
let next = records
let changed = false
for (const [paneKey, record] of Object.entries(records)) {
if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) {
continue
}
if (next === records) {
next = { ...records }
}
delete next[paneKey]
changed = true
}
return { records: next, changed }
}
export function collectSleepingAgentSessionRecordsForWorktree(
state: AppState,
worktreeId: string,
paneKeys?: string[]
options?: readonly string[] | CollectSleepingAgentSessionRecordsOptions
): Record<string, SleepingAgentSessionRecord> {
const capturedAt = Date.now()
const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null
const collectOptions = normalizeSleepingAgentSessionCollectOptions(options)
const allowedPaneKeys = collectOptions.paneKeys ? new Set(collectOptions.paneKeys) : null
const isManualWorktreeSleep = collectOptions.captureMode === 'manual-worktree-sleep'
const isCompletedAgentHibernation = collectOptions.captureMode === 'completed-agent-hibernation'
const isWorktreeOwnedCapture = isManualWorktreeSleep || isCompletedAgentHibernation
// Why: hibernated completions are intentional worktree-owned records; wake
// treats originless completed records as ambiguous legacy captures.
const origin: SleepingAgentSessionRecord['origin'] | undefined = isWorktreeOwnedCapture
? 'worktree-sleep'
: undefined
const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`)
const records: Record<string, SleepingAgentSessionRecord> = {}
for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
if (isCompletedAgentHibernation) {
continue
}
if (allowedPaneKeys && !allowedPaneKeys.has(retained.entry.paneKey)) {
continue
}
@@ -371,7 +444,8 @@ export function collectSleepingAgentSessionRecordsForWorktree(
worktreeId,
tab: retained.tab,
capturedAt,
launchConfig: getLaunchConfigForEntry(state, retained.entry)
launchConfig: getLaunchConfigForEntry(state, retained.entry),
origin
})
if (record) {
records[record.paneKey] = record
@@ -387,12 +461,19 @@ export function collectSleepingAgentSessionRecordsForWorktree(
if (!belongsToWorktree) {
continue
}
if (isManualWorktreeSleep && !isValidManualSleepLiveAgentEntry(state, entry, capturedAt)) {
continue
}
if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) {
continue
}
const record = sleepingRecordFromEntry({
state,
entry,
worktreeId,
capturedAt,
launchConfig: getLaunchConfigForEntry(state, entry)
launchConfig: getLaunchConfigForEntry(state, entry),
origin
})
if (record) {
records[record.paneKey] = record
@@ -1787,11 +1868,17 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
captureSleepingAgentSessionsByWorktree: (worktreeId, paneKeys) => {
set((s) => {
const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, paneKeys)
const next: Record<string, SleepingAgentSessionRecord> = {
...s.sleepingAgentSessionsByPaneKey
}
let changed = false
const records = collectSleepingAgentSessionRecordsForWorktree(s, worktreeId, {
paneKeys,
captureMode: 'manual-worktree-sleep'
})
const replaced = removeSleepingRecordsReplacedByManualWorktreeSleep(
s.sleepingAgentSessionsByPaneKey,
worktreeId,
paneKeys
)
const next: Record<string, SleepingAgentSessionRecord> = { ...replaced.records }
let changed = replaced.changed
for (const record of Object.values(records)) {
if (next[record.paneKey] !== record) {
@@ -2697,6 +2697,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
[siblingLeaf]: 'pty-shell'
})
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
origin: 'worktree-sleep',
providerSession: { key: 'session_id', id: 'target-session' }
})
expect(state.sleepingAgentSessionsByPaneKey[siblingPaneKey]).toBe(siblingSleepingRecordBefore)
@@ -3218,6 +3219,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
it('commits sleep state after exact runtime stop for runtime-backed PTYs', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const now = Date.now()
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) =>
Promise.resolve(
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? {
@@ -3245,12 +3247,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
state: 'working',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ updatedAt: now, stateStartedAt: now },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
@@ -3269,6 +3271,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
)
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
origin: 'worktree-sleep',
providerSession: { key: 'session_id', id: 'live-session' }
})
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeUndefined()
@@ -3532,6 +3535,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
it('commits the pre-stop sleeping record when exact-stop exit clears live status', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const now = Date.now()
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => {
if (args.method === 'terminal.stopExact') {
store.getState().removeAgentStatus('tab-1:live')
@@ -3565,12 +3569,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
store.getState().setAgentStatus(
'tab-1:live',
{
state: 'done',
state: 'working',
prompt: 'resume live',
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ updatedAt: now, stateStartedAt: now },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'live-session' } }
)
@@ -3582,6 +3586,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
origin: 'worktree-sleep',
providerSession: { key: 'session_id', id: 'live-session' }
})
})
@@ -3856,6 +3861,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
it('captures resumable provider session metadata before dropping sleep-time rows', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const now = Date.now()
seedStore(store, {
worktreesByRepo: {
@@ -3875,7 +3881,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
agentType: 'codex'
},
'Codex',
{ updatedAt: 1000, stateStartedAt: 1000 },
{ updatedAt: now, stateStartedAt: now },
{ tabId: 'tab-1', worktreeId: wt },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
@@ -3889,13 +3895,14 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
tabId: 'tab-1',
worktreeId: wt,
agent: 'codex',
origin: 'worktree-sleep',
providerSession: { key: 'session_id', id: 'codex-session-1' },
prompt: 'resume this',
terminalTitle: 'Codex'
})
})
it('captures only allowlisted sleeping pane sessions when requested', async () => {
it('skips allowlisted done live sleeping pane sessions during manual sleep', async () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
@@ -3946,10 +3953,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
const state = store.getState()
expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
paneKey: 'tab-1:live',
providerSession: { key: 'session_id', id: 'live-session' }
})
expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
expect(state.sleepingAgentSessionsByPaneKey['tab-1:retained']).toBeUndefined()
})
@@ -4076,6 +4080,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
paneKey: 'tab-1:live',
origin: 'worktree-sleep',
providerSession: { key: 'session_id', id: 'live-session' }
})
expect(state.retainedAgentsByPaneKey['tab-1:retained']).toBeUndefined()
@@ -4140,6 +4145,9 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
})
const state = store.getState()
expect(state.sleepingAgentSessionsByPaneKey['tab-1:interrupted']).toBeUndefined()
expect(state.sleepingAgentSessionsByPaneKey['tab-1:working']).toBeUndefined()
expect(state.sleepingAgentSessionsByPaneKey['tab-1:retained-only']).toBeUndefined()
expect(state.retainedAgentsByPaneKey['tab-1:interrupted']).toBeUndefined()
expect(state.retainedAgentsByPaneKey['tab-1:working']).toBeUndefined()
expect(state.retainedAgentsByPaneKey['tab-1:retained-only']).toBeUndefined()
+27 -7
View File
@@ -60,6 +60,7 @@ import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-co
import {
collectHibernatedCompletionEvidenceForWorktree,
collectSleepingAgentSessionRecordsForWorktree,
removeSleepingRecordsReplacedByManualWorktreeSleep,
type AgentStatusWorktreeShutdownReason
} from './agent-status'
@@ -1727,7 +1728,10 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const sleepingAgentSessionRecords = collectSleepingAgentSessionRecordsForWorktree(
state,
worktreeId,
paneKeys
{
paneKeys,
captureMode: 'completed-agent-hibernation'
}
)
const retainedCompletionEvidence = collectHibernatedCompletionEvidenceForWorktree(
state,
@@ -1912,7 +1916,13 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
const expectedRuntimePtyIds = sortedUniquePtyIds(opts?.expectedRuntimePtyIds)
const shutdownPtyIds = sortedUniquePtyIds([...ptyIds, ...expectedRuntimePtyIds])
const sleepingAgentSessionRecords = keepIdentifiers
? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, opts?.sleepingPaneKeys)
? collectSleepingAgentSessionRecordsForWorktree(get(), worktreeId, {
paneKeys: opts?.sleepingPaneKeys,
...(shutdownReason === 'manual-sleep' ? { captureMode: 'manual-worktree-sleep' } : {}),
...(shutdownReason === 'auto-hibernate-completed-agent'
? { captureMode: 'completed-agent-hibernation' }
: {})
})
: {}
const retainedCompletionEvidence =
shutdownReason === 'auto-hibernate-completed-agent'
@@ -2178,12 +2188,22 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
if (keepIdentifiers) {
set((s) => ({
sleepingAgentSessionsByPaneKey: {
...s.sleepingAgentSessionsByPaneKey,
...sleepingAgentSessionRecords
set((s) => {
const base =
shutdownReason === 'manual-sleep'
? removeSleepingRecordsReplacedByManualWorktreeSleep(
s.sleepingAgentSessionsByPaneKey,
worktreeId,
opts?.sleepingPaneKeys
).records
: s.sleepingAgentSessionsByPaneKey
return {
sleepingAgentSessionsByPaneKey: {
...base,
...sleepingAgentSessionRecords
}
}
}))
})
} else {
get().clearSleepingAgentSessionsByWorktree(worktreeId)
}
+1
View File
@@ -40,6 +40,7 @@ export type SleepingAgentSessionRecord = {
updatedAt: number
terminalTitle?: string
lastAssistantMessage?: string
interrupted?: boolean
connectionId?: string | null
launchConfig?: SleepingAgentLaunchConfig
/** How the record was captured. Worktree-sleep records (legacy records have
@@ -329,6 +329,36 @@ describe('parseWorkspaceSession', () => {
}
})
it('preserves interrupted sleeping agent records across hydration', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
sleepingAgentSessionsByPaneKey: {
'tab1:pane-1': {
paneKey: 'tab1:pane-1',
tabId: 'tab1',
worktreeId: 'wt',
agent: 'codex',
providerSession: { key: 'session_id', id: 'codex-session' },
prompt: 'continue',
state: 'done',
capturedAt: 10,
updatedAt: 9,
interrupted: true,
origin: 'worktree-sleep'
}
}
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.interrupted).toBe(true)
}
})
it('preserves legacy live sleeping agent origins across hydration', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
@@ -77,6 +77,7 @@ const sleepingAgentSessionRecordSchema = z.object({
updatedAt: z.number().finite().positive(),
terminalTitle: z.string().optional(),
lastAssistantMessage: z.string().optional(),
interrupted: z.boolean().optional(),
connectionId: z.string().nullable().optional(),
launchConfig: sleepingAgentLaunchConfigSchema.optional(),
origin: z.enum(['worktree-sleep', 'quit', 'live']).optional()