fix(resume): honor scoped transcript claims across workspaces

This commit is contained in:
Neil
2026-09-11 01:19:00 -07:00
committed by Neil
parent 5fa62feda7
commit f8a05693cc
2 changed files with 156 additions and 7 deletions
@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
const initial = useAppStore.getState()
afterEach(() => useAppStore.setState(initial, true))
function seed(
claim: 'status' | 'startup' | 'automatic',
options: { foreignHost?: boolean; foreignAccount?: boolean; folder?: boolean } = {}
) {
const worktreeId = 'repo::/one'
const otherId = options.folder ? 'folder:two' : 'repo::/two'
const record: SleepingAgentSessionRecord = {
paneKey: 'old:leaf',
tabId: 'old',
worktreeId,
agent: 'claude',
providerSession: {
key: 'session_id',
id: 'session',
transcriptPath: '/account-a/session.jsonl'
},
prompt: '',
state: 'working',
origin: 'quit',
capturedAt: 1,
updatedAt: 1
}
const providerSession = {
...record.providerSession,
...(options.foreignAccount ? { transcriptPath: '/account-b/session.jsonl' } : {})
}
const otherHost = options.foreignHost ? 'ssh:other' : 'local'
useAppStore.setState({
repos: [{ id: 'repo', executionHostId: 'local' }],
worktreesByRepo: {
repo: [
{ id: worktreeId, repoId: 'repo', hostId: 'local', path: '/one' },
{ id: otherId, repoId: 'repo', hostId: otherHost, path: '/two' }
]
},
folderWorkspaces: options.folder
? [{ id: 'two', projectGroupId: 'group', executionHostId: otherHost }]
: [],
projectGroups: options.folder ? [{ id: 'group', executionHostId: otherHost }] : [],
tabsByWorktree: { [otherId]: [{ id: 'owner', worktreeId: otherId, ptyId: 'live' }] },
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record },
agentStatusByPaneKey:
claim === 'status'
? {
'owner:leaf': {
paneKey: 'owner:leaf',
tabId: 'owner',
worktreeId: otherId,
agentType: 'claude',
providerSession,
state: 'working'
}
}
: {},
pendingStartupByTabId:
claim === 'startup'
? { owner: { launchAgent: 'claude', resumeProviderSession: providerSession } }
: {},
automaticAgentResumeClaimsByTabId:
claim === 'automatic'
? { owner: { worktreeId: otherId, launchAgent: 'claude', providerSession } }
: {}
} as never)
return { record, worktreeId }
}
describe('sleeping resume claims across workspaces', () => {
it.each(['status', 'startup', 'automatic'] as const)(
'honors a same-host same-transcript %s claim',
(claim) => {
const { record, worktreeId } = seed(claim)
expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(0)
expect(useAppStore.getState().tabsByWorktree[worktreeId]).toBeUndefined()
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
}
)
it.each([{ foreignHost: true }, { foreignAccount: true }])(
'does not claim another host or account: %j',
(options) => {
const { worktreeId } = seed('status', options)
expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(1)
}
)
it('coalesces consecutive worktree sweeps before a queued resume spawns', () => {
const { record, worktreeId } = seed('status')
const second = { ...record, paneKey: 'second:leaf', tabId: 'second', worktreeId: 'repo::/two' }
useAppStore.setState({
tabsByWorktree: {},
agentStatusByPaneKey: {},
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record, [second.paneKey]: second }
})
expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(1)
expect(resumeSleepingAgentSessionsForWorktree(second.worktreeId)).toBe(0)
expect(Object.values(useAppStore.getState().tabsByWorktree).flat()).toHaveLength(1)
})
it('honors a folder workspace owner', () => {
const { worktreeId } = seed('status', { folder: true })
expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(0)
})
it('does not infer an account match from a legacy ID-only record', () => {
const { record, worktreeId } = seed('status')
useAppStore.setState({
sleepingAgentSessionsByPaneKey: {
[record.paneKey]: { ...record, providerSession: { key: 'session_id', id: 'session' } }
}
})
expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(1)
})
})
@@ -1,4 +1,5 @@
import { useAppStore } from '@/store'
import { resolveWorktreeOperationRouteResult } from './worktree-operation-route'
import {
agentProviderSessionsEqual,
type SleepingAgentSessionRecord
@@ -88,17 +89,47 @@ function activeOrQueuedResumeClaimsProviderSession(
state: ReturnType<typeof useAppStore.getState>,
samePaneOwnsRecovery: boolean
): boolean {
const worktreeTabIds = new Set(
(state.tabsByWorktree[record.worktreeId] ?? []).map((tab) => tab.id)
const tabWorktrees = new Map(
Object.entries(state.tabsByWorktree).flatMap(([worktreeId, tabs]) =>
tabs.map((tab) => [tab.id, worktreeId] as const)
)
)
const recordRoute = resolveWorktreeOperationRouteResult(state, record.worktreeId)
const matchesScope = (
tabId: string,
providerSession: SleepingAgentSessionRecord['providerSession'] | undefined
): boolean => {
const worktreeId = tabWorktrees.get(tabId)
if (!worktreeId) {
return false
}
if (worktreeId === record.worktreeId) {
return true
}
// Across workspaces, the exact transcript supplies the account namespace missing from the ID.
if (
!record.providerSession.transcriptPath ||
providerSession?.transcriptPath !== record.providerSession.transcriptPath
) {
return false
}
const route = resolveWorktreeOperationRouteResult(state, worktreeId)
return (
recordRoute.kind === 'resolved' &&
route.kind === 'resolved' &&
recordRoute.route.executionHostId !== null &&
recordRoute.route.executionHostId === route.route.executionHostId &&
recordRoute.route.runtimeEnvironmentId === route.route.runtimeEnvironmentId
)
}
for (const entry of Object.values(state.agentStatusByPaneKey)) {
// Why: only an owned pane needs its record; hidden/live panes still dedupe by status.
if (samePaneOwnsRecovery && entry.paneKey === record.paneKey) {
continue
}
if (
worktreeTabIds.has(getAgentStatusTabId(entry) ?? '') &&
entry.worktreeId === record.worktreeId &&
matchesScope(getAgentStatusTabId(entry) ?? '', entry.providerSession) &&
entry.worktreeId === tabWorktrees.get(getAgentStatusTabId(entry) ?? '') &&
entry.agentType === record.agent &&
entry.state !== 'done' &&
agentProviderSessionsEqual(record.agent, entry.providerSession, record.providerSession)
@@ -109,7 +140,7 @@ function activeOrQueuedResumeClaimsProviderSession(
for (const [tabId, startup] of Object.entries(state.pendingStartupByTabId)) {
if (
worktreeTabIds.has(tabId) &&
matchesScope(tabId, startup.resumeProviderSession) &&
startup.launchAgent === record.agent &&
agentProviderSessionsEqual(
record.agent,
@@ -123,8 +154,8 @@ function activeOrQueuedResumeClaimsProviderSession(
for (const [tabId, claim] of Object.entries(state.automaticAgentResumeClaimsByTabId)) {
if (
worktreeTabIds.has(tabId) &&
claim.worktreeId === record.worktreeId &&
matchesScope(tabId, claim.providerSession) &&
claim.worktreeId === tabWorktrees.get(tabId) &&
claim.launchAgent === record.agent &&
agentProviderSessionsEqual(record.agent, claim.providerSession, record.providerSession)
) {