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:
Neil
2026-08-07 20:10:07 -07:00
parent a7e31e5e10
commit 73dda849de
3 changed files with 225 additions and 17 deletions
@@ -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)
})
})
+44
View File
@@ -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
+28 -17
View File
@@ -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(