diff --git a/src/main/ipc/pty-launch-race-test-fixture.ts b/src/main/ipc/pty-launch-race-test-fixture.ts index dc4586ec75d..d5efb9298b9 100644 --- a/src/main/ipc/pty-launch-race-test-fixture.ts +++ b/src/main/ipc/pty-launch-race-test-fixture.ts @@ -141,7 +141,7 @@ export function sleptWorktreeLock() { refuse = resolve }) const acquireWorktreeTerminalSpawn = vi.fn( - async (_worktreeId?: string, opts?: { refuseSleptWorktree?: boolean }) => { + async (_target?: unknown, opts?: { refuseSleptWorktree?: boolean }) => { if (opts?.refuseSleptWorktree) { await refusal throw new Error(WORKTREE_TERMINALS_SLEEPING_ERROR) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 70ecd030b3f..31dfdfc6c17 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -1,4 +1,5 @@ import { isRestoreSpawnIntent } from '../../../../shared/host-spawn-admission' +import { spawnWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' import { makePaneSpawnReservationKey, @@ -124,7 +125,10 @@ export async function buildPtyIpcSpawnOptions( const runtime = ctx.deps.runtime const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn ctx.releaseWorktreeSpawn = acquireWorktreeSpawn - ? await acquireWorktreeSpawn.call(runtime, args.worktreeId, { connectionId: args.connectionId }) + ? await acquireWorktreeSpawn.call( + runtime, + spawnWorktreeTarget(args.worktreeId, args.connectionId) + ) : undefined return null } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index 789827890f8..91c2c58ec67 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -1,4 +1,5 @@ import type { PtySpawnResult } from '../../../providers/pty-provider-contract' +import { spawnWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { ptyIncarnationById } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach' @@ -20,10 +21,11 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise const runtime = ctx.deps.runtime const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn ctx.releaseWorktreeSpawn = acquireWorktreeSpawn - ? await acquireWorktreeSpawn.call(runtime, args.worktreeId, { - refuseSleptWorktree: args.refuseSleptWorktree, - connectionId: args.connectionId - }) + ? await acquireWorktreeSpawn.call( + runtime, + spawnWorktreeTarget(args.worktreeId, args.connectionId), + { refuseSleptWorktree: args.refuseSleptWorktree } + ) : undefined let awaitingAnotherStart = false try { diff --git a/src/main/runtime/automatic-activation-host-sleep.test.ts b/src/main/runtime/automatic-activation-host-sleep.test.ts index 667ba373609..6156fbdc254 100644 --- a/src/main/runtime/automatic-activation-host-sleep.test.ts +++ b/src/main/runtime/automatic-activation-host-sleep.test.ts @@ -1,5 +1,6 @@ // #24399: a plain shell pane has no agent sleep record, so only the host's sleep state proves it was slept. import { describe, expect, it, vi } from 'vitest' +import { spawnWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' // Fragments stay side-effect ordered: mocks, then lifecycle, then fixtures. @@ -74,16 +75,21 @@ describe('automatic tab activation honors the host sleep state', () => { const sleeping = runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`) await vi.waitFor(() => expect(listProcesses).toHaveBeenCalled()) // Queued behind the sleep's exclusive hold, so the pre-spawn check has already passed. - const automaticSpawn = runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID, { - refuseSleptWorktree: true - }) + const automaticSpawn = runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null), + { + refuseSleptWorktree: true + } + ) inventory.resolve([]) await expect(sleeping).resolves.toBeDefined() await expect(automaticSpawn).rejects.toThrow('worktree_terminals_sleeping') expect(sleepPhases()).not.toContain('woken') // The refusal released the lock, so a user spawn still proceeds and wakes. - const release = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + const release = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) release() expect(sleepPhases()).toContain('woken') }) diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index 43eb15f66b3..684546b3ad6 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -1,5 +1,9 @@ import { withDurableRuntimeStore } from './runtime-durable-store-fixture' import { describe, expect, it } from 'vitest' +import { + spawnWorktreeTarget, + type HostWorktreeTarget +} from '../../shared/worktree/host-qualified-identity' import { getDefaultWorkspaceSession } from '../../shared/constants' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { makePaneKey } from '../../shared/stable-pane-id' @@ -46,7 +50,7 @@ type RuntimeInternals = { onClientEvent: (listener: (event: RuntimeClientEvent) => void) => () => void onPtyExit: (ptyId: string, exitCode: number) => void sleepTerminalsForWorktree: (worktreeSelector: string) => Promise - acquireWorktreeTerminalSpawn: (worktreeId?: string) => Promise<() => void> + acquireWorktreeTerminalSpawn: (target: HostWorktreeTarget | undefined) => Promise<() => void> getTerminalSleepClientEventSnapshot: () => RuntimeClientEvent[] } @@ -318,7 +322,9 @@ describe('folder workspaces sharing one directory', () => { internals.onClientEvent((event) => events.push(event)) await internals.sleepTerminalsForWorktree(`id:${WORKSPACE_A}`) - const release = await internals.acquireWorktreeTerminalSpawn(WORKSPACE_B) + const release = await internals.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(WORKSPACE_B, null) + ) release() // A shared identity key would wake A's stopped terminals on B's spawn and tell clients so. @@ -334,9 +340,11 @@ describe('folder workspaces sharing one directory', () => { it('does not serialize a sibling instance behind this instance held terminal mutation', async () => { const internals = createRuntimeInternals({ processLists: [[], []] }) - const releaseA = await internals.acquireWorktreeTerminalSpawn(WORKSPACE_A) + const releaseA = await internals.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(WORKSPACE_A, null) + ) - const spawnB = internals.acquireWorktreeTerminalSpawn(WORKSPACE_B) + const spawnB = internals.acquireWorktreeTerminalSpawn(spawnWorktreeTarget(WORKSPACE_B, null)) expect(await raceAgainstMicrotaskDrain(spawnB)).toBe('acquired') // Control: spawns intentionally share the per-worktree lock (only sleep @@ -353,10 +361,12 @@ describe('folder workspaces sharing one directory', () => { it('grants concurrent spawns for one instance while still excluding its sleep', async () => { const internals = createRuntimeInternals({ processLists: [[], []] }) - const releaseFirst = await internals.acquireWorktreeTerminalSpawn(WORKSPACE_A) + const releaseFirst = await internals.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(WORKSPACE_A, null) + ) // Why: a multi-tab worktree activates every tab at once; queueing them // behind each other was the activation latency staircase. - const second = internals.acquireWorktreeTerminalSpawn(WORKSPACE_A) + const second = internals.acquireWorktreeTerminalSpawn(spawnWorktreeTarget(WORKSPACE_A, null)) expect(await raceAgainstMicrotaskDrain(second)).toBe('acquired') const sleepA = internals.sleepTerminalsForWorktree(`id:${WORKSPACE_A}`) 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 cde57bce75b..6d8059a212e 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 @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { spawnWorktreeTarget } from '../../../shared/worktree/host-qualified-identity' import { MOCK_GIT_WORKTREES, OrcaRuntimeService, @@ -251,10 +252,12 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ phase: 'started', ptyIds: ['pty-before-sleep'] }) ]) let spawnLeaseAcquired = false - const spawnLease = runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID).then((release) => { - spawnLeaseAcquired = true - return release - }) + const spawnLease = runtime + .acquireWorktreeTerminalSpawn(spawnWorktreeTarget(TEST_WORKTREE_ID, null)) + .then((release) => { + spawnLeaseAcquired = true + return release + }) await Promise.resolve() expect(spawnLeaseAcquired).toBe(false) @@ -281,7 +284,9 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: async () => null, listProcesses }) - const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) const sleep = runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`) await Promise.resolve() @@ -305,7 +310,9 @@ describe('OrcaRuntimeService', () => { getForegroundProcess: async () => null, listProcesses }) - const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) const sleep = runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`) const rejection = expect(sleep).rejects.toThrow('terminal_worktree_sleep_timeout') @@ -316,7 +323,9 @@ describe('OrcaRuntimeService', () => { expect(listProcesses).not.toHaveBeenCalled() expect(stopAndWait).not.toHaveBeenCalled() - const releaseNextSpawn = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + const releaseNextSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) releaseNextSpawn() } finally { vi.useRealTimers() @@ -351,7 +360,9 @@ describe('OrcaRuntimeService', () => { await runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`) // The wake acquires the mutation and emits 'woken'; a throwing subscriber must not surface. - const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) releaseSpawn() // Isolation: the second subscriber still received the 'woken' event. @@ -419,7 +430,9 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ phase: 'committed', ptyIds: ['pty-preserved-history'] }) ]) await runtime.sleepTerminalsForWorktree(`id:${TEST_WORKTREE_ID}`) - const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) releaseSpawn() const sleepEvents = events.filter((event) => event.type === 'worktreeTerminalSleepState') diff --git a/src/main/runtime/owned-session-teardown-policy.test.ts b/src/main/runtime/owned-session-teardown-policy.test.ts index 06bad768a44..2ef60f9c1c3 100644 --- a/src/main/runtime/owned-session-teardown-policy.test.ts +++ b/src/main/runtime/owned-session-teardown-policy.test.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' +import { spawnWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import type { RuntimeNotifier } from './runtime-notifier-contract' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' @@ -81,7 +82,9 @@ describe('owned-session teardown policy', () => { // Settled: the host now refuses an automatic spawn into the slept worktree. expect(sleepPhases()[0]).toBe('started') await expect( - runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID, { refuseSleptWorktree: true }) + runtime.acquireWorktreeTerminalSpawn(spawnWorktreeTarget(TEST_WORKTREE_ID, null), { + refuseSleptWorktree: true + }) ).rejects.toThrow() }) diff --git a/src/main/runtime/project-removal-terminal-stop.test.ts b/src/main/runtime/project-removal-terminal-stop.test.ts index f384707b4f4..55965968545 100644 --- a/src/main/runtime/project-removal-terminal-stop.test.ts +++ b/src/main/runtime/project-removal-terminal-stop.test.ts @@ -3,6 +3,7 @@ * stops the workspaces it has loaded, and an unhydrated or hidden one would keep running. */ import { describe, expect, it, vi } from 'vitest' +import { spawnWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { Repo } from '../../shared/repo-types' import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' import { TEST_REPO_ID, TEST_WORKTREE_ID, store } from './orca-runtime-test-fixtures.spec' @@ -121,7 +122,9 @@ describe('project removal on the execution host', () => { it('waits out a spawn already in flight and stops what it registers', async () => { const { runtime, stopAndWait } = createRemovableRuntime() runtime.registerPty('pty-early', TEST_WORKTREE_ID, null) - const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) const removal = runtime.removeProject(TEST_REPO_ID) await new Promise((resolve) => setTimeout(resolve, 0)) @@ -154,9 +157,9 @@ describe('project removal on the execution host', () => { await runtime.removeProject(TEST_REPO_ID) - await expect(runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID)).rejects.toThrow( - 'repo_not_found' - ) + await expect( + runtime.acquireWorktreeTerminalSpawn(spawnWorktreeTarget(TEST_WORKTREE_ID, null)) + ).rejects.toThrow('repo_not_found') }) it('refuses an SSH spawn after removal even though a local same-id sibling survives', async () => { @@ -183,9 +186,44 @@ describe('project removal on the execution host', () => { await runtime.removeProject('path:/srv/ssh-copy') await expect( - runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID, { connectionId: 'ssh-target' }) + runtime.acquireWorktreeTerminalSpawn(spawnWorktreeTarget(TEST_WORKTREE_ID, 'ssh-target')) ).rejects.toThrow('repo_not_found') - const releaseLocal = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID) + const releaseLocal = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) + releaseLocal() + }) + + it('keeps admitting a self-stamped row after its SSH twin is removed', async () => { + const sshRow: Repo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-target' } + // A paired client's registration: the runtime stamp names files on this host. + const selfRow: Repo = { ...store.getRepo(TEST_REPO_ID)!, executionHostId: 'runtime:env-self' } + let rows = [sshRow, selfRow] + const runtime = new OrcaRuntimeService({ + ...store, + getRepo: (id: string) => rows.find((row) => row.id === id), + getRepos: () => rows, + removeProjectForHost: () => { + rows = [selfRow] + } + }) + runtime.setPtyController({ + write: () => true, + kill: vi.fn(() => false), + stopAndWait: vi.fn(async () => true), + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + await runtime.removeProject(sshRow) + + await expect( + runtime.acquireWorktreeTerminalSpawn(spawnWorktreeTarget(TEST_WORKTREE_ID, 'ssh-target')) + ).rejects.toThrow('repo_not_found') + const releaseLocal = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(TEST_WORKTREE_ID, null) + ) releaseLocal() }) @@ -211,9 +249,9 @@ describe('project removal on the execution host', () => { await runtime.removeProject(TEST_REPO_ID) - await expect(runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID)).rejects.toThrow( - 'repo_not_found' - ) + await expect( + runtime.acquireWorktreeTerminalSpawn(spawnWorktreeTarget(TEST_WORKTREE_ID, null)) + ).rejects.toThrow('repo_not_found') }) it('stops nothing when a same-id sibling row would claim the same PTYs', async () => { diff --git a/src/main/runtime/terminal-list-pending-pty-registration.test.ts b/src/main/runtime/terminal-list-pending-pty-registration.test.ts index 62a5daf78e3..685866994af 100644 --- a/src/main/runtime/terminal-list-pending-pty-registration.test.ts +++ b/src/main/runtime/terminal-list-pending-pty-registration.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { spawnWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import { createInventoryRuntime, deferred, @@ -51,7 +52,9 @@ describe('terminal listing while a spawn binding is being persisted', () => { }) const releaseSpawn = - phase === 'provider' ? await runtime.acquireWorktreeTerminalSpawn(WORKTREE) : undefined + phase === 'provider' + ? await runtime.acquireWorktreeTerminalSpawn(spawnWorktreeTarget(WORKTREE, null)) + : undefined if (phase === 'binding') { runtime.beginPtyRegistration(ptyId, incarnationId) } @@ -100,7 +103,9 @@ describe('terminal listing while a spawn binding is being persisted', () => { it('continues reporting a committed surface while another spawn is pending', async () => { const { runtime } = createInventoryRuntime(async () => [processRow()]) runtime.register() - const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(WORKTREE) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(WORKTREE, null) + ) try { const listed = await runtime.listTerminals(`id:${WORKTREE}`) expect(listed.terminals).toEqual([ @@ -113,7 +118,9 @@ describe('terminal listing while a spawn binding is being persisted', () => { it('does not block orphan recovery in an unrelated workspace', async () => { const { runtime } = createInventoryRuntime(async () => [processRow()]) - const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn('repo::/tmp/another-workspace') + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget('repo::/tmp/another-workspace', null) + ) try { const listed = await runtime.listTerminals(`id:${WORKTREE}`) expect(listed.terminals).toEqual([ @@ -126,7 +133,9 @@ describe('terminal listing while a spawn binding is being persisted', () => { it('permits orphan recovery once the competing spawn admission has ended', async () => { const { runtime } = createInventoryRuntime(async () => [processRow()]) - const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn(WORKTREE) + const releaseSpawn = await runtime.acquireWorktreeTerminalSpawn( + spawnWorktreeTarget(WORKTREE, null) + ) runtime.beginPtyRegistration(PTY, PREDECESSOR) try { await expect(runtime.listTerminals(`id:${WORKTREE}`)).rejects.toThrow( diff --git a/src/main/runtime/terminal/stop/project-terminal-stop.ts b/src/main/runtime/terminal/stop/project-terminal-stop.ts index 3b16c32e831..f86d7525672 100644 --- a/src/main/runtime/terminal/stop/project-terminal-stop.ts +++ b/src/main/runtime/terminal/stop/project-terminal-stop.ts @@ -1,10 +1,12 @@ import { OrcaRuntimeWithStopExactTerminalsForWorktree } from './worktree-terminal-stop-targets' import { + getConnectionExecutionHostId, getRepoExecutionHostId, getRepoSshConnectionId, - normalizeHostPart, - parseExecutionHostId + parseExecutionHostId, + type ExecutionHostId } from '../../../../shared/execution-host' +import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { Repo } from '../../../../shared/repo-types' import { getRuntimeBrowserPageRegistry } from '../../runtime-browser-page-registry' @@ -60,22 +62,23 @@ export function listProjectTerminalWorktreeIds( } export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntimeWithStopExactTerminalsForWorktree { - /** Removed projects' PTY fences by id; ids are UUIDs, so a remaining row means a same-id sibling. */ - private readonly removedProjectFences = new Map>() + /** Removed projects' spawn hosts by id; ids are UUIDs, so a remaining row means a same-id sibling. */ + private readonly removedProjectHosts = new Map>() override async acquireWorktreeTerminalSpawn( - worktreeId?: string, - opts: { refuseSleptWorktree?: boolean; connectionId?: string | null } = {} + target: HostWorktreeTarget | undefined, + opts: { refuseSleptWorktree?: boolean } = {} ): Promise<() => void> { - const release = await super.acquireWorktreeTerminalSpawn(worktreeId, opts) - const repoId = worktreeId ? getRepoIdFromWorktreeId(worktreeId) : null - const removedFences = repoId ? this.removedProjectFences.get(repoId) : undefined + const release = await super.acquireWorktreeTerminalSpawn(target, opts) + const repoId = target ? getRepoIdFromWorktreeId(target.id) : null + const removedHosts = repoId ? this.removedProjectHosts.get(repoId) : undefined // Why under the lease: a spawn that resolved its workspace before removal queues behind the sweep. // Why the fence: a surviving same-id row on another host must not admit the removed host's spawn. if ( + target && repoId && - removedFences && - !this.removedProjectAdmitsSpawn(repoId, removedFences, opts.connectionId) + removedHosts && + !this.removedProjectAdmitsSpawn(repoId, removedHosts, target.hostId) ) { release() throw new Error('repo_not_found') @@ -164,9 +167,9 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime // Why not the row's stamp: a `runtime:` stamp is how paired clients address a project that runs // here, natively or over its nested SSH connection. const fence = { resolvedConnectionId: getRepoSshConnectionId(repo) } - const removedFences = this.removedProjectFences.get(repo.id) ?? new Set() - removedFences.add(fence.resolvedConnectionId) - this.removedProjectFences.set(repo.id, removedFences) + const removedHosts = this.removedProjectHosts.get(repo.id) ?? new Set() + removedHosts.add(getConnectionExecutionHostId(fence.resolvedConnectionId)) + this.removedProjectHosts.set(repo.id, removedHosts) if (this.projectSiblingSharesFence(repo)) { return { stopped: 0, worktreeIds: [] } } @@ -252,15 +255,17 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime /** Only a surviving row on the spawn's own host, or any row when no removal touched it. */ private removedProjectAdmitsSpawn( repoId: string, - removedFences: ReadonlySet, - spawnConnectionId: string | null | undefined + removedHosts: ReadonlySet, + spawnHostId: ExecutionHostId ): boolean { - const connectionId = normalizeHostPart(spawnConnectionId) + // Why every row: a `runtime:` stamp can name files registered here (repo-execution-host.ts). const survivors = (this.store?.getRepos?.() ?? []).filter((row) => row.id === repoId) - if (!removedFences.has(connectionId)) { + if (!removedHosts.has(spawnHostId)) { return survivors.length > 0 } - return survivors.some((row) => getRepoSshConnectionId(row) === connectionId) + return survivors.some( + (row) => getConnectionExecutionHostId(getRepoSshConnectionId(row)) === spawnHostId + ) } /** A paired server's row is residue here: its sessions run on that server, never in this process. */ diff --git a/src/main/runtime/terminal/stop/worktree-terminal-stop.ts b/src/main/runtime/terminal/stop/worktree-terminal-stop.ts index ca993e042cc..d2ef0a09205 100644 --- a/src/main/runtime/terminal/stop/worktree-terminal-stop.ts +++ b/src/main/runtime/terminal/stop/worktree-terminal-stop.ts @@ -8,6 +8,7 @@ import type { RuntimeWorktreeTerminalSleepResult } from '../../../../shared/runtime-types' import type { WorktreeTerminalStopOptions } from './worktree-terminal-stop-options' +import type { HostWorktreeTarget } from '../../../../shared/worktree/host-qualified-identity' import type { WorktreeTerminalMutationKind } from '../../worktree-terminal-mutation-lock' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' import { cloneWorkspaceSessionState } from '../../../persistence/restoring-sessions/session-owner-fields' @@ -265,13 +266,15 @@ export abstract class OrcaRuntimeWithStopTerminalsForWorktree extends OrcaRuntim } } + /** `target` is the spawn's own workspace copy: the host whose provider runs it. */ async acquireWorktreeTerminalSpawn( - worktreeId?: string, + target: HostWorktreeTarget | undefined, opts: { refuseSleptWorktree?: boolean } = {} ): Promise<() => void> { - if (!worktreeId) { + if (!target) { return () => {} } + const worktreeId = target.id const release = await this.acquireWorktreeTerminalMutation(worktreeId, 'shared') const key = worktreeIdentityKey(worktreeId) const sleepState = this.terminalSleepStateByWorktreeId.get(key) diff --git a/src/shared/worktree/host-qualified-identity.ts b/src/shared/worktree/host-qualified-identity.ts index 965c7cd78de..c8393c8ce83 100644 --- a/src/shared/worktree/host-qualified-identity.ts +++ b/src/shared/worktree/host-qualified-identity.ts @@ -1,4 +1,9 @@ -import { parseExecutionHostId, type ExecutionHostId } from '../execution-host' +import { + getConnectionExecutionHostId, + normalizeHostPart, + parseExecutionHostId, + type ExecutionHostId +} from '../execution-host' import type { Worktree } from './types' /** @@ -39,6 +44,19 @@ export function hostWorktreeTarget(row: { return { id: row.id, hostId: row.hostId } as HostWorktreeTarget } +/** A PTY spawn's workspace copy: its `connectionId` is what picks the provider that runs it. */ +export function spawnWorktreeTarget( + worktreeId: string | undefined, + connectionId: string | null | undefined +): HostWorktreeTarget | undefined { + return worktreeId + ? hostWorktreeTarget({ + id: worktreeId, + hostId: getConnectionExecutionHostId(normalizeHostPart(connectionId)) + }) + : undefined +} + /** * Stable key for one workspace on one host (STA-4343). *