mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
fix(runtime): project removal and edits stay on the selected project's host (#30584)
* fix(runtime): project removal and settings writes keep to the resolved execution host - Removing a project now closes its native Claude/Codex chats on this host and retires their tabs. - A terminal spawn after removal is admitted only by a surviving row on the spawn's own host. - repo.update and repo.setBaseRef write the resolved row's host, not a same-id sibling. * fix(runtime): close project chats alongside the terminal stop, with their own floor - A failed or budget-spent terminal stop no longer skips the chat closes. - Spawn admission still refuses on any host once no row with the removed id survives.
This commit is contained in:
@@ -124,7 +124,7 @@ export async function buildPtyIpcSpawnOptions(
|
||||
const runtime = ctx.deps.runtime
|
||||
const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn
|
||||
ctx.releaseWorktreeSpawn = acquireWorktreeSpawn
|
||||
? await acquireWorktreeSpawn.call(runtime, args.worktreeId)
|
||||
? await acquireWorktreeSpawn.call(runtime, args.worktreeId, { connectionId: args.connectionId })
|
||||
: undefined
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
|
||||
const acquireWorktreeSpawn = runtime?.acquireWorktreeTerminalSpawn
|
||||
ctx.releaseWorktreeSpawn = acquireWorktreeSpawn
|
||||
? await acquireWorktreeSpawn.call(runtime, args.worktreeId, {
|
||||
refuseSleptWorktree: args.refuseSleptWorktree
|
||||
refuseSleptWorktree: args.refuseSleptWorktree,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
: undefined
|
||||
let awaitingAnotherStart = false
|
||||
|
||||
@@ -339,7 +339,7 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW
|
||||
getStore: () => this.store,
|
||||
resolveRepo: (selector) => this.resolveRepoSelector(selector),
|
||||
forgetTerminalTopology: (repoId) => this.terminalTopologyRevisionByRepoId.delete(repoId),
|
||||
stopProjectTerminals: (repo, deadline) => this.stopTerminalsForProject(repo, { deadline }),
|
||||
stopProjectTerminals: (repo, options) => this.stopTerminalsForProject(repo, options),
|
||||
invalidateResolvedWorktrees: () => this.invalidateResolvedWorktreeCache(),
|
||||
invalidateWorktreeScan: (repoId) => this.invalidateWorktreeScanCacheForRepo(repoId),
|
||||
notifyReposChanged: () => this.notifyReposChanged()
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* A native Claude/Codex chat has no PTY or pane, so the terminal sweep of a project removal never
|
||||
* sees it. The host closes those sessions and retires their chat tabs itself.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { getRepoExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
const hostRef: { current: unknown } = { current: null }
|
||||
|
||||
vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({
|
||||
getStructuredAgentSessionHost: () => hostRef.current
|
||||
}))
|
||||
|
||||
const { OrcaRuntimeService } = await import('./orca-runtime-test-mocks.spec')
|
||||
const { TEST_REPO_ID, TEST_WORKTREE_ID, store } = await import('./orca-runtime-test-fixtures.spec')
|
||||
|
||||
type FakeRecord = {
|
||||
sessionId: string
|
||||
provider: 'claude' | 'codex'
|
||||
location: { executionHostId: string; workspaceId: string }
|
||||
lease: { claimStatus: string; deathEvidence: unknown }
|
||||
}
|
||||
|
||||
function record(sessionId: string, workspaceId: string, executionHostId = 'local'): FakeRecord {
|
||||
return {
|
||||
sessionId,
|
||||
provider: 'claude',
|
||||
location: { executionHostId, workspaceId },
|
||||
lease: { claimStatus: 'live', deathEvidence: null }
|
||||
}
|
||||
}
|
||||
|
||||
function installHost(records: FakeRecord[], detached: string[] = []) {
|
||||
const held = new Set(
|
||||
records.map((entry) => entry.sessionId).filter((id) => !detached.includes(id))
|
||||
)
|
||||
const visible = new Set(records.map((entry) => entry.sessionId))
|
||||
const closed: string[] = []
|
||||
hostRef.current = {
|
||||
deps: {
|
||||
store: {
|
||||
listRecords: () => records,
|
||||
getRecord: (sessionId: string) => records.find((entry) => entry.sessionId === sessionId),
|
||||
getSessionTabId: (sessionId: string) => (visible.has(sessionId) ? `tab-${sessionId}` : null)
|
||||
},
|
||||
logger: { warn: () => {} }
|
||||
},
|
||||
hasSession: (sessionId: string) => held.has(sessionId),
|
||||
getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...visible] }),
|
||||
setSessionTabVisibility: async (sessionId: string, isVisible: boolean) => {
|
||||
if (isVisible) {
|
||||
visible.add(sessionId)
|
||||
} else {
|
||||
visible.delete(sessionId)
|
||||
}
|
||||
},
|
||||
close: async (sessionId: string) => {
|
||||
closed.push(sessionId)
|
||||
held.delete(sessionId)
|
||||
const entry = records.find((candidate) => candidate.sessionId === sessionId)
|
||||
if (entry) {
|
||||
entry.lease.claimStatus = 'released'
|
||||
entry.lease.deathEvidence = { kind: 'exit-observed', detail: 'closed', observedAt: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { closed, visible }
|
||||
}
|
||||
|
||||
function createRuntime(rows: Repo[]) {
|
||||
let current = rows
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepo: (id: string) => current.find((row) => row.id === id),
|
||||
getRepos: () => current,
|
||||
removeProjectForHost: (id: string, hostId: string) => {
|
||||
current = current.filter((row) => row.id !== id || getRepoExecutionHostId(row) !== hostId)
|
||||
}
|
||||
})
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: vi.fn(() => false),
|
||||
stopAndWait: vi.fn(async () => true),
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('project removal and native chat sessions', () => {
|
||||
beforeEach(() => {
|
||||
hostRef.current = null
|
||||
})
|
||||
|
||||
it("closes the project's live chats and retires every chat tab, on this host only", async () => {
|
||||
const hiddenWorkspace = `${TEST_REPO_ID}::/tmp/hidden-worktree`
|
||||
const { closed, visible } = installHost(
|
||||
[
|
||||
record('live-chat', TEST_WORKTREE_ID),
|
||||
record('resting-chat', hiddenWorkspace),
|
||||
record('ssh-twin-chat', TEST_WORKTREE_ID, 'ssh:ssh-target'),
|
||||
record('other-project-chat', 'repo-2::/tmp/other')
|
||||
],
|
||||
['resting-chat']
|
||||
)
|
||||
const runtime = createRuntime([store.getRepo(TEST_REPO_ID)!])
|
||||
|
||||
await expect(runtime.removeProject(TEST_REPO_ID)).resolves.toEqual({ removed: true })
|
||||
|
||||
expect(closed).toEqual(['live-chat'])
|
||||
expect([...visible].sort()).toEqual(['other-project-chat', 'ssh-twin-chat'])
|
||||
})
|
||||
|
||||
it('leaves the local twin chats alone when removing the SSH copy of the id', async () => {
|
||||
const localRow = store.getRepo(TEST_REPO_ID)!
|
||||
const sshRow: Repo = { ...localRow, path: '/srv/ssh-copy', connectionId: 'ssh-target' }
|
||||
const { closed, visible } = installHost([
|
||||
record('local-chat', TEST_WORKTREE_ID),
|
||||
record('ssh-chat', TEST_WORKTREE_ID, 'ssh:ssh-target')
|
||||
])
|
||||
const runtime = createRuntime([localRow, sshRow])
|
||||
|
||||
await runtime.removeProject('path:/srv/ssh-copy')
|
||||
|
||||
expect(closed).toEqual(['ssh-chat'])
|
||||
expect([...visible]).toEqual(['local-chat'])
|
||||
})
|
||||
|
||||
it('still closes the chats when the terminal stop fails', async () => {
|
||||
const { closed } = installHost([record('live-chat', TEST_WORKTREE_ID)])
|
||||
const runtime = createRuntime([store.getRepo(TEST_REPO_ID)!])
|
||||
runtime.registerPty('pty-1', TEST_WORKTREE_ID, null)
|
||||
vi.spyOn(runtime, 'stopTerminalsForWorktree').mockRejectedValue(
|
||||
new Error('runtime_unavailable')
|
||||
)
|
||||
|
||||
await expect(runtime.removeProject(TEST_REPO_ID)).resolves.toEqual({ removed: true })
|
||||
|
||||
expect(closed).toEqual(['live-chat'])
|
||||
})
|
||||
|
||||
it('still closes the chats when the terminal stops spent the removal budget', async () => {
|
||||
const { closed } = installHost([record('live-chat', TEST_WORKTREE_ID)])
|
||||
const runtime = createRuntime([])
|
||||
|
||||
await runtime.stopTerminalsForProject(store.getRepo(TEST_REPO_ID)!, {
|
||||
deadline: Date.now() - 1,
|
||||
closeStructuredSessions: true
|
||||
})
|
||||
|
||||
expect(closed).toEqual(['live-chat'])
|
||||
})
|
||||
})
|
||||
@@ -159,6 +159,63 @@ describe('project removal on the execution host', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses an SSH spawn after removal even though a local same-id sibling survives', async () => {
|
||||
const localRow = store.getRepo(TEST_REPO_ID)!
|
||||
const sshRow: Repo = { ...localRow, path: '/srv/ssh-copy', connectionId: 'ssh-target' }
|
||||
let rows = [localRow, sshRow]
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepo: (id: string) => rows.find((row) => row.id === id),
|
||||
getRepos: () => rows,
|
||||
removeProjectForHost: () => {
|
||||
rows = [localRow]
|
||||
}
|
||||
})
|
||||
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('path:/srv/ssh-copy')
|
||||
|
||||
await expect(
|
||||
runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID, { connectionId: 'ssh-target' })
|
||||
).rejects.toThrow('repo_not_found')
|
||||
const releaseLocal = await runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID)
|
||||
releaseLocal()
|
||||
})
|
||||
|
||||
it('refuses a spawn on any host once no row with the removed id survives', async () => {
|
||||
const sshRow: Repo = { ...store.getRepo(TEST_REPO_ID)!, connectionId: 'ssh-target' }
|
||||
let rows = [sshRow]
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getRepo: (id: string) => rows.find((row) => row.id === id),
|
||||
getRepos: () => rows,
|
||||
removeProjectForHost: () => {
|
||||
rows = []
|
||||
}
|
||||
})
|
||||
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(TEST_REPO_ID)
|
||||
|
||||
await expect(runtime.acquireWorktreeTerminalSpawn(TEST_WORKTREE_ID)).rejects.toThrow(
|
||||
'repo_not_found'
|
||||
)
|
||||
})
|
||||
|
||||
it('stops nothing when a same-id sibling row would claim the same PTYs', async () => {
|
||||
const removeProjectForHost = vi.fn()
|
||||
const stopAndWait = vi.fn(async (_ptyId: string) => true)
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { findRepoRowForHostScopedWrite } from '../persistence/tracking-repos/repo-update-operations'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
|
||||
export function registerRuntimeRemoveProjectHostScopeTests(): void {
|
||||
function makeRepos(): Repo[] {
|
||||
@@ -43,6 +45,7 @@ export function registerRuntimeRemoveProjectHostScopeTests(): void {
|
||||
}
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the store stub covers every read and write repo.rm and repo.update drive.
|
||||
const runtime = new OrcaRuntimeService({
|
||||
getRepos: () => [...repos],
|
||||
getRepo: (id: string) => repos.find((repo) => repo.id === id) ?? null,
|
||||
@@ -51,6 +54,11 @@ export function registerRuntimeRemoveProjectHostScopeTests(): void {
|
||||
setWorktreeMeta: vi.fn(),
|
||||
removeWorktreeMeta: vi.fn(),
|
||||
getGitHubCache: () => null,
|
||||
// Same row lookup the real store's host-scoped write uses.
|
||||
updateRepo: (id: string, updates: Partial<Repo>, hostId?: ExecutionHostId) => {
|
||||
const row = findRepoRowForHostScopedWrite(repos, id, hostId)
|
||||
return row ? Object.assign(row, updates) : null
|
||||
},
|
||||
removeProjectForHost
|
||||
} as never)
|
||||
return { runtime, repos, removeProjectForHost }
|
||||
@@ -81,4 +89,22 @@ export function registerRuntimeRemoveProjectHostScopeTests(): void {
|
||||
expect(repos).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('repo.update with the same repo id on two execution hosts', () => {
|
||||
it('writes the SSH row a path selector picked, not its local sibling', async () => {
|
||||
const { runtime, repos } = createRuntime()
|
||||
|
||||
await runtime.updateRepo('path:/remote/dup', { displayName: 'Renamed Remote' })
|
||||
|
||||
expect(repos.map((repo) => repo.displayName)).toEqual(['Dup Local', 'Renamed Remote'])
|
||||
})
|
||||
|
||||
it('sets the base ref on the SSH row a path selector picked', async () => {
|
||||
const { runtime, repos } = createRuntime()
|
||||
|
||||
await runtime.setRepoBaseRef('path:/remote/dup', 'origin/dev')
|
||||
|
||||
expect(repos.map((repo) => repo.worktreeBaseRef)).toEqual([undefined, 'origin/dev'])
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ type RuntimeRepositorySettingsDependencies = {
|
||||
resolveRepo: (selector: string) => Promise<Repo>
|
||||
forgetTerminalTopology: (repoId: string) => void
|
||||
/** Optional so test doubles and older wiring keep removing without it. */
|
||||
stopProjectTerminals?: (repo: Repo, deadline: number) => Promise<unknown>
|
||||
stopProjectTerminals?: (
|
||||
repo: Repo,
|
||||
options: { deadline: number; closeStructuredSessions: boolean }
|
||||
) => Promise<unknown>
|
||||
invalidateResolvedWorktrees: () => void
|
||||
invalidateWorktreeScan: (repoId: string) => void
|
||||
notifyReposChanged: () => void
|
||||
@@ -70,7 +73,11 @@ export class RuntimeRepositorySettingsController {
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support base refs.')
|
||||
}
|
||||
const updated = store.updateRepo(repo.id, { worktreeBaseRef: baseRef })
|
||||
const updated = store.updateRepo(
|
||||
repo.id,
|
||||
{ worktreeBaseRef: baseRef },
|
||||
getRepoExecutionHostId(repo)
|
||||
)
|
||||
if (!updated) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
@@ -98,7 +105,12 @@ export class RuntimeRepositorySettingsController {
|
||||
}
|
||||
const write = (proven: Pick<Repo, 'folderUpgradeGitRootPath'>): Repo | null => {
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
return store.updateRepo(repo.id, { ...sanitizedUpdates, ...proven })
|
||||
// Why the host: a same-id sibling on another host would take an id-only write.
|
||||
return store.updateRepo(
|
||||
repo.id,
|
||||
{ ...sanitizedUpdates, ...proven },
|
||||
getRepoExecutionHostId(repo)
|
||||
)
|
||||
}
|
||||
const updated =
|
||||
sanitizedUpdates.kind === 'git' && isFolderRepo(repo)
|
||||
@@ -130,18 +142,21 @@ export class RuntimeRepositorySettingsController {
|
||||
// Why here: the host owns these shells; a client only stops the workspaces it has loaded,
|
||||
// and older clients stop none. Bounded so repo.rm still answers inside the client's timeout.
|
||||
const deadline = Date.now() + PROJECT_TERMINAL_STOP_BUDGET_MS
|
||||
const stopProjectTerminals = async (): Promise<void> => {
|
||||
await this.deps.stopProjectTerminals?.(repo, deadline).catch((error: unknown) => {
|
||||
console.warn('[runtime] failed to stop project terminals on removal', error)
|
||||
})
|
||||
const stopProjectTerminals = async (closeStructuredSessions: boolean): Promise<void> => {
|
||||
await this.deps
|
||||
.stopProjectTerminals?.(repo, { deadline, closeStructuredSessions })
|
||||
.catch((error: unknown) => {
|
||||
console.warn('[runtime] failed to stop project terminals on removal', error)
|
||||
})
|
||||
}
|
||||
await stopProjectTerminals()
|
||||
await stopProjectTerminals(false)
|
||||
// Why again: a WSL move may have fenced the project while the stop was awaited.
|
||||
assertWslMoveRepoWritable(store, repo)
|
||||
store.removeProjectForHost(repo.id, hostId)
|
||||
// Why again: a terminal spawned while the first stop ran is past that sweep's snapshot, and
|
||||
// nothing can spawn into the project once it is gone.
|
||||
await stopProjectTerminals()
|
||||
// nothing can spawn into the project once it is gone. Chats only now: retiring their tabs is
|
||||
// irreversible, and the WSL fence above can still refuse the removal.
|
||||
await stopProjectTerminals(true)
|
||||
this.deps.forgetTerminalTopology(repo.id)
|
||||
this.deps.invalidateResolvedWorktrees()
|
||||
this.deps.invalidateWorktreeScan(repo.id)
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { closeStructuredAgentSessionChild } from './structured-agent-session-close'
|
||||
import { retireSettledStructuredWorkerTab } from './structured-agent-session-tab-retirement'
|
||||
import type { WorktreePtyHostFence } from './worktree-pty-host-fence'
|
||||
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import type { StructuredAgentId } from '../../shared/agent-session-provider-handle'
|
||||
|
||||
@@ -115,6 +116,24 @@ export type StructuredSessionsForWorktree = {
|
||||
export function listStructuredSessionsForWorktree(
|
||||
worktreeId: string,
|
||||
fence: StructuredSessionHostFence
|
||||
): StructuredSessionsForWorktree {
|
||||
return listFencedStructuredSessions((workspaceId) => workspaceId === worktreeId, fence)
|
||||
}
|
||||
|
||||
/** Structured sessions in every workspace of a project, on the fenced host only. */
|
||||
export function listStructuredSessionsForProject(
|
||||
repoId: string,
|
||||
fence: StructuredSessionHostFence
|
||||
): StructuredSessionsForWorktree {
|
||||
return listFencedStructuredSessions(
|
||||
(workspaceId) => getRepoIdFromWorktreeId(workspaceId) === repoId,
|
||||
fence
|
||||
)
|
||||
}
|
||||
|
||||
function listFencedStructuredSessions(
|
||||
inScope: (workspaceId: string) => boolean,
|
||||
fence: StructuredSessionHostFence
|
||||
): StructuredSessionsForWorktree {
|
||||
const host = getStructuredAgentSessionHost()
|
||||
if (!host) {
|
||||
@@ -129,8 +148,7 @@ export function listStructuredSessionsForWorktree(
|
||||
const hostId = structuredSessionTeardownHostId(fence)
|
||||
const members = records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.location.workspaceId === worktreeId && record.location.executionHostId === hostId
|
||||
(record) => inScope(record.location.workspaceId) && record.location.executionHostId === hostId
|
||||
)
|
||||
.map((record) => ({ sessionId: record.sessionId, agent: record.provider }))
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { OrcaRuntimeWithStopExactTerminalsForWorktree } from './worktree-terminal-stop-targets'
|
||||
import { getRepoExecutionHostId, getRepoSshConnectionId } from '../../../../shared/execution-host'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
getRepoSshConnectionId,
|
||||
normalizeHostPart
|
||||
} from '../../../../shared/execution-host'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import {
|
||||
closeStructuredSessionsForWorktree,
|
||||
createStructuredSweepProgress,
|
||||
listStructuredSessionsForProject,
|
||||
retireStructuredSessionTabsForWorktree,
|
||||
unclosedStructuredSessions
|
||||
} from '../../structured-session-worktree-teardown'
|
||||
|
||||
const PROJECT_INVENTORY_REFRESH_BUDGET_MS = 3_000
|
||||
// Inside repo.rm's 15 s client timeout after the 10 s terminal budget.
|
||||
const PROJECT_CHAT_CLOSE_FLOOR_MS = 3_000
|
||||
|
||||
/** The value, or null once the deadline passes first. */
|
||||
async function raceDeadline<T>(value: Promise<T>, deadline: number | undefined): Promise<T | null> {
|
||||
@@ -44,17 +57,23 @@ export function listProjectTerminalWorktreeIds(
|
||||
}
|
||||
|
||||
export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntimeWithStopExactTerminalsForWorktree {
|
||||
/** Projects this host removed; ids are UUIDs, so a remaining row means a same-id sibling. */
|
||||
private readonly removedProjectIds = new Set<string>()
|
||||
/** 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>>()
|
||||
|
||||
override async acquireWorktreeTerminalSpawn(
|
||||
worktreeId?: string,
|
||||
opts: { refuseSleptWorktree?: boolean } = {}
|
||||
opts: { refuseSleptWorktree?: boolean; connectionId?: string | null } = {}
|
||||
): Promise<() => void> {
|
||||
const release = await super.acquireWorktreeTerminalSpawn(worktreeId, opts)
|
||||
const repoId = worktreeId ? getRepoIdFromWorktreeId(worktreeId) : null
|
||||
const removedFences = repoId ? this.removedProjectFences.get(repoId) : undefined
|
||||
// Why under the lease: a spawn that resolved its workspace before removal queues behind the sweep.
|
||||
if (repoId && this.removedProjectIds.has(repoId) && !this.store?.getRepo?.(repoId)) {
|
||||
// Why the fence: a surviving same-id row on another host must not admit the removed host's spawn.
|
||||
if (
|
||||
repoId &&
|
||||
removedFences &&
|
||||
!this.removedProjectAdmitsSpawn(repoId, removedFences, opts.connectionId)
|
||||
) {
|
||||
release()
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
@@ -67,7 +86,31 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime
|
||||
*/
|
||||
async stopTerminalsForProject(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
|
||||
options: { deadline?: number } = {}
|
||||
options: { deadline?: number; closeStructuredSessions?: boolean } = {}
|
||||
): Promise<{ stopped: number }> {
|
||||
const { closeStructuredSessions, ...stopOptions } = options
|
||||
// Why alongside, with its own floor: a failed or budget-spent terminal stop must not skip the
|
||||
// chat closes, since their tabs are retired either way.
|
||||
const chats = closeStructuredSessions
|
||||
? this.closeStructuredSessionsForProject(
|
||||
repo,
|
||||
options.deadline === undefined
|
||||
? undefined
|
||||
: Math.max(options.deadline, Date.now() + PROJECT_CHAT_CLOSE_FLOOR_MS)
|
||||
).catch((error: unknown) => {
|
||||
console.warn('[runtime] project agent session close failed', error)
|
||||
})
|
||||
: null
|
||||
try {
|
||||
return await this.stopProjectPtys(repo, stopOptions)
|
||||
} finally {
|
||||
await chats
|
||||
}
|
||||
}
|
||||
|
||||
private async stopProjectPtys(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
|
||||
options: { deadline?: number }
|
||||
): Promise<{ stopped: number }> {
|
||||
// Why: after a restart, surviving daemon sessions are in the provider inventory before ptysById.
|
||||
// Bounded so a slow worktree scan cannot spend the budget the known PTYs' stops need.
|
||||
@@ -91,15 +134,10 @@ 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) }
|
||||
this.removedProjectIds.add(repo.id)
|
||||
// Why: a same-id sibling row with the same fence owns PTYs this table cannot tell apart.
|
||||
const siblingSharesFence = (this.store?.getRepos?.() ?? []).some(
|
||||
(row) =>
|
||||
row.id === repo.id &&
|
||||
getRepoExecutionHostId(row) !== getRepoExecutionHostId(repo) &&
|
||||
getRepoSshConnectionId(row) === fence.resolvedConnectionId
|
||||
)
|
||||
if (siblingSharesFence) {
|
||||
const removedFences = this.removedProjectFences.get(repo.id) ?? new Set<string | null>()
|
||||
removedFences.add(fence.resolvedConnectionId)
|
||||
this.removedProjectFences.set(repo.id, removedFences)
|
||||
if (this.projectSiblingSharesFence(repo)) {
|
||||
return { stopped: 0 }
|
||||
}
|
||||
let stopped = 0
|
||||
@@ -129,4 +167,60 @@ export abstract class OrcaRuntimeWithStopTerminalsForProject extends OrcaRuntime
|
||||
}
|
||||
return { stopped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a removed project's native chats on this host and retires their chat tabs. They have
|
||||
* no PTY or pane, so the terminal sweep never reaches them. Best-effort: the project is gone.
|
||||
*/
|
||||
private async closeStructuredSessionsForProject(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>,
|
||||
deadline: number | undefined
|
||||
): Promise<void> {
|
||||
if (this.projectSiblingSharesFence(repo)) {
|
||||
return
|
||||
}
|
||||
const { members, live } = listStructuredSessionsForProject(repo.id, {
|
||||
resolvedConnectionId: getRepoSshConnectionId(repo)
|
||||
})
|
||||
const progress = createStructuredSweepProgress(live)
|
||||
await raceDeadline(
|
||||
closeStructuredSessionsForWorktree(progress, deadline ?? Infinity, { runtime: this }),
|
||||
deadline
|
||||
)
|
||||
const liveIds = new Set(live.map((session) => session.sessionId))
|
||||
await retireStructuredSessionTabsForWorktree(
|
||||
[
|
||||
...members.filter((session) => !liveIds.has(session.sessionId)),
|
||||
...unclosedStructuredSessions(progress)
|
||||
],
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
): boolean {
|
||||
const connectionId = normalizeHostPart(spawnConnectionId)
|
||||
const survivors = (this.store?.getRepos?.() ?? []).filter((row) => row.id === repoId)
|
||||
if (!removedFences.has(connectionId)) {
|
||||
return survivors.length > 0
|
||||
}
|
||||
return survivors.some((row) => getRepoSshConnectionId(row) === connectionId)
|
||||
}
|
||||
|
||||
/** A same-id sibling row with the same fence owns sessions this host cannot tell apart. */
|
||||
private projectSiblingSharesFence(
|
||||
repo: Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>
|
||||
): boolean {
|
||||
const connectionId = getRepoSshConnectionId(repo)
|
||||
return (this.store?.getRepos?.() ?? []).some(
|
||||
(row) =>
|
||||
row.id === repo.id &&
|
||||
getRepoExecutionHostId(row) !== getRepoExecutionHostId(repo) &&
|
||||
getRepoSshConnectionId(row) === connectionId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user