fix(worktrees): stop terminals after external deletion

This commit is contained in:
Neil
2026-07-27 19:22:02 -07:00
parent 2cf91b8e69
commit ec14a28181
9 changed files with 511 additions and 5 deletions
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest'
import type { IPtyProvider } from '../providers/types'
import type { Repo } from '../../shared/types'
import type { OrcaRuntimeService } from './orca-runtime'
import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation'
function createProvider(sessionIds: string[]): IPtyProvider {
return {
listProcesses: vi.fn(async () =>
sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' }))
),
shutdown: vi.fn(async () => {})
} as unknown as IPtyProvider
}
function createRuntime(): OrcaRuntimeService {
return {
stopTerminalsForWorktree: vi.fn(async () => ({ stopped: 0 }))
} as unknown as OrcaRuntimeService
}
const localRepo: Repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1
}
describe('stopMissingWorktreeTerminals', () => {
it('stops only locally owned worktrees absent from the authoritative scan', async () => {
const deletedId = 'repo-1::/workspace/deleted'
const survivingId = 'repo-1::/workspace/surviving'
const provider = createProvider([
`${deletedId}@@deleted-session`,
`${survivingId}@@surviving-session`
])
const runtime = createRuntime()
const result = await stopMissingWorktreeTerminals(
localRepo,
[deletedId, survivingId, 'repo-2::/workspace/other'],
[survivingId],
{
runtime,
getLocalProvider: () => provider,
getSshProvider: () => undefined
}
)
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
expect(provider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@deleted-session`,
expect.objectContaining({ immediate: true })
)
expect(provider.shutdown).not.toHaveBeenCalledWith(
`${survivingId}@@surviving-session`,
expect.anything()
)
})
it('uses the owning SSH provider without consulting the local provider', async () => {
const deletedId = 'repo-1::/workspace/deleted'
const localProvider = createProvider([`${deletedId}@@local-session`])
const sshProvider = createProvider([`${deletedId}@@ssh-session`])
const getSshProvider = vi.fn(() => sshProvider)
await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, [deletedId], [], {
runtime: createRuntime(),
getLocalProvider: () => localProvider,
getSshProvider
})
expect(getSshProvider).toHaveBeenCalledWith('ssh-1')
expect(sshProvider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@ssh-session`,
expect.objectContaining({ immediate: true })
)
expect(localProvider.listProcesses).not.toHaveBeenCalled()
})
it('still stops graph-visible sessions when the owning provider is unavailable', async () => {
const deletedId = 'repo-1::/workspace/deleted'
const runtime = createRuntime()
const result = await stopMissingWorktreeTerminals(
{ ...localRepo, connectionId: 'ssh-1' },
[deletedId],
[],
{
runtime,
getLocalProvider: () => null,
getSshProvider: () => undefined
}
)
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
expect(runtime.stopTerminalsForWorktree).toHaveBeenCalledWith(deletedId)
})
})
@@ -0,0 +1,79 @@
import type { IPtyProvider } from '../providers/types'
import type { Repo } from '../../shared/types'
import { splitWorktreeId } from '../../shared/worktree-id'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import type { OrcaRuntimeService } from './orca-runtime'
import { killAllProcessesForWorktree } from './worktree-teardown'
const MISSING_WORKTREE_TEARDOWN_CONCURRENCY = 4
type MissingWorktreeTerminalReconciliationDeps = {
runtime: OrcaRuntimeService
getLocalProvider: () => IPtyProvider | null
getSshProvider: (connectionId: string) => IPtyProvider | undefined
onPtyStopped?: (ptyId: string) => void
}
export async function stopMissingWorktreeTerminals(
repo: Repo,
knownWorktreeIds: readonly string[],
detectedWorktreeIds: readonly string[],
deps: MissingWorktreeTerminalReconciliationDeps
): Promise<{ stoppedWorktreeIds: string[] }> {
const detectedIds = new Set(detectedWorktreeIds)
const missingIds = [
...new Set(
knownWorktreeIds.filter(
(worktreeId) =>
splitWorktreeId(worktreeId)?.repoId === repo.id && !detectedIds.has(worktreeId)
)
)
]
if (missingIds.length === 0) {
return { stoppedWorktreeIds: [] }
}
const provider = repo.connectionId
? deps.getSshProvider(repo.connectionId)
: deps.getLocalProvider()
if (!provider) {
const stoppedWorktreeIds = (
await mapWithConcurrency(
missingIds,
MISSING_WORKTREE_TEARDOWN_CONCURRENCY,
async (worktreeId) => {
try {
await deps.runtime.stopTerminalsForWorktree(worktreeId)
return worktreeId
} catch {
return null
}
}
)
).filter((worktreeId): worktreeId is string => worktreeId !== null)
return { stoppedWorktreeIds }
}
const stoppedWorktreeIds = (
await mapWithConcurrency(
missingIds,
MISSING_WORKTREE_TEARDOWN_CONCURRENCY,
async (worktreeId) => {
try {
await killAllProcessesForWorktree(worktreeId, {
runtime: deps.runtime,
localProvider: provider,
onPtyStopped: deps.onPtyStopped,
...(repo.connectionId ? { includeLocalRegistry: false } : {})
})
return worktreeId
} catch (error) {
console.warn(`[worktree-teardown] Failed to stop missing workspace ${worktreeId}`, error)
return null
}
}
)
).filter((worktreeId): worktreeId is string => worktreeId !== null)
return { stoppedWorktreeIds }
}
+100
View File
@@ -31373,6 +31373,106 @@ describe('OrcaRuntimeService', () => {
expect(removeWorktreeLineage).not.toHaveBeenCalled()
})
it('revalidates missing worktrees before stopping their local provider sessions', async () => {
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
const survivingId = `${TEST_REPO_ID}::/tmp/surviving`
const localProvider = {
listProcesses: vi.fn(async () => [
{ id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' },
{ id: `${survivingId}@@surviving-session`, cwd: '/tmp/surviving', title: 'shell' }
]),
shutdown: vi.fn(async () => {})
}
const runtime = new OrcaRuntimeService(store, undefined, {
getLocalProvider: () => localProvider as never
})
vi.spyOn(runtime, 'listDetectedManagedWorktrees').mockResolvedValue({
repoId: TEST_REPO_ID,
authoritative: true,
source: 'git',
worktrees: [{ id: survivingId }] as never
})
const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
deletedId,
survivingId
])
expect(result).toEqual({ stoppedWorktreeIds: [deletedId] })
expect(localProvider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@deleted-session`,
expect.objectContaining({ immediate: true })
)
expect(localProvider.shutdown).not.toHaveBeenCalledWith(
`${survivingId}@@surviving-session`,
expect.anything()
)
})
it('does not stop sessions after a non-authoritative revalidation', async () => {
const localProvider = {
listProcesses: vi.fn(async () => []),
shutdown: vi.fn(async () => {})
}
const runtime = new OrcaRuntimeService(store, undefined, {
getLocalProvider: () => localProvider as never
})
vi.spyOn(runtime, 'listDetectedManagedWorktrees').mockResolvedValue({
repoId: TEST_REPO_ID,
authoritative: false,
source: 'metadata-fallback',
worktrees: []
})
await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [
`${TEST_REPO_ID}::/tmp/deleted`
])
expect(localProvider.listProcesses).not.toHaveBeenCalled()
})
it('uses the connection-scoped repo when local and SSH repo ids collide', async () => {
const deletedId = `${TEST_REPO_ID}::/tmp/deleted`
const localProvider = {
listProcesses: vi.fn(async () => []),
shutdown: vi.fn(async () => {})
}
const sshProvider = {
listProcesses: vi.fn(async () => [
{ id: `${deletedId}@@ssh-session`, cwd: '/tmp/deleted', title: 'shell' }
]),
shutdown: vi.fn(async () => {})
}
const localRepo = store.getRepos()[0]
const sshRepo = { ...localRepo, connectionId: 'ssh-1' }
const runtime = new OrcaRuntimeService(
{
...store,
getRepos: () => [localRepo, sshRepo]
} as never,
undefined,
{
getLocalProvider: () => localProvider as never,
getSshProvider: (connectionId) =>
connectionId === 'ssh-1' ? (sshProvider as never) : undefined
}
)
vi.spyOn(runtime, 'listDetectedManagedWorktrees').mockResolvedValue({
repoId: TEST_REPO_ID,
authoritative: true,
source: 'git',
worktrees: []
})
await runtime.teardownMissingManagedWorktreeTerminals(TEST_REPO_ID, [deletedId], 'ssh-1')
expect(sshProvider.shutdown).toHaveBeenCalledWith(
`${deletedId}@@ssh-session`,
expect.objectContaining({ immediate: true })
)
expect(localProvider.listProcesses).not.toHaveBeenCalled()
})
it('hydrates runtime detected lists with instance-validated legacy lineage', async () => {
const parentPath = join(tmpdir(), 'worktree-parent')
const childPath = join(tmpdir(), 'worktree-child')
+50 -2
View File
@@ -885,6 +885,7 @@ import {
registerTerminalViewAttributesApplier
} from './terminal-view-attribute-store'
import { killAllProcessesForWorktree, teardownRpcDeadline } from './worktree-teardown'
import { stopMissingWorktreeTerminals } from './missing-worktree-terminal-reconciliation'
import {
MobileNotificationReplayBuffer,
type ReplayableMobileNotification
@@ -17944,8 +17945,11 @@ export class OrcaRuntimeService {
}
}
async listDetectedManagedWorktrees(repoSelector: string): Promise<DetectedWorktreeListResult> {
const repo = await this.resolveRepoSelector(repoSelector)
async listDetectedManagedWorktrees(
repoSelector: string,
connectionId?: string | null
): Promise<DetectedWorktreeListResult> {
const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
const store = this.requireStore()
if (isFolderRepo(repo)) {
const worktrees = listRuntimeFolderWorkspaces(store, repo)
@@ -17995,6 +17999,50 @@ export class OrcaRuntimeService {
}
}
async teardownMissingManagedWorktreeTerminals(
repoSelector: string,
knownWorktreeIds: readonly string[],
connectionId?: string | null
): Promise<{ stoppedWorktreeIds: string[] }> {
const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId)
const detected = await this.listDetectedManagedWorktrees(`id:${repo.id}`, connectionId)
if (!detected.authoritative) {
return { stoppedWorktreeIds: [] }
}
return stopMissingWorktreeTerminals(
repo,
knownWorktreeIds,
detected.worktrees.map((worktree) => worktree.id),
{
runtime: this,
getLocalProvider: () => this.getLocalProvider(),
getSshProvider: (connectionId) => this.getSshProviderFn?.(connectionId),
onPtyStopped: this.onPtyStopped ?? undefined
}
)
}
private resolveRepoSelectorForConnection(
repoSelector: string,
connectionId?: string | null
): Promise<Repo> {
if (connectionId === undefined) {
return this.resolveRepoSelector(repoSelector)
}
const repoId = repoSelector.startsWith('id:') ? repoSelector.slice(3) : repoSelector
const matches = this.requireStore()
.getRepos()
.filter(
(repo) =>
repo.id === repoId &&
(repo.connectionId?.trim() || null) === (connectionId?.trim() || null)
)
if (matches.length !== 1) {
throw new Error(matches.length > 1 ? 'selector_ambiguous' : 'repo_not_found')
}
return Promise.resolve(matches[0])
}
private isRuntimeWorktreeVisible(
worktree: Worktree,
agentScratchWorktreePathMatcher?: AgentScratchWorktreePathMatcher
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { RpcDispatcher } from '../dispatcher'
import { WORKTREE_METHODS } from './worktree'
describe('worktree missing-terminal teardown RPC', () => {
it('routes the connection-scoped request through the runtime owner', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
teardownMissingManagedWorktreeTerminals: vi
.fn()
.mockResolvedValue({ stoppedWorktreeIds: ['repo-1::/workspace/deleted'] })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch({
id: 'request-1',
authToken: 'token',
method: 'worktree.teardownMissingTerminals',
params: {
repo: 'repo-1',
worktreeIds: ['repo-1::/workspace/deleted'],
connectionId: 'ssh-1'
}
})
expect(runtime.teardownMissingManagedWorktreeTerminals).toHaveBeenCalledWith(
'repo-1',
['repo-1::/workspace/deleted'],
'ssh-1'
)
expect(response).toMatchObject({ ok: true })
})
})
@@ -48,6 +48,11 @@ export const WorktreeDetectedListParams = z.object({
.pipe(z.string().min(1, 'Missing repo selector'))
})
export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams.extend({
worktreeIds: z.array(z.string().min(1)).max(10_000),
connectionId: z.string().nullable().optional()
})
export const WorktreePsParams = z.object({
limit: OptionalFiniteNumber
})
+12 -1
View File
@@ -19,7 +19,8 @@ import {
WorktreeResolvePrBase,
WorktreeSelector,
WorktreeSet,
WorktreeSortOrder
WorktreeSortOrder,
WorktreeTeardownMissingTerminalsParams
} from './worktree-schemas'
export const WORKTREE_METHODS: RpcMethod[] = [
@@ -38,6 +39,16 @@ export const WORKTREE_METHODS: RpcMethod[] = [
params: WorktreeDetectedListParams,
handler: async (params, { runtime }) => runtime.listDetectedManagedWorktrees(params.repo)
}),
defineMethod({
name: 'worktree.teardownMissingTerminals',
params: WorktreeTeardownMissingTerminalsParams,
handler: async (params, { runtime }) =>
runtime.teardownMissingManagedWorktreeTerminals(
params.repo,
params.worktreeIds,
params.connectionId
)
}),
defineMethod({
name: 'worktree.lineageList',
params: null,
@@ -83,6 +83,13 @@ const mockApi = {
runtimeEnvironments: {
call: runtimeEnvironmentTransportCall
},
runtime: {
call: vi.fn().mockResolvedValue({
id: 'runtime-call',
ok: true,
result: { stoppedWorktreeIds: [] }
})
},
ephemeralVm: {
cancelProvision: vi.fn().mockResolvedValue({ cancelled: true }),
cleanup: vi.fn().mockResolvedValue({}),
@@ -1038,6 +1045,66 @@ describe('fetchWorktrees', () => {
expect(hasDismissedHugeRepoWarning(beginHugeRepoWarningProbe(hidden))).toBe(false)
})
it('awaits missing-worktree terminal teardown before purging renderer state', async () => {
const store = createTestStore()
const deleted = makeWorktree({
id: 'repo1::/path/deleted',
repoId: 'repo1',
path: '/path/deleted'
})
const surviving = makeWorktree({
id: 'repo1::/path/surviving',
repoId: 'repo1',
path: '/path/surviving'
})
let finishTeardown!: () => void
mockApi.runtime.call.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishTeardown = () =>
resolve({
id: 'teardown',
ok: true,
result: { stoppedWorktreeIds: [deleted.id] }
})
})
)
mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving]))
store.setState({
repos: [
{
id: 'repo1',
path: '/path/repo1',
displayName: 'Repo 1',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-1'
}
],
worktreesByRepo: { repo1: [deleted, surviving] },
detectedWorktreesByRepo: {
repo1: makeDetectedResult('repo1', [deleted, surviving])
},
tabsByWorktree: {
[deleted.id]: [{ id: 'tab-deleted', worktreeId: deleted.id }]
}
} as unknown as Partial<AppState>)
const refresh = store.getState().fetchWorktrees('repo1')
await vi.waitFor(() => expect(mockApi.runtime.call).toHaveBeenCalledTimes(1))
expect(mockApi.runtime.call).toHaveBeenCalledWith({
method: 'worktree.teardownMissingTerminals',
params: { repo: 'repo1', worktreeIds: [deleted.id], connectionId: 'ssh-1' }
})
expect(store.getState().tabsByWorktree[deleted.id]).toBeDefined()
finishTeardown()
await refresh
expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined()
})
it('clears a hidden dismissal across hydrated fetch-all delete and recreation', async () => {
const store = createTestStore()
const visible = makeWorktree({
@@ -1174,6 +1241,7 @@ describe('fetchWorktrees', () => {
expect(store.getState().worktreesByRepo.repo1).toEqual([fallback])
expect(store.getState().sortEpoch).toBe(8)
expect(result).toBe(false)
expect(mockApi.runtime.call).not.toHaveBeenCalled()
})
it('does not purge remembered right sidebar tabs on a transient empty refresh', async () => {
+63 -2
View File
@@ -633,6 +633,35 @@ function isRuntimeMethodNotFoundError(error: unknown): boolean {
return error instanceof RuntimeRpcCallError && error.code === 'method_not_found'
}
async function teardownMissingWorktreeTerminalsBestEffort(
settings: AppState['settings'],
repoId: string,
connectionId: string | null | undefined,
knownWorktreeIds: readonly string[],
detected: DetectedWorktreeListResult
): Promise<void> {
if (!detected.authoritative || knownWorktreeIds.length === 0) {
return
}
const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id))
const missingIds = knownWorktreeIds.filter((worktreeId) => !detectedIds.has(worktreeId))
if (missingIds.length === 0) {
return
}
try {
await callRuntimeRpc(
getActiveRuntimeTarget(settings),
'worktree.teardownMissingTerminals',
{ repo: repoId, worktreeIds: missingIds, connectionId: connectionId ?? null },
{ timeoutMs: 30_000 }
)
} catch (error) {
if (!isRuntimeMethodNotFoundError(error)) {
console.warn(`Failed to stop terminals for missing worktrees in repo ${repoId}:`, error)
}
}
}
// Why: a mobile-scope web pairing is denied worktree/repo RPCs (else silently empty workspaces); surface one deduped toast (stable id) instead of spamming per-repo.
const RUNTIME_SCOPE_FORBIDDEN_TOAST_ID = 'runtime-scope-forbidden'
@@ -1003,6 +1032,8 @@ function detectedWorktreeRefreshKey(
repoId: string,
options: {
executionHostId: ExecutionHostId
connectionId?: string | null
knownWorktreeIds: readonly string[]
requireAuthoritative?: boolean
reuseRecentCompatibilityFailure?: boolean
}
@@ -1029,6 +1060,8 @@ async function listDetectedWorktreesForRepoCoalesced(
repoId: string,
options: {
executionHostId: ExecutionHostId
connectionId?: string | null
knownWorktreeIds: readonly string[]
requireAuthoritative?: boolean
reuseRecentCompatibilityFailure?: boolean
}
@@ -1060,6 +1093,13 @@ async function listDetectedWorktreesForRepoCoalesced(
) {
throw new Error('runtime_environment_generation_changed')
}
await teardownMissingWorktreeTerminalsBestEffort(
settings,
repoId,
options.connectionId,
options.knownWorktreeIds,
result
)
return result
} finally {
if (detectedWorktreeRefreshesInFlight.get(key) === refresh) {
@@ -2373,10 +2413,18 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const hostId = repoHostId(ownerState, repoId)
const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId)
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
const repoOwner = findRepoForHost(ownerState.repos, repoId, {
hostId,
settings: ownerState.settings
})
const result = await listDetectedWorktreesForRepoCoalesced(
settingsForRepoOwner(ownerState, repoId, hostId),
repoId,
{ executionHostId: hostId }
{
executionHostId: hostId,
connectionId: repoOwner?.connectionId,
knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId)
}
)
set((s) => {
if (!repoHasExecutionHost(s, repoId, hostId, ownerWasMissingAtStart)) {
@@ -2421,6 +2469,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
: repoHostId(ownerState, repoId, options?.executionHostId)
const ownerWasMissingAtStart = repoOwners.length === 0
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
const repoOwner = findRepoForHost(ownerState.repos, repoId, {
hostId,
settings: ownerState.settings
})
const ownerSettings = settingsForRepoOwner(ownerState, repoId, hostId)
const settings =
useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId
@@ -2428,6 +2480,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
: ownerSettings
const detected = await listDetectedWorktreesForRepoCoalesced(settings, repoId, {
executionHostId: hostId,
connectionId: repoOwner?.connectionId,
knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId),
requireAuthoritative: options?.requireAuthoritative
})
if (options?.requireAuthoritative && !detected.authoritative) {
@@ -2578,6 +2632,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const settings = settingsForKnownRepoOwner(requestStartedState.settings, r)
const detected = await listDetectedWorktreesForRepoCoalesced(settings, r.id, {
executionHostId: hostId,
connectionId: r.connectionId,
knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId),
reuseRecentCompatibilityFailure: true
})
let incoming = toVisibleWorktrees(detected, hostId, setup)
@@ -2663,7 +2719,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
const detected = await listDetectedWorktreesForRepoCoalesced(
settingsForKnownRepoOwner(requestStartedState.settings, r),
r.id,
{ executionHostId: hostId, reuseRecentCompatibilityFailure: true }
{
executionHostId: hostId,
connectionId: r.connectionId,
knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId),
reuseRecentCompatibilityFailure: true
}
)
let incoming = toVisibleWorktrees(detected, hostId, setup)
const latestState = get()