From fe060f9ba0a4e7e4791c71a0da03460e87ee4bdf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:16:48 -0700 Subject: [PATCH] refactor(runtime): check workspace removal and recovery ownership (#27624) --- config/ts-nocheck-baseline.txt | 3 - .../ipc/registered-worktree-roots-cache.ts | 10 +- ...ered-worktree-roots-scoped-invalidation.ts | 2 +- src/main/project-runtime-git-options.ts | 4 +- .../runtime/folder-workspace-pty-teardown.ts | 5 +- ...e-get-agent-session-execution-namespace.ts | 2 +- src/main/runtime/orca-runtime-get-status.ts | 2 +- .../orca-runtime-preserved-branch-cleanup.ts | 4 +- .../worktree-removal-execution-host.spec.ts | 159 +++++++++++++++++- ...time-interrupted-local-worktree-removal.ts | 6 +- .../runtime/unstopped-pty-verification.ts | 6 +- .../workspace-registration-removal.ts} | 47 ++++-- .../workspace-removal-target.ts} | 69 +++++--- .../workspace-removal.ts} | 110 ++++++++---- .../workspaces/workspace-repo-scan-refresh.ts | 4 +- .../runtime/worktree-removal-route-listing.ts | 4 +- src/main/runtime/worktree-teardown.ts | 8 +- 17 files changed, 339 insertions(+), 106 deletions(-) rename src/main/runtime/{orca-runtime-remove-orphan-or-folder-worktree.ts => workspaces/workspace-registration-removal.ts} (72%) rename src/main/runtime/{orca-runtime-resolve-worktree-removal-target.ts => workspaces/workspace-removal-target.ts} (78%) rename src/main/runtime/{orca-runtime-remove-managed-worktree.ts => workspaces/workspace-removal.ts} (68%) diff --git a/config/ts-nocheck-baseline.txt b/config/ts-nocheck-baseline.txt index 9a82d6fc3fb..d73715b37d5 100644 --- a/config/ts-nocheck-baseline.txt +++ b/config/ts-nocheck-baseline.txt @@ -56,13 +56,10 @@ src/main/runtime/orca-runtime-refresh-pty-worktree-records-from-controller.ts src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts src/main/runtime/orca-runtime-refuse-unattributed-mobile-session-tab-close.ts src/main/runtime/orca-runtime-register-pty.ts -src/main/runtime/orca-runtime-remove-managed-worktree.ts -src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts src/main/runtime/orca-runtime-resolve-terminal-pane.ts src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts -src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index ecdabd46abb..4596713b832 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -17,6 +17,8 @@ import { type RegisteredOwner } from './registered-worktree-root-owner' +type RepoSource = Pick + const registeredWorktreeRoots = new Set() const registeredOwners = new Map() const registeredWorktreeRootsRevisionByRepo = new Map() @@ -24,7 +26,7 @@ const CREATED_WORKTREE_ROOTS_MAX = 64 let revisionSequence = 0 let baseRevision = 0 let invalidationGeneration = 0 -let registryStore: Store | null = null +let registryStore: RepoSource | null = null let currentOwners = new Map() let registeredWorktreeRootsDirty = true let registeredWorktreeRootsRefresh: Promise | null = null @@ -35,7 +37,7 @@ function advanceOwner(owner: RegisteredOwner): void { registeredWorktreeRootsRevisionByRepo.set(owner.repoId, owner.revision) } -function resolveOwnerForRepo(store: Store, repo: Repo | string): RegisteredOwner | undefined { +function resolveOwnerForRepo(store: RepoSource, repo: Repo | string): RegisteredOwner | undefined { const repos = synchronizeOwners(store) const key = resolveWorktreeRootOwner(repos, repo, currentOwners) return key === undefined ? undefined : registeredOwners.get(key) @@ -51,7 +53,7 @@ function relistOwners(owners: Iterable): void { registeredWorktreeRootsDirty = true } -function synchronizeOwners(store: Store): Repo[] { +function synchronizeOwners(store: RepoSource): Repo[] { const repos = store.getRepos() const owners = getLocalWorktreeRootOwners(repos) if (registryStore !== store) { @@ -147,7 +149,7 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals * the global side-effect-token fence, and bumping it would retire in-flight tokens for * untouched repos. `advanceOwner` still records this repo's new revision. */ -export function markAuthorizedRootsOwnerDirty(store: Store, repo: Repo | string): boolean { +export function markAuthorizedRootsOwnerDirty(store: RepoSource, repo: Repo | string): boolean { const owner = resolveOwnerForRepo(store, repo) if (!owner) { return false diff --git a/src/main/ipc/registered-worktree-roots-scoped-invalidation.ts b/src/main/ipc/registered-worktree-roots-scoped-invalidation.ts index 39efe7e9194..c751992f303 100644 --- a/src/main/ipc/registered-worktree-roots-scoped-invalidation.ts +++ b/src/main/ipc/registered-worktree-roots-scoped-invalidation.ts @@ -20,7 +20,7 @@ import { * import, folder->git upgrade) must keep using the global form. */ export function invalidateAuthorizedRootsCacheForRepo( - store: Store | null | undefined, + store: Pick | null | undefined, repo: Repo | string ): void { if (!store || !markAuthorizedRootsOwnerDirty(store, repo)) { diff --git a/src/main/project-runtime-git-options.ts b/src/main/project-runtime-git-options.ts index 4e4e90d9312..f7ae8e59ee4 100644 --- a/src/main/project-runtime-git-options.ts +++ b/src/main/project-runtime-git-options.ts @@ -26,7 +26,7 @@ export type LocalProjectGhExecOptions = LocalProjectWorktreeGitOptions & { } export function getLocalProjectGitExecOptions( - store: Store, + store: ProjectRuntimeResolutionStore, repo: Repo ): LocalProjectGitExecOptions { // Why: local git must run in the same resolved project runtime as agents, @@ -56,7 +56,7 @@ function getLocalProjectGitExecOptionsForRuntime( } export function getLocalProjectWorktreeGitOptions( - store: Store, + store: ProjectRuntimeResolutionStore, repo: Repo ): LocalProjectWorktreeGitOptions { const { wslDistro } = getLocalProjectGitExecOptions(store, repo) diff --git a/src/main/runtime/folder-workspace-pty-teardown.ts b/src/main/runtime/folder-workspace-pty-teardown.ts index 38fd1f73689..949b65b17ab 100644 --- a/src/main/runtime/folder-workspace-pty-teardown.ts +++ b/src/main/runtime/folder-workspace-pty-teardown.ts @@ -1,9 +1,8 @@ -import { killAllProcessesForWorktree } from './worktree-teardown' +import { killAllProcessesForWorktree, type WorktreeTeardownRuntime } from './worktree-teardown' import type { IPtyProvider } from '../providers/pty-provider-contract' -import type { OrcaRuntimeService } from './orca-runtime' export type FolderWorkspacePtyTeardownDeps = { - runtime: OrcaRuntimeService + runtime: WorktreeTeardownRuntime getLocalProvider: () => IPtyProvider | null onPtyStopped: ((ptyId: string) => void) | null } diff --git a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts index f778985e0c2..2fd4be30ede 100644 --- a/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts +++ b/src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithResolveWorktreeRemovalTarget } from './orca-runtime-resolve-worktree-removal-target' +import { OrcaRuntimeWithResolveWorktreeRemovalTarget } from './workspaces/workspace-removal-target' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import type { TuiAgent } from '../../shared/tui-agent' import { parseWslUncPath } from '../../shared/wsl-paths' diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index db6689d9661..307b5e86dd4 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -286,7 +286,7 @@ export abstract class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntime return { tabId, worktreeId: pty.worktreeId } } - protected notifyWorktreesChanged( + notifyWorktreesChanged( repoId: string, options?: { invalidateGitScans?: boolean; sortOrderOnly?: boolean } ): void { diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index e3ca6b45f22..19e86089beb 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -65,13 +65,13 @@ export abstract class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeW createdWorktree?: Worktree ): Promise - protected readonly preservedBranchCleanup = new RuntimePreservedBranchCleanup(() => + readonly preservedBranchCleanup = new RuntimePreservedBranchCleanup(() => this.store ? this.requireStore() : null ) protected readonly getLocalProviderFn: (() => IPtyProvider) | null - protected readonly onPtyStopped: ((ptyId: string) => void) | null + readonly onPtyStopped: ((ptyId: string) => void) | null protected readonly onTerminalAgentStatus: | ((event: RuntimeTerminalAgentStatusEvent) => void) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts index 02a05c59af2..f2199ebedd7 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts @@ -1,10 +1,30 @@ -import { listWorktrees, listWorktreesStrict, removeWorktree } from '../orca-runtime-test-mocks.spec' +import { + OrcaRuntimeService, + listWorktrees, + listWorktreesStrict, + removeWorktree +} from '../orca-runtime-test-mocks.spec' import type { WorktreeMeta } from '../orca-runtime-test-mocks.spec' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TEST_WORKTREE_ID, makeWorktreeMeta, store } from '../orca-runtime-test-fixtures.spec' +import { + TEST_REPO_ID, + TEST_REPO_PATH, + TEST_WORKTREE_ID, + TEST_WORKTREE_PATH, + makeWorktreeMeta, + store +} from '../orca-runtime-test-fixtures.spec' import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' import type { ExecutionHostId } from '../../../shared/execution-host' +import type { RuntimeStore } from '../runtime-store-contract' +import type { WorktreeRemovalRecord } from '../../worktree-removal-records' +import { failedWorktreeRemovals, pendingWorktreeRemovals } from '../../worktree-removal-table' +import { + _resetPendingWorktreeRemovalsForTests, + _settlePendingWorktreeRemovalsForTests +} from '../../worktree-background-removal' +import { RuntimeWorktreeRemovalInFlight } from '../runtime-worktree-removal-in-flight' const REMOTE_REPO_PATH = '/remote/repo' @@ -109,3 +129,136 @@ describe('OrcaRuntimeService worktree removal execution host', () => { expect(metaById[TEST_WORKTREE_ID]).toBeDefined() }) }) + +class RemovalRuntime extends OrcaRuntimeService { + protected override readonly removeManagedWorktreeInFlight = new RuntimeWorktreeRemovalInFlight() + readonly helperFailure = new Error('runtime helper override') + stopAtHelper = false + stopAtStore = false + readonly purges: { + store: RuntimeStore + worktreeId: string + repoId: string + hostId?: ExecutionHostId + }[] = [] + + protected override localRemovalJobHost(runtimeStore: RuntimeStore) { + if (this.stopAtHelper) { + throw this.helperFailure + } + return super.localRemovalJobHost(runtimeStore) + } + + protected override requireStore(): RuntimeStore { + if (this.stopAtStore) { + throw this.helperFailure + } + return super.requireStore() + } + + currentRemovalQueue() { + return this.removeManagedWorktreeInFlight + } + + protected override purgeRemovedWorktree( + runtimeStore: RuntimeStore, + worktreeId: string, + repoId: string, + hostId?: ExecutionHostId + ): void { + this.purges.push({ store: runtimeStore, worktreeId, repoId, hostId }) + } + + recoveryJobHost() { + return this.localRemovalJobHost(store) + } + + retryRemoval(record: WorktreeRemovalRecord) { + return this.retryFailedLocalRemoval( + { kind: 'local' }, + { id: record.worktreeId, path: record.worktreePath }, + [], + {} + ) + } +} + +describe('workspace removal runtime dispatch', () => { + let runtime: RemovalRuntime + let record: WorktreeRemovalRecord + + beforeEach(() => { + _resetPendingWorktreeRemovalsForTests() + runtime = new RemovalRuntime(store) + record = { + worktreeId: TEST_WORKTREE_ID, + repoId: TEST_REPO_ID, + repoPath: TEST_REPO_PATH, + worktreePath: TEST_WORKTREE_PATH, + branch: 'feature', + head: '', + deleteBranch: false, + force: false, + requestedAt: Date.now() + } + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(async () => { + await _settlePendingWorktreeRemovalsForTests() + _resetPendingWorktreeRemovalsForTests() + vi.restoreAllMocks() + }) + + it('retains runtime purge dispatch in a recovery job', () => { + runtime.recoveryJobHost().purge(record) + expect(runtime.purges).toEqual([ + { store, worktreeId: record.worktreeId, repoId: record.repoId, hostId: 'local' } + ]) + }) + + it('uses the runtime helper override before resuming an interrupted removal', () => { + runtime.stopAtHelper = true + pendingWorktreeRemovals.set(record.worktreeId, record) + expect(() => runtime.finishInterruptedWorktreeRemovals()).toThrow(runtime.helperFailure) + }) + + it('uses the runtime helper override before retrying a failed removal', () => { + runtime.stopAtHelper = true + failedWorktreeRemovals.set(record.worktreeId, record) + expect(() => runtime.retryRemoval(record)).toThrow(runtime.helperFailure) + }) + + it('captures the watcher callback and retains its job-host receiver', async () => { + const calls: { receiver: unknown; path: string }[] = [] + runtime.acquireFileWatcherRemoval = async function (this: unknown, path: string) { + calls.push({ receiver: this, path }) + return { finish: async () => {} } + } + const captured = runtime.acquireFileWatcherRemoval + const host = runtime.recoveryJobHost() + runtime.acquireFileWatcherRemoval = async () => { + throw new Error('replacement callback') + } + await host.acquireWatcherRemoval(record.worktreePath) + expect(host.acquireWatcherRemoval).toBe(captured) + expect(calls).toEqual([{ receiver: host, path: record.worktreePath }]) + }) + + it('reads the current runtime removal queue', async () => { + const replacement = runtime.currentRemovalQueue() + const failure = new Error('current removal queue') + const get = vi.spyOn(replacement, 'get').mockImplementation(() => { + throw failure + }) + runtime.stopAtStore = true + runtime['resolveWorktreeRemovalTarget'] = async () => ({ + id: TEST_WORKTREE_ID, + repoId: TEST_REPO_ID, + path: TEST_WORKTREE_PATH + }) + runtime['joinPendingWorktreeRemoval'] = () => undefined + await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID)).rejects.toBe(failure) + expect(get).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts index 9f94feac0dc..fb71fc499e1 100644 --- a/src/main/runtime/runtime-interrupted-local-worktree-removal.ts +++ b/src/main/runtime/runtime-interrupted-local-worktree-removal.ts @@ -4,7 +4,7 @@ import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { Store } from '../persistence' +import type { RuntimeStore } from './runtime-store-contract' import { releaseStartupRemovalFence, type BackgroundWorktreeRemovalJob @@ -32,7 +32,7 @@ import { } from './runtime-registered-local-worktree-removal' type InterruptedWorktreeRemovalHost = { - store: Store + store: RuntimeStore acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> closeWatchers: (path: string) => Promise preservedBranchCleanup: Pick @@ -94,7 +94,7 @@ type InterruptedLocalWorktreeRemovalArgs = Pick< RuntimeLocalWorktreeRemovalFinishArgs, 'removedPushTarget' | 'closeWatchers' | 'preserveBranchHead' | 'finishRemoval' > & { - store: Store + store: RuntimeStore record: WorktreeRemovalRecord acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }> stopPtys?: () => Promise diff --git a/src/main/runtime/unstopped-pty-verification.ts b/src/main/runtime/unstopped-pty-verification.ts index 9a37c4c58c2..b280133c571 100644 --- a/src/main/runtime/unstopped-pty-verification.ts +++ b/src/main/runtime/unstopped-pty-verification.ts @@ -1,5 +1,7 @@ import type { IPtyProvider } from '../providers/pty-provider-contract' import type { OrcaRuntimeService } from './orca-runtime' + +type UnstoppedPtyRuntime = Pick import { UNSTOPPED_PTY_DETAIL_SEPARATOR, STILL_LIVE_DETAIL_PREFIX, @@ -65,7 +67,7 @@ export async function verifyUnstoppedPtys( */ export function unverifiableStopVerdict( failedPtyIds: readonly string[], - runtime: OrcaRuntimeService | undefined + runtime: UnstoppedPtyRuntime | undefined ): UnstoppedPtyVerdict | null { for (const ptyId of failedPtyIds) { const verdict = runtime?.getPtyLivenessVerdict?.(ptyId) @@ -81,7 +83,7 @@ export async function resolveUnstoppedPtyVerdict( provider: IPtyProvider, sweepBudgetMs: number, providerObservesOwningHost: boolean, - runtime?: OrcaRuntimeService + runtime?: UnstoppedPtyRuntime ): Promise { if (failedPtyIds.length === 0) { return { status: 'exited' } diff --git a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts b/src/main/runtime/workspaces/workspace-registration-removal.ts similarity index 72% rename from src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts rename to src/main/runtime/workspaces/workspace-registration-removal.ts index f78ee49c762..d67f8afaadb 100644 --- a/src/main/runtime/orca-runtime-remove-orphan-or-folder-worktree.ts +++ b/src/main/runtime/workspaces/workspace-registration-removal.ts @@ -1,11 +1,30 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService. -import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree/id' -import { parseExecutionHostId } from '../../shared/execution-host' -import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' -import { isFolderRepo } from '../../shared/repo-kind' -import { getRuntimeFolderWorkspaceRootId } from './runtime-folder-workspace' -import { killAllProcessesForWorktree } from './worktree-teardown' -import { teardownFolderWorkspacePtys } from './folder-workspace-pty-teardown' +import type { OrcaRuntimeWithRemoveManagedWorktree } from './workspace-removal' +import type { RuntimeStore } from '../runtime-store-contract' +import type { RuntimeWorktreeRemovalTarget } from '../runtime-worktree-selection' +import type { Repo } from '../../../shared/repo-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorktreeTeardownRuntime } from '../worktree-teardown' +import { splitWorktreeId, splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +import { parseExecutionHostId } from '../../../shared/execution-host' +import { invalidateAuthorizedRootsCache } from '../../ipc/filesystem-auth' +import { isFolderRepo } from '../../../shared/repo-kind' +import { getRuntimeFolderWorkspaceRootId } from '../runtime-folder-workspace' +import { killAllProcessesForWorktree } from '../worktree-teardown' +import { teardownFolderWorkspacePtys } from '../folder-workspace-pty-teardown' + +type WorkspaceRegistrationRemovalRuntime = WorktreeTeardownRuntime & + Pick< + OrcaRuntimeWithRemoveManagedWorktree, + | 'getLocalProvider' + | 'onPtyStopped' + | 'acquireFileWatcherRemoval' + | 'clearOptimisticReconcileToken' + | 'removeWorktreeMetadataAndHistory' + | 'preservedBranchCleanup' + | 'invalidateResolvedWorktreeCache' + | 'invalidateWorktreeScanCacheForRepo' + | 'notifyWorktreesChanged' + > export async function removeOrphanOrFolderWorktree({ runtime, @@ -15,12 +34,12 @@ export async function removeOrphanOrFolderWorktree({ removalHostId, repo }: { - runtime: unknown - store: unknown - removalTarget: unknown - cleanupHostId?: string - removalHostId?: string - repo?: unknown + runtime: WorkspaceRegistrationRemovalRuntime + store: RuntimeStore + removalTarget: RuntimeWorktreeRemovalTarget + cleanupHostId?: ExecutionHostId + removalHostId?: ExecutionHostId + repo?: Repo }): Promise<{ warning?: string } | undefined> { if (!repo) { const orphanHost = parseExecutionHostId(store.getWorktreeMeta(removalTarget.id)?.hostId) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/workspaces/workspace-removal-target.ts similarity index 78% rename from src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts rename to src/main/runtime/workspaces/workspace-removal-target.ts index 3670ae4a73b..c934c825b09 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/workspaces/workspace-removal-target.ts @@ -1,42 +1,59 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithRemoveManagedWorktree } from './orca-runtime-remove-managed-worktree' -import type { ExecutionHostId } from '../../shared/execution-host' +import type { ResolvedWorktree } from '../runtime-worktree-path-identity' +import type { AgentLaunchPreferences } from '../../../shared/agent-session-host-authority' +import { OrcaRuntimeWithRemoveManagedWorktree } from './workspace-removal' import type { RemoveManagedWorktreeOptions, RuntimeWorktreeRemovalTarget -} from './runtime-worktree-selection' -import { resolveRuntimeWorktreeRemovalTarget } from './runtime-worktree-removal-target' -import type { RuntimeStore } from './runtime-store-contract' -import { splitWorktreeId } from '../../shared/worktree/id' -import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../worktree-removal-repo-owner' -import { advertisedUrlWatcher } from '../ports/advertised-url-watcher' -import { deleteWorktreeHistoryDir } from '../terminal-history-deletion' -import { forgetFailedAgentLaunches } from '../agent-launch/agent-launch-pane-attachment' -import { closeClientHostedBrowserPagesForWorktree } from './worktree-browser-client-page-close' +} from '../runtime-worktree-selection' +import { resolveRuntimeWorktreeRemovalTarget } from '../runtime-worktree-removal-target' +import type { RuntimeStore } from '../runtime-store-contract' +import { splitWorktreeId } from '../../../shared/worktree/id' +import { runtimeWorktreeIdsEqual } from '../runtime-worktree-path-identity' +import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../worktree-removal-repo-owner' +import { advertisedUrlWatcher } from '../../ports/advertised-url-watcher' +import { deleteWorktreeHistoryDir } from '../../terminal-history-deletion' +import { forgetFailedAgentLaunches } from '../../agent-launch/agent-launch-pane-attachment' +import { closeClientHostedBrowserPagesForWorktree } from '../worktree-browser-client-page-close' import type { ForceDeleteWorktreeBranchResult, RemoveWorktreeResult -} from '../../shared/worktree/create-types' -import type { RuntimeTerminalRename } from '../../shared/runtime-types' -import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' -import type { TerminalCreateOptions } from './runtime-terminal-contracts' -import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../shared/execution-host' -import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' +} from '../../../shared/worktree/create-types' +import type { RuntimeTerminalRename } from '../../../shared/runtime-types' +import type { TerminalWorkspaceLaunchScope } from '../runtime-legacy-worker-terminal-recovery-types' +import type { TerminalCreateOptions } from '../runtime-terminal-contracts' +import { + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { invalidateAuthorizedRootsCacheForRepo } from '../../ipc/filesystem-auth' import { resumeInterruptedWorktreeRemovals, retryFailedWorktreeRemoval, waitForPendingWorktreeRemoval -} from '../worktree-background-removal' -import { interruptedLocalWorktreeRemovalJob } from './runtime-interrupted-local-worktree-removal' -import { retryFailedRemovalUnlessRegistered } from '../worktree-removal-table' -import type { GitWorktreeInfo } from '../../shared/worktree/types' +} from '../../worktree-background-removal' +import { interruptedLocalWorktreeRemovalJob } from '../runtime-interrupted-local-worktree-removal' +import { retryFailedRemovalUnlessRegistered } from '../../worktree-removal-table' +import type { GitWorktreeInfo } from '../../../shared/worktree/types' import { resolveAgentTerminalCreateOptions, withHostAccountLaunchEnv -} from './runtime-agent-terminal-startup' +} from '../runtime-agent-terminal-startup' + +export abstract class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { + protected abstract resolveExplicitWorktreeIdScoped( + worktreeId: string, + requiredHostId?: ExecutionHostId + ): Promise + abstract invalidateResolvedWorktreeCache(): void + abstract invalidateWorktreeScanCacheForRepo(repoId: string): void + protected abstract cancelScheduledMobileSessionTabsChanged(worktreeId: string): void + protected abstract notifyMobileSessionTabsRemoved(worktreeId: string): void + protected abstract assertGraphReady(): void + protected abstract toAgentSessionOptions( + preferences: AgentLaunchPreferences | undefined + ): Record | undefined -export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWithRemoveManagedWorktree { protected async resolveWorktreeRemovalTarget( worktreeSelector: string, requiredHostId?: ExecutionHostId @@ -127,7 +144,7 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith invalidateAuthorizedRootsCacheForRepo(store, repoId) } - protected removeWorktreeMetadataAndHistory( + removeWorktreeMetadataAndHistory( store: RuntimeStore, worktreeId: string, hostId?: ExecutionHostId diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/workspaces/workspace-removal.ts similarity index 68% rename from src/main/runtime/orca-runtime-remove-managed-worktree.ts rename to src/main/runtime/workspaces/workspace-removal.ts index a50686c36fa..637196e72f8 100644 --- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts +++ b/src/main/runtime/workspaces/workspace-removal.ts @@ -1,36 +1,75 @@ -// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithCreateManagedRemoteWorktree } from './orca-runtime-create-managed-remote-worktree' +import type { RuntimeStore } from '../runtime-store-contract' +import type { OrcaRuntimeWithStopTerminalsForWorktree } from '../orca-runtime-stop-terminals-for-worktree' +import type { GitWorktreeInfo } from '../../../shared/worktree/types' +import { OrcaRuntimeWithCreateManagedRemoteWorktree } from '../orca-runtime-create-managed-remote-worktree' import { resolveWorktreeRemovalMetadata, resolveWorktreeRemovalRepoOwner -} from '../worktree-removal-repo-owner' -import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host' -import { finishAcceptedWorktreeRemoval } from '../worktree-background-removal' -import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' +} from '../../worktree-removal-repo-owner' +import type { RemoveWorktreeResult } from '../../../shared/worktree/create-types' +import { + getRepoExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { finishAcceptedWorktreeRemoval } from '../../worktree-background-removal' +import { preservedBranchCleanupScopeKey } from '../../../shared/preserved-branch-cleanup' import { getRuntimeWorktreeRemovalOptionsKey, - type RemoveManagedWorktreeOptions -} from './runtime-worktree-selection' -import { withWorktreeSpan } from '../observability/instrumentation' + type RemoveManagedWorktreeOptions, + type RuntimeWorktreeRemovalTarget +} from '../runtime-worktree-selection' +import { withWorktreeSpan } from '../../observability/instrumentation' import { resolveWorktreeRemovalHome, resolveWorktreeRemovalRoute -} from '../worktree-removal-execution-host-route' -import { listWorktreesOnRemovalRoute } from './worktree-removal-route-listing' -import { isPrunableGitFileWorktree } from '../worktree-prunable-git-file' -import { findRegisteredDeletableWorktree } from '../worktree-removal-safety' -import { removeRuntimeUnregisteredWorktree } from './runtime-unregistered-worktree-removal' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' -import { formatWorktreeRemovalError } from '../ipc/worktree-logic' -import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { isLocalRuntimeWorktreePathMissing } from './runtime-worktree-filesystem' -import { removeStaleLocalWorktreeRegistration } from '../local-worktree-removal-recovery' -import { cleanupUnusedWorktreePushTargetRemote } from '../ipc/worktree-remote' -import { removeRuntimeRegisteredLocalWorktree } from './runtime-registered-local-worktree-removal' -import { removeOrphanOrFolderWorktree } from './orca-runtime-remove-orphan-or-folder-worktree' +} from '../../worktree-removal-execution-host-route' +import { listWorktreesOnRemovalRoute } from '../worktree-removal-route-listing' +import { isPrunableGitFileWorktree } from '../../worktree-prunable-git-file' +import { findRegisteredDeletableWorktree } from '../../worktree-removal-safety' +import { removeRuntimeUnregisteredWorktree } from '../runtime-unregistered-worktree-removal' +import { assertWorktreeUnlockedForRemoval } from '../../../shared/worktree/removal' +import { formatWorktreeRemovalError } from '../../ipc/worktree-logic' +import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path' +import { isLocalRuntimeWorktreePathMissing } from '../runtime-worktree-filesystem' +import { removeStaleLocalWorktreeRegistration } from '../../local-worktree-removal-recovery' +import { cleanupUnusedWorktreePushTargetRemote } from '../../ipc/worktree-remote' +import { removeRuntimeRegisteredLocalWorktree } from '../runtime-registered-local-worktree-removal' +import { removeOrphanOrFolderWorktree } from './workspace-registration-removal' + +export abstract class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateManagedRemoteWorktree { + abstract forgetStructuredSessionMail(sessionId: string): void + abstract stopTerminalsForWorktree( + ...args: Parameters + ): ReturnType + abstract removeWorktreeMetadataAndHistory( + store: RuntimeStore, + worktreeId: string, + hostId?: ExecutionHostId + ): void + abstract invalidateResolvedWorktreeCache(): void + abstract invalidateWorktreeScanCacheForRepo(repoId: string): void + protected abstract resolveWorktreeRemovalTarget( + worktreeSelector: string, + requiredHostId?: ExecutionHostId + ): Promise + protected abstract joinPendingWorktreeRemoval( + worktreeId: string, + options: RemoveManagedWorktreeOptions + ): Promise | undefined + protected abstract purgeRemovedWorktree( + store: RuntimeStore, + worktreeId: string, + repoId: string, + removalHostId?: ExecutionHostId + ): void + protected abstract retryFailedLocalRemoval( + route: { kind: string }, + target: { id: string; path: string }, + registeredWorktrees: readonly GitWorktreeInfo[], + options: RemoveManagedWorktreeOptions + ): boolean -export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateManagedRemoteWorktree { async removeManagedWorktree( worktreeSelector: string, options: RemoveManagedWorktreeOptions = {} @@ -109,22 +148,23 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM // One host for the whole removal. Listing on a different host from the one the prune and // the delete use is how an `executionHostId: 'ssh:*'`-only row got listed remotely and // deleted here; the route refuses rather than falling back to this machine. - const route = resolveWorktreeRemovalRoute(removalHostId) + // The helper returns for missing/folder repos; this path has a tracked repo and its host. + const route = resolveWorktreeRemovalRoute(removalHostId!) const { localWorktreeGitOptions, registeredWorktrees } = await listWorktreesOnRemovalRoute( route, - repo, + repo!, this.requireStore() ) const removedMeta = resolveWorktreeRemovalMetadata( store, removalTarget.repoId, removalTarget.id, - removalHostId + removalHostId! ) const removedPushTarget = removedMeta?.pushTarget ?? removalTarget.pushTarget const removalHome = resolveWorktreeRemovalHome(route) const registeredWorktree = findRegisteredDeletableWorktree( - repo.path, + repo!.path, removalTarget.path, registeredWorktrees, removalHome @@ -134,7 +174,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM } if (!registeredWorktree) { return removeRuntimeUnregisteredWorktree({ - repo, + repo: repo!, target: removalTarget, registeredWorktrees, removedMeta, @@ -152,7 +192,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM finishRemoval: () => { this.preservedBranchCleanup.delete(removalTarget.id, cleanupHostId) purgeRemovedWorktree() - this.notifyWorktreesChanged(repo.id) + this.notifyWorktreesChanged(repo!.id) } }) } @@ -177,13 +217,13 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM ) { const removalResult = await removeStaleLocalWorktreeRegistration({ canonicalWorktreePath, - repoPath: repo.path, + repoPath: repo!.path, localWorktreeGitOptions, registeredWorktree, deleteBranch }) await cleanupUnusedWorktreePushTargetRemote( - repo.path, + repo!.path, removalTarget.id, removedPushTarget, store, @@ -197,11 +237,11 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM removedPushTarget ) purgeRemovedWorktree() - this.notifyWorktreesChanged(repo.id) + this.notifyWorktreesChanged(repo!.id) return removalResult ?? {} } return removeRuntimeRegisteredLocalWorktree({ - repo, + repo: repo!, target: removalTarget, registeredWorktree, removedPushTarget, @@ -236,7 +276,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM purgeRemovedWorktree() }, onRemoved: emitRemoved, - publish: () => this.publishWorktreeRemovalChange(repo.id) + publish: () => this.publishWorktreeRemovalChange(repo!.id) }) }) })() diff --git a/src/main/runtime/workspaces/workspace-repo-scan-refresh.ts b/src/main/runtime/workspaces/workspace-repo-scan-refresh.ts index 7f2c34e41b7..1c9a174a911 100644 --- a/src/main/runtime/workspaces/workspace-repo-scan-refresh.ts +++ b/src/main/runtime/workspaces/workspace-repo-scan-refresh.ts @@ -174,11 +174,11 @@ export abstract class RuntimeWorkspaceRepoScanRefresh extends RuntimeWorkspaceCa return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree])) } - protected invalidateResolvedWorktreeCache(): void { + invalidateResolvedWorktreeCache(): void { this.resolvedWorktrees.invalidateResolved() } - protected invalidateWorktreeScanCacheForRepo(repoId: string): void { + invalidateWorktreeScanCacheForRepo(repoId: string): void { const prefix = `${repoId}\0` const scopeKeys = new Set( this.store diff --git a/src/main/runtime/worktree-removal-route-listing.ts b/src/main/runtime/worktree-removal-route-listing.ts index 902710f2d72..34c2c088ac2 100644 --- a/src/main/runtime/worktree-removal-route-listing.ts +++ b/src/main/runtime/worktree-removal-route-listing.ts @@ -1,5 +1,5 @@ import type { Repo } from '../../shared/repo-types' -import type { Store } from '../persistence' +import type { ProjectRuntimeResolutionStore } from '../local-project-runtime-resolution' import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { WorktreeRemovalRoute } from '../worktree-removal-execution-host-route' import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options' @@ -13,7 +13,7 @@ import { listWorktreesStrict } from '../git/worktree' export async function listWorktreesOnRemovalRoute( _route: WorktreeRemovalRoute, repo: Repo, - store: Store + store: ProjectRuntimeResolutionStore ): Promise<{ localWorktreeGitOptions: LocalProjectWorktreeGitOptions registeredWorktrees: GitWorktreeInfo[] diff --git a/src/main/runtime/worktree-teardown.ts b/src/main/runtime/worktree-teardown.ts index 8acef762bef..17816ba0921 100644 --- a/src/main/runtime/worktree-teardown.ts +++ b/src/main/runtime/worktree-teardown.ts @@ -20,7 +20,8 @@ import { listStructuredSessionsForWorktree, retireStructuredSessionTabsForWorktree, unclosedStructuredSessions, - type StructuredSessionInWorkspace + type StructuredSessionInWorkspace, + type StructuredWorktreeSweepRuntime } from './structured-session-worktree-teardown' import { createWorktreeSweepTracker, @@ -34,8 +35,11 @@ import { resolveUnstoppedPtyVerdict } from './unstopped-pty-verification' +export type WorktreeTeardownRuntime = StructuredWorktreeSweepRuntime & + Pick + export type WorktreeTeardownDeps = { - runtime?: OrcaRuntimeService + runtime?: WorktreeTeardownRuntime /** Authoritative id for callers whose selector no longer resolves (orphaned workspace). */ resolvedWorktreeId?: string /** SSH connection owning `resolvedWorktreeId`; prevents same-id cross-host graph matches. */