mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
fix(resume): honor scoped transcript claims across workspaces
This commit is contained in:
@@ -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)
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user