fix(runtime): a terminal spawn names its host; the removed-project fence keys by host (R4-2) (#30798)

* fix(runtime): a terminal spawn names its host; a removed project's fence is per (project, host)

acquireWorktreeTerminalSpawn takes the spawn's HostWorktreeTarget instead of a
bare id plus an optional connectionId that defaulted to this machine. The
removal fence records ExecutionHostIds and only counts surviving rows that run
in this process, so a paired server's same-id residue row no longer readmits a
removed local project's spawn.

* fix(runtime): keep every surviving row in the spawn fence; normalize the spawn connection (review)
This commit is contained in:
Neil
2026-10-19 10:44:12 -07:00
committed by GitHub
parent 30f5bef947
commit b30a798dee
12 changed files with 172 additions and 61 deletions
+1 -1
View File
@@ -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)
+5 -1
View File
@@ -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
}
+6 -4
View File
@@ -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 {
@@ -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')
})
@@ -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<unknown>
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}`)
@@ -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')
@@ -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()
})
@@ -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 () => {
@@ -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(
@@ -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<string, Set<string | null>>()
/** Removed projects' spawn hosts by id; ids are UUIDs, so a remaining row means a same-id sibling. */
private readonly removedProjectHosts = new Map<string, Set<ExecutionHostId>>()
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<string | null>()
removedFences.add(fence.resolvedConnectionId)
this.removedProjectFences.set(repo.id, removedFences)
const removedHosts = this.removedProjectHosts.get(repo.id) ?? new Set<ExecutionHostId>()
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<string | null>,
spawnConnectionId: string | null | undefined
removedHosts: ReadonlySet<ExecutionHostId>,
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. */
@@ -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)
+19 -1
View File
@@ -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).
*