From 75b6fa2201c232dd17dbe14646c263b390d1e4bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:13:50 -0700 Subject: [PATCH] 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. --- src/main/ipc/pty/ipc/spawn-options.ts | 2 +- src/main/ipc/pty/runtime/spawn-execute.ts | 3 +- .../construction/runtime-host-services.ts | 2 +- ...roject-removal-structured-sessions.test.ts | 155 ++++++++++++++++++ .../project-removal-terminal-stop.test.ts | 57 +++++++ .../runtime-remove-project-host-scope.spec.ts | 26 +++ .../runtime-repository-settings-controller.ts | 35 ++-- .../structured-session-worktree-teardown.ts | 22 ++- .../terminal/stop/project-terminal-stop.ts | 124 ++++++++++++-- 9 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 src/main/runtime/project-removal-structured-sessions.test.ts diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index ee34f7f868e..70ecd030b3f 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -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 } diff --git a/src/main/ipc/pty/runtime/spawn-execute.ts b/src/main/ipc/pty/runtime/spawn-execute.ts index a0302e49b52..789827890f8 100644 --- a/src/main/ipc/pty/runtime/spawn-execute.ts +++ b/src/main/ipc/pty/runtime/spawn-execute.ts @@ -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 diff --git a/src/main/runtime/construction/runtime-host-services.ts b/src/main/runtime/construction/runtime-host-services.ts index 5ec003ab2d5..ccb7fd0fc55 100644 --- a/src/main/runtime/construction/runtime-host-services.ts +++ b/src/main/runtime/construction/runtime-host-services.ts @@ -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() diff --git a/src/main/runtime/project-removal-structured-sessions.test.ts b/src/main/runtime/project-removal-structured-sessions.test.ts new file mode 100644 index 00000000000..1783409cfe4 --- /dev/null +++ b/src/main/runtime/project-removal-structured-sessions.test.ts @@ -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']) + }) +}) diff --git a/src/main/runtime/project-removal-terminal-stop.test.ts b/src/main/runtime/project-removal-terminal-stop.test.ts index 3858a739f73..f637761fbc2 100644 --- a/src/main/runtime/project-removal-terminal-stop.test.ts +++ b/src/main/runtime/project-removal-terminal-stop.test.ts @@ -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) diff --git a/src/main/runtime/runtime-remove-project-host-scope.spec.ts b/src/main/runtime/runtime-remove-project-host-scope.spec.ts index f0d74db89f4..fabf93c1be1 100644 --- a/src/main/runtime/runtime-remove-project-host-scope.spec.ts +++ b/src/main/runtime/runtime-remove-project-host-scope.spec.ts @@ -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, 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']) + }) + }) } diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index 6eb883b5b87..c742b36ee7a 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -15,7 +15,10 @@ type RuntimeRepositorySettingsDependencies = { resolveRepo: (selector: string) => Promise forgetTerminalTopology: (repoId: string) => void /** Optional so test doubles and older wiring keep removing without it. */ - stopProjectTerminals?: (repo: Repo, deadline: number) => Promise + stopProjectTerminals?: ( + repo: Repo, + options: { deadline: number; closeStructuredSessions: boolean } + ) => Promise 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 | 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 => { - 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 => { + 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) diff --git a/src/main/runtime/structured-session-worktree-teardown.ts b/src/main/runtime/structured-session-worktree-teardown.ts index e2d9aad5432..8ef44fbf92b 100644 --- a/src/main/runtime/structured-session-worktree-teardown.ts +++ b/src/main/runtime/structured-session-worktree-teardown.ts @@ -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 { diff --git a/src/main/runtime/terminal/stop/project-terminal-stop.ts b/src/main/runtime/terminal/stop/project-terminal-stop.ts index 8f2fdf0302e..db723dc1b40 100644 --- a/src/main/runtime/terminal/stop/project-terminal-stop.ts +++ b/src/main/runtime/terminal/stop/project-terminal-stop.ts @@ -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(value: Promise, deadline: number | undefined): Promise { @@ -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() + /** Removed projects' PTY fences by id; ids are UUIDs, so a remaining row means a same-id sibling. */ + private readonly removedProjectFences = new Map>() 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, - 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, + 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() + 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, + deadline: number | undefined + ): Promise { + 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, + 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 + ): boolean { + const connectionId = getRepoSshConnectionId(repo) + return (this.store?.getRepos?.() ?? []).some( + (row) => + row.id === repo.id && + getRepoExecutionHostId(row) !== getRepoExecutionHostId(repo) && + getRepoSshConnectionId(row) === connectionId + ) + } }