mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(runtime): preserve pane reservation creator identity
This commit is contained in:
@@ -631,6 +631,132 @@ describe('registerPtyHandlers', () => {
|
||||
return spawn
|
||||
}
|
||||
|
||||
function createFullRuntimePaneRaceHarness() {
|
||||
type ProviderSpawnOptions = {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
sessionId?: string
|
||||
}
|
||||
const folder = {
|
||||
id: 'full-runtime-pane-race-folder',
|
||||
projectGroupId: 'full-runtime-pane-race-group',
|
||||
name: 'Full runtime pane race',
|
||||
folderPath: process.cwd(),
|
||||
connectionId: null,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const group = {
|
||||
id: folder.projectGroupId,
|
||||
name: folder.projectGroupId,
|
||||
parentPath: folder.folderPath,
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual' as const,
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const pending: {
|
||||
options: ProviderSpawnOptions
|
||||
resolve: (result: { id: string }) => void
|
||||
}[] = []
|
||||
const providerSpawn = vi.fn(
|
||||
(options: ProviderSpawnOptions) =>
|
||||
new Promise<{ id: string }>((resolve) => pending.push({ options, resolve }))
|
||||
)
|
||||
const shutdown = vi.fn()
|
||||
installDaemonTestProvider({ spawn: providerSpawn, shutdown })
|
||||
const store = {
|
||||
getFolderWorkspace: vi.fn(() => folder),
|
||||
getFolderWorkspaces: vi.fn(() => [folder]),
|
||||
getProjectGroups: vi.fn(() => [group]),
|
||||
getRepos: vi.fn(() => []),
|
||||
getSettings: vi.fn(() => ({ claudeAgentTeamsMode: 'native-panes-shim' as const })),
|
||||
persistPtyBinding: vi.fn(),
|
||||
removePtyBindingIfMatches: vi.fn()
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
vi.spyOn(runtime, 'createPreAllocatedTerminalHandle').mockReturnValue('term_renderer_creator')
|
||||
registerPtyHandlers(
|
||||
mainWindow as never,
|
||||
runtime,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
store as never
|
||||
)
|
||||
const folderKey = `folder:${folder.id}`
|
||||
const tabId = 'tab-full-runtime-pane-race'
|
||||
const leafId = '16161616-1616-4616-8616-161616161616'
|
||||
const paneKey = makePaneKey(tabId, leafId)
|
||||
const launchConfig = (owner: string) => ({
|
||||
agentCommand: 'claude --teammate-mode auto',
|
||||
agentArgs: `--${owner}`,
|
||||
agentEnv: { OWNER: owner }
|
||||
})
|
||||
const spawnRenderer = (sessionId?: string) =>
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: folder.folderPath,
|
||||
worktreeId: folderKey,
|
||||
tabId,
|
||||
leafId,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
env: { ORCA_PANE_KEY: paneKey },
|
||||
command: 'claude --teammate-mode auto',
|
||||
launchAgent: 'claude',
|
||||
launchConfig: launchConfig('creator')
|
||||
}) as Promise<{ id: string; spawnDisposition: string }>
|
||||
const spawnRuntime = (opts: {
|
||||
sessionId?: string
|
||||
signal?: AbortSignal
|
||||
acceptWorkspaceInventory?: () => boolean
|
||||
preAllocatedHandle?: string
|
||||
}) =>
|
||||
runtime.createTerminal(`id:${folderKey}`, {
|
||||
command: 'claude --teammate-mode auto',
|
||||
launchAgent: 'claude',
|
||||
launchConfig: launchConfig('waiter'),
|
||||
title: 'waiter title',
|
||||
tabId,
|
||||
leafId,
|
||||
persistHostSessionBinding: true,
|
||||
spawnReservationFreshness:
|
||||
runtime.getTerminalSpawnReservationFreshness(folderKey, null) ?? undefined,
|
||||
...opts
|
||||
})
|
||||
const teams = (runtime as unknown as { claudeAgentTeams: ClaudeAgentTeamsService })
|
||||
.claudeAgentTeams
|
||||
return {
|
||||
folder,
|
||||
group,
|
||||
folderKey,
|
||||
tabId,
|
||||
leafId,
|
||||
paneKey,
|
||||
pending,
|
||||
providerSpawn,
|
||||
shutdown,
|
||||
store,
|
||||
runtime,
|
||||
teams,
|
||||
launchConfig,
|
||||
spawnRenderer,
|
||||
spawnRuntime
|
||||
}
|
||||
}
|
||||
|
||||
function installObservableDaemonTestProvider() {
|
||||
const spawn = vi.fn(async (options: { sessionId?: string }) => ({
|
||||
id: options.sessionId ?? 'daemon-pty'
|
||||
@@ -8630,6 +8756,190 @@ describe('registerPtyHandlers', () => {
|
||||
clearProviderPtyState(creatorSessionId)
|
||||
})
|
||||
|
||||
it('adopts a renderer creator without rebinding its full runtime identity', async () => {
|
||||
const harness = createFullRuntimePaneRaceHarness()
|
||||
const sessionId = 'caller-owned-full-runtime-session'
|
||||
const rendererSpawn = harness.spawnRenderer(sessionId)
|
||||
await vi.waitFor(() => expect(harness.providerSpawn).toHaveBeenCalledOnce())
|
||||
const creatorOptions = harness.pending[0]!.options
|
||||
expect(creatorOptions.env?.ORCA_TERMINAL_HANDLE).toBe('term_renderer_creator')
|
||||
|
||||
const runtimeSpawn = harness.spawnRuntime({
|
||||
sessionId,
|
||||
preAllocatedHandle: 'term_runtime_waiter',
|
||||
acceptWorkspaceInventory: () => true
|
||||
})
|
||||
await vi.waitFor(() => expect(harness.teams.getActiveTeamCount()).toBe(2))
|
||||
|
||||
harness.runtime.registerPreAllocatedHandleForPty(sessionId, 'term_renderer_creator')
|
||||
harness.runtime.registerPty(sessionId, harness.folderKey, null, {
|
||||
tabId: harness.tabId,
|
||||
leafId: harness.leafId
|
||||
})
|
||||
const creatorLaunchConfig = harness.launchConfig('stored-creator')
|
||||
const creatorPty = (
|
||||
harness.runtime as unknown as {
|
||||
ptysById: Map<
|
||||
string,
|
||||
{
|
||||
runtimeSessionOwned: boolean
|
||||
title: string | null
|
||||
titleUpdatedAt: number | null
|
||||
launchConfig: ReturnType<typeof harness.launchConfig> | null
|
||||
launchToken: string | null
|
||||
launchAgent: TuiAgent | null
|
||||
}
|
||||
>
|
||||
}
|
||||
).ptysById.get(sessionId)!
|
||||
Object.assign(creatorPty, {
|
||||
runtimeSessionOwned: false,
|
||||
title: 'creator title',
|
||||
titleUpdatedAt: Date.now(),
|
||||
launchConfig: creatorLaunchConfig,
|
||||
launchToken: 'creator-launch-token',
|
||||
launchAgent: null
|
||||
})
|
||||
|
||||
harness.pending[0]!.resolve({ id: sessionId })
|
||||
await expect(Promise.all([rendererSpawn, runtimeSpawn])).resolves.toEqual([
|
||||
expect.objectContaining({ id: sessionId, spawnDisposition: 'created' }),
|
||||
expect.objectContaining({
|
||||
handle: 'term_renderer_creator',
|
||||
ptyId: sessionId,
|
||||
tabId: harness.tabId,
|
||||
paneKey: harness.paneKey,
|
||||
worktreeId: harness.folderKey,
|
||||
title: 'creator title',
|
||||
surface: 'background'
|
||||
})
|
||||
])
|
||||
|
||||
expect(harness.providerSpawn).toHaveBeenCalledOnce()
|
||||
expect(harness.teams.getActiveTeamCount()).toBe(1)
|
||||
expect(creatorPty).toMatchObject({
|
||||
runtimeSessionOwned: false,
|
||||
title: 'creator title',
|
||||
launchConfig: creatorLaunchConfig,
|
||||
launchToken: 'creator-launch-token',
|
||||
launchAgent: null
|
||||
})
|
||||
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(sessionId)
|
||||
expect(piClearPtyMock).not.toHaveBeenCalledWith(sessionId)
|
||||
clearProviderPtyState(sessionId)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ fence: 'inventory', expected: 'tab_not_found' },
|
||||
{ fence: 'abort', expected: 'client_disconnected' }
|
||||
] as const)('cleans a provisional runtime team before a $fence spawn exit', async (testCase) => {
|
||||
const harness = createFullRuntimePaneRaceHarness()
|
||||
const abort = new AbortController()
|
||||
if (testCase.fence === 'abort') {
|
||||
abort.abort()
|
||||
}
|
||||
const removeTeam = vi.spyOn(harness.teams, 'removeTeamForLaunchEnv')
|
||||
|
||||
await expect(
|
||||
harness.spawnRuntime({
|
||||
sessionId: `caller-owned-pre-spawn-${testCase.fence}`,
|
||||
preAllocatedHandle: `term_pre_spawn_${testCase.fence}`,
|
||||
signal: abort.signal,
|
||||
acceptWorkspaceInventory: () => testCase.fence !== 'inventory'
|
||||
})
|
||||
).rejects.toThrow(testCase.expected)
|
||||
|
||||
expect(harness.providerSpawn).not.toHaveBeenCalled()
|
||||
expect(removeTeam).toHaveBeenCalledOnce()
|
||||
expect(harness.teams.getActiveTeamCount()).toBe(0)
|
||||
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(
|
||||
`caller-owned-pre-spawn-${testCase.fence}`
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ fence: 'inventory', expected: 'tab_not_found' },
|
||||
{ fence: 'abort', expected: 'client_disconnected' }
|
||||
] as const)(
|
||||
'abandons only the runtime waiter when a $fence changes after reservation',
|
||||
async (testCase) => {
|
||||
const harness = createFullRuntimePaneRaceHarness()
|
||||
const sessionId = `caller-owned-post-spawn-${testCase.fence}`
|
||||
const rendererSpawn = harness.spawnRenderer(sessionId)
|
||||
await vi.waitFor(() => expect(harness.providerSpawn).toHaveBeenCalledOnce())
|
||||
let inventoryAccepted = true
|
||||
const abort = new AbortController()
|
||||
const runtimeSpawn = harness.spawnRuntime({
|
||||
sessionId,
|
||||
preAllocatedHandle: `term_post_spawn_${testCase.fence}`,
|
||||
signal: abort.signal,
|
||||
acceptWorkspaceInventory: () => inventoryAccepted
|
||||
})
|
||||
await vi.waitFor(() => expect(harness.teams.getActiveTeamCount()).toBe(2))
|
||||
|
||||
if (testCase.fence === 'inventory') {
|
||||
inventoryAccepted = false
|
||||
} else {
|
||||
abort.abort()
|
||||
}
|
||||
harness.pending[0]!.resolve({ id: sessionId })
|
||||
|
||||
await expect(rendererSpawn).resolves.toMatchObject({
|
||||
id: sessionId,
|
||||
spawnDisposition: 'created'
|
||||
})
|
||||
await expect(runtimeSpawn).rejects.toThrow(testCase.expected)
|
||||
expect(harness.providerSpawn).toHaveBeenCalledOnce()
|
||||
expect(harness.shutdown).not.toHaveBeenCalled()
|
||||
expect(harness.teams.getActiveTeamCount()).toBe(1)
|
||||
expect(
|
||||
(harness.runtime as unknown as { handleByPtyId: Map<string, string> }).handleByPtyId.get(
|
||||
sessionId
|
||||
)
|
||||
).toBe('term_renderer_creator')
|
||||
expect(openCodeClearPtyMock).not.toHaveBeenCalledWith(sessionId)
|
||||
expect(piClearPtyMock).not.toHaveBeenCalledWith(sessionId)
|
||||
clearProviderPtyState(sessionId)
|
||||
}
|
||||
)
|
||||
|
||||
it('isolates a changed folder route from the renderer creator reservation', async () => {
|
||||
const harness = createFullRuntimePaneRaceHarness()
|
||||
const oldPath = harness.folder.folderPath
|
||||
const rendererSpawn = harness.spawnRenderer()
|
||||
await vi.waitFor(() => expect(harness.providerSpawn).toHaveBeenCalledOnce())
|
||||
|
||||
harness.folder.folderPath = join(process.cwd(), 'src')
|
||||
harness.group.parentPath = harness.folder.folderPath
|
||||
const runtimeSpawn = harness.spawnRuntime({
|
||||
preAllocatedHandle: 'term_current_folder_route',
|
||||
acceptWorkspaceInventory: () => harness.folder.folderPath.endsWith('src')
|
||||
})
|
||||
expect(harness.providerSpawn).toHaveBeenCalledOnce()
|
||||
const oldSessionId = harness.pending[0]!.options.sessionId!
|
||||
harness.pending[0]!.resolve({ id: oldSessionId })
|
||||
await expect(rendererSpawn).resolves.toMatchObject({ id: oldSessionId })
|
||||
await vi.waitFor(() => expect(harness.providerSpawn).toHaveBeenCalledTimes(2))
|
||||
expect(harness.pending.map(({ options }) => options.cwd)).toEqual([
|
||||
oldPath,
|
||||
harness.folder.folderPath
|
||||
])
|
||||
|
||||
const currentSessionId = harness.pending[1]!.options.sessionId!
|
||||
expect(currentSessionId).not.toBe(oldSessionId)
|
||||
harness.pending[1]!.resolve({ id: currentSessionId })
|
||||
|
||||
await expect(runtimeSpawn).resolves.toMatchObject({
|
||||
handle: 'term_current_folder_route',
|
||||
ptyId: currentSessionId,
|
||||
worktreeId: harness.folderKey
|
||||
})
|
||||
expect(harness.providerSpawn).toHaveBeenCalledTimes(2)
|
||||
expect(harness.shutdown).not.toHaveBeenCalled()
|
||||
clearProviderPtyState(oldSessionId)
|
||||
clearProviderPtyState(currentSessionId)
|
||||
})
|
||||
|
||||
it.each(['runtime', 'renderer'] as const)(
|
||||
'preserves caller-owned session state for a %s pane waiter',
|
||||
async (waiterKind) => {
|
||||
|
||||
@@ -73,6 +73,28 @@ function createServiceWithLeader(): {
|
||||
}
|
||||
|
||||
describe('ClaudeAgentTeamsService', () => {
|
||||
it('removes only the team named by its launch identity', () => {
|
||||
const service = new ClaudeAgentTeamsService()
|
||||
const first = service.createLaunchEnv({
|
||||
leaderHandle: 'shared-handle',
|
||||
baseEnv: {},
|
||||
shimDir: '/tmp/orca-shim',
|
||||
shimBin: 'orca'
|
||||
})
|
||||
const second = service.createLaunchEnv({
|
||||
leaderHandle: 'shared-handle',
|
||||
baseEnv: {},
|
||||
shimDir: '/tmp/orca-shim',
|
||||
shimBin: 'orca'
|
||||
})
|
||||
|
||||
service.removeTeamForLaunchEnv(first.env)
|
||||
|
||||
expect(service.getActiveTeamCount()).toBe(1)
|
||||
service.removeTeamForLaunchEnv(second.env)
|
||||
expect(service.getActiveTeamCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('supports Claude core tmux teammate sequence with native splits', async () => {
|
||||
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
|
||||
const request = (argv: string[]) =>
|
||||
|
||||
@@ -81,6 +81,15 @@ export class ClaudeAgentTeamsService {
|
||||
}
|
||||
}
|
||||
|
||||
removeTeamForLaunchEnv(env: Readonly<Record<string, string>>): void {
|
||||
const teamId = env.ORCA_AGENT_TEAMS_TEAM_ID
|
||||
const token = env.ORCA_AGENT_TEAMS_TOKEN
|
||||
const team = teamId ? this.teams.get(teamId) : undefined
|
||||
if (team && team.token === token) {
|
||||
this.teams.delete(teamId)
|
||||
}
|
||||
}
|
||||
|
||||
getActiveTeamCount(): number {
|
||||
return this.teams.size
|
||||
}
|
||||
|
||||
@@ -25429,13 +25429,26 @@ export class OrcaRuntimeService {
|
||||
)
|
||||
const terminalColorQueryReplies =
|
||||
launchOpts.terminalColorQueryReplies ?? getTerminalViewColorQueryReplyColors()
|
||||
if (launchOpts.signal?.aborted) {
|
||||
throw new Error('client_disconnected')
|
||||
let ownsProvisionalAgentTeam = agentTeamsPlan !== null
|
||||
const discardProvisionalAgentTeam = (): void => {
|
||||
if (!ownsProvisionalAgentTeam || !agentTeamsPlan) {
|
||||
return
|
||||
}
|
||||
ownsProvisionalAgentTeam = false
|
||||
this.claudeAgentTeams.removeTeamForLaunchEnv(agentTeamsPlan.env)
|
||||
}
|
||||
if (launchOpts.acceptWorkspaceInventory?.() === false) {
|
||||
throw new Error('tab_not_found')
|
||||
const getPreAdoptionRejection = (): string | null => {
|
||||
if (launchOpts.signal?.aborted) {
|
||||
return 'client_disconnected'
|
||||
}
|
||||
return launchOpts.acceptWorkspaceInventory?.() === false ? 'tab_not_found' : null
|
||||
}
|
||||
const result = await this.ptyController.spawn({
|
||||
const preSpawnRejection = getPreAdoptionRejection()
|
||||
if (preSpawnRejection) {
|
||||
discardProvisionalAgentTeam()
|
||||
throw new Error(preSpawnRejection)
|
||||
}
|
||||
const spawnPromise = this.ptyController.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd,
|
||||
@@ -25492,7 +25505,16 @@ export class OrcaRuntimeService {
|
||||
? { persistHostSessionBinding: true }
|
||||
: {})
|
||||
})
|
||||
if (launchOpts.acceptWorkspaceInventory?.() === false) {
|
||||
let result: Awaited<typeof spawnPromise>
|
||||
try {
|
||||
result = await spawnPromise
|
||||
} catch (error) {
|
||||
discardProvisionalAgentTeam()
|
||||
throw error
|
||||
}
|
||||
const postSpawnRejection = getPreAdoptionRejection()
|
||||
if (postSpawnRejection) {
|
||||
discardProvisionalAgentTeam()
|
||||
const shouldKill = result.spawnRetirementToken
|
||||
? this.ptyController.releaseSpawnReservation?.(result.id, result.spawnRetirementToken) ===
|
||||
true
|
||||
@@ -25503,7 +25525,38 @@ export class OrcaRuntimeService {
|
||||
if (result.spawnDisposition === 'created') {
|
||||
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
|
||||
}
|
||||
throw new Error('tab_not_found')
|
||||
throw new Error(postSpawnRejection)
|
||||
}
|
||||
if (result.spawnDisposition === 'awaited') {
|
||||
discardProvisionalAgentTeam()
|
||||
const creatorPty = this.ptysById.get(result.id)
|
||||
if (!creatorPty) {
|
||||
if (
|
||||
result.spawnRetirementToken &&
|
||||
this.ptyController.releaseSpawnReservation?.(result.id, result.spawnRetirementToken) ===
|
||||
true
|
||||
) {
|
||||
this.ptyController.kill(result.id)
|
||||
}
|
||||
throw new Error('terminal_not_found')
|
||||
}
|
||||
if (result.spawnRetirementToken) {
|
||||
this.ptyController.adoptSpawnReservation?.(result.id, result.spawnRetirementToken)
|
||||
}
|
||||
reportPtySpawnCommitted()
|
||||
return {
|
||||
handle: this.issuePtyHandle(creatorPty),
|
||||
...(creatorPty.tabId ? { tabId: creatorPty.tabId } : {}),
|
||||
paneKey: creatorPty.paneKey,
|
||||
ptyId: creatorPty.ptyId,
|
||||
worktreeId: creatorPty.worktreeId,
|
||||
title: getLatestPtyTitle(creatorPty),
|
||||
...this.getPtyExecutionHostMetadata(creatorPty.ptyId),
|
||||
surface: 'background',
|
||||
...(result.agentSessionEnsure
|
||||
? { agentSessionDisposition: result.agentSessionEnsure.disposition }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
if (result.spawnRetirementToken) {
|
||||
this.ptyController.adoptSpawnReservation?.(result.id, result.spawnRetirementToken)
|
||||
@@ -25519,12 +25572,14 @@ export class OrcaRuntimeService {
|
||||
try {
|
||||
this.assertPtyDidNotExitBeforeRegistration(result.id, result.incarnationId)
|
||||
} catch (error) {
|
||||
discardProvisionalAgentTeam()
|
||||
if (error instanceof Error && error.message === 'agent_session_exited_during_start') {
|
||||
this.releaseRejectedPtyRegistrationFence(result.id, result.incarnationId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
|
||||
ownsProvisionalAgentTeam = false
|
||||
if (result.wslDistro) {
|
||||
this.preparePtyExecutionContext(result.id, result.wslDistro)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user