mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Prevent stale sleeping agents from resuming (#6018)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user