From 936579113da1e97bfaebe1833c338fc3ffebab3e Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:33:00 +0000 Subject: [PATCH] feat(workspace-cleanup): classify released orchestration worker workspaces for cleanup A workspace a worker start created gets the 'orchestration-worker' reason once every dispatch on it released its terminal and its work landed in the default branch, a merged review or the coordinator's branch. Commits the coordinator's branch already holds no longer count as unpushed. --- .../workspace-cleanup-activity.ts | 36 +++ .../workspace-cleanup-candidate.ts | 28 +- .../workspace-cleanup-git-evidence.ts | 13 +- .../workspace-cleanup-merge-evidence.ts | 68 +++-- ...pace-cleanup-orchestration-workers.test.ts | 281 ++++++++++++++++++ ...workspace-cleanup-orchestration-workers.ts | 74 +++++ .../workspace-cleanup-progress-emitter.ts | 3 + .../workspace-cleanup-scan.ts | 53 +--- .../workspace-cleanup/workspace-cleanup.ts | 7 +- .../worker-created-worktrees.ts | 144 +++++++++ .../workspace-cleanup-scan-method.ts | 5 +- .../window/attach-main-window-services.ts | 2 +- .../workspace-cleanup-git-evidence.ts | 6 +- .../workspace-cleanup/workspace-cleanup.ts | 18 +- 14 files changed, 664 insertions(+), 74 deletions(-) create mode 100644 src/main/ipc/workspace-cleanup/workspace-cleanup-orchestration-workers.test.ts create mode 100644 src/main/ipc/workspace-cleanup/workspace-cleanup-orchestration-workers.ts create mode 100644 src/main/runtime/orchestration/db/worker-dispatch/worker-created-worktrees.ts diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-activity.ts index 93283df7045..a1567b7e471 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-activity.ts @@ -4,6 +4,11 @@ import type { Worktree } from '../../../shared/worktree/types' import { resolveGitMetadataPath } from '../../../shared/git/metadata/git-metadata-path' import { getPersistedWorkspaceCleanupActivityAt } from '../../../shared/workspace-cleanup/workspace-cleanup' import type { WorkspaceCleanupGitRoute } from './workspace-cleanup-git-route' +import { + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + WorkspaceCleanupScanCancelledError, + withWorkspaceCleanupTimeout +} from './workspace-cleanup-scan-primitives' type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> type ReadTextFile = (targetPath: string, options?: { tailBytes?: number }) => Promise @@ -179,3 +184,34 @@ async function readMtime(targetPath: string, statPath: StatPath): Promise void, + signal?: AbortSignal, + fsActivityCache?: WorkspaceCleanupFsActivityCache +): Promise { + try { + return await withWorkspaceCleanupTimeout( + () => + resolveWorkspaceCleanupActivityWorktree( + route, + worktree, + undefined, + undefined, + fsActivityCache + ), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out reading worktree activity.', + signal + ) + } catch (error) { + if (error instanceof WorkspaceCleanupScanCancelledError) { + throw error + } + onActivityStatsUnavailable() + console.warn('Workspace cleanup activity scan failed', error) + return resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + } +} diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts index 68e621ea727..f2a4e728aa5 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts @@ -9,6 +9,7 @@ import { getWorkspaceCleanupAutomationRunReasons, getWorkspaceCleanupInactivityReasons, getWorkspaceCleanupMergeReasons, + getWorkspaceCleanupOrchestrationWorkerReasons, isWorkspaceOldForCleanup, type WorkspaceCleanupAutomationRunLookup, type WorkspaceCleanupBlocker, @@ -26,6 +27,7 @@ import { } from '../../../shared/workspace-cleanup/workspace-cleanup-candidate-fields' import type { WorkspaceCleanupGitRoute } from './workspace-cleanup-git-route' import type { RuntimeStore } from '../../runtime/construction/runtime-store-contract' +import type { WorkspaceCleanupReleasedWorker } from './workspace-cleanup-orchestration-workers' export async function buildWorkspaceCleanupCandidate(args: { repo: Repo @@ -36,9 +38,20 @@ export async function buildWorkspaceCleanupCandidate(args: { skipGit: boolean forceGitCheck: boolean findAutomationRun: WorkspaceCleanupAutomationRunLookup + releasedWorker?: WorkspaceCleanupReleasedWorker signal?: AbortSignal }): Promise { - const { repo, worktree, scannedAt, route, prCache, skipGit, forceGitCheck, signal } = args + const { + repo, + worktree, + scannedAt, + route, + prCache, + skipGit, + forceGitCheck, + releasedWorker, + signal + } = args const blockers: WorkspaceCleanupBlocker[] = [] const reasons = getWorkspaceCleanupWorkspaceReasons(worktree, scannedAt, args.findAutomationRun) const repoIsFolder = isFolderRepo(repo) @@ -64,9 +77,20 @@ export async function buildWorkspaceCleanupCandidate(args: { const gitEvidence = !shouldReadGit ? createEmptyWorkspaceCleanupGitEvidence() - : await readWorkspaceCleanupGitEvidence(worktree, repo, route, prCache, signal) + : await readWorkspaceCleanupGitEvidence( + worktree, + repo, + route, + prCache, + signal, + releasedWorker?.coordinatorBranch + ) appendWorkspaceCleanupItems(blockers, gitEvidence.blockers) appendWorkspaceCleanupItems(reasons, getWorkspaceCleanupMergeReasons(gitEvidence.merge)) + appendWorkspaceCleanupItems( + reasons, + getWorkspaceCleanupOrchestrationWorkerReasons(releasedWorker !== undefined, gitEvidence.merge) + ) const candidateWithoutFingerprint: WorkspaceCleanupCandidate = { worktreeId: worktree.id, diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts index 9b261fa40b1..c159754a347 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -54,7 +54,8 @@ export async function readWorkspaceCleanupGitEvidence( repo: Repo, repoRoute: WorkspaceCleanupGitRoute, prCache: PRCacheView, - signal?: AbortSignal + signal?: AbortSignal, + coordinatorBranch?: string | null ): Promise { const blockers: WorkspaceCleanupBlocker[] = [] let status: GitStatusResult @@ -112,8 +113,14 @@ export async function readWorkspaceCleanupGitEvidence( const upstreamAhead = status.upstreamStatus.hasUpstream ? status.upstreamStatus.ahead : null const upstreamBehind = status.upstreamStatus.hasUpstream ? status.upstreamStatus.behind : null - const merge = await readWorkspaceCleanupMergeEvidence(worktree, repo, prCache, signal) - // Commits already in the base or a merged review are not at risk, even after a squash rewrote them. + const merge = await readWorkspaceCleanupMergeEvidence( + worktree, + repo, + prCache, + signal, + coordinatorBranch + ) + // Commits already in the base, a merged review or the coordinator's branch are not at risk, even after a squash rewrote them. const headLanded = isWorkspaceCleanupHeadLanded(merge) if (!headLanded && upstreamAhead !== null && upstreamAhead > 0) { blockers.push('unpushed-commits') diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-merge-evidence.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-merge-evidence.ts index a545738d355..ddd0a217dd1 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-merge-evidence.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-merge-evidence.ts @@ -24,11 +24,12 @@ export async function readWorkspaceCleanupMergeEvidence( worktree: Worktree, repo: Repo, prCache: PRCacheView, - signal?: AbortSignal + signal?: AbortSignal, + coordinatorBranch?: string | null ): Promise { try { return await withWorkspaceCleanupTimeout( - (signal) => readMergeEvidence(worktree, repo, prCache, signal), + (signal) => readMergeEvidence(worktree, repo, prCache, signal, coordinatorBranch), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out reading merge evidence.', signal @@ -45,7 +46,8 @@ async function readMergeEvidence( worktree: Worktree, repo: Repo, prCache: PRCacheView, - signal: AbortSignal + signal: AbortSignal, + coordinatorBranch: string | null | undefined ): Promise { const git: CleanupGit = (argv) => gitExecFileAsync(argv, { cwd: worktree.path, signal }) const baseOid = await resolveWorkspaceCleanupBaseOid(worktree, repo, git) @@ -63,34 +65,48 @@ async function readMergeEvidence( } const branchName = worktree.branch.replace(/^refs\/heads\//, '') // The proof reads the branch ref, so it only speaks for HEAD while HEAD is still that branch. - // Prove against the base alone: the default target list includes the worktree's HEAD, the branch itself. - const headInDefaultBranch = - aheadOfDefault === 0 || - (branchName.length > 0 && - (await resolveCommitOid(git, `refs/heads/${branchName}`)) === headOid && - (await localBranchHasNoUnmergedChanges({ - cwd: worktree.path, - branchName, - targetRefs: [baseOid], - refreshRemoteTargets: false, - options: { signal } - }))) + // Prove against the target alone: the default target list includes the worktree's HEAD, the branch itself. + let branchAtHead: Promise | undefined + const headLandedIn = async (targetOid: string): Promise => + branchName.length > 0 && + (await (branchAtHead ??= resolveCommitOid(git, `refs/heads/${branchName}`).then( + (oid) => oid === headOid + ))) && + localBranchHasNoUnmergedChanges({ + cwd: worktree.path, + branchName, + targetRefs: [targetOid], + refreshRemoteTargets: false, + options: { signal } + }) + const headInDefaultBranch = aheadOfDefault === 0 || (await headLandedIn(baseOid)) const review = readWorkspaceReviewPR(worktree, repo, prCache) const head = headOid.toLowerCase() + const mergedReview = + review?.state === 'merged' + ? { + number: review.number, + provider: 'github' as const, + containsHead: [review.headSha, review.confirmedContainedHeadOid].some( + (oid) => oid?.trim().toLowerCase() === head + ) + } + : undefined + // Only worth proving when HEAD has not already landed another way. + const coordinatorOid = + coordinatorBranch && !headInDefaultBranch && mergedReview?.containsHead !== true + ? await resolveCommitOid(git, `refs/heads/${coordinatorBranch}`) + : null + const headInCoordinatorBranch = + coordinatorOid === null + ? undefined + : (await git(['rev-list', '--count', `${coordinatorOid}..${headOid}`])).stdout.trim() === + '0' || (await headLandedIn(coordinatorOid)) return { headInDefaultBranch, aheadOfDefault, - ...(review?.state === 'merged' - ? { - mergedReview: { - number: review.number, - provider: 'github' as const, - containsHead: [review.headSha, review.confirmedContainedHeadOid].some( - (oid) => oid?.trim().toLowerCase() === head - ) - } - } - : {}) + ...(headInCoordinatorBranch !== undefined ? { headInCoordinatorBranch } : {}), + ...(mergedReview ? { mergedReview } : {}) } } diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-orchestration-workers.test.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-orchestration-workers.test.ts new file mode 100644 index 00000000000..57c05d349b7 --- /dev/null +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-orchestration-workers.test.ts @@ -0,0 +1,281 @@ +// Real Git and a real orchestration database: squash proofs and release rows are what mocks cannot model. +import { execFile } from 'node:child_process' +import { mkdtemp, realpath, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installRealWorkerThreadChildProcesses } from '../../worker-thread-child-process/worker-thread-child-process-real.test-fixtures' +import { removeTree } from '../../../shared/windows-transient-lock-removal' +import type { Repo } from '../../../shared/repo-types' +import type { WorktreeMeta } from '../../../shared/worktree/meta-types' +import type { WorktreeLineage } from '../../../shared/worktree/lineage-types' +import type { RuntimeStore } from '../../runtime/construction/runtime-store-contract' +import { OrchestrationDb } from '../../runtime/orchestration/db/orchestration-db' +import '../../runtime/orchestration/db/contract-constants' +import '../../runtime/orchestration/db/messages/mailbox-routing-page' +import '../../runtime/orchestration/db/dispatch-row-writer' +import '../../runtime/orchestration/db/attempt-outcome-projection' +import { scanWorkspaceCleanup } from './workspace-cleanup-scan' + +const execFileAsync = promisify(execFile) +// Before any worker the tests start, whose terminals SQLite stamps with the real time. +const WORKSPACE_CREATED_AT = Date.parse('2026-01-01T00:00:00Z') + +installRealWorkerThreadChildProcesses() + +let scratchDir = '' +let repoPath = '' +let db: OrchestrationDb +const meta: Record = {} +const lineage: Record = {} + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout.trim() +} + +async function commitFile(cwd: string, name: string): Promise { + await writeFile(join(cwd, name), `${name}\n`) + await git(['add', name], cwd) + await git(['commit', '-qm', name], cwd) +} + +async function addWorktree(name: string, from: string): Promise<{ id: string; path: string }> { + const path = join(scratchDir, name) + await git(['worktree', 'add', '-q', '-b', name, path, from], repoPath) + const id = `repo::${path}` + meta[id] = { + instanceId: `${name}-instance`, + displayName: name, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: WORKSPACE_CREATED_AT, + createdAt: WORKSPACE_CREATED_AT + } + return { id, path } +} + +async function addChild( + name: string, + coordinatorId: string +): Promise<{ id: string; path: string }> { + const child = await addWorktree(name, 'coordinator') + lineage[child.id] = { + worktreeId: child.id, + worktreeInstanceId: `${name}-instance`, + parentWorktreeId: coordinatorId, + parentWorktreeInstanceId: 'coordinator-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: WORKSPACE_CREATED_AT + } + return child +} + +/** Starts a worker on the workspace and settles its report; follow-ups reuse the same terminal. */ +function startWorker( + worktreeId: string, + handle: string, + options: { + action?: 'created_child' | 'reused' + terminalOwnership?: 'created' | 'external' + hostScope?: string + } = {} +): string { + const task = db.createTask({ runId: 'run_legacy_local', spec: handle }) + const dispatchId = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: 9, + taskId: task.id, + startOptions: {} + }).dispatch.id + db.prepareStartingWorkerAuthority({ + dispatchId, + handle, + paneKey: `tab_${handle}:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa`, + processIncarnation: `pty:${handle}:1`, + worktreeId, + effects: [{ kind: 'worktree', action: options.action ?? 'created_child', id: worktreeId }], + setupState: 'not_applicable', + terminalOwnership: options.terminalOwnership ?? 'created', + ...(options.hostScope ? { hostScope: options.hostScope } : {}) + }) + db.markWorkerDispatchReady(dispatchId) + db.settleWorkerReport({ taskId: task.id, dispatchId, outcome: 'succeeded', result: 'done' }) + return dispatchId +} + +function releaseWorker(dispatchId: string): void { + const requested = db.requestWorkerTerminalRelease(dispatchId) + if (requested.disposition !== 'requested') { + throw new Error(`release was ${requested.disposition}`) + } + db.settleWorkerTerminalRelease(requested.resource.id) +} + +async function scan(worktreeIds: string[]) { + const repo: Repo = { + id: 'repo', + path: repoPath, + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the scan reads only these store fields for a local repo. + const store = { + getRepos: () => [repo], + getWorktreeMeta: (id: string) => meta[id], + getAllWorktreeMeta: () => meta, + getWorktreeLineage: (id: string) => lineage[id], + getGitHubCache: () => ({ pr: {}, issue: {} }) + } as unknown as RuntimeStore + const result = await scanWorkspaceCleanup( + store, + { worktreeIds }, + { getOrchestrationDb: () => db } + ) + return new Map( + result.candidates.map((candidate) => [ + candidate.worktreeId, + { reasons: candidate.reasons, blockers: candidate.blockers } + ]) + ) +} + +beforeEach(async () => { + // Only Date: real timers keep Git subprocess timeouts working. A day after creation keeps rows active. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(WORKSPACE_CREATED_AT + 24 * 60 * 60 * 1000) + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-cleanup-workers-'))) + repoPath = join(scratchDir, 'repo') + await git(['init', '-q', '-b', 'main', repoPath], scratchDir) + await git(['config', 'user.email', 'cleanup@example.invalid'], repoPath) + await git(['config', 'user.name', 'Cleanup'], repoPath) + await git(['config', 'maintenance.auto', 'false'], repoPath) + await git(['config', 'gc.auto', '0'], repoPath) + await commitFile(repoPath, 'seed.txt') + db = new OrchestrationDb(':memory:') +}) + +afterEach(async () => { + vi.useRealTimers() + db.close() + for (const key of Object.keys(meta)) { + delete meta[key] + } + for (const key of Object.keys(lineage)) { + delete lineage[key] + } + await removeTree(scratchDir) +}) + +describe('workspace cleanup for released orchestration workers', () => { + it('suggests a released worker whose work its coordinator squash-merged, and keeps the rest', async () => { + const coordinator = await addWorktree('coordinator', 'main') + const landed = await addChild('landed', coordinator.id) + const unreleased = await addChild('unreleased', coordinator.id) + const unlanded = await addChild('unlanded', coordinator.id) + await commitFile(landed.path, 'landed.txt') + await commitFile(unreleased.path, 'unreleased.txt') + await commitFile(unlanded.path, 'unlanded.txt') + // The coordinator squashes landed's and unreleased's work, never unlanded's. + await git(['merge', '-q', '--squash', 'landed'], coordinator.path) + await git(['commit', '-qm', 'squash landed'], coordinator.path) + await git(['merge', '-q', '--squash', 'unreleased'], coordinator.path) + await git(['commit', '-qm', 'squash unreleased'], coordinator.path) + releaseWorker(startWorker(landed.id, 'term_landed')) + startWorker(unreleased.id, 'term_unreleased') + releaseWorker(startWorker(unlanded.id, 'term_unlanded')) + + const candidates = await scan([landed.id, unreleased.id, unlanded.id]) + + expect(candidates.get(landed.id)).toEqual({ reasons: ['orchestration-worker'], blockers: [] }) + expect(candidates.get(unreleased.id)).toEqual({ reasons: [], blockers: ['unpushed-commits'] }) + expect(candidates.get(unlanded.id)).toEqual({ reasons: [], blockers: ['unpushed-commits'] }) + }) + + it('suggests a released worker with no commits of its own but never a workspace a worker only reused', async () => { + const coordinator = await addWorktree('coordinator', 'main') + const created = await addChild('created', coordinator.id) + const reused = await addWorktree('reused', 'main') + releaseWorker(startWorker(created.id, 'term_created')) + releaseWorker(startWorker(reused.id, 'term_reused', { action: 'reused' })) + + const candidates = await scan([created.id, reused.id]) + + expect(candidates.get(created.id)?.reasons).toEqual(['unchanged', 'orchestration-worker']) + expect(candidates.get(reused.id)?.reasons).toEqual(['unchanged']) + }) + + it('suggests a worker workspace once the follow-up that took over its terminal is released', async () => { + const coordinator = await addWorktree('coordinator', 'main') + const child = await addChild('child', coordinator.id) + startWorker(child.id, 'term_child') + const followUp = startWorker(child.id, 'term_child', { + action: 'reused', + terminalOwnership: 'external' + }) + expect((await scan([child.id])).get(child.id)?.reasons).toEqual(['unchanged']) + + releaseWorker(followUp) + + expect((await scan([child.id])).get(child.id)?.reasons).toEqual([ + 'unchanged', + 'orchestration-worker' + ]) + }) + + it('never suggests a workspace recreated at the old path of a worker that a later worker only reused', async () => { + const coordinator = await addWorktree('coordinator', 'main') + const child = await addChild('child', coordinator.id) + const first = startWorker(child.id, 'term_child') + releaseWorker(first) + // The first worker ran before the path was reused. + db.db + .prepare( + "UPDATE worker_terminal_resources SET created_at = '2025-01-01 00:00:00' WHERE origin_dispatch_id = ?" + ) + .run(first) + // The user removes the worker's workspace and makes a new one at the same path. + await git(['worktree', 'remove', child.path], repoPath) + await git(['branch', '-D', 'child'], repoPath) + await addChild('child', coordinator.id) + releaseWorker(startWorker(child.id, 'term_later', { action: 'reused' })) + + expect((await scan([child.id])).get(child.id)?.reasons).toEqual(['unchanged']) + }) + + it('keeps a released worker workspace while another worker is still starting in it', async () => { + const coordinator = await addWorktree('coordinator', 'main') + const child = await addChild('child', coordinator.id) + releaseWorker(startWorker(child.id, 'term_child')) + const retry = db.createTask({ runId: 'run_legacy_local', spec: 'retry' }) + db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: 9, + taskId: retry.id, + startOptions: { worktree: `id:${child.id}`, resolvedWorktreeId: child.id } + }) + + expect((await scan([child.id])).get(child.id)?.reasons).toEqual(['unchanged']) + }) + + it('never suggests a workspace on this machine for an SSH worker that used the same id', async () => { + const coordinator = await addWorktree('coordinator', 'main') + const child = await addChild('child', coordinator.id) + releaseWorker( + startWorker(child.id, 'term_ssh', { + hostScope: JSON.stringify({ kind: 'ssh', targetId: 'ssh-target' }) + }) + ) + + expect((await scan([child.id])).get(child.id)?.reasons).toEqual(['unchanged']) + }) +}) diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-orchestration-workers.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-orchestration-workers.ts new file mode 100644 index 00000000000..fda9ede5dff --- /dev/null +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-orchestration-workers.ts @@ -0,0 +1,74 @@ +import type { Worktree } from '../../../shared/worktree/types' +import { isValidResolvedWorktreeLineageEdge } from '../../../shared/resolved-worktree-lineage' +import { normalizeLocalBranchName } from '../../runtime/workspaces/catalog/runtime-worktree-selection' +import type { RuntimeStore } from '../../runtime/construction/runtime-store-contract' +import type { OrchestrationDb } from '../../runtime/orchestration/db/orchestration-db' +import { + listWorkerCreatedWorktrees, + type WorkerCreatedWorktree +} from '../../runtime/orchestration/db/worker-dispatch/worker-created-worktrees' + +/** A workspace a worker start created whose worker has been released. */ +export type WorkspaceCleanupReleasedWorker = { + /** The coordinator's branch, when the coordinator's workspace is in the same repo; work landed there is not lost. */ + coordinatorBranch: string | null +} + +export type WorkspaceCleanupReleasedWorkerLookup = ( + worktree: Worktree, + repoWorktrees: readonly Worktree[] +) => WorkspaceCleanupReleasedWorker | undefined + +/** Reads the host's workers at most once per scan; a host that never ran orchestration has none. */ +export function createWorkspaceCleanupReleasedWorkerLookup( + store: RuntimeStore, + getOrchestrationDb: (() => OrchestrationDb | null) | undefined +): WorkspaceCleanupReleasedWorkerLookup { + let workerCreatedWorktrees: ReadonlyMap | undefined + return (worktree, repoWorktrees) => { + workerCreatedWorktrees ??= readWorkerCreatedWorktrees(getOrchestrationDb) + return resolveReleasedWorker(worktree, repoWorktrees, workerCreatedWorktrees, store) + } +} + +function readWorkerCreatedWorktrees( + getOrchestrationDb: (() => OrchestrationDb | null) | undefined +): ReadonlyMap { + try { + const db = getOrchestrationDb?.() + return db ? listWorkerCreatedWorktrees(db) : new Map() + } catch (error) { + console.warn('Workspace cleanup could not read orchestration workers', error) + return new Map() + } +} + +function resolveReleasedWorker( + worktree: Worktree, + repoWorktrees: readonly Worktree[], + workerCreatedWorktrees: ReadonlyMap, + store: RuntimeStore +): WorkspaceCleanupReleasedWorker | undefined { + const worker = workerCreatedWorktrees.get(worktree.id) + // SQLite stamps whole seconds. A workspace made after the worker started reuses the path; it is not the worker's. + if ( + !worker?.released || + worktree.createdAt === undefined || + worktree.createdAt >= worker.workerStartedAt + 1000 + ) { + return undefined + } + const lineage = store.getWorktreeLineage?.(worktree.id) + const coordinator = lineage + ? repoWorktrees.find((candidate) => + isValidResolvedWorktreeLineageEdge(worktree, candidate, lineage) + ) + : undefined + const coordinatorBranch = normalizeLocalBranchName(coordinator?.branch) + return { + coordinatorBranch: + coordinatorBranch && coordinatorBranch !== normalizeLocalBranchName(worktree.branch) + ? coordinatorBranch + : null + } +} diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-progress-emitter.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-progress-emitter.ts index 027db458edf..3e5316a8612 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-progress-emitter.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-progress-emitter.ts @@ -4,12 +4,15 @@ import type { WorkspaceCleanupScanProgress } from '../../../shared/workspace-cleanup/workspace-cleanup' import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives' +import type { OrchestrationDb } from '../../runtime/orchestration/db/orchestration-db' const WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS = 100 export type WorkspaceCleanupScanOptions = { onProgress?: (progress: WorkspaceCleanupScanProgress) => void signal?: AbortSignal + /** The scanning host's orchestration database, so released workers' workspaces can be classified. */ + getOrchestrationDb?: () => OrchestrationDb | null } export type WorkspaceCleanupProgressEmitter = { diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-scan.ts index 75ccd1b6ac8..ca5b29ecc2e 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-scan.ts @@ -4,7 +4,7 @@ import type { PRInfo } from '../../../shared/github/pull-requests/pull-request-t import type { PRCacheView } from '../../../shared/sidebar-list/workspace-review-facts' import { readWorktreeMetaForHost } from '../../persistence/host-qualified-worktree-meta' import type { Repo } from '../../../shared/repo-types' -import type { GitWorktreeInfo, Worktree } from '../../../shared/worktree/types' +import type { GitWorktreeInfo } from '../../../shared/worktree/types' import { mergeWorktree } from '../worktree-metadata-merge' import type { WorkspaceCleanupAutomationRunLookup, @@ -21,7 +21,7 @@ import type { WorkspaceCleanupGitRoute } from './workspace-cleanup-git-route' import { shouldScanBroadWorkspaceCleanupWorktree } from './workspace-cleanup-scan-eligibility' import { resolvePersistedWorkspaceCleanupActivityWorktree, - resolveWorkspaceCleanupActivityWorktree, + resolveWorkspaceCleanupActivityWithTimeout, type WorkspaceCleanupFsActivityCache } from './workspace-cleanup-activity' import { @@ -32,12 +32,10 @@ import { } from './workspace-cleanup-candidate' import { synthesizeDisconnectedSshCleanupCandidates } from './workspace-cleanup-disconnected-ssh' import { - WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, WorkspaceCleanupScanCancelledError, appendWorkspaceCleanupItems, mapWorkspaceCleanupWithConcurrency, - throwIfWorkspaceCleanupScanAborted, - withWorkspaceCleanupTimeout + throwIfWorkspaceCleanupScanAborted } from './workspace-cleanup-scan-primitives' import { listWorkspaceCleanupFolderWorkspaces } from './workspace-cleanup-folder-workspaces' import { @@ -50,6 +48,10 @@ import { } from './workspace-cleanup-scan-targets' import { isWorktreeMetaOwnedByRepo } from '../../workspaces/catalog/worktree-metadata-ownership' import { runtimeVisibleRepos } from '../../runtime/workspaces/catalog/runtime-visible-catalog' +import { + createWorkspaceCleanupReleasedWorkerLookup, + type WorkspaceCleanupReleasedWorkerLookup +} from './workspace-cleanup-orchestration-workers' const WORKTREE_SCAN_CONCURRENCY = 3 // Overlap repo scans to avoid summing SSH listing round-trip latency. @@ -86,6 +88,10 @@ export async function scanWorkspaceCleanup( // One snapshot per scan: merged-review evidence reads it without the network. const prCache = store.getGitHubCache().pr const findAutomationRun = createWorkspaceCleanupAutomationRunLookup(store) + const findReleasedWorker = createWorkspaceCleanupReleasedWorkerLookup( + store, + options.getOrchestrationDb + ) const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options) const errors: WorkspaceCleanupScanResult['errors'] = [] const candidates: WorkspaceCleanupCandidate[] = [] @@ -103,6 +109,7 @@ export async function scanWorkspaceCleanup( scannedAt, prCache, findAutomationRun, + findReleasedWorker, targetWorktreeIds: targetWorktreeIdsByRepo.get(repo.id), refreshTargetActivity: args.worktreeId !== undefined || args.refreshActivity === true, includeAllWorkspaces: args.includeAllWorkspaces === true, @@ -132,6 +139,7 @@ async function scanRepoWorkspaces( scannedAt: number prCache: PRCacheView findAutomationRun: WorkspaceCleanupAutomationRunLookup + findReleasedWorker: WorkspaceCleanupReleasedWorkerLookup targetWorktreeIds?: ReadonlySet refreshTargetActivity: boolean includeAllWorkspaces: boolean @@ -146,6 +154,7 @@ async function scanRepoWorkspaces( scannedAt, prCache, findAutomationRun, + findReleasedWorker, targetWorktreeIds, refreshTargetActivity, includeAllWorkspaces, @@ -243,7 +252,7 @@ async function scanRepoWorkspaces( activityStatsUnavailable || (targetWorktreeIds ? !refreshTargetActivity : persistedActivityIsRecent) ? persistedActivityWorktree - : await resolveCleanupActivityWithTimeout( + : await resolveWorkspaceCleanupActivityWithTimeout( route, worktree, () => { @@ -269,6 +278,7 @@ async function scanRepoWorkspaces( skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id) || !isInactive, forceGitCheck: Boolean(targetWorktreeIds), findAutomationRun, + releasedWorker: findReleasedWorker(worktreeWithActivity, mergedWorktrees), signal }).catch((error) => { if (error instanceof WorkspaceCleanupScanCancelledError) { @@ -287,34 +297,3 @@ async function scanRepoWorkspaces( return { scannedAt, candidates, errors } } - -async function resolveCleanupActivityWithTimeout( - route: WorkspaceCleanupGitRoute, - worktree: Worktree, - onActivityStatsUnavailable: () => void, - signal?: AbortSignal, - fsActivityCache?: WorkspaceCleanupFsActivityCache -): Promise { - try { - return await withWorkspaceCleanupTimeout( - () => - resolveWorkspaceCleanupActivityWorktree( - route, - worktree, - undefined, - undefined, - fsActivityCache - ), - WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, - 'Timed out reading worktree activity.', - signal - ) - } catch (error) { - if (error instanceof WorkspaceCleanupScanCancelledError) { - throw error - } - onActivityStatsUnavailable() - console.warn('Workspace cleanup activity scan failed', error) - return resolvePersistedWorkspaceCleanupActivityWorktree(worktree) - } -} diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup.ts index a0cae5ab93f..58fb43334f5 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron' import type { Store } from '../../persistence/loading-store/store' +import type { OrchestrationDb } from '../../runtime/orchestration/db/orchestration-db' import { WORKSPACE_CLEANUP_CLASSIFIER_VERSION, type WorkspaceCleanupDismissArgs, @@ -32,7 +33,10 @@ function getBroadScanModeKey(senderId: number, args: WorkspaceCleanupScanArgs): return `${senderId}\0${args.includeAllWorkspaces === true}` } -export function registerWorkspaceCleanupHandlers(store: Store): void { +export function registerWorkspaceCleanupHandlers( + store: Store, + getOrchestrationDb?: () => OrchestrationDb | null +): void { const snapshotDirectory = store.getProfileStorageDirectory() ipcMain.removeHandler('workspaceCleanup:scan') ipcMain.removeHandler('workspaceCleanup:cancelScan') @@ -66,6 +70,7 @@ export function registerWorkspaceCleanupHandlers(store: Store): void { try { const result = await scanWorkspaceCleanup(store, scanArgs, { signal: controller.signal, + getOrchestrationDb, onProgress: scanArgs.scanId ? (progress) => { if (!controller.signal.aborted && !sender.isDestroyed()) { diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-created-worktrees.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-created-worktrees.ts new file mode 100644 index 00000000000..6743a2f1cf6 --- /dev/null +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-created-worktrees.ts @@ -0,0 +1,144 @@ +import { + WORKER_SETTLED_STATES, + type WorkerTerminalReleaseState +} from '../../terminal-authority/release/worker-terminal-ownership' +import type { WorkerDispatchState } from '../orchestration-records' +import { dispatchCompletionEpoch } from '../dispatch-context/dispatch-completion-epoch' +import type { OrchestrationDb } from '../orchestration-db' +import { parseWorkerTerminalPriorOwnerIds } from '../pane-key-match' +import { readWorkerTerminalHostScope } from '../../../../../shared/orchestration/workers/worker-terminal-host-scope' + +export type WorkerCreatedWorktree = { + /** When the newest creating worker's terminal started; a workspace made later at that path is not the worker's. */ + workerStartedAt: number + /** Every dispatch that used the workspace has released its terminal or settled without one. */ + released: boolean +} + +type WorkerWorktreeUseRow = { + dispatch_id: string + worktree_id: string + state: WorkerDispatchState + effects: string + terminal_handle: string | null +} + +type WorkerTerminalRow = { + origin_dispatch_id: string + owner_dispatch_id: string + prior_owner_dispatch_ids: string + created_at: string + host_scope: string | null + worktree_id: string | null + release_state: WorkerTerminalReleaseState +} + +/** Workspaces a worker start created, keyed by worktree id. Reused workspaces are never listed. */ +export function listWorkerCreatedWorktrees( + db: OrchestrationDb +): Map { + // Local workers live in worker_dispatches; on a federated worker's host they live in remote_dispatch_attachments. + // A worker still starting in an existing workspace has only its resolved target, not worktree_id yet. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the query selects exactly these columns. + const rows = db.db + .prepare( + `SELECT * FROM ( + SELECT dispatch_id, state, effects, agent_terminal_handle AS terminal_handle, + COALESCE(worktree_id, CASE WHEN json_valid(start_options) + THEN json_extract(start_options, '$.resolvedWorktreeId') END) AS worktree_id + FROM worker_dispatches + ) WHERE worktree_id IS NOT NULL + UNION ALL + SELECT dispatch_id, state, effects, terminal_handle, worktree_id + FROM remote_dispatch_attachments WHERE worktree_id IS NOT NULL` + ) + .all() as WorkerWorktreeUseRow[] + const terminals = readWorkerTerminals(db) + const uses = new Map() + for (const row of rows) { + const use = uses.get(row.worktree_id) ?? { startedAt: null, held: false } + const startedAt = terminals.startedAtByOrigin.get(row.dispatch_id) + if (startedAt !== undefined && createdWorktree(row.effects, row.worktree_id)) { + use.startedAt = Math.max(use.startedAt ?? 0, startedAt) + } + // A terminal row speaks for its owner; a dispatch whose terminal moved on, or never existed, holds nothing once settled. + use.held ||= + !terminals.owners.has(row.dispatch_id) && + ((row.terminal_handle !== null && !terminals.handedOff.has(row.dispatch_id)) || + !WORKER_SETTLED_STATES.includes(row.state)) + uses.set(row.worktree_id, use) + } + const created = new Map() + for (const [worktreeId, use] of uses) { + if (use.startedAt !== null) { + created.set(worktreeId, { + workerStartedAt: use.startedAt, + released: + !use.held && + terminals.releasedWorktreeIds.has(worktreeId) && + !terminals.heldWorktreeIds.has(worktreeId) + }) + } + } + return created +} + +function readWorkerTerminals(db: OrchestrationDb): { + startedAtByOrigin: Map + owners: Set + handedOff: Set + releasedWorktreeIds: Set + heldWorktreeIds: Set +} { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the query selects exactly these columns. + const rows = db.db + .prepare( + `SELECT origin_dispatch_id, owner_dispatch_id, prior_owner_dispatch_ids, created_at, + host_scope, worktree_id, release_state + FROM worker_terminal_resources` + ) + .all() as WorkerTerminalRow[] + const terminals = { + startedAtByOrigin: new Map(), + owners: new Set(), + handedOff: new Set(), + releasedWorktreeIds: new Set(), + heldWorktreeIds: new Set() + } + for (const row of rows) { + const startedAt = dispatchCompletionEpoch(row.created_at) + // An SSH worker's workspace can share its id with one on this machine; only this machine's workers count. + const hostScope = readWorkerTerminalHostScope(row.host_scope).kind + if (startedAt !== null && (hostScope === 'absent' || hostScope === 'local')) { + terminals.startedAtByOrigin.set(row.origin_dispatch_id, startedAt) + } + terminals.owners.add(row.owner_dispatch_id) + for (const dispatchId of parseWorkerTerminalPriorOwnerIds(row.prior_owner_dispatch_ids) ?? []) { + terminals.handedOff.add(dispatchId) + } + // Includes a terminal placed in the workspace before its dispatch recorded the workspace. + if (row.worktree_id !== null && row.release_state === 'released') { + terminals.releasedWorktreeIds.add(row.worktree_id) + } else if (row.worktree_id !== null) { + terminals.heldWorktreeIds.add(row.worktree_id) + } + } + return terminals +} + +function createdWorktree(effectsJson: string, worktreeId: string): boolean { + try { + const effects: unknown = JSON.parse(effectsJson) + return ( + Array.isArray(effects) && + effects.some( + (effect) => + effect?.kind === 'worktree' && + effect.id === worktreeId && + (effect.action === 'created_child' || effect.action === 'created_top_level') + ) + ) + } catch { + return false + } +} diff --git a/src/main/runtime/rpc/methods/workspaces/workspace-cleanup-scan-method.ts b/src/main/runtime/rpc/methods/workspaces/workspace-cleanup-scan-method.ts index 263e4cd8b78..6582b527569 100644 --- a/src/main/runtime/rpc/methods/workspaces/workspace-cleanup-scan-method.ts +++ b/src/main/runtime/rpc/methods/workspaces/workspace-cleanup-scan-method.ts @@ -8,5 +8,8 @@ export const WORKSPACE_CLEANUP_SCAN_METHOD = defineMethod({ permission: 'workspace', params: WorkspaceCleanupScanParams, handler: (params, { runtime, signal }) => - scanWorkspaceCleanup(runtime.requireStore(), params, { signal }) + scanWorkspaceCleanup(runtime.requireStore(), params, { + signal, + getOrchestrationDb: () => runtime.getExistingOrchestrationDb() + }) }) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index e76388c6a9e..c8b3ef84955 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -92,7 +92,7 @@ export function attachMainWindowServices( scheduleWorktreeBaseDirectoryWatcherSync(store, mainWindow) // Folder projects have no watcher target; poll for external git init so they upgrade without restart (#11477). startFolderRepoGitUpgradeWatch(store, mainWindow) - registerWorkspaceCleanupHandlers(store) + registerWorkspaceCleanupHandlers(store, () => runtime.getExistingOrchestrationDb()) registerPtyHandlers( mainWindow, runtime, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts index 71e497f865e..554dd2fc5da 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -26,7 +26,11 @@ const GIT_DERIVED_BLOCKERS: ReadonlySet = new Set([ 'unknown-base' ]) -const GIT_DERIVED_REASONS: ReadonlySet = new Set(['merged', 'unchanged']) +const GIT_DERIVED_REASONS: ReadonlySet = new Set([ + 'merged', + 'unchanged', + 'orchestration-worker' +]) export function needsWorkspaceCleanupGitEvidence( filters: WorkspaceCleanupFilterState, diff --git a/src/shared/workspace-cleanup/workspace-cleanup.ts b/src/shared/workspace-cleanup/workspace-cleanup.ts index d4a5cd8adfc..77c6a692508 100644 --- a/src/shared/workspace-cleanup/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup/workspace-cleanup.ts @@ -20,6 +20,7 @@ export type WorkspaceCleanupReason = | 'merged' | 'unchanged' | 'automation-run' + | 'orchestration-worker' /** What HEAD holds beyond the workspace's base ref (worktree base, else project base, else repo default). */ export type WorkspaceCleanupMergeEvidence = { @@ -33,6 +34,8 @@ export type WorkspaceCleanupMergeEvidence = { headInDefaultBranch: boolean /** Commits on HEAD the base ref cannot reach. */ aheadOfDefault: number + /** Set only for a released orchestration worker's workspace: every change on HEAD is in its coordinator's branch. */ + headInCoordinatorBranch?: boolean } export type WorkspaceCleanupInactivityInput = { @@ -240,15 +243,26 @@ export function getWorkspaceCleanupInactivityReasons( return reasons } -/** HEAD's commits already live in the base or in a merged review, so removing it loses no commits. */ +/** HEAD's commits already live in the base, a merged review or the coordinator's branch, so removing it loses no commits. */ export function isWorkspaceCleanupHeadLanded( merge: WorkspaceCleanupMergeEvidence | undefined ): boolean { return ( - merge !== undefined && (merge.headInDefaultBranch || merge.mergedReview?.containsHead === true) + merge !== undefined && + (merge.headInDefaultBranch || + merge.mergedReview?.containsHead === true || + merge.headInCoordinatorBranch === true) ) } +/** A workspace a worker start created, whose worker was released, and whose work already landed. */ +export function getWorkspaceCleanupOrchestrationWorkerReasons( + releasedWorker: boolean, + merge: WorkspaceCleanupMergeEvidence | undefined +): WorkspaceCleanupReason[] { + return releasedWorker && isWorkspaceCleanupHeadLanded(merge) ? ['orchestration-worker'] : [] +} + export function getWorkspaceCleanupMergeReasons( merge: WorkspaceCleanupMergeEvidence | undefined ): WorkspaceCleanupReason[] {