mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
fix(runtime): keep folder-workspace suffix in runtime PTY identity
Folder projects back several independent workspaces with one directory; their ids differ only by `::workspace:<uuid>`. runtimeWorktreeIdsEqual and runtimeWorktreeIdentityKey used splitWorktreeIdForFilesystem, which strips that suffix (correct for cwd/filesystem callers), so two live sessions — and the folder root — compared EQUAL in runtime identity. Controller inventory refresh then re-stamped a sibling's PTY with the wrong worktree id, exact-target selection leaked sibling PTYs, and resolveTerminalSessionWorktreeId saw >1 matching session key as soon as both workspaces had tabs, permanently failing the exact-persisted-surface check and throwing terminal_orphan_competing_owner — leaving paired/mobile clients on "Loading terminal" forever. Switch both helpers to the suffix-preserving splitWorktreeId (keeping path normalization so Windows/WSL/SSH spellings still match), collapse the duplicated inline comparison in stopTerminalsForWorktree onto the shared helper, and break the now-exposed cwd-fallback tie toward the caller's target so an unattributed PTY still lands in the workspace being listed. Filesystem callers keep splitWorktreeIdForFilesystem, so both sessions still launch in the shared physical directory.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { WorkspaceSessionState } from '../../shared/types'
|
||||
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
// Folder projects back several workspaces with ONE directory; only the
|
||||
// `::workspace:<uuid>` suffix separates them, so runtime PTY identity must keep it.
|
||||
const REPO_ID = 'repo-1'
|
||||
const FOLDER_PATH = '/tmp/folder-project'
|
||||
const ROOT_ID = `${REPO_ID}::${FOLDER_PATH}`
|
||||
const WORKSPACE_A = `${ROOT_ID}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa`
|
||||
const WORKSPACE_B = `${ROOT_ID}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb`
|
||||
const PTY_A = `${WORKSPACE_A}@@pty-a`
|
||||
const PTY_B = `${WORKSPACE_B}@@pty-b`
|
||||
const LEAF_B = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
|
||||
const REPO = {
|
||||
id: REPO_ID,
|
||||
path: FOLDER_PATH,
|
||||
displayName: 'folder-project',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1
|
||||
} as const
|
||||
|
||||
type RuntimeInternals = {
|
||||
buildResolvedWorktreeFromId: (worktreeId: string) => unknown
|
||||
refreshPtyWorktreeRecordsWithControllerInventory: (
|
||||
resolvedWorktrees: unknown[],
|
||||
targetWorktreeId?: string | null
|
||||
) => Promise<{ livePtyIds: Set<string> } | null>
|
||||
getLivePtyIdsForWorktree: (worktreeId: string) => Set<string>
|
||||
hasExactPersistedTerminalSurfaceIdentity: (expected: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
leafId: string
|
||||
ptyId: string
|
||||
incarnationId: string
|
||||
}) => boolean
|
||||
ptysById: Map<string, { worktreeId: string }>
|
||||
recordPtyWorktree: (ptyId: string, worktreeId: string, state?: Record<string, unknown>) => unknown
|
||||
}
|
||||
|
||||
const OWNED_CONTROLLER_SESSIONS = [
|
||||
{ id: PTY_A, worktreeId: WORKSPACE_A, cwd: FOLDER_PATH, title: 'a' },
|
||||
{ id: PTY_B, worktreeId: WORKSPACE_B, cwd: FOLDER_PATH, title: 'b' }
|
||||
]
|
||||
|
||||
function createRuntimeInternals(
|
||||
options: { session?: WorkspaceSessionState; sessions?: unknown[] } = {}
|
||||
): RuntimeInternals {
|
||||
const meta = { [WORKSPACE_A]: { hostId: 'local' }, [WORKSPACE_B]: { hostId: 'local' } }
|
||||
const store = {
|
||||
getRepos: () => [REPO],
|
||||
getRepo: (id: string) => (id === REPO_ID ? REPO : undefined),
|
||||
getAllWorktreeMeta: () => meta,
|
||||
getWorktreeMeta: (worktreeId: string) => meta[worktreeId as keyof typeof meta],
|
||||
getWorkspaceSession: () => options.session ?? getDefaultWorkspaceSession(),
|
||||
setWorkspaceSession: () => {},
|
||||
flushOrThrow: () => {}
|
||||
} as never
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => options.sessions ?? OWNED_CONTROLLER_SESSIONS
|
||||
} as never)
|
||||
return runtime as unknown as RuntimeInternals
|
||||
}
|
||||
|
||||
describe('folder workspaces sharing one directory', () => {
|
||||
it('keeps each workspace instance bound to its own controller PTY', async () => {
|
||||
const internals = createRuntimeInternals()
|
||||
const resolvedWorktrees = [WORKSPACE_A, WORKSPACE_B].map((id) =>
|
||||
internals.buildResolvedWorktreeFromId(id)
|
||||
)
|
||||
|
||||
await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees)
|
||||
|
||||
expect(internals.ptysById.get(PTY_A)?.worktreeId).toBe(WORKSPACE_A)
|
||||
expect(internals.ptysById.get(PTY_B)?.worktreeId).toBe(WORKSPACE_B)
|
||||
})
|
||||
|
||||
it('selects only the targeted workspace instance from a controller inventory', async () => {
|
||||
const internals = createRuntimeInternals()
|
||||
const resolvedWorktrees = [WORKSPACE_A, WORKSPACE_B].map((id) =>
|
||||
internals.buildResolvedWorktreeFromId(id)
|
||||
)
|
||||
|
||||
const inventory = await internals.refreshPtyWorktreeRecordsWithControllerInventory(
|
||||
resolvedWorktrees,
|
||||
WORKSPACE_B
|
||||
)
|
||||
|
||||
expect([...(inventory?.livePtyIds ?? [])]).toEqual([PTY_B])
|
||||
})
|
||||
|
||||
it('reports live PTYs per workspace instance and for the folder root separately', () => {
|
||||
const internals = createRuntimeInternals()
|
||||
internals.recordPtyWorktree(PTY_A, WORKSPACE_A, { connected: true })
|
||||
internals.recordPtyWorktree(PTY_B, WORKSPACE_B, { connected: true })
|
||||
internals.recordPtyWorktree('pty-root', ROOT_ID, { connected: true })
|
||||
|
||||
expect([...internals.getLivePtyIdsForWorktree(WORKSPACE_A)]).toEqual([PTY_A])
|
||||
expect([...internals.getLivePtyIdsForWorktree(WORKSPACE_B)]).toEqual([PTY_B])
|
||||
expect([...internals.getLivePtyIdsForWorktree(ROOT_ID)]).toEqual(['pty-root'])
|
||||
})
|
||||
|
||||
it('resolves a persisted terminal surface while a sibling instance also has tabs', () => {
|
||||
const paneKey = makePaneKey('tab-b', LEAF_B)
|
||||
const session: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
[WORKSPACE_A]: [{ id: 'tab-a', worktreeId: WORKSPACE_A, title: 'A' }],
|
||||
[WORKSPACE_B]: [{ id: 'tab-b', worktreeId: WORKSPACE_B, title: 'B' }]
|
||||
} as never,
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-b': { root: null, activeLeafId: LEAF_B, ptyIdsByLeafId: { [LEAF_B]: PTY_B } }
|
||||
} as never,
|
||||
terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-b' }
|
||||
}
|
||||
const internals = createRuntimeInternals({ session })
|
||||
|
||||
expect(
|
||||
internals.hasExactPersistedTerminalSurfaceIdentity({
|
||||
worktreeId: WORKSPACE_B,
|
||||
tabId: 'tab-b',
|
||||
leafId: LEAF_B,
|
||||
ptyId: PTY_B,
|
||||
incarnationId: 'inc-b'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('attributes an unowned cwd-only PTY to the targeted instance, not a sibling', async () => {
|
||||
const internals = createRuntimeInternals({
|
||||
sessions: [{ id: 'legacy-cwd-pty', cwd: `${FOLDER_PATH}/src`, title: 'legacy' }]
|
||||
})
|
||||
const resolvedWorktrees = [WORKSPACE_A, WORKSPACE_B].map((id) =>
|
||||
internals.buildResolvedWorktreeFromId(id)
|
||||
)
|
||||
|
||||
const inventory = await internals.refreshPtyWorktreeRecordsWithControllerInventory(
|
||||
resolvedWorktrees,
|
||||
WORKSPACE_B
|
||||
)
|
||||
|
||||
expect([...(inventory?.livePtyIds ?? [])]).toEqual(['legacy-cwd-pty'])
|
||||
expect(internals.ptysById.get('legacy-cwd-pty')?.worktreeId).toBe(WORKSPACE_B)
|
||||
})
|
||||
})
|
||||
@@ -3541,6 +3541,50 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(terminals.terminals[0]?.worktreePath).toBe(TEST_FOLDER_WORKSPACE_PATH)
|
||||
})
|
||||
|
||||
it('keeps same-path folder workspace instance PTYs scoped to their exact ids', async () => {
|
||||
const firstWorktreeId = `${TEST_REPO_ID}::${TEST_FOLDER_WORKSPACE_PATH}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}11111111-1111-4111-8111-111111111111`
|
||||
const secondWorktreeId = `${TEST_REPO_ID}::${TEST_FOLDER_WORKSPACE_PATH}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}22222222-2222-4222-8222-222222222222`
|
||||
vi.mocked(listWorktrees).mockClear()
|
||||
vi.mocked(listWorktrees).mockRejectedValue(
|
||||
new Error('folder explicit-id fallback should not rescan worktrees')
|
||||
)
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null,
|
||||
listProcesses: async () => [
|
||||
{
|
||||
id: 'first-folder-pty',
|
||||
cwd: TEST_FOLDER_WORKSPACE_PATH,
|
||||
title: 'first',
|
||||
worktreeId: firstWorktreeId
|
||||
},
|
||||
{
|
||||
id: 'second-folder-pty',
|
||||
cwd: TEST_FOLDER_WORKSPACE_PATH,
|
||||
title: 'second',
|
||||
worktreeId: secondWorktreeId
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const terminals = await runtime.listTerminals(`id:${secondWorktreeId}`)
|
||||
|
||||
expect(listWorktrees).not.toHaveBeenCalled()
|
||||
expect(terminals.terminals).toHaveLength(1)
|
||||
expect(terminals.terminals[0]).toMatchObject({
|
||||
ptyId: 'second-folder-pty',
|
||||
worktreeId: secondWorktreeId,
|
||||
worktreePath: TEST_FOLDER_WORKSPACE_PATH
|
||||
})
|
||||
const internals = runtime as unknown as {
|
||||
ptysById: Map<string, { worktreeId: string }>
|
||||
}
|
||||
expect(internals.ptysById.get('first-folder-pty')).toBeUndefined()
|
||||
expect(internals.ptysById.get('second-folder-pty')?.worktreeId).toBe(secondWorktreeId)
|
||||
})
|
||||
|
||||
it('routes PTY output through the PTY leaf index in large terminal graphs', () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const liveLeafCount = 2773
|
||||
|
||||
@@ -27229,18 +27229,9 @@ export class OrcaRuntimeService {
|
||||
return { stopped: 0 }
|
||||
}
|
||||
// Preserve folder-instance suffixes while normalizing cross-platform path spelling.
|
||||
const parsedTarget = splitWorktreeId(worktree.id)
|
||||
const ownsWorktree = options.resolvedWorktreeId
|
||||
? (candidate: string | undefined): boolean => {
|
||||
if (!candidate) {
|
||||
return false
|
||||
}
|
||||
const parsedCandidate = splitWorktreeId(candidate)
|
||||
return parsedCandidate && parsedTarget
|
||||
? parsedCandidate.repoId === parsedTarget.repoId &&
|
||||
runtimePathsEqual(parsedCandidate.worktreePath, parsedTarget.worktreePath)
|
||||
: candidate === worktree.id
|
||||
}
|
||||
? (candidate: string | undefined): boolean =>
|
||||
candidate ? runtimeWorktreeIdsEqual(candidate, worktree.id) : false
|
||||
: (candidate: string | undefined): boolean => candidate === worktree.id
|
||||
const ownsHost = (ptyId: string, connectionId?: string | null): boolean => {
|
||||
if (options.resolvedRuntimeEnvironmentId !== undefined) {
|
||||
@@ -29292,7 +29283,7 @@ export class OrcaRuntimeService {
|
||||
: (session.worktreeId ??
|
||||
persistedWorktree?.id ??
|
||||
inferredWorktreeId ??
|
||||
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd))
|
||||
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd, targetWorktreeId))
|
||||
const persistedSurface = persistedIndexes.surfaceByPtyId.get(session.id)
|
||||
const restoresExactSurface =
|
||||
persistedSurface &&
|
||||
@@ -36930,9 +36921,16 @@ function runtimePathsEqual(left: string, right: string): boolean {
|
||||
return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: runtime identity is per *workspace*, not per checkout dir. Folder projects back
|
||||
* several independent workspaces with one directory, separated only by the
|
||||
* `::workspace:<uuid>` suffix that filesystem callers must strip; stripping it here
|
||||
* instead lets one session steal a sibling's PTYs. Normalize only path spelling, so
|
||||
* Windows/WSL/SSH ids still match themselves across hosts.
|
||||
*/
|
||||
function runtimeWorktreeIdsEqual(left: string, right: string): boolean {
|
||||
const parsedLeft = splitWorktreeIdForFilesystem(left)
|
||||
const parsedRight = splitWorktreeIdForFilesystem(right)
|
||||
const parsedLeft = splitWorktreeId(left)
|
||||
const parsedRight = splitWorktreeId(right)
|
||||
return parsedLeft && parsedRight
|
||||
? parsedLeft.repoId === parsedRight.repoId &&
|
||||
runtimePathsEqual(parsedLeft.worktreePath, parsedRight.worktreePath)
|
||||
@@ -36940,7 +36938,8 @@ function runtimeWorktreeIdsEqual(left: string, right: string): boolean {
|
||||
}
|
||||
|
||||
function runtimeWorktreeIdentityKey(worktreeId: string): string {
|
||||
const parsed = splitWorktreeIdForFilesystem(worktreeId)
|
||||
// Same suffix rule: this keys PTY refresh, sleep, and mutation-queue state per session.
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
return parsed
|
||||
? `${parsed.repoId}\0${normalizeRuntimePathForComparison(parsed.worktreePath)}`
|
||||
: worktreeId
|
||||
@@ -37225,7 +37224,8 @@ function includeTargetResolvedWorktree(
|
||||
|
||||
function findResolvedWorktreeIdForPath(
|
||||
resolvedWorktrees: ResolvedWorktree[],
|
||||
cwd: string
|
||||
cwd: string,
|
||||
targetWorktreeId?: string | null
|
||||
): string | null {
|
||||
if (!cwd) {
|
||||
return null
|
||||
@@ -37233,7 +37233,18 @@ function findResolvedWorktreeIdForPath(
|
||||
const matches = resolvedWorktrees
|
||||
.filter((worktree) => isPathInsideOrEqual(worktree.path, cwd))
|
||||
.sort((left, right) => right.path.length - left.path.length)
|
||||
return matches[0]?.id ?? null
|
||||
// Why: a cwd cannot distinguish folder-workspace siblings, which all share one
|
||||
// directory. Break that tie toward the caller's target instead of store order,
|
||||
// so an unattributed PTY still lands in the workspace being listed. Only ties at
|
||||
// the deepest path qualify — a nested worktree must still beat its parent.
|
||||
const deepest = matches.filter((worktree) => worktree.path.length === matches[0]?.path.length)
|
||||
return (
|
||||
(deepest.length > 1
|
||||
? deepest.find((worktree) => worktree.id === targetWorktreeId)?.id
|
||||
: undefined) ??
|
||||
matches[0]?.id ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function getLeafWorktreeStatus(
|
||||
|
||||
Reference in New Issue
Block a user