Files
orca/src/shared/workspace-cleanup.ts
T
Brennan Benson 723158a519 feat(workspace-cleanup): blockers become labels, not refusals (#16282)
* wip(workspace-cleanup): PR 3 blockers-become-labels, recovered from a dead worker

Uncommitted work recovered from a worker that died with 'Agent process stop was
requested but never confirmed'. Committed as-is to preserve it; NOT verified yet.

* Fix workspace cleanup review regressions

* fix(workspace-cleanup): drop filter chips for the safety fields this PR removes

The cleanup dialog crashed on every open. My merge of #15300 brought in the
applied-filter chips, which read `safety.tiers` and `safety.selectableOnly` --
the exact fields this PR deletes. Electron QA caught it; the chip derivation runs
on open, so it threw before anything rendered.

Removed the two chip branches, their formatter entries, and the now-dead 'tier'
chip-kind label. The test that swept every chip keeps its breadth by using
`safety.dismissed`, which survives.

Worth noting what did not catch this: typecheck flagged only the test file, not
the source, because the QA agent had already patched the source locally without
committing. A merge that compiles can still remove a field a caller reads at
runtime, and only opening the dialog proved it.
2026-08-24 16:26:33 -07:00

303 lines
9.5 KiB
TypeScript

// Type-only, so the cycle back through workspace-cleanup-filter-model erases at build.
import type { WorkspaceCleanupBrowseState } from './workspace-cleanup-browse-state'
import type { ExecutionHostId } from './execution-host'
import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity'
export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2
export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000
export const WORKSPACE_CLEANUP_IDLE_MS = 30 * 24 * 60 * 60 * 1000
export type WorkspaceCleanupTier = 'ready' | 'review' | 'protected'
export type WorkspaceCleanupReason = 'archived' | 'idle-clean'
export type WorkspaceCleanupInactivityInput = {
isArchived: boolean
lastActivityAt: number
}
export type WorkspaceCleanupBlocker =
| 'main-worktree'
| 'folder-repo'
| 'pinned'
| 'active-workspace'
| 'running-terminal'
| 'terminal-liveness-unknown'
| 'dirty-editor-buffer'
| 'volatile-local-context'
| 'recent-visible-context'
| 'live-agent'
| 'ssh-disconnected'
| 'git-status-error'
| 'dirty-files'
| 'unpushed-commits'
| 'unknown-base'
| 'dismissed'
export type WorkspaceCleanupDismissal = {
worktreeId: string
dismissedAt: number
fingerprint: string
classifierVersion: number
// Why (STA-4343): `repoId::path` ids repeat across hosts, so ignoring one
// host's row must not hide another host's. Optional: a dismissal persisted
// before this field keeps its legacy id-only match.
executionHostId?: ExecutionHostId
}
export type WorkspaceCleanupUIState = {
dismissals: Record<string, WorkspaceCleanupDismissal>
// Why optional: a host that predates the flat list still writes dismissals-only state.
browse?: WorkspaceCleanupBrowseState
}
export type WorkspaceCleanupCandidate = {
worktreeId: string
repoId: string
repoName: string
connectionId: string | null
executionHostId?: ExecutionHostId
displayName: string
branch: string
path: string
/** @deprecated Current renderers ignore this legacy verdict; retained for older paired clients. */
tier: WorkspaceCleanupTier
/** @deprecated Current renderers ignore this legacy verdict; retained for older paired clients. */
selectedByDefault: boolean
reasons: WorkspaceCleanupReason[]
blockers: WorkspaceCleanupBlocker[]
lastActivityAt: number
createdAt?: number
localContext: {
terminalTabCount: number
cleanEditorTabCount: number
browserTabCount: number
diffCommentCount: number
newestDiffCommentAt: number | null
retainedDoneAgentCount: number
}
git: {
clean: boolean | null
upstreamAhead: number | null
upstreamBehind: number | null
checkedAt: number | null
}
fingerprint: string
}
export type WorkspaceCleanupScanArgs = {
worktreeId?: string
/** Non-destructive evidence refresh; bounded so one renderer cannot enqueue an unbounded scan. */
worktreeIds?: string[]
skipGitWorktreeIds?: string[]
scanId?: string
// Why: optional so an older client still receives the legacy suggestion-only
// broad scan; only a client that renders the full list asks for every row.
includeAllWorkspaces?: boolean
/** Re-stat activity for targeted rows; removal preflight needs fresh, batched evidence scans do not. */
refreshActivity?: boolean
}
export const WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT = 500
export type WorkspaceCleanupLocalProcessArgs = {
worktreeId: string
connectionId?: string | null
worktreePath?: string
}
export type WorkspaceCleanupSnapshotPruneBatchArgs = {
batchId: string
}
export type WorkspaceCleanupSnapshotPruneRecordArgs = WorkspaceCleanupSnapshotPruneBatchArgs & {
worktreeId: string
executionHostId?: ExecutionHostId
}
export type WorkspaceCleanupScanError = {
repoId: string
repoName: string
message: string
/** Optional for compatibility with older hosts that did not qualify scan errors. */
executionHostId?: ExecutionHostId
}
export type WorkspaceCleanupScanResult = {
scannedAt: number
candidates: WorkspaceCleanupCandidate[]
errors: WorkspaceCleanupScanError[]
}
export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & {
scanId: string
scannedWorktreeCount: number
totalWorktreeCount: number
candidateMode?: 'append' | 'snapshot'
}
/** One-shot consent to remove a workspace whose git status could not be read. */
export type WorkspaceCleanupUnverifiedRemovalConsent = {
/** Host-qualified candidate identity, never a bare worktree id. */
identity: string
/** Minted for one removal call and released when that call settles. */
attemptId: string
}
export type WorkspaceCleanupLocalProcessResult = {
hasKillableProcesses: boolean | null
}
export type WorkspaceCleanupDismissArgs = {
dismissals: WorkspaceCleanupDismissal[]
/** Removed worktrees' persisted dismissals are dead weight; prune them. */
removedWorktreeIds?: string[]
}
const LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> = new Set([
'main-worktree',
'folder-repo',
'pinned',
'active-workspace',
'running-terminal',
'terminal-liveness-unknown',
'dirty-editor-buffer',
'volatile-local-context',
'live-agent',
'recent-visible-context',
'ssh-disconnected',
'git-status-error',
'dirty-files',
'unpushed-commits',
'unknown-base',
'dismissed'
])
const WORKSPACE_CLEANUP_QUEUE_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> = new Set([
'main-worktree',
'folder-repo',
'ssh-disconnected'
])
export const WORKSPACE_CLEANUP_FORCE_REMOVE_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> =
new Set(['dirty-files', 'unpushed-commits', 'unknown-base'])
export const WORKSPACE_CLEANUP_BULK_SELECT_EXCLUSIONS: ReadonlySet<WorkspaceCleanupBlocker> =
new Set(['active-workspace', 'live-agent', 'dismissed'])
export function canQueueWorkspaceCleanupCandidate(
candidate: Pick<WorkspaceCleanupCandidate, 'blockers'>
): boolean {
return !candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_QUEUE_BLOCKERS.has(blocker))
}
export function shouldForceWorkspaceCleanupRemoval(
candidate: Pick<WorkspaceCleanupCandidate, 'blockers' | 'git'>
): boolean {
return (
candidate.git.clean !== true ||
candidate.git.checkedAt === null ||
candidate.blockers.some((blocker) => WORKSPACE_CLEANUP_FORCE_REMOVE_BLOCKERS.has(blocker))
)
}
function canSelectWorkspaceCleanupCandidate(
candidate: Pick<WorkspaceCleanupCandidate, 'blockers' | 'git' | 'reasons'>
): boolean {
return (
candidate.reasons.length > 0 &&
candidate.git.clean === true &&
candidate.git.checkedAt !== null &&
!candidate.blockers.some((blocker) => LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker))
)
}
type WorkspaceCleanupPolicyInput = Omit<WorkspaceCleanupCandidate, 'tier' | 'selectedByDefault'> &
Partial<Pick<WorkspaceCleanupCandidate, 'tier' | 'selectedByDefault'>>
export function applyWorkspaceCleanupPolicy(
candidate: WorkspaceCleanupPolicyInput
): WorkspaceCleanupCandidate {
const canSelect = canSelectWorkspaceCleanupCandidate(candidate)
const hasHardBlocker = candidate.blockers.some((blocker) =>
LEGACY_WORKSPACE_CLEANUP_HARD_BLOCKERS.has(blocker)
)
const tier: WorkspaceCleanupTier = hasHardBlocker ? 'protected' : canSelect ? 'ready' : 'review'
return {
...candidate,
tier,
selectedByDefault: tier === 'ready' && canSelect
}
}
export function createWorkspaceCleanupFingerprint(args: {
branch: string
head: string
gitClean: boolean | null
lastActivityAt: number
classifierVersion?: number
}): string {
const version = args.classifierVersion ?? WORKSPACE_CLEANUP_CLASSIFIER_VERSION
const lastActivityBucket = Math.floor((args.lastActivityAt || 0) / (24 * 60 * 60 * 1000))
return [
version,
args.branch,
args.head,
args.gitClean === null ? 'unknown' : args.gitClean ? 'clean' : 'dirty',
lastActivityBucket
].join('|')
}
export function getWorkspaceCleanupInactivityReasons(
workspace: WorkspaceCleanupInactivityInput,
scannedAt: number
): WorkspaceCleanupReason[] {
const reasons: WorkspaceCleanupReason[] = []
if (
workspace.isArchived &&
scannedAt - workspace.lastActivityAt >= WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS
) {
reasons.push('archived')
}
if (scannedAt - workspace.lastActivityAt >= WORKSPACE_CLEANUP_IDLE_MS) {
reasons.push('idle-clean')
}
return reasons
}
/** Newest activity stamp Orca itself persisted; 0 when it never observed the workspace. */
export function getPersistedWorkspaceCleanupActivityAt(workspace: {
createdAt?: number
lastActivityAt: number
}): number {
const lastActivityAt = Number.isFinite(workspace.lastActivityAt) ? workspace.lastActivityAt : 0
const createdAt = Number.isFinite(workspace.createdAt) ? (workspace.createdAt ?? 0) : 0
return Math.max(lastActivityAt, createdAt)
}
export function isWorkspaceOldForCleanup(
workspace: WorkspaceCleanupInactivityInput,
scannedAt: number
): boolean {
return getWorkspaceCleanupInactivityReasons(workspace, scannedAt).length > 0
}
export function shouldHideWorkspaceCleanupCandidate(
candidate: Pick<
WorkspaceCleanupCandidate,
'worktreeId' | 'fingerprint' | 'connectionId' | 'executionHostId'
>,
dismissal: WorkspaceCleanupDismissal | undefined
): boolean {
return (
dismissal?.worktreeId === candidate.worktreeId &&
dismissal.fingerprint === candidate.fingerprint &&
dismissal.classifierVersion === WORKSPACE_CLEANUP_CLASSIFIER_VERSION &&
// A host-qualified dismissal hides only its own host's row; a legacy one
// (no host recorded) keeps hiding every row that matches the fingerprint.
(dismissal.executionHostId === undefined ||
dismissal.executionHostId === getWorkspaceCleanupCandidateHostId(candidate))
)
}