diff --git a/src/renderer/src/lib/resume-sleeping-agent-cross-workspace.test.ts b/src/renderer/src/lib/resume-sleeping-agent-cross-workspace.test.ts new file mode 100644 index 00000000000..55311860fa2 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-cross-workspace.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index e0b5b79ac6e..ae76c50f967 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -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, 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) ) {