fix(runtime): bind serve-host terminals to their workspace at create (#38864)

Terminals a paired desktop or mobile client opened on a headless serve
host got a workspace-less serve-<uuid> session id, so the host inferred
their owner from the cwd. Folder workspaces that share a path then traded
those terminals whenever one was listed, and deleting a workspace stopped
terminals shown in another.

Serve ids now carry the workspace like every other daemon id. Inventory
refresh keeps a persisted layout binding even when a targeted listing does
not include its workspace, so restored serve-<uuid> terminals stay put.
A cwd guess is recorded as such and never drives a worktree-scoped stop.

Fixes #36716

Reported-by: Guy Shechter (@shechter)

Co-authored-by: Bing.Z <zzb@gxsmjx.com>
This commit is contained in:
Neil
2026-11-05 22:53:21 -08:00
committed by GitHub
co-authored by Bing.Z
parent 4c13ae4632
commit 8ea884135e
21 changed files with 446 additions and 76 deletions
@@ -65,7 +65,7 @@ describe('PTY inventory refresh keeps recorded floating ownership', () => {
await runtime.listTerminals(`id:${folderWorkspaceKey}`)
const listed = await runtime.listTerminals()
const terminal = listed.terminals.find((entry) => entry.ptyId?.startsWith('serve-'))
const terminal = listed.terminals.find((entry) => entry.ptyId?.includes('@@serve-'))
expect(terminal?.worktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID)
expect(terminal?.worktreeId).not.toBe(folderWorkspaceKey)
const floating = await runtime.listMobileSessionTabs(`id:${FLOATING_TERMINAL_WORKTREE_ID}`)
@@ -371,7 +371,7 @@ describe('OrcaRuntimeService', () => {
worktreeId: TEST_WORKTREE_ID,
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
sessionId: expect.stringMatching(/^serve-/),
sessionId: expect.stringMatching(/@@serve-/),
isNewSession: true,
persistHostSessionBinding: true
})
@@ -124,7 +124,12 @@ describe('OrcaRuntimeService', () => {
it('reports a PTY that remains live after acknowledged worktree sleep', async () => {
const runtime = new OrcaRuntimeService(store)
const liveProcess = { id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' }
const liveProcess = {
id: 'pty-1',
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
title: 'Claude'
}
runtime.setPtyController({
write: () => true,
kill: () => false,
@@ -163,7 +168,16 @@ describe('OrcaRuntimeService', () => {
if (listCount === 2) {
throw new Error('daemon unavailable')
}
return listCount === 1 ? [{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' }] : []
return listCount === 1
? [
{
id: 'pty-1',
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
title: 'Claude'
}
]
: []
}
})
@@ -197,7 +211,8 @@ describe('OrcaRuntimeService', () => {
it('coalesces two clients sleeping the same host worktree', async () => {
const runtime = new OrcaRuntimeService(store)
const initialInventory = deferred<{ id: string; cwd: string; title: string }[]>()
const initialInventory =
deferred<{ id: string; cwd: string; worktreeId: string; title: string }[]>()
const listProcesses = vi
.fn()
.mockImplementationOnce(() => initialInventory.promise)
@@ -216,7 +231,9 @@ describe('OrcaRuntimeService', () => {
const first = runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)
const second = runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)
initialInventory.resolve([{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' }])
initialInventory.resolve([
{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID, title: 'Claude' }
])
const [firstResult, secondResult] = await Promise.all([first, second])
expect(secondResult).toEqual(firstResult)
@@ -231,7 +248,14 @@ describe('OrcaRuntimeService', () => {
runtime.onClientEvent((event) => events.push(event))
const listProcesses = vi
.fn()
.mockResolvedValueOnce([{ id: 'pty-before-sleep', cwd: TEST_WORKTREE_PATH, title: 'Shell' }])
.mockResolvedValueOnce([
{
id: 'pty-before-sleep',
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
title: 'Shell'
}
])
.mockResolvedValueOnce([])
runtime.setPtyController({
write: () => true,
@@ -341,7 +365,11 @@ describe('OrcaRuntimeService', () => {
}
})
runtime.onClientEvent((event) => secondListenerEvents.push(event))
const processLists = [[{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' }], [], []]
const processLists = [
[{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID, title: 'Claude' }],
[],
[]
]
runtime.setPtyController({
write: () => true,
kill: () => false,
@@ -400,7 +428,14 @@ describe('OrcaRuntimeService', () => {
const events: RuntimeClientEvent[] = []
runtime.onClientEvent((event) => events.push(event))
const processLists = [
[{ id: 'pty-preserved-history', cwd: TEST_WORKTREE_PATH, title: 'Shell' }],
[
{
id: 'pty-preserved-history',
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
title: 'Shell'
}
],
[],
[]
]
@@ -437,12 +472,17 @@ describe('OrcaRuntimeService', () => {
runtime.onClientEvent((event) => events.push(event))
}
const secondStop = deferred<boolean>()
const failedPty = { id: 'pty-fails', cwd: TEST_WORKTREE_PATH, title: 'Claude' }
const failedPty = {
id: 'pty-fails',
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID,
title: 'Claude'
}
const listProcesses = vi
.fn()
.mockResolvedValueOnce([
failedPty,
{ id: 'pty-slow', cwd: TEST_WORKTREE_PATH, title: 'Shell' }
{ id: 'pty-slow', cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID, title: 'Shell' }
])
.mockResolvedValueOnce([failedPty])
.mockResolvedValueOnce([failedPty])
@@ -16,8 +16,8 @@ describe('OrcaRuntimeService', () => {
stopAndWait,
getForegroundProcess: async () => null,
listProcesses: async () => [
{ id: 'pty-run', cwd: TEST_WORKTREE_PATH, title: 'Claude' },
{ id: 'pty-user', cwd: TEST_WORKTREE_PATH, title: 'Shell' }
{ id: 'pty-run', cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID, title: 'Claude' },
{ id: 'pty-user', cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID, title: 'Shell' }
]
})
@@ -142,10 +142,10 @@ describe('OrcaRuntimeService', () => {
const stopped: string[] = []
const processLists = [
[
{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' },
{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' }
{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID, title: 'Claude' },
{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID, title: 'Shell' }
],
[{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, title: 'Shell' }]
[{ id: 'pty-shell', cwd: TEST_WORKTREE_PATH, worktreeId: TEST_WORKTREE_ID, title: 'Shell' }]
]
runtime.setPtyController({
write: () => true,
@@ -18,6 +18,16 @@ import {
syncSinglePty
} from './orca-runtime-test-fixtures.spec'
// A live PTY as a host lists it: the daemon reports its workspace, never just a cwd (#36716).
function listedPty(
id: string,
title: string,
cwd = TEST_WORKTREE_PATH,
worktreeId = TEST_WORKTREE_ID
) {
return { id, cwd, worktreeId, title }
}
describe('OrcaRuntimeService', () => {
it('durably closes every terminal in one workspace without touching a sibling', async () => {
const otherWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-b`
@@ -604,13 +614,7 @@ describe('OrcaRuntimeService', () => {
it('sleeps every freshly discovered worktree PTY without a hydrated renderer graph', async () => {
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
const processLists = [
[
{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' },
{ id: 'pty-2', cwd: TEST_WORKTREE_PATH, title: 'Shell' }
],
[]
]
const processLists = [[listedPty('pty-1', 'Claude'), listedPty('pty-2', 'Shell')], []]
runtime.setPtyController({
write: () => true,
kill: () => false,
@@ -806,7 +810,7 @@ describe('OrcaRuntimeService', () => {
kill: () => false,
stopAndWait: vi.fn(async () => false),
getForegroundProcess: async () => null,
listProcesses: async () => [{ id: 'pty-1', cwd: TEST_WORKTREE_PATH, title: 'Claude' }]
listProcesses: async () => [listedPty('pty-1', 'Claude')]
})
await expect(runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`)).rejects.toThrow(
@@ -828,11 +832,9 @@ describe('OrcaRuntimeService', () => {
])
const runtime = new OrcaRuntimeService(store)
const stopped: string[] = []
const otherPty = { id: 'pty-other', cwd: otherWorktreePath, title: 'Other' }
const processLists = [
[{ id: 'pty-target', cwd: TEST_WORKTREE_PATH, title: 'Target' }, otherPty],
[otherPty]
]
const otherId = `${TEST_REPO_ID}::${otherWorktreePath}`
const otherPty = listedPty('pty-other', 'Other', otherWorktreePath, otherId)
const processLists = [[listedPty('pty-target', 'Target'), otherPty], [otherPty]]
runtime.setPtyController({
write: () => true,
kill: () => false,
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { parsePtySessionId } from '../../shared/terminal/pty/lifecycle/pty-session-id-format'
import { withBoundPane } from './orca-runtime-tests/bind-first-pane.test-fixture'
// Preserve side-effect order: mocks, lifecycle, fixtures.
await import('./orca-runtime-tests/runtime-test-module-registration.spec')
const { OrcaRuntimeService } = await import('./orca-runtime')
await import('./orca-runtime-tests/orca-runtime-test-lifecycle.spec')
const { createFolderWorkspaceRuntimeStore, makeFolderProjectGroup, makeFolderWorkspace } =
await import('./orca-runtime-tests/orca-runtime-test-fixtures.spec')
const FOLDER = process.cwd()
const H = '10000000-0000-4000-8000-000000000001'
const B = '10000000-0000-4000-8000-000000000002'
// Same-path folder workspaces on a headless serve host (#36716).
function makeServeHost(seed: { ptyId: string; worktreeId?: string }[] = []) {
const folders = [H, B].map((id) => makeFolderWorkspace({ id, folderPath: FOLDER }))
let session = getDefaultWorkspaceSession()
for (const [index, { ptyId, worktreeId }] of seed.entries()) {
if (worktreeId) {
session = withBoundPane(session, {
worktreeId,
tabId: `tab-${index}`,
leafId: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
ptyId
})
}
}
const store = {
...createFolderWorkspaceRuntimeStore(
folders[0],
makeFolderProjectGroup({ parentPath: FOLDER })
),
getFolderWorkspaces: () => folders,
getWorkspaceSession: () => session,
setWorkspaceSession: (next: typeof session) => {
session = next
},
flushOrThrow: () => {}
}
const live = new Set(seed.map(({ ptyId }) => ptyId))
const runtime = new OrcaRuntimeService(store)
const spawn = vi.fn(
async (args: {
sessionId?: string
worktreeId?: string
tabId?: string
leafId?: string
persistHostSessionBinding?: boolean
}) => {
if (!args.worktreeId || !args.tabId || !args.leafId) {
throw new Error('missing identity')
}
const id = args.sessionId ?? `${args.worktreeId}@@control`
live.add(id)
if (args.persistHostSessionBinding) {
session = withBoundPane(session, {
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.leafId,
ptyId: id
})
}
return { id }
}
)
const stopAndWait = vi.fn(async (id: string) => {
live.delete(id)
runtime.onPtyExit(id, 0)
return true
})
runtime.setPtyController({
spawn,
stopAndWait,
write: () => true,
kill: () => true,
hasPty: (id: string) => live.has(id),
getForegroundProcess: async () => null,
// Like the daemon, report only the workspace a session id names; every shell shares one cwd.
listProcesses: async () =>
[...live].map((id) => {
const { worktreeId } = parsePtySessionId(id)
return {
id,
incarnationId: `${id}-incarnation`,
terminalHandle: `term_${id}`,
wslDistro: null,
cwd: FOLDER,
title: 'shell',
...(worktreeId ? { worktreeId } : {})
}
})
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
const ownerOf = async (ptyId: string) =>
(await runtime.listTerminals()).terminals.find((entry) => entry.ptyId === ptyId)?.worktreeId
return { runtime, spawn, stopAndWait, live, ownerOf }
}
describe('serve host terminal workspace ownership', () => {
it('keeps a paired client terminal in its workspace when a same-path sibling refreshes', async () => {
const { runtime, spawn, stopAndWait, live, ownerOf } = makeServeHost()
await runtime.createMobileSessionTerminal(`id:folder:${H}`, { activate: false })
const spawned = await spawn.mock.results.at(-1)?.value
const id = spawned?.id ?? ''
expect(id).toMatch(new RegExp(`^folder:${H}@@serve-`))
await runtime.listMobileSessionTabs(`id:folder:${B}`)
expect((await runtime.listTerminals(`id:folder:${B}`)).terminals).toEqual([])
expect(await ownerOf(id)).toBe(`folder:${H}`)
await runtime.stopTerminalsForWorktree(`id:folder:${B}`)
expect(stopAndWait).not.toHaveBeenCalled()
expect(live.has(id)).toBe(true)
})
it('keeps a restored workspace-less serve terminal with its persisted binding', async () => {
const legacy = 'serve-00000000-0000-4000-8000-0000000000aa'
const { runtime, stopAndWait, live, ownerOf } = makeServeHost([
{ ptyId: legacy, worktreeId: `folder:${H}` }
])
await runtime.listMobileSessionTabs(`id:folder:${B}`)
expect((await runtime.listTerminals(`id:folder:${B}`)).terminals).toEqual([])
expect(await ownerOf(legacy)).toBe(`folder:${H}`)
await runtime.stopTerminalsForWorktree(`id:folder:${B}`)
expect(stopAndWait).not.toHaveBeenCalled()
expect(live.has(legacy)).toBe(true)
})
it('never stops an unbound terminal that a workspace only claims by cwd', async () => {
const unbound = 'unbound-shell'
const { runtime, stopAndWait, live } = makeServeHost([{ ptyId: unbound }])
await runtime.listMobileSessionTabs(`id:folder:${B}`)
const listed = await runtime.listTerminals(`id:folder:${B}`)
expect(listed.terminals.map((entry) => entry.ptyId)).toEqual([unbound])
await runtime.stopTerminalsForWorktree(`id:folder:${B}`)
await runtime.sleepTerminalsForWorktree(`id:folder:${B}`)
expect(stopAndWait).not.toHaveBeenCalled()
expect(live.has(unbound)).toBe(true)
})
it('stops a cwd-filed terminal with its workspace once a pane adopts it', async () => {
const orphan = 'unbound-shell'
const { runtime, stopAndWait, live } = makeServeHost([{ ptyId: orphan }])
const before = await runtime.listTerminals(`id:folder:${B}`)
await runtime.adoptTerminalOrphans({
worktree: `id:folder:${B}`,
expectedTopologyRevision: before.topologyRevisions?.[`folder:${B}`] ?? 0,
claims: [
{
terminal: `term_${orphan}`,
ptyId: orphan,
incarnationId: `${orphan}-incarnation`,
tabId: 'adopted-tab',
leafId: '00000000-0000-4000-8000-0000000000ad'
}
]
})
await runtime.stopTerminalsForWorktree(`id:folder:${B}`)
expect(stopAndWait).toHaveBeenCalledWith(orphan)
expect(live.has(orphan)).toBe(false)
})
})
@@ -22,6 +22,7 @@ import type { WorkspaceSessionState } from '../../../../shared/workspace-session
import type { RuntimeStore } from '../../runtime-store-contract'
import { SSH_PANE_RECOVERY_GRACE_MS } from '../../terminal/recovery/ssh-pane-recovery-window'
import { findTerminalTabIdForLeaf } from '../../../persistence/terminal-topology/terminal-topology-membership'
import { isServeMintedPtySessionId } from '../../../../shared/terminal/pty/lifecycle/pty-session-id-format'
export abstract class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession {
protected abstract getAcceptedRendererIdentityKeysForMobileSessionSnapshot(
@@ -181,7 +182,7 @@ export abstract class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs e
}
protected isServeOwnedPtyId(ptyId: string | null | undefined): boolean {
return typeof ptyId === 'string' && ptyId.startsWith('serve-')
return typeof ptyId === 'string' && isServeMintedPtySessionId(ptyId)
}
protected isSshOwnedPtyId(ptyId: string | null | undefined): boolean {
@@ -69,6 +69,7 @@ export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceR
| 'wslDistro'
| 'incarnationId'
| 'agentSessionOwners'
| 'worktreeInferredFromCwd'
>
> = {}
): RuntimePtyWorktreeRecord {
@@ -95,6 +96,7 @@ export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceR
ptyId,
incarnationId: state.incarnationId ?? null,
worktreeId,
...(state.worktreeInferredFromCwd ? { worktreeInferredFromCwd: true } : {}),
connectionId,
runtimeSessionOwned: state.runtimeSessionOwned ?? false,
isWsl: state.isWsl ?? null,
@@ -165,7 +167,7 @@ export abstract class OrcaRuntimeWithRecordPtyWorktree extends RuntimeWorkspaceR
return pty
}
this.terminalIdentity.setPtyWorktreeId(pty, worktreeId)
this.terminalIdentity.setPtyWorktree(pty, worktreeId, state.worktreeInferredFromCwd === true)
if (
state.incarnationId !== undefined &&
pty.incarnationId !== null &&
@@ -66,61 +66,45 @@ export class TerminalIdentitySource {
return record
}
setPtyWorktreeId(record: RuntimePtyWorktreeRecord, value: string): void {
/** `inferredFromCwd` marks a guess that must never drive a worktree-scoped stop (#36716). */
setPtyWorktree(record: RuntimePtyWorktreeRecord, worktreeId: string, inferredFromCwd: boolean) {
this.setPtyField(record, 'worktreeId', worktreeId)
this.setPtyField(record, 'worktreeInferredFromCwd', inferredFromCwd || undefined)
}
/** A binding (pane, layout) confirms the recorded worktree, so a cwd guess no longer guards it. */
confirmPtyWorktree(record: Pick<RuntimePtyWorktreeRecord, 'ptyId'>): void {
const owned = this.ownedPtys.get(record)
if (owned && owned.worktreeId !== value) {
owned.worktreeId = value
if (owned?.worktreeInferredFromCwd) {
delete owned.worktreeInferredFromCwd
this.changed()
}
}
setPtyIncarnationId(record: RuntimePtyWorktreeRecord, value: string | null): void {
const owned = this.ownedPtys.get(record)
if (owned && owned.incarnationId !== value) {
owned.incarnationId = value
this.changed()
}
this.setPtyField(record, 'incarnationId', value)
}
setPtyConnectionId(record: RuntimePtyWorktreeRecord, value: string | null): void {
const owned = this.ownedPtys.get(record)
if (owned && owned.connectionId !== value) {
owned.connectionId = value
this.changed()
}
this.setPtyField(record, 'connectionId', value)
}
setPtyConnected(record: RuntimePtyWorktreeRecord, value: boolean): void {
const owned = this.ownedPtys.get(record)
if (owned && owned.connected !== value) {
owned.connected = value
this.changed()
}
this.setPtyField(record, 'connected', value)
}
setPtyTabId(record: RuntimePtyWorktreeRecord, value: string | null | undefined): void {
const owned = this.ownedPtys.get(record)
if (owned && owned.tabId !== value) {
owned.tabId = value
this.changed()
}
this.setPtyField(record, 'tabId', value)
}
setPtyIsWsl(record: RuntimePtyWorktreeRecord, value: boolean | null): void {
const owned = this.ownedPtys.get(record)
if (owned && owned.isWsl !== value) {
owned.isWsl = value
this.changed()
}
this.setPtyField(record, 'isWsl', value)
}
setPtyWslDistro(record: RuntimePtyWorktreeRecord, value: string | null): void {
const owned = this.ownedPtys.get(record)
if (owned && owned.wslDistro !== value) {
owned.wslDistro = value
this.changed()
}
this.setPtyField(record, 'wslDistro', value)
}
setRecordedSurface(
record: Pick<
RuntimePtyWorktreeRecord,
@@ -302,6 +286,18 @@ export class TerminalIdentitySource {
this.changed()
}
private setPtyField<K extends keyof RuntimePtyWorktreeRecord>(
record: RuntimePtyWorktreeRecord,
key: K,
value: RuntimePtyWorktreeRecord[K]
): void {
const owned = this.ownedPtys.get(record)
if (owned && owned[key] !== value) {
owned[key] = value
this.changed()
}
}
private bind(map: Map<string, string>, key: string, value: string): void {
const previous = map.get(key)
if (previous !== value) {
@@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto'
import { copySleepingAgentLaunchConfig } from './runtime-agent-launch-resolution'
import { deriveRemoteRuntimeTerminalCreateHandle } from '../identity/remote-runtime-terminal-create-identity'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { mintServePtySessionId } from '../../../../shared/terminal/pty/lifecycle/pty-session-id-format'
import { placeCreatedSessionTab } from '../../../../shared/session-tab-placement'
import { insertTabIdIntoOrder } from '../../../../shared/workspace-layout/tab-order'
import type { OrcaRuntimeWithBuildPtyTerminalSummary as PtyTerminalSummaryMethods } from '../query/pty-terminal-summary'
@@ -68,7 +69,8 @@ export abstract class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal ext
const cwd = this.resolveWorkspaceTerminalStartupCwd(workspace, opts.cwd)
// Why: an SSH sessionId means reattach; only synthesize local serve ids so SSH fresh terminals still call pty.spawn.
const stableSessionId =
opts.identity?.sessionId ?? (workspace.connectionId ? undefined : `serve-${randomUUID()}`)
opts.identity?.sessionId ??
(workspace.connectionId ? undefined : mintServePtySessionId(workspace.id, randomUUID()))
const isNewSession = stableSessionId !== undefined && opts.identity?.sessionId === undefined
const mutationHandle = opts.createMutation
? deriveRemoteRuntimeTerminalCreateHandle(
@@ -134,16 +134,25 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve
inferredWorktreeId &&
worktreeIdsEqual(session.worktreeId, inferredWorktreeId)
)
const recordedPty = this.ptysById.get(session.id)
// Explicit unresolved provider ownership wins unless the session id proves it predates a persisted rename.
const worktreeId = providerWorktree
const boundWorktreeId = providerWorktree
? providerWorktree.id
: hasMigrationEvidence
? (persistedWorktree?.id ?? null)
: (session.worktreeId ??
persistedWorktree?.id ??
// A binding outside a targeted list belongs to that sibling; cwd must not re-file it (#36716).
persistedWorktreeId ??
(recordedFloating ? FLOATING_TERMINAL_WORKTREE_ID : undefined) ??
inferredWorktreeId ??
findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd, targetWorktreeId))
(recordedPty && !recordedPty.worktreeInferredFromCwd
? recordedPty.worktreeId
: undefined))
const worktreeInferredFromCwd = boundWorktreeId === undefined
const worktreeId = worktreeInferredFromCwd
? findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd, targetWorktreeId)
: boundWorktreeId
const persistedSurface = persistedIndexes.surfaceByPtyId.get(session.id)
const restoresExactSurface =
persistedSurface &&
@@ -174,6 +183,7 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve
...(session.incarnationId ? { incarnationId: session.incarnationId } : {}),
agentSessionOwners: session.incarnationId ? (session.agentSessionOwners ?? []) : [],
spawnPaneKey: session.paneKey ?? null,
...(worktreeInferredFromCwd ? { worktreeInferredFromCwd: true } : {}),
...(session.wslDistro !== undefined
? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro }
: {}),
@@ -68,6 +68,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
readonly ptyId: string
readonly incarnationId: PtyIncarnationId | null
readonly worktreeId: string
/** Set only when no binding named the workspace; worktree-scoped stops never act on a cwd guess (#36716). */
readonly worktreeInferredFromCwd?: true
readonly connectionId: string | null
runtimeSessionOwned: boolean
readonly isWsl: boolean | null
@@ -26,6 +26,7 @@ export function collectWorktreePtyIds(
for (const pty of ptysById.values()) {
if (
worktreeIdsEqual(pty.worktreeId, worktreeId) &&
!pty.worktreeInferredFromCwd &&
(includeDisconnected || pty.connected) &&
worktreePtyBelongsToHost(pty.ptyId, pty.connectionId, hostFence)
) {
@@ -34,3 +35,11 @@ export function collectWorktreePtyIds(
}
return ptyIds
}
/** Drops PTYs whose workspace only a cwd guess named; worktree-scoped stops never act on one (#36716). */
export function withoutCwdGuesses(
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>,
ptyIds: ReadonlySet<string>
): Set<string> {
return new Set([...ptyIds].filter((ptyId) => !ptysById.get(ptyId)?.worktreeInferredFromCwd))
}
@@ -8,6 +8,7 @@ import type { RuntimeWorktreeTerminalSleepResult } from '../../../../shared/runt
import { teardownRpcDeadline } from '../../workspaces/removal/worktree-teardown-deadline'
import { sleptNothing } from './worktree-terminal-sleep-result'
import type { WorktreeSleepScope } from './worktree-terminal-sleep-key'
import { withoutCwdGuesses } from './worktree-pty-collection'
// Renderer waits 15s; release the spawn fence and return verified failure before its deadline.
const WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS = 12_000
@@ -23,10 +24,10 @@ export abstract class OrcaRuntimeWithSleepResolvedWorktreeTerminals extends Orca
'exclusive',
sleepDeadline
)
const { key } = scope
// Why fenced: a same-id twin's PTYs are another host's to sleep, never filed under `key`.
const { key, fence } = scope
// Fenced: a same-id twin's PTYs are another host's; a cwd guess may be a same-path sibling's (#36716).
const liveHere = (fresh: ReadonlySet<string>) =>
this.getLivePtyIdsForWorktree(worktree.id, fresh, scope.fence)
withoutCwdGuesses(this.ptysById, this.getLivePtyIdsForWorktree(worktree.id, fresh, fence))
const existingSleepState = this.terminalSleepStateByHostWorktree.get(key)
if (existingSleepState?.phase === 'sleeping') {
try {
@@ -19,7 +19,7 @@ export function spawnSurfaceClaimSequence(graphSequence: number): number {
}
/** Write pane and graph standing together; main-owned layouts store no duplicate claim. */
type SurfaceWrites = Pick<TerminalIdentitySource, 'setRecordedSurface'>
type SurfaceWrites = Pick<TerminalIdentitySource, 'setRecordedSurface' | 'confirmPtyWorktree'>
export function recordPtySurfaceClaim(
source: SurfaceWrites,
@@ -48,6 +48,8 @@ export function recordPtySurface(
paneKey: string,
graphSequence: number
): void {
// A pane is a binding, so worktree stops and sleep act on it even if a cwd guess filed it (#36716).
source.confirmPtyWorktree(pty)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
return
}
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { OrcaRuntimeService } from '../../orca-runtime'
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const FOLDER: FolderWorkspace = {
id: 'notes-1',
projectGroupId: 'group-1',
name: 'Notes',
folderPath: '/fixture/notes',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
const FOLDER_KEY = folderWorkspaceKey(FOLDER.id)
const PLACED_LEAF = '33333333-3333-4333-8333-333333333333'
function runtimeWithCwdOnlyTerminal() {
// The host reports no workspace for this terminal, so only its cwd places it (#36716).
const live = [{ id: 'pty-agent', cwd: FOLDER.folderPath, title: 'claude' }]
const store = {
getRepos: () => [],
getProjectGroups: () => [{ id: 'group-1', parentPath: '/fixture' }],
getFolderWorkspaces: () => [FOLDER],
getSettings: () => ({}),
getAllWorktreeMeta: () => ({}),
getWorkspaceSessionHostIds: () => ['local'],
getWorkspaceSession: () => getDefaultWorkspaceSession(),
applyLayoutCommand: vi.fn(async () => ({
ok: true as const,
result: { tabId: 'tab-new', leafId: PLACED_LEAF, paneKey: `tab-new:${PLACED_LEAF}` }
}))
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup pass reads only these store methods.
const runtime = new OrcaRuntimeService(store as never)
const stopAndWait = vi.fn(async () => true)
runtime.setPtyController({
stopAndWait,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [...live],
listProcessesWithHostScope: async () => ({
processes: [...live],
hostIds: ['local' as const]
})
})
return { runtime, stopAndWait }
}
describe('startup placement of a cwd-filed live terminal', () => {
it('stops the terminal with its workspace once a pane binds it', async () => {
const { runtime, stopAndWait } = runtimeWithCwdOnlyTerminal()
expect(await runtime.placeUnshownLiveTerminals()).toEqual({
placed: ['pty-agent'],
declined: []
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await runtime.stopTerminalsForWorktree(`id:${FOLDER_KEY}`)
expect(stopAndWait).toHaveBeenCalledWith('pty-agent')
})
})
@@ -26,6 +26,8 @@ const nestedSshStore = {
getRepos: () => [nestedSshRepo]
}
// A cwd alone never files a terminal for a worktree-scoped stop (#36716); the daemon id names the owner.
const LOCAL_PTY_ID = `${TEST_WORKTREE_ID}@@local`
const TWIN_PTY_ID = toAppSshPtyId('ssh-twin', 'relay-pty-1')
const twinPty = {
id: TWIN_PTY_ID,
@@ -40,7 +42,7 @@ async function sleepLocalCopy(runtimeStore: typeof store = store, twinLive = fal
runtime.onClientEvent((event) => events.push(event))
const twin = twinLive ? [twinPty] : []
const processLists = [
[{ id: 'pty-local', cwd: TEST_WORKTREE_PATH, title: 'Shell' }, ...twin],
[{ id: LOCAL_PTY_ID, cwd: TEST_WORKTREE_PATH, title: 'Shell' }, ...twin],
twin
]
const stopAndWait = vi.fn(async (ptyId: string) => {
@@ -119,9 +121,9 @@ describe('worktree terminal sleep across same-id host twins', () => {
it("sleeping this computer's copy leaves the SSH twin's live terminal running and unfiled", async () => {
const { runtime, phases, stopAndWait } = await sleepLocalCopy(store, true)
expect(stopAndWait.mock.calls.map(([ptyId]) => ptyId)).toEqual(['pty-local'])
expect(stopAndWait.mock.calls.map(([ptyId]) => ptyId)).toEqual([LOCAL_PTY_ID])
expect(runtime.getTerminalSleepClientEventSnapshot()).toEqual([
expect.objectContaining({ phase: 'committed', ptyIds: ['pty-local'] })
expect.objectContaining({ phase: 'committed', ptyIds: [LOCAL_PTY_ID] })
])
const release = await runtime.acquireWorktreeTerminalSpawn(
spawnWorktreeTarget(TEST_WORKTREE_ID, 'ssh-twin')
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
isPtySessionIdOwnedByWorktree,
isServeMintedPtySessionId,
mintServePtySessionId,
parsePtySessionId
} from './pty-session-id-format'
const UUID = '10000000-0000-4000-8000-0000000000aa'
// The parsers are unchanged since v1.4.243, so released clients read serve ids the same way (#36716).
describe('serve-minted pty session ids', () => {
it.each([
['folder', 'folder:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
['POSIX worktree', 'repo-1::/Users/me/src/app:v2/wt'],
['SSH worktree', 'repo-ssh::/home/dev/a@b/wt'],
['Windows worktree', 'repo-2::C:\\Users\\me\\wt'],
['WSL worktree', 'repo-3::\\\\wsl.localhost\\Ubuntu\\home\\me\\wt']
])('round-trips a %s workspace id', (_kind, worktreeId) => {
const id = mintServePtySessionId(worktreeId, UUID)
expect(parsePtySessionId(id)).toEqual({ worktreeId })
expect(isServeMintedPtySessionId(id)).toBe(true)
expect(isPtySessionIdOwnedByWorktree(id, worktreeId)).toBe(true)
expect(isPtySessionIdOwnedByWorktree(id, `${worktreeId}-sibling`)).toBe(false)
})
it('still recognizes the workspace-less ids older hosts minted', () => {
const legacy = `serve-${UUID}`
expect(isServeMintedPtySessionId(legacy)).toBe(true)
expect(parsePtySessionId(legacy)).toEqual({ worktreeId: null })
})
it('does not treat a workspace path containing serve- as a serve id', () => {
expect(isServeMintedPtySessionId('repo::/srv/serve-app@@deadbeef')).toBe(false)
})
})
@@ -28,3 +28,20 @@ export function isPtySessionIdOwnedByWorktree(sessionId: string, worktreeId: str
const separatorIdx = sessionId.lastIndexOf(PTY_SESSION_ID_SEPARATOR)
return separatorIdx === -1 || sessionId.slice(0, separatorIdx) === worktreeId
}
const SERVE_PTY_SESSION_TAG = 'serve-'
/** Serve ids carry their workspace like daemon-minted ids so inventory never re-files them by cwd (#36716). */
export function mintServePtySessionId(worktreeId: string, uuid: string): string {
return `${worktreeId}${PTY_SESSION_ID_SEPARATOR}${SERVE_PTY_SESSION_TAG}${uuid}`
}
/** Accepts the workspace-less `serve-<uuid>` ids that older hosts minted and still restore. */
export function isServeMintedPtySessionId(sessionId: string): boolean {
const separatorIdx = sessionId.lastIndexOf(PTY_SESSION_ID_SEPARATOR)
const tag =
separatorIdx === -1
? sessionId
: sessionId.slice(separatorIdx + PTY_SESSION_ID_SEPARATOR.length)
return tag.startsWith(SERVE_PTY_SESSION_TAG)
}
@@ -5,6 +5,7 @@ import os from 'node:os'
import path from 'node:path'
import { expect, type Page } from '@stablyai/playwright-test'
import { quotePosixShell } from '../../../src/shared/shell/launch/shell-argument-quoting'
import { isServeMintedPtySessionId } from '../../../src/shared/terminal/pty/lifecycle/pty-session-id-format'
export type RuntimeRpcCall = <TResult>(method: string, params: unknown) => Promise<TResult>
@@ -39,7 +40,7 @@ const HOST_TERMINAL_SURFACE_SEPARATOR = '::'
function isDaemonPtyIdForm(ptyId: string, worktreeId: string): boolean {
return (
ptyId.startsWith(`${worktreeId}@@`) &&
!ptyId.startsWith('serve-') &&
!isServeMintedPtySessionId(ptyId) &&
!ptyId.startsWith('ssh-') &&
ptyId.slice(`${worktreeId}@@`.length).length > 0
)
+2 -1
View File
@@ -8,6 +8,7 @@ import { decodePairingOffer } from '../../../src/shared/remote-runtime/pairing/p
import { pairedRuntimeConnectionFromOffer } from '../../../src/shared/remote-runtime/pairing/paired-runtime-connection'
import { subscribeRemoteRuntimeRequest } from '../../../src/shared/remote-runtime/remote-runtime-client'
import type { RuntimeMobileSessionTabsResult } from '../../../src/shared/runtime-session-contracts'
import { isServeMintedPtySessionId } from '../../../src/shared/terminal/pty/lifecycle/pty-session-id-format'
import type {
RuntimeTerminalListResult,
RuntimeTerminalRead
@@ -47,7 +48,7 @@ export class IdLabels {
if (!id) {
return null
}
const kind = id.includes('@@') ? 'pty@@' : id.startsWith('serve-') ? 'ptyServe' : 'pty'
const kind = isServeMintedPtySessionId(id) ? 'ptyServe' : id.includes('@@') ? 'pty@@' : 'pty'
return this.label(kind, id)
}
}