fix: persist live agent resume checkpoints (#5633)

This commit is contained in:
Jinwoo Hong 2026-06-17 14:19:54 -07:00 committed by GitHub
parent 82ad55e2d1
commit ee2bc5eb02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 426 additions and 37 deletions

View File

@ -183,7 +183,10 @@ export function resolveDisabledCreatePrHeaderAction(
{ value0: copy.reviewLabel }
)
break
default:
case 'existing_review':
case 'fork_head_unsupported':
case 'unsupported_provider':
case null:
title = translate(
'auto.components.right.sidebar.source.control.primary.action.f0c6e2a581',
'This branch is not ready for a {{value0}} yet.',

View File

@ -3,23 +3,30 @@ import { useCallback, useEffect, useState } from 'react'
import { Check, Copy, TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { translate } from '@/i18n/i18n'
const PULL_POLICY_ERROR_PREFIX = 'Pull needs a Git pull policy for divergent branches.'
const PULL_POLICY_OPTIONS = [
{
label: 'Merge',
description: 'Create a merge commit when local and remote both changed.',
labelKey: 'auto.components.right.sidebar.pull.policy.notice.merge',
labelFallback: 'Merge',
descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.mergeDescription',
descriptionFallback: 'Create a merge commit when local and remote both changed.',
command: 'git config pull.rebase false'
},
{
label: 'Rebase',
description: 'Replay local commits on top of the remote branch.',
labelKey: 'auto.components.right.sidebar.pull.policy.notice.rebase',
labelFallback: 'Rebase',
descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.rebaseDescription',
descriptionFallback: 'Replay local commits on top of the remote branch.',
command: 'git config pull.rebase true'
},
{
label: 'Fast-forward only',
description: 'Only pull when no merge or rebase is needed.',
labelKey: 'auto.components.right.sidebar.pull.policy.notice.fastForwardOnly',
labelFallback: 'Fast-forward only',
descriptionKey: 'auto.components.right.sidebar.pull.policy.notice.fastForwardOnlyDescription',
descriptionFallback: 'Only pull when no merge or rebase is needed.',
command: 'git config pull.ff only'
}
] as const
@ -65,20 +72,29 @@ export function PullPolicyRemoteActionNotice({
</span>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold text-foreground">Pull needs a policy</span>
<span className="text-xs font-semibold text-foreground">
{translate(
'auto.components.right.sidebar.pull.policy.notice.title',
'Pull needs a policy'
)}
</span>
<span className="shrink-0 rounded-full bg-destructive/10 px-1.5 py-px text-[10px] leading-4 font-semibold text-destructive">
Diverged
{translate('auto.components.right.sidebar.pull.policy.notice.diverged', 'Diverged')}
</span>
</div>
<p className="text-[11px] leading-4 text-muted-foreground">
This branch has local and remote commits. Run one command in this worktree or on the
SSH host, then try Pull or Sync again.
{translate(
'auto.components.right.sidebar.pull.policy.notice.body',
'This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.'
)}
</p>
</div>
</div>
<div className="space-y-1.5">
{PULL_POLICY_OPTIONS.map((option) => {
const copied = copiedCommand === option.command
const label = translate(option.labelKey, option.labelFallback)
const description = translate(option.descriptionKey, option.descriptionFallback)
return (
<div
key={option.command}
@ -87,11 +103,9 @@ export function PullPolicyRemoteActionNotice({
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[11px] leading-4 font-semibold text-foreground">
{option.label}
{label}
</div>
<p className="text-[11px] leading-4 text-muted-foreground">
{option.description}
</p>
<p className="text-[11px] leading-4 text-muted-foreground">{description}</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
@ -100,7 +114,11 @@ export function PullPolicyRemoteActionNotice({
variant="ghost"
size="icon-xs"
className="mt-0.5 shrink-0"
aria-label={`Copy ${option.label.toLowerCase()} pull policy command`}
aria-label={translate(
'auto.components.right.sidebar.pull.policy.notice.copyAria',
'Copy {{value0}} pull policy command',
{ value0: label.toLowerCase() }
)}
onClick={() => handleCopyCommand(option.command)}
>
{copied ? (
@ -111,7 +129,15 @@ export function PullPolicyRemoteActionNotice({
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{copied ? 'Copied' : 'Copy command'}
{copied
? translate(
'auto.components.right.sidebar.pull.policy.notice.copied',
'Copied'
)
: translate(
'auto.components.right.sidebar.pull.policy.notice.copyCommand',
'Copy command'
)}
</TooltipContent>
</Tooltip>
</div>

View File

@ -8736,7 +8736,19 @@
"c39d0c75c3": "Linked review branch target is unavailable.",
"8c6d15a07d": "Create PR",
"d37e68f61d": "Preparing branch for review…",
"c72e5e65d1": "Prepare this branch and create a {{value0}}"
"c72e5e65d1": "Prepare this branch and create a {{value0}}",
"b8e4f2a901": "Wait for the remote operation to finish.",
"c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
"d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
"e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
"f4c0e6a925": "Commit changes before creating a {{value0}}.",
"a5d1f7b036": "Publish commits before creating a {{value0}}.",
"b6e2a8c147": "Push commits before creating a {{value0}}.",
"c7f3b9d258": "Sync this branch before creating a {{value0}}.",
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@ -8911,6 +8923,24 @@
"8c2d5fab13": "Copy commit hash",
"9d3e60bc24": "Copy commit message",
"ae4f71cd35": "Explain changes"
},
"pull": {
"policy": {
"notice": {
"merge": "Merge",
"mergeDescription": "Create a merge commit when local and remote both changed.",
"rebase": "Rebase",
"rebaseDescription": "Replay local commits on top of the remote branch.",
"fastForwardOnly": "Fast-forward only",
"fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
"title": "Pull needs a policy",
"diverged": "Diverged",
"body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
"copyAria": "Copy {{value0}} pull policy command",
"copied": "Copied",
"copyCommand": "Copy command"
}
}
}
}
},

View File

@ -8736,7 +8736,19 @@
"c39d0c75c3": "El destino de la rama de revisión vinculada no está disponible.",
"8c6d15a07d": "Crear PR",
"d37e68f61d": "Preparando la rama para revisión…",
"c72e5e65d1": "Prepara esta rama y crea un {{value0}}"
"c72e5e65d1": "Prepara esta rama y crea un {{value0}}",
"b8e4f2a901": "Wait for the remote operation to finish.",
"c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
"d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
"e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
"f4c0e6a925": "Commit changes before creating a {{value0}}.",
"a5d1f7b036": "Publish commits before creating a {{value0}}.",
"b6e2a8c147": "Push commits before creating a {{value0}}.",
"c7f3b9d258": "Sync this branch before creating a {{value0}}.",
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@ -8911,6 +8923,24 @@
"8c2d5fab13": "Copiar hash del commit",
"9d3e60bc24": "Copiar mensaje del commit",
"ae4f71cd35": "Explicar los cambios"
},
"pull": {
"policy": {
"notice": {
"merge": "Merge",
"mergeDescription": "Create a merge commit when local and remote both changed.",
"rebase": "Rebase",
"rebaseDescription": "Replay local commits on top of the remote branch.",
"fastForwardOnly": "Fast-forward only",
"fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
"title": "Pull needs a policy",
"diverged": "Diverged",
"body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
"copyAria": "Copy {{value0}} pull policy command",
"copied": "Copied",
"copyCommand": "Copy command"
}
}
}
}
},

View File

@ -8736,7 +8736,19 @@
"c39d0c75c3": "リンクされたレビュー ブランチのターゲットを利用できません。",
"8c6d15a07d": "PR を作成",
"d37e68f61d": "レビュー用にブランチを準備中…",
"c72e5e65d1": "このブランチを準備して {{value0}} を作成"
"c72e5e65d1": "このブランチを準備して {{value0}} を作成",
"b8e4f2a901": "Wait for the remote operation to finish.",
"c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
"d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
"e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
"f4c0e6a925": "Commit changes before creating a {{value0}}.",
"a5d1f7b036": "Publish commits before creating a {{value0}}.",
"b6e2a8c147": "Push commits before creating a {{value0}}.",
"c7f3b9d258": "Sync this branch before creating a {{value0}}.",
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@ -8911,6 +8923,24 @@
"8c2d5fab13": "コミットハッシュをコピー",
"9d3e60bc24": "コミットメッセージをコピー",
"ae4f71cd35": "変更を説明"
},
"pull": {
"policy": {
"notice": {
"merge": "Merge",
"mergeDescription": "Create a merge commit when local and remote both changed.",
"rebase": "Rebase",
"rebaseDescription": "Replay local commits on top of the remote branch.",
"fastForwardOnly": "Fast-forward only",
"fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
"title": "Pull needs a policy",
"diverged": "Diverged",
"body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
"copyAria": "Copy {{value0}} pull policy command",
"copied": "Copied",
"copyCommand": "Copy command"
}
}
}
}
},

View File

@ -8736,7 +8736,19 @@
"c39d0c75c3": "연결된 검토 브랜치 대상을 사용할 수 없습니다.",
"8c6d15a07d": "PR 생성",
"d37e68f61d": "검토를 위해 브랜치를 준비하는 중…",
"c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성"
"c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성",
"b8e4f2a901": "Wait for the remote operation to finish.",
"c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
"d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
"e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
"f4c0e6a925": "Commit changes before creating a {{value0}}.",
"a5d1f7b036": "Publish commits before creating a {{value0}}.",
"b6e2a8c147": "Push commits before creating a {{value0}}.",
"c7f3b9d258": "Sync this branch before creating a {{value0}}.",
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@ -8911,6 +8923,24 @@
"8c2d5fab13": "커밋 해시 복사",
"9d3e60bc24": "커밋 메시지 복사",
"ae4f71cd35": "변경 사항 설명"
},
"pull": {
"policy": {
"notice": {
"merge": "Merge",
"mergeDescription": "Create a merge commit when local and remote both changed.",
"rebase": "Rebase",
"rebaseDescription": "Replay local commits on top of the remote branch.",
"fastForwardOnly": "Fast-forward only",
"fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
"title": "Pull needs a policy",
"diverged": "Diverged",
"body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
"copyAria": "Copy {{value0}} pull policy command",
"copied": "Copied",
"copyCommand": "Copy command"
}
}
}
}
},

View File

@ -8736,7 +8736,19 @@
"c39d0c75c3": "已关联的评审分支目标不可用。",
"8c6d15a07d": "创建 PR",
"d37e68f61d": "正在准备分支以供评审…",
"c72e5e65d1": "准备此分支并创建 {{value0}}"
"c72e5e65d1": "准备此分支并创建 {{value0}}",
"b8e4f2a901": "Wait for the remote operation to finish.",
"c9f3a1b802": "Resolve conflicts before creating a {{value0}}.",
"d2a8c4e703": "No changes on this branch to include in a {{value0}}.",
"e3b9d5f814": "Cannot create a {{value0}} from the default branch.",
"f4c0e6a925": "Commit changes before creating a {{value0}}.",
"a5d1f7b036": "Publish commits before creating a {{value0}}.",
"b6e2a8c147": "Push commits before creating a {{value0}}.",
"c7f3b9d258": "Sync this branch before creating a {{value0}}.",
"d8a4c0e369": "Authenticate before creating a {{value0}}.",
"e9b5d1f470": "Check out a branch before creating a {{value0}}.",
"f0c6e2a581": "This branch is not ready for a {{value0}} yet.",
"h3i4j5k607": "Checking whether this branch can create a {{value0}}…"
}
}
}
@ -8911,6 +8923,24 @@
"8c2d5fab13": "复制提交哈希",
"9d3e60bc24": "复制提交信息",
"ae4f71cd35": "解释更改"
},
"pull": {
"policy": {
"notice": {
"merge": "Merge",
"mergeDescription": "Create a merge commit when local and remote both changed.",
"rebase": "Rebase",
"rebaseDescription": "Replay local commits on top of the remote branch.",
"fastForwardOnly": "Fast-forward only",
"fastForwardOnlyDescription": "Only pull when no merge or rebase is needed.",
"title": "Pull needs a policy",
"diverged": "Diverged",
"body": "This branch has local and remote commits. Run one command in this worktree or on the SSH host, then try Pull or Sync again.",
"copyAria": "Copy {{value0}} pull policy command",
"copied": "Copied",
"copyCommand": "Copy command"
}
}
}
}
},

View File

@ -58,6 +58,20 @@ describe('resumeSleepingAgentSessionsForWorktree', () => {
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
})
it('skips live-checkpoint records — their restored pane owns recovery', () => {
const record = makeRecord({ origin: 'live' })
useAppStore.setState({
tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
} as never)
const launched = resumeSleepingAgentSessionsForWorktree('wt-1')
expect(launched).toBe(0)
expect(useAppStore.getState().tabsByWorktree['wt-1']).toHaveLength(1)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
})
it('resumes legacy sleep records without an origin even when their tab still exists', () => {
const record = makeRecord()
useAppStore.setState({

View File

@ -81,11 +81,10 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean
export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number {
const records = Object.values(useAppStore.getState().sleepingAgentSessionsByPaneKey)
.filter((record) => record.worktreeId === worktreeId)
// Why: quit-time captures (#5232) cover panes that still exist in the
// restored session. Those panes own their own recovery — warm reattach
// when the daemon kept the agent alive, or the pane-level cold-restore
// resume — so launching a separate tab here would duplicate the session.
.filter((record) => record.origin !== 'quit')
// Why: pane-owned captures (#5232/#5626) cover panes that still exist in
// the restored session. Those panes own their own recovery — warm reattach
// when the daemon kept the agent alive, or pane-level cold-restore resume.
.filter((record) => record.origin !== 'quit' && record.origin !== 'live')
.sort((a, b) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)
let launched = 0

View File

@ -135,6 +135,58 @@ describe('createSessionWriteSubscriber', () => {
cleanup()
})
it('writes a live agent recovery checkpoint when provider session metadata arrives', () => {
const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>()
const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist })
useAppStore.setState({
workspaceSessionReady: true,
hydrationSucceeded: true,
tabsByWorktree: {
'wt-1': [
{
id: 'tab-1',
ptyId: null,
worktreeId: 'wt-1',
title: 'Codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
} as never)
vi.advanceTimersByTime(200)
persist.mockClear()
useAppStore.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'Fix tests',
agentType: 'codex'
},
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
vi.advanceTimersByTime(200)
expect(persist).toHaveBeenCalledWith({
patch: {
sleepingAgentSessionsByPaneKey: {
'tab-1:leaf-1': expect.objectContaining({
providerSession: { key: 'session_id', id: 'codex-session-1' },
origin: 'live'
})
}
}
})
cleanup()
})
it('writes exactly once when a relevant field changes', () => {
const persist = vi.fn<(payload: WorkspaceSessionWrite) => void>()
const cleanup = createSessionWriteSubscriber({ store: useAppStore, persist })

View File

@ -24,6 +24,112 @@ function makeAgentEntry(overrides: {
}
describe('captureAllSleepingAgentSessions', () => {
it('checkpoints a live resumable provider session before quit-time capture', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'finish the task',
agentType: 'codex'
},
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
// Why: Windows update/reboot exits can miss beforeunload; the provider
// session handle must already be durable for pane-level cold restore.
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
agent: 'codex',
worktreeId: 'wt-1',
tabId: 'tab-1',
providerSession: { key: 'session_id', id: 'codex-session-1' },
origin: 'live'
})
})
it('does not rewrite the live checkpoint for same-session status ticks', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'first prompt',
agentType: 'codex'
},
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
const firstRecord = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'second prompt',
agentType: 'codex'
},
'Codex',
{ updatedAt: 20, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(firstRecord)
})
it('clears the live checkpoint when the agent finishes', () => {
const store = createTestStore()
store.setState({
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
}
} as Partial<AppState>)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'working',
prompt: 'finish the task',
agentType: 'codex'
},
'Codex',
{ updatedAt: 10, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
store.getState().setAgentStatus(
'tab-1:leaf-1',
{
state: 'done',
prompt: 'finish the task',
agentType: 'codex'
},
'Codex',
{ updatedAt: 20, stateStartedAt: 10 },
{ tabId: 'tab-1', worktreeId: 'wt-1' },
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
)
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
})
it('captures resumable agents across every worktree, not just one', () => {
const store = createTestStore()
store.setState({

View File

@ -274,6 +274,23 @@ export function collectSleepingAgentSessionRecordsForWorktree(
return records
}
function recoveryRecordMatches(
existing: SleepingAgentSessionRecord | undefined,
next: SleepingAgentSessionRecord
): boolean {
if (!existing) {
return false
}
return (
existing.origin === next.origin &&
existing.agent === next.agent &&
existing.worktreeId === next.worktreeId &&
existing.tabId === next.tabId &&
existing.providerSession.key === next.providerSession.key &&
existing.providerSession.id === next.providerSession.id
)
}
function pruneMigrationUnsupportedEntries(
entries: Record<string, MigrationUnsupportedPtyEntry>,
predicate: (entry: MigrationUnsupportedPtyEntry) => boolean
@ -611,11 +628,30 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
)
const hasSleepingRecord = paneKey in s.sleepingAgentSessionsByPaneKey
const nextSleepingAgentSessions = hasSleepingRecord
? { ...s.sleepingAgentSessionsByPaneKey }
: s.sleepingAgentSessionsByPaneKey
if (hasSleepingRecord) {
const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
const liveRecoveryWorktreeId =
entry.state === 'done'
? null
: (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey))
const liveRecoveryRecord = liveRecoveryWorktreeId
? sleepingRecordFromEntry({
state: s,
entry,
worktreeId: liveRecoveryWorktreeId,
capturedAt: updatedAt,
origin: 'live'
})
: null
let nextSleepingAgentSessions = s.sleepingAgentSessionsByPaneKey
if (liveRecoveryRecord) {
if (!recoveryRecordMatches(existingSleepingRecord, liveRecoveryRecord)) {
nextSleepingAgentSessions = {
...s.sleepingAgentSessionsByPaneKey,
[paneKey]: liveRecoveryRecord
}
}
} else if (existingSleepingRecord) {
nextSleepingAgentSessions = { ...s.sleepingAgentSessionsByPaneKey }
delete nextSleepingAgentSessions[paneKey]
}
return {

View File

@ -36,10 +36,10 @@ export type SleepingAgentSessionRecord = {
connectionId?: string | null
/** How the record was captured. Worktree-sleep records (legacy records have
* no origin) are consumed by worktree activation, which opens a fresh tab.
* Quit records describe panes that still exist in the restored session, so
* only the pane's own cold-restore path may consume them activation
* Quit/live records describe panes that still exist in the restored session,
* so only the pane's own cold-restore path may consume them activation
* launching a tab too would duplicate a warm-reattached session (#5232). */
origin?: 'worktree-sleep' | 'quit'
origin?: 'worktree-sleep' | 'quit' | 'live'
}
const RESUMABLE_TUI_AGENT_SET: ReadonlySet<string> = new Set(RESUMABLE_TUI_AGENTS)

View File

@ -126,13 +126,15 @@ describe('parseWorkspaceSession', () => {
capturedAt: 10,
updatedAt: 9,
terminalTitle: 'Codex',
lastAssistantMessage: 'done'
lastAssistantMessage: 'done',
origin: 'live'
}
}
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.agent).toBe('codex')
expect(result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.origin).toBe('live')
}
})

View File

@ -109,7 +109,8 @@ const sleepingAgentSessionRecordSchema = z.object({
updatedAt: z.number().finite().positive(),
terminalTitle: z.string().optional(),
lastAssistantMessage: z.string().optional(),
connectionId: z.string().nullable().optional()
connectionId: z.string().nullable().optional(),
origin: z.enum(['worktree-sleep', 'quit', 'live']).optional()
})
const sleepingAgentSessionsByPaneKeySchema = z.preprocess((raw) => {