diff --git a/src/main/runtime/floating-terminal-inventory-ownership.test.ts b/src/main/runtime/floating-terminal-inventory-ownership.test.ts index 573486d7499..f8d1020831f 100644 --- a/src/main/runtime/floating-terminal-inventory-ownership.test.ts +++ b/src/main/runtime/floating-terminal-inventory-ownership.test.ts @@ -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}`) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-02.spec.ts index f2034095e9f..114d88766d9 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-02.spec.ts @@ -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 }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec.ts index 26d539d735c..9f98ac7a867 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-02.spec.ts @@ -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() - 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]) diff --git a/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec.ts index daf35939a73..16c61f54425 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown-part-03.spec.ts @@ -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, diff --git a/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown.spec.ts index d9566f5c74d..46d7f4d0c82 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-sleep-and-teardown.spec.ts @@ -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, diff --git a/src/main/runtime/serve-terminal-workspace-binding.test.ts b/src/main/runtime/serve-terminal-workspace-binding.test.ts new file mode 100644 index 00000000000..4574ba54dc0 --- /dev/null +++ b/src/main/runtime/serve-terminal-workspace-binding.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/session-lifecycle/projection/reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/session-lifecycle/projection/reconcile-headless-mobile-session-browser-tabs.ts index bbb8e85d97e..bb2bbfee661 100644 --- a/src/main/runtime/session-lifecycle/projection/reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/session-lifecycle/projection/reconcile-headless-mobile-session-browser-tabs.ts @@ -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 { diff --git a/src/main/runtime/terminal/identity/pty-worktree-record.ts b/src/main/runtime/terminal/identity/pty-worktree-record.ts index c402cfdf2c4..f8570427529 100644 --- a/src/main/runtime/terminal/identity/pty-worktree-record.ts +++ b/src/main/runtime/terminal/identity/pty-worktree-record.ts @@ -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 && diff --git a/src/main/runtime/terminal/identity/terminal-identity-source.ts b/src/main/runtime/terminal/identity/terminal-identity-source.ts index def01100408..657319a8f16 100644 --- a/src/main/runtime/terminal/identity/terminal-identity-source.ts +++ b/src/main/runtime/terminal/identity/terminal-identity-source.ts @@ -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): 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( + 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, key: string, value: string): void { const previous = map.get(key) if (previous !== value) { diff --git a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts index ab20e9b54da..269f64027f8 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-owned-create.ts @@ -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( diff --git a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts index 262b037cc29..0252e3b4615 100644 --- a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts +++ b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts @@ -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 } : {}), diff --git a/src/main/runtime/terminal/records/runtime-terminal-state-records.ts b/src/main/runtime/terminal/records/runtime-terminal-state-records.ts index b784e1a3396..6a9e705f9df 100644 --- a/src/main/runtime/terminal/records/runtime-terminal-state-records.ts +++ b/src/main/runtime/terminal/records/runtime-terminal-state-records.ts @@ -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 diff --git a/src/main/runtime/terminal/stop/worktree-pty-collection.ts b/src/main/runtime/terminal/stop/worktree-pty-collection.ts index c06828db5ca..e36f3ff5c96 100644 --- a/src/main/runtime/terminal/stop/worktree-pty-collection.ts +++ b/src/main/runtime/terminal/stop/worktree-pty-collection.ts @@ -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, + ptyIds: ReadonlySet +): Set { + return new Set([...ptyIds].filter((ptyId) => !ptysById.get(ptyId)?.worktreeInferredFromCwd)) +} diff --git a/src/main/runtime/terminal/stop/worktree-terminal-sleep.ts b/src/main/runtime/terminal/stop/worktree-terminal-sleep.ts index a52c330ba06..c4ebfdb2e98 100644 --- a/src/main/runtime/terminal/stop/worktree-terminal-sleep.ts +++ b/src/main/runtime/terminal/stop/worktree-terminal-sleep.ts @@ -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) => - 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 { diff --git a/src/main/runtime/terminal/surface/pty-recorded-surface-topology.ts b/src/main/runtime/terminal/surface/pty-recorded-surface-topology.ts index 66930f26804..01caed00ad5 100644 --- a/src/main/runtime/terminal/surface/pty-recorded-surface-topology.ts +++ b/src/main/runtime/terminal/surface/pty-recorded-surface-topology.ts @@ -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 +type SurfaceWrites = Pick 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 } diff --git a/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.test.ts b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.test.ts new file mode 100644 index 00000000000..b6fb9bb5afe --- /dev/null +++ b/src/main/runtime/workspaces/persisted-surfaces/restored-terminal-placement.test.ts @@ -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') + }) +}) diff --git a/src/main/runtime/workspaces/sleep/worktree-terminal-sleep-host-twin.test.ts b/src/main/runtime/workspaces/sleep/worktree-terminal-sleep-host-twin.test.ts index 44d75c5b7ea..46900b7f768 100644 --- a/src/main/runtime/workspaces/sleep/worktree-terminal-sleep-host-twin.test.ts +++ b/src/main/runtime/workspaces/sleep/worktree-terminal-sleep-host-twin.test.ts @@ -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') diff --git a/src/shared/terminal/pty/lifecycle/pty-session-id-format.test.ts b/src/shared/terminal/pty/lifecycle/pty-session-id-format.test.ts new file mode 100644 index 00000000000..ec123969dc3 --- /dev/null +++ b/src/shared/terminal/pty/lifecycle/pty-session-id-format.test.ts @@ -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) + }) +}) diff --git a/src/shared/terminal/pty/lifecycle/pty-session-id-format.ts b/src/shared/terminal/pty/lifecycle/pty-session-id-format.ts index 44c8eef9163..8e4da7b7cce 100644 --- a/src/shared/terminal/pty/lifecycle/pty-session-id-format.ts +++ b/src/shared/terminal/pty/lifecycle/pty-session-id-format.ts @@ -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-` 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) +} diff --git a/tests/e2e/helpers/host-created-terminal-retention-oracle.ts b/tests/e2e/helpers/host-created-terminal-retention-oracle.ts index 199d9b7a7ee..25d9c6f37dc 100644 --- a/tests/e2e/helpers/host-created-terminal-retention-oracle.ts +++ b/tests/e2e/helpers/host-created-terminal-retention-oracle.ts @@ -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 = (method: string, params: unknown) => Promise @@ -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 ) diff --git a/tests/e2e/helpers/layout-compat-baseline.ts b/tests/e2e/helpers/layout-compat-baseline.ts index 7d9c0cd7d25..7fea75e92bb 100644 --- a/tests/e2e/helpers/layout-compat-baseline.ts +++ b/tests/e2e/helpers/layout-compat-baseline.ts @@ -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) } }