From 5f642841fdd3b9f896fb9d7fd8871002e6f5b883 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:05:13 -0700 Subject: [PATCH] fix(worktrees): stop terminals after external deletion (#11237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(worktrees): stop terminals after external deletion * fix(worktrees): request teardown per caller and revalidate uncached Two defects let the original fix silently strand PTYs: - teardown rode the scan's coalescing promise, so any caller that joined an in-flight scan purged its renderer state without ever asking for a sweep; it now runs per caller against its own known-id snapshot, deduped on the request it actually produces so fan-out still shares one host sweep. - the runtime's authoritative recheck was served from the 30s worktree-scan cache, which can still list a directory git already dropped. The renderer purges either way, so a stale miss leaked those processes permanently. Co-authored-by: Orca * perf(worktrees): enumerate the host once per teardown sweep An agent cleaning up N workspaces made killAllProcessesForWorktree issue one full provider enumeration per missing worktree: O(N) relay round-trips carrying O(N^2) rows. At 30 worktrees over an 80ms-RTT SSH link that is 30 scans and ~1.3s of stalled teardown; it scales linearly from there. Share one point-in-time process list across the sweep — every worktree in it is already known-missing, so a single snapshot answers all of them. A failed scan is never shared: it falls back to a per-caller scan so one transient relay error cannot suppress the sweep for the whole batch. Pinned requirePhysicalStop:false since that path re-lists after shutdown and must not read a pre-shutdown snapshot. Co-authored-by: Orca * test(worktrees): pin the disconnected-SSH no-teardown invariant main's new directSshAuthority gate bails before any refresh when an SSH target is not connected. That is exactly the #10562 safety rule — "host unreachable" must never be read as "worktree deleted" — so pin it: a disconnected target issues no teardown RPC and keeps its renderer state. Co-authored-by: Orca * fix(worktrees): keep selector grammar intact when scoping by connection resolveRepoSelectorForConnection matched the selector as a bare repo id, so an explicit connection identity silently changed the grammar: `path:` and `name:` selectors resolved to repo_not_found on that path alone, losing the whole sweep. A connection identity should only *narrow* the candidate set. Extract the selector matching both paths now share, and stop re-resolving an already-resolved repo: teardown rescanned via `id:`, which throws selector_ambiguous when an id is duplicated across hosts even though the caller's own selector was unambiguous. Reported as a P2 by Greptile (as redundant work); it is load-bearing. Co-authored-by: Orca * fix(worktrees): keep the shared snapshot out of provider internals The snapshot proxy passed itself as the Reflect.get receiver, so prototype methods invoked through it ran with `this` bound to the proxy. A provider whose own shutdown() re-read state via `this.listProcesses()` would then silently get this sweep's cached snapshot instead of the live host — batching leaking past the calls it was built for. Bind non-listProcesses members to the target so only the sweep's own calls share the snapshot. No shipped provider does this today; the point is that adding one must not quietly change teardown semantics. Raised by Greptile as an undocumented implicit constraint; closed structurally rather than by comment. Co-authored-by: Orca --------- Co-authored-by: Orca --- ...g-worktree-terminal-reconciliation.test.ts | 210 ++++++++++++++++++ ...issing-worktree-terminal-reconciliation.ts | 122 ++++++++++ src/main/runtime/orca-runtime.test.ts | 185 +++++++++++++++ src/main/runtime/orca-runtime.ts | 104 +++++++-- ...worktree-missing-terminal-teardown.test.ts | 34 +++ .../runtime/rpc/methods/worktree-schemas.ts | 5 + src/main/runtime/rpc/methods/worktree.ts | 13 +- .../src/store/slices/worktrees.test.ts | 194 +++++++++++++++- src/renderer/src/store/slices/worktrees.ts | 106 ++++++++- 9 files changed, 948 insertions(+), 25 deletions(-) create mode 100644 src/main/runtime/missing-worktree-terminal-reconciliation.test.ts create mode 100644 src/main/runtime/missing-worktree-terminal-reconciliation.ts create mode 100644 src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts new file mode 100644 index 00000000000..f218f3a2c4f --- /dev/null +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.test.ts @@ -0,0 +1,210 @@ +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) + }) + + // Why: an agent cleaning up workspaces deletes many at once. Enumerating the + // host once per missing worktree is O(N) relay round-trips carrying O(N^2) + // rows — on SSH that stalls teardown for minutes. + it('enumerates the host once for the whole sweep', async () => { + const ids = Array.from({ length: 25 }, (_, index) => `repo-1::/workspace/wt-${index}`) + const provider = createProvider(ids.map((id) => `${id}@@session`)) + + const result = await stopMissingWorktreeTerminals( + { ...localRepo, connectionId: 'ssh-1' }, + ids, + [], + { runtime: createRuntime(), getLocalProvider: () => null, getSshProvider: () => provider } + ) + + expect(result.stoppedWorktreeIds).toHaveLength(ids.length) + expect(provider.listProcesses).toHaveBeenCalledTimes(1) + expect(provider.shutdown).toHaveBeenCalledTimes(ids.length) + }) + + // Why: real providers put listProcesses/shutdown on the prototype and use `this`; + // batching them behind a wrapper must not break that binding. + it('keeps provider method binding intact while batching', async () => { + class PrototypeProvider { + listCalls = 0 + shutdownCalls: string[] = [] + private readonly sessions: { id: string; cwd: string; title: string }[] + constructor(sessionIds: string[]) { + this.sessions = sessionIds.map((id) => ({ id, cwd: '/workspace', title: 'shell' })) + } + async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + this.listCalls += 1 + return this.sessions + } + async shutdown(sessionId: string): Promise { + this.shutdownCalls.push(sessionId) + } + } + const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b', 'repo-1::/workspace/c'] + const provider = new PrototypeProvider(ids.map((id) => `${id}@@session`)) + + const result = await stopMissingWorktreeTerminals( + { ...localRepo, connectionId: 'ssh-1' }, + ids, + [], + { + runtime: createRuntime(), + getLocalProvider: () => null, + getSshProvider: () => provider as unknown as IPtyProvider + } + ) + + expect(result.stoppedWorktreeIds).toHaveLength(ids.length) + expect(provider.listCalls).toBe(1) + expect(provider.shutdownCalls).toHaveLength(ids.length) + }) + + // Why: the batching must not leak past the calls it was built for. If the proxy + // were the receiver, a provider whose own method called `this.listProcesses()` + // would silently read this sweep's cached snapshot instead of the live host. + it('does not serve the shared snapshot to provider-internal listProcesses', async () => { + class SelfListingProvider { + listCalls = 0 + constructor(private readonly sessions: { id: string; cwd: string; title: string }[]) {} + async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> { + this.listCalls += 1 + return this.sessions + } + async shutdown(): Promise { + // A provider that re-reads live state as part of stopping. + await this.listProcesses() + } + } + const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b'] + const provider = new SelfListingProvider( + ids.map((id) => ({ id: `${id}@@session`, cwd: '/workspace', title: 'shell' })) + ) + + await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], { + runtime: createRuntime(), + getLocalProvider: () => null, + getSshProvider: () => provider as unknown as IPtyProvider + }) + + // One shared sweep scan, plus each shutdown's own live re-read. + expect(provider.listCalls).toBe(1 + ids.length) + }) + + // Why: caching a rejected scan would let one transient failure suppress + // teardown for every remaining worktree in the sweep. + it('does not reuse a failed process scan', async () => { + const ids = ['repo-1::/workspace/a', 'repo-1::/workspace/b'] + const listProcesses = vi + .fn() + .mockRejectedValueOnce(new Error('relay dropped')) + .mockResolvedValue([{ id: `${ids[1]}@@session`, cwd: '/workspace', title: 'shell' }]) + const provider = { listProcesses, shutdown: vi.fn(async () => {}) } as unknown as IPtyProvider + + await stopMissingWorktreeTerminals({ ...localRepo, connectionId: 'ssh-1' }, ids, [], { + runtime: createRuntime(), + getLocalProvider: () => null, + getSshProvider: () => provider + }) + + expect(listProcesses.mock.calls.length).toBeGreaterThan(1) + expect(provider.shutdown).toHaveBeenCalledWith( + `${ids[1]}@@session`, + expect.objectContaining({ immediate: true }) + ) + }) +}) diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.ts new file mode 100644 index 00000000000..b25c4fc9f83 --- /dev/null +++ b/src/main/runtime/missing-worktree-terminal-reconciliation.ts @@ -0,0 +1,122 @@ +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 + +// Why: every worktree in one sweep is already known-missing, so they can all be +// served by a single point-in-time process list. Without this, an agent that +// deletes N workspaces costs N full host enumerations — O(N) relay round-trips +// carrying O(N^2) rows, which is minutes of stalled teardown on a remote host. +// +// Safe only because this sweep is best-effort. Do NOT reuse this wrapper on a +// `requirePhysicalStop` path: that re-lists *after* shutdown to prove a PTY +// exited, and a pre-shutdown snapshot would make the proof read stale rows. +function withSharedProcessSnapshot(provider: IPtyProvider): IPtyProvider { + let snapshot: Promise>> | null = null + return new Proxy(provider, { + get(target, property) { + if (property !== 'listProcesses') { + // Why: bind other members to `target`, not the proxy. With the proxy as + // receiver, a provider whose own method called `this.listProcesses()` + // would silently read this sweep's cached snapshot instead of the live + // host — the batching must not leak past the calls it was built for. + const member: unknown = Reflect.get(target, property) + return typeof member === 'function' ? member.bind(target) : member + } + return async (opts?: { deadlineMs?: number }) => { + const pending = (snapshot ??= target.listProcesses(opts)) + try { + return await pending + } catch { + // Why: a shared *rejection* would let one transient relay failure suppress + // the provider sweep for every worktree in the batch — the exact leak this + // reconciliation exists to prevent. Failures fall back to a per-caller scan, + // restoring pre-batching isolation while success still costs one round-trip. + if (snapshot === pending) { + snapshot = null + } + return target.listProcesses(opts) + } + } + } + }) +} + +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 ownedProvider = repo.connectionId + ? deps.getSshProvider(repo.connectionId) + : deps.getLocalProvider() + const provider = ownedProvider ? withSharedProcessSnapshot(ownedProvider) : ownedProvider + 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, + // Why: the shared process snapshot is only valid while nothing needs a + // post-shutdown re-list, so this sweep stays explicitly best-effort. + requirePhysicalStop: false, + ...(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 } +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 72663c7f908..ac7c23276c1 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -34409,6 +34409,191 @@ 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 as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown }, + 'listDetectedWorktreesForResolvedRepo' + ).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() + ) + }) + + // Why (#10562): the renderer purges its state regardless of the sweep result, so + // revalidating against a cached scan (30s TTL) that still lists an already-deleted + // directory would strand those PTYs permanently — nothing asks a second time. + it('revalidates against a fresh scan instead of a warm worktree-scan cache', async () => { + const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const localProvider = { + listProcesses: vi.fn(async () => [ + { id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' } + ]), + shutdown: vi.fn(async () => {}) + } + const runtime = new OrcaRuntimeService(store, undefined, { + getLocalProvider: () => localProvider as never + }) + + // Warm the scan cache while the worktree still exists. + vi.mocked(listWorktrees).mockResolvedValueOnce([ + { path: '/tmp/deleted', head: 'abc', branch: 'd', isBare: false, isMainWorktree: false } + ]) + await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [deletedId]) + expect(localProvider.shutdown).not.toHaveBeenCalled() + + // The worktree is now gone; the cached scan must not mask that. + vi.mocked(listWorktrees).mockResolvedValue([]) + const result = await runtime.teardownMissingManagedWorktreeTerminals(`id:${TEST_REPO_ID}`, [ + deletedId + ]) + + expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) + expect(localProvider.shutdown).toHaveBeenCalledWith( + `${deletedId}@@deleted-session`, + expect.objectContaining({ immediate: true }) + ) + }) + + 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 as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown }, + 'listDetectedWorktreesForResolvedRepo' + ).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() + }) + + // Why: an explicit connection identity only narrows the selector, it must not + // change the grammar. Treating the selector as a bare repo id made every + // `path:`/`name:` selector fail repo_not_found on this path alone. + it('resolves non-id selectors when a connection identity is supplied', async () => { + const deletedId = `${TEST_REPO_ID}::/tmp/deleted` + const localProvider = { + listProcesses: vi.fn(async () => [ + { id: `${deletedId}@@deleted-session`, cwd: '/tmp/deleted', title: 'shell' } + ]), + shutdown: vi.fn(async () => {}) + } + const localRepo = store.getRepos()[0] + const runtime = new OrcaRuntimeService( + { ...store, getRepos: () => [localRepo, { ...localRepo, connectionId: 'ssh-1' }] } as never, + undefined, + { getLocalProvider: () => localProvider as never } + ) + vi.spyOn( + runtime as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown }, + 'listDetectedWorktreesForResolvedRepo' + ).mockResolvedValue({ + repoId: TEST_REPO_ID, + authoritative: true, + source: 'git', + worktrees: [] + }) + + // `path:` is ambiguous across the two rows; connectionId null selects the local one. + const result = await runtime.teardownMissingManagedWorktreeTerminals( + `path:${localRepo.path}`, + [deletedId], + null + ) + + expect(result).toEqual({ stoppedWorktreeIds: [deletedId] }) + expect(localProvider.shutdown).toHaveBeenCalledWith( + `${deletedId}@@deleted-session`, + expect.objectContaining({ immediate: true }) + ) + }) + + 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 as unknown as { listDetectedWorktreesForResolvedRepo: () => unknown }, + 'listDetectedWorktreesForResolvedRepo' + ).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') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 161314dbb16..c9e16a5cd83 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -929,6 +929,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 @@ -19425,8 +19426,18 @@ export class OrcaRuntimeService { } } - async listDetectedManagedWorktrees(repoSelector: string): Promise { - const repo = await this.resolveRepoSelector(repoSelector) + async listDetectedManagedWorktrees( + repoSelector: string, + connectionId?: string | null + ): Promise { + return this.listDetectedWorktreesForResolvedRepo( + await this.resolveRepoSelectorForConnection(repoSelector, connectionId) + ) + } + + private async listDetectedWorktreesForResolvedRepo( + repo: Repo + ): Promise { const store = this.requireStore() if (isFolderRepo(repo)) { const worktrees = listRuntimeFolderWorkspaces(store, repo) @@ -19476,6 +19487,56 @@ export class OrcaRuntimeService { } } + async teardownMissingManagedWorktreeTerminals( + repoSelector: string, + knownWorktreeIds: readonly string[], + connectionId?: string | null + ): Promise<{ stoppedWorktreeIds: string[] }> { + const repo = await this.resolveRepoSelectorForConnection(repoSelector, connectionId) + // Why: killing PTYs must be proven against the host right now — a cached scan + // (30s TTL) can still list a directory git already dropped, and the renderer + // purges its state either way, so a stale miss strands those processes for good. + this.invalidateWorktreeScanCacheForRepo(repo.id) + // Why: rescanning by `id:` would re-resolve the already-resolved repo, and a + // duplicate id across hosts makes that second lookup throw selector_ambiguous + // even though the caller's selector was unique — losing the sweep entirely. + const detected = await this.listDetectedWorktreesForResolvedRepo(repo) + 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 { + if (connectionId === undefined) { + return this.resolveRepoSelector(repoSelector) + } + // Why: an explicit connection identity only *narrows* the selector; it must not + // change the grammar. Matching the selector as a bare repo id would make + // `path:`/`name:` selectors resolve to repo_not_found on this path alone. + const wanted = connectionId?.trim() || null + const matches = this.selectReposBySelector(repoSelector).filter( + (repo) => (repo.connectionId?.trim() || null) === wanted + ) + 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 @@ -26684,27 +26745,32 @@ export class OrcaRuntimeService { return this.store?.getAllWorkspaceLineage?.() ?? {} } + // Why: one selector grammar, so connection-scoped resolution can narrow the same + // candidate set instead of reimplementing (and diverging from) the matching rules. + private selectReposBySelector(selector: string): Repo[] { + const repos = this.store?.getRepos() ?? [] + if (selector.startsWith('id:')) { + return repos.filter((repo) => repo.id === selector.slice(3)) + } + if (selector.startsWith('path:')) { + return repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) + } + if (selector.startsWith('name:')) { + return repos.filter((repo) => repo.displayName === selector.slice(5)) + } + return repos.filter( + (repo) => + repo.id === selector || + runtimePathsEqual(repo.path, selector) || + repo.displayName === selector + ) + } + private async resolveRepoSelector(selector: string): Promise { if (!this.store) { throw new Error('repo_not_found') } - const repos = this.store.getRepos() - let candidates: Repo[] - - if (selector.startsWith('id:')) { - candidates = repos.filter((repo) => repo.id === selector.slice(3)) - } else if (selector.startsWith('path:')) { - candidates = repos.filter((repo) => runtimePathsEqual(repo.path, selector.slice(5))) - } else if (selector.startsWith('name:')) { - candidates = repos.filter((repo) => repo.displayName === selector.slice(5)) - } else { - candidates = repos.filter( - (repo) => - repo.id === selector || - runtimePathsEqual(repo.path, selector) || - repo.displayName === selector - ) - } + const candidates = this.selectReposBySelector(selector) if (candidates.length === 1) { return candidates[0] diff --git a/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts b/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts new file mode 100644 index 00000000000..9ea904af68d --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-missing-terminal-teardown.test.ts @@ -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 }) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 00b6c986f7b..caf8dd025ff 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -51,6 +51,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 }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index c2d96214994..9c6205ed86d 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -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, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index f87ad733294..dbcbff9f74e 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -123,6 +123,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({}), @@ -703,6 +710,126 @@ describe('fetchWorktrees', () => { expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) }) + // Why (#10562): the scan coalesces but each caller carries its own known-id + // snapshot, so a caller that joined an in-flight scan must still request + // teardown — otherwise it purges renderer state and strands live PTYs. + it('stops terminals for a caller that coalesced onto an in-flight scan', async () => { + const store = createTestStore() + const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' }) + const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' }) + + // Caller A starts before hydration, so its known-id snapshot is empty. + store.setState({ + repos: [{ id: 'repo1', path: '/p/repo1', displayName: 'R', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + tabsByWorktree: {} + } as unknown as Partial) + + let releaseScan!: () => void + const scanStarted = new Promise((resolve) => { + mockApi.worktrees.listDetected.mockImplementationOnce(async ({ repoId }) => { + resolve() + await new Promise((release) => { + releaseScan = release + }) + return makeDetectedResult(repoId, [surviving]) + }) + }) + + const callerA = store.getState().fetchDetectedWorktrees('repo1') + await scanStarted + + // Hydration lands mid-scan: the renderer now owns `deleted` and its tabs. + store.setState({ + worktreesByRepo: { repo1: [deleted, surviving] }, + detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) }, + tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] } + } as unknown as Partial) + + const callerB = store.getState().fetchWorktrees('repo1') + releaseScan() + await Promise.all([callerA, callerB]) + + // One shared scan, but the coalesced caller still asked the host to sweep. + expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(1) + expect(mockApi.runtime.call).toHaveBeenCalledWith({ + method: 'worktree.teardownMissingTerminals', + params: { repo: 'repo1', worktreeIds: [deleted.id], connectionId: null } + }) + expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined() + }) + + // Why: teardown rides outside the scan coalescer, so identical fan-out requests + // must share one host sweep instead of re-scanning the host per caller. + it('shares one teardown sweep across identical concurrent refreshes', async () => { + const store = createTestStore() + const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' }) + const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' }) + + store.setState({ + repos: [{ id: 'repo1', path: '/p/repo1', displayName: 'R', badgeColor: '#000', addedAt: 0 }], + worktreesByRepo: { repo1: [deleted, surviving] }, + detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) }, + tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] } + } as unknown as Partial) + + mockApi.worktrees.listDetected.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving])) + ) + + await Promise.all([ + store.getState().fetchWorktrees('repo1'), + store.getState().fetchDetectedWorktrees('repo1'), + store.getState().fetchWorktrees('repo1') + ]) + + expect(mockApi.runtime.call).toHaveBeenCalledTimes(1) + expect(store.getState().tabsByWorktree[deleted.id]).toBeUndefined() + }) + + // Why (#10562): "I can't reach the host" must never be read as "the worktree is + // gone". A disconnected SSH target has no authoritative scan, so it must not + // purge and must not kill anything on the far side. + it('never stops terminals for a disconnected SSH target', async () => { + const store = createTestStore() + const deleted = makeWorktree({ id: 'repo1::/p/deleted', repoId: 'repo1', path: '/p/deleted' }) + const surviving = makeWorktree({ id: 'repo1::/p/surv', repoId: 'repo1', path: '/p/surv' }) + + store.setState({ + repos: [ + { + id: 'repo1', + path: '/p/repo1', + displayName: 'R', + badgeColor: '#000', + addedAt: 0, + connectionId: TEST_SSH_AUTHORITY.targetId + } + ], + // Why: the target dropped — no connected authority for this host. + sshConnectionStates: new Map([ + [ + TEST_SSH_AUTHORITY.targetId, + { + targetId: TEST_SSH_AUTHORITY.targetId, + status: 'disconnected', + error: null, + reconnectAttempt: 1 + } + ] + ]), + worktreesByRepo: { repo1: [deleted, surviving] }, + detectedWorktreesByRepo: { repo1: makeDetectedResult('repo1', [deleted, surviving]) }, + tabsByWorktree: { [deleted.id]: [{ id: 'tab-d', worktreeId: deleted.id }] } + } as unknown as Partial) + + await store.getState().fetchWorktrees('repo1') + + expect(mockApi.runtime.call).not.toHaveBeenCalled() + expect(store.getState().tabsByWorktree[deleted.id]).toBeDefined() + }) + it('coalesces fetchDetectedWorktrees with a matching fetchWorktrees refresh', async () => { const store = createTestStore() const refreshed = makeWorktree({ @@ -1500,6 +1627,68 @@ 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.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, 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) + + 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({ @@ -1557,7 +1746,9 @@ describe('fetchWorktrees', () => { path: '/path/surviving' }) - mockApi.worktrees.listDetected.mockResolvedValueOnce(makeDetectedResult('repo1', [surviving])) + mockApi.worktrees.listDetected.mockImplementationOnce(async (args) => + qualifyDetectedResult(args, makeDetectedResult('repo1', [surviving])) + ) store.setState({ worktreesByRepo: { repo1: [] }, detectedWorktreesByRepo: { @@ -1636,6 +1827,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 () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2600c4def8b..27f04a3c8eb 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -169,6 +169,10 @@ type DetectedWorktreeRefreshOptions = BackgroundRuntimeRefreshOptions & { executionHostId: ExecutionHostId requireAuthoritative?: boolean directSshAuthority?: DirectSshAuthority + // Why (#10562): the caller's own view of what it is about to purge. Teardown is + // requested per caller, so this must never be shared with a coalesced scan. + connectionId?: string | null + knownWorktreeIds?: readonly string[] } type AdmittedDetectedWorktreeRefresh = { @@ -695,6 +699,64 @@ function isRuntimeMethodNotFoundError(error: unknown): boolean { return error instanceof RuntimeRpcCallError && error.code === 'method_not_found' } +// Why: teardown cannot ride the scan's coalescing (each caller has its own known-id +// snapshot), so dedupe on the request it actually produces — identical fan-out +// requests share one host sweep instead of re-scanning per caller. +const missingWorktreeTeardownsInFlight = new Map>() + +async function teardownMissingWorktreeTerminalsBestEffort( + settings: AppState['settings'], + repoId: string, + connectionId: string | null | undefined, + // Why: refreshes that never purge omit this; an absent snapshot means + // "nothing to reconcile", never "purge everything". + knownWorktreeIds: readonly string[] | undefined, + detected: DetectedWorktreeListResult +): Promise { + if (!detected.authoritative || !knownWorktreeIds || 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 + } + const target = getActiveRuntimeTarget(settings) + const normalizedConnectionId = connectionId ?? null + const key = [ + target.kind === 'local' ? 'local' : `runtime:${target.environmentId}`, + repoId, + normalizedConnectionId ?? '', + [...missingIds].sort().join('\n') + ].join('\0') + const existing = missingWorktreeTeardownsInFlight.get(key) + if (existing) { + return existing + } + const teardown = (async () => { + try { + await callRuntimeRpc( + target, + 'worktree.teardownMissingTerminals', + { repo: repoId, worktreeIds: missingIds, connectionId: normalizedConnectionId }, + { timeoutMs: 30_000 } + ) + } catch (error) { + if (!isRuntimeMethodNotFoundError(error)) { + console.warn(`Failed to stop terminals for missing worktrees in repo ${repoId}:`, error) + } + } + })() + missingWorktreeTeardownsInFlight.set(key, teardown) + try { + await teardown + } finally { + if (missingWorktreeTeardownsInFlight.get(key) === teardown) { + missingWorktreeTeardownsInFlight.delete(key) + } + } +} + // 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' @@ -1264,6 +1326,16 @@ async function listDetectedWorktreesForRepoCoalesced( ) { throw new Error('runtime_environment_generation_changed') } + // Why (#10562): the scan coalesces, but teardown must not — each caller carries + // its own known-id snapshot and purges its own state, so a caller that joined + // an in-flight scan would otherwise purge without ever stopping those terminals. + await teardownMissingWorktreeTerminalsBestEffort( + settings, + repoId, + options.connectionId, + options.knownWorktreeIds, + result + ) return { status: 'admitted', result, @@ -1311,6 +1383,13 @@ async function listDetectedWorktreesForRepoCoalesced( directSshAuthority: options.directSshAuthority } } + await teardownMissingWorktreeTerminalsBestEffort( + settings, + repoId, + options.connectionId, + options.knownWorktreeIds, + providerResult.result + ) return { status: 'admitted', result: providerResult.result, @@ -2955,6 +3034,10 @@ export const createWorktreeSlice: StateCreator 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 parsedHost = parseExecutionHostId(hostId) const directSshAuthority = parsedHost?.kind === 'ssh' @@ -2966,7 +3049,12 @@ export const createWorktreeSlice: StateCreator const refresh = await listDetectedWorktreesForRepoCoalesced( settingsForRepoOwner(ownerState, repoId, hostId), repoId, - { executionHostId: hostId, directSshAuthority } + { + executionHostId: hostId, + directSshAuthority, + connectionId: repoOwner?.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) + } ) if (refresh.status !== 'admitted') { return null @@ -3034,6 +3122,10 @@ export const createWorktreeSlice: StateCreator ? LOCAL_EXECUTION_HOST_ID : repoHostId(ownerState, repoId, options?.executionHostId) 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 @@ -3050,7 +3142,9 @@ export const createWorktreeSlice: StateCreator const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { executionHostId: hostId, requireAuthoritative: options?.requireAuthoritative, - directSshAuthority + directSshAuthority, + connectionId: repoOwner?.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) }) if (refresh.status !== 'admitted') { return directCallerAuthority ? refresh.providerResult : false @@ -3108,7 +3202,9 @@ export const createWorktreeSlice: StateCreator const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { executionHostId: hostId, reuseRecentCompatibilityFailure: true, - directSshAuthority + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) }) if (refresh.status !== 'admitted') { return @@ -3160,7 +3256,9 @@ export const createWorktreeSlice: StateCreator { executionHostId: hostId, reuseRecentCompatibilityFailure: true, - directSshAuthority + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) } ) if (refresh.status !== 'admitted') {