mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user