fix: persist live agent resume checkpoints (#5633)
This commit is contained in:
parent
82ad55e2d1
commit
ee2bc5eb02
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue