Harden workspace cleanup scan performance

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-26 15:00:50 -07:00
co-authored by Orca
parent 2188047ee7
commit c0e53a2abc
14 changed files with 390 additions and 47 deletions
+7 -2
View File
@@ -1,10 +1,15 @@
export type GitRuntimeOptions = {
wslDistro?: string
signal?: AbortSignal
}
export function gitOptionsForWorktree(
cwd: string,
options: GitRuntimeOptions = {}
): { cwd: string; wslDistro?: string } {
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.signal ? { signal: options.signal } : {})
}
}
+14
View File
@@ -836,6 +836,7 @@ type GitStreamOptions = {
cwd: string
env?: NodeJS.ProcessEnv
wslDistro?: string
signal?: AbortSignal
/** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */
maxBuffer?: number
/**
@@ -863,6 +864,10 @@ export async function gitStreamStdout(
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
return withGitSpan({ args, cwd: options.cwd }, async () => {
return new Promise<GitStreamResult>((resolve, reject) => {
if (options.signal?.aborted) {
reject(createAbortError())
return
}
const child = gitSpawn(args, {
cwd: options.cwd,
env: nonInteractiveGitEnv(options.env),
@@ -887,6 +892,7 @@ export async function gitStreamStdout(
child.stderr?.off('data', onStderrData)
child.off('error', onError)
child.off('close', onClose)
options.signal?.removeEventListener('abort', onAbort)
// Flush any bytes the decoders were holding for an incomplete sequence.
stdoutDecoder.end()
stderrDecoder.end()
@@ -953,11 +959,19 @@ export async function gitStreamStdout(
}
finish(new Error(`git exited with ${code}: ${stderr}`))
}
function onAbort(): void {
killSpawnedCommandTree(child)
finish(createAbortError())
}
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
options.signal?.addEventListener('abort', onAbort, { once: true })
if (options.signal?.aborted) {
onAbort()
}
})
})
}
+4
View File
@@ -109,6 +109,9 @@ export async function getStatus(
options: GetStatusOptions = {}
): Promise<GitStatusResult> {
gitDiffReadDedupe.clear()
if (options.signal) {
return runGetStatus(worktreePath, options)
}
// Why: dedupe only concurrent identical reads; after settle, callers must
// execute a fresh status read rather than observing a cached result.
const cacheKey = getStatusReadKey(worktreePath, options)
@@ -190,6 +193,7 @@ async function runGetStatus(
// Why: status polling is read-like; avoid refreshing the index and racing
// terminal Git commands on `.git/worktrees/*/index.lock`.
env: gitOptionalLocksDisabledEnv(),
signal: options.signal,
onStdout: (chunk) => parser.update(chunk, limit)
})
if (!stoppedEarly) {
+7 -2
View File
@@ -29,6 +29,7 @@ type SparseWorktreeCreateError = Error & {
export type GitWorktreeExecOptions = {
wslDistro?: string
signal?: AbortSignal
}
export type AddWorktreeOptions = GitWorktreeExecOptions & {
@@ -69,8 +70,12 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
function gitExecOptions(
cwd: string,
options: GitWorktreeExecOptions = {}
): { cwd: string; wslDistro?: string } {
return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd }
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.signal ? { signal: options.signal } : {})
}
}
function getErrorCode(error: unknown): string | undefined {
+13 -6
View File
@@ -37,7 +37,10 @@ export async function readWorkspaceCleanupGitEvidence(
try {
status = await withWorkspaceCleanupTimeout(
repo.connectionId ? provider!.getStatus(worktree.path) : getStatus(worktree.path),
(signal) =>
repo.connectionId
? provider!.getStatus(worktree.path, { signal })
: getStatus(worktree.path, { signal }),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out reading git status.'
)
@@ -90,11 +93,15 @@ async function readUnpushedCommitCount(
): Promise<number | null> {
try {
const result = await withWorkspaceCleanupTimeout(
repo.connectionId
? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path)
: gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], {
cwd: worktree.path
}),
(signal) =>
repo.connectionId
? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path, {
signal
})
: gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], {
cwd: worktree.path,
signal
}),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out checking unpushed commits.'
)
@@ -33,16 +33,20 @@ export async function mapWorkspaceCleanupWithConcurrency<T, R>(
}
export async function withWorkspaceCleanupTimeout<T>(
promise: Promise<T>,
run: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
message: string
): Promise<T> {
const controller = new AbortController()
let timeoutId: NodeJS.Timeout | undefined
const timeoutPromise = new Promise<T>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs)
timeoutId = setTimeout(() => {
controller.abort()
reject(new Error(message))
}, timeoutMs)
})
try {
return await Promise.race([promise, timeoutPromise])
return await Promise.race([run(controller.signal), timeoutPromise])
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
+12 -10
View File
@@ -182,7 +182,7 @@ async function listCleanupGitWorktrees(
return {
provider,
gitWorktrees: await withWorkspaceCleanupTimeout(
provider.listWorktrees(repo.path),
(signal) => provider.listWorktrees(repo.path, { signal }),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out listing SSH worktrees.'
)
@@ -191,7 +191,7 @@ async function listCleanupGitWorktrees(
return {
provider: null,
gitWorktrees: await withWorkspaceCleanupTimeout(
listRepoWorktrees(repo),
(signal) => listRepoWorktrees(repo, { signal }),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out listing worktrees.'
)
@@ -222,11 +222,13 @@ function createProgressEmitter(
scannedAt: number,
options: WorkspaceCleanupScanOptions
): WorkspaceCleanupProgressEmitter {
const candidates: WorkspaceCleanupCandidate[] = []
const errors: WorkspaceCleanupScanError[] = []
let totalWorktreeCount = 0
let scannedWorktreeCount = 0
const emit = (): void => {
const emit = (
candidates: WorkspaceCleanupCandidate[],
candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot'
): void => {
if (!scanId) {
return
}
@@ -235,23 +237,23 @@ function createProgressEmitter(
scannedAt,
totalWorktreeCount,
scannedWorktreeCount,
candidates: [...candidates],
errors: [...errors]
candidates,
errors: [...errors],
candidateMode
})
}
return {
addDiscovered: (count) => {
totalWorktreeCount += count
emit()
emit([], 'append')
},
addCandidate: (candidate) => {
candidates.push(candidate)
scannedWorktreeCount += 1
emit()
emit([candidate], 'append')
},
addErrors: (newErrors) => {
appendWorkspaceCleanupItems(errors, newErrors)
emit()
emit([], 'append')
}
}
}
+52 -10
View File
@@ -10,6 +10,7 @@ import type {
Repo,
WorktreeMeta
} from '../../shared/types'
import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup'
const {
listRepoWorktreesMock,
@@ -203,14 +204,23 @@ describe('workspace cleanup scan', () => {
totalWorktreeCount: 2,
candidates: []
})
const candidateProgress = progress.filter(
(event): event is WorkspaceCleanupScanProgress =>
(event as WorkspaceCleanupScanProgress).candidateMode === 'append' &&
(event as WorkspaceCleanupScanProgress).candidates.length > 0
)
expect(candidateProgress).toHaveLength(2)
expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true)
const progressWorktreeIds = candidateProgress.flatMap((event) =>
event.candidates.map((candidate) => candidate.worktreeId)
)
expect(progressWorktreeIds).toEqual(
expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b'])
)
expect(progress.at(-1)).toMatchObject({
scanId: 'scan-1',
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: expect.arrayContaining([
expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-a' }),
expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-b' })
])
totalWorktreeCount: 2
})
})
@@ -228,6 +238,30 @@ describe('workspace cleanup scan', () => {
])
})
it('aborts a hung worktree list when the cleanup timeout fires', async () => {
let signal: AbortSignal | undefined
listRepoWorktreesMock.mockImplementation((_repo: Repo, options?: { signal?: AbortSignal }) => {
signal = options?.signal
return new Promise<GitWorktreeInfo[]>(() => {})
})
const scan = scanWorkspaceCleanup(makeStore())
await vi.advanceTimersByTimeAsync(8_000)
await expect(scan).resolves.toEqual({
scannedAt: NOW,
candidates: [],
errors: [
{
repoId: 'repo-1',
repoName: 'Repo',
message: 'Timed out listing worktrees.'
}
]
})
expect(signal?.aborted).toBe(true)
})
it('skips disconnected remote workspaces without a scan warning', async () => {
const result = await scanWorkspaceCleanup(
makeStore({
@@ -306,8 +340,12 @@ describe('workspace cleanup scan', () => {
})
)
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo')
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature')
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', {
signal: expect.any(AbortSignal)
})
expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', {
signal: expect.any(AbortSignal)
})
expect(result.errors).toEqual([])
expect(result.candidates[0]).toMatchObject({
connectionId: 'ssh-1',
@@ -330,7 +368,9 @@ describe('workspace cleanup scan', () => {
})
)
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo')
expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', {
signal: expect.any(AbortSignal)
})
expect(result.errors).toEqual([])
expect(result.candidates).toEqual([])
})
@@ -389,7 +429,9 @@ describe('workspace cleanup scan', () => {
)
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)
expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo)
expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo, {
signal: expect.any(AbortSignal)
})
expect(result.candidates).toHaveLength(1)
expect(result.candidates[0]).toMatchObject({
worktreeId: 'repo-2::/repo-two-feature',
@@ -440,7 +482,7 @@ describe('workspace cleanup scan', () => {
expect(getStatusMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-list', '--count', 'HEAD', '--not', '--remotes'],
{ cwd: '/repo-feature' }
{ cwd: '/repo-feature', signal: expect.any(AbortSignal) }
)
expect(result.candidates[0]).toMatchObject({
tier: 'ready',
+9 -5
View File
@@ -103,11 +103,15 @@ export class SshGitProvider implements IGitProvider {
const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache
? { bypassEffectiveUpstreamNegativeCache: true }
: {}
return (await this.mux.request('git.status', {
worktreePath,
...includeIgnoredArgs,
...upstreamCacheBypassArgs
})) as GitStatusResult
return (await this.mux.request(
'git.status',
{
worktreePath,
...includeIgnoredArgs,
...upstreamCacheBypassArgs
},
{ signal: options?.signal }
)) as GitStatusResult
}
async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]> {
+1
View File
@@ -168,6 +168,7 @@ export type IFilesystemProvider = {
export type GitProviderStatusOptions = {
includeIgnored?: boolean
bypassEffectiveUpstreamNegativeCache?: boolean
signal?: AbortSignal
}
export type IGitProvider = {
+1
View File
@@ -6,6 +6,7 @@ import { areWorktreePathsEqual } from './ipc/worktree-logic'
type LocalRepoWorktreeListOptions = {
wslDistro?: string
signal?: AbortSignal
}
export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean {
@@ -185,6 +185,83 @@ describe('workspace cleanup viewed rows', () => {
})
})
it('does not re-probe previously enriched rows during cumulative progress updates', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' })
const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockResolvedValue(false)
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
;(
globalThis.window as unknown as {
api: {
pty?: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: typeof getForegroundProcess
}
}
}
).api.pty = { hasChildProcesses, getForegroundProcess }
const store = createCleanupTestStore()
store.setState({
tabsByWorktree: {
'repo1::/tmp/terminal': [
{ id: 'tab-1', title: 'zsh' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [terminalCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
})
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: [laterCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
pending.resolve({
scannedAt: NOW,
candidates: [terminalCandidate, laterCandidate],
errors: []
})
await scanPromise
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
})
it('does not join broad cleanup scans with different explicit args', async () => {
const firstPending = deferred<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
@@ -63,6 +63,12 @@ type EnrichOptions = {
applyDismissals?: boolean
}
type WorkspaceCleanupEnrichmentCacheEntry = {
inputSignature: string
localSignature: string
candidate: WorkspaceCleanupCandidate
}
const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000
const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000
const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4
@@ -72,6 +78,10 @@ let inFlightWorkspaceCleanupScan: {
promise: Promise<WorkspaceCleanupScanResult>
} | null = null
let latestWorkspaceCleanupScanToken = 0
let workspaceCleanupEnrichmentCache: {
scanToken: number
entries: Map<string, WorkspaceCleanupEnrichmentCacheEntry>
} | null = null
const SHELL_PROCESS_NAMES = new Set([
'bash',
@@ -144,11 +154,16 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
workspaceCleanupError: null
})
const scanToken = ++latestWorkspaceCleanupScanToken
workspaceCleanupEnrichmentCache = { scanToken, entries: new Map() }
const promise = (async () => {
const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => {
void applyWorkspaceCleanupProgress(progress, scanToken, get, set)
})
const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, get())
const enriched = await enrichWorkspaceCleanupCandidatesForScan(
scan.candidates,
get(),
scanToken
)
const result = { ...scan, candidates: enriched }
if (scanToken === latestWorkspaceCleanupScanToken) {
set({
@@ -315,20 +330,81 @@ async function applyWorkspaceCleanupProgress(
replace?: false
) => void
): Promise<void> {
const enriched = await enrichWorkspaceCleanupCandidates(progress.candidates, getState())
const state = getState()
const previousCandidates =
progress.candidateMode === 'append' &&
state.workspaceCleanupProgress?.scanId === progress.scanId
? state.workspaceCleanupProgress.candidates
: []
const enrichedProgressCandidates = await enrichWorkspaceCleanupCandidatesForScan(
progress.candidates,
state,
scanToken
)
const candidates =
progress.candidateMode === 'append'
? appendWorkspaceCleanupProgressCandidates(previousCandidates, enrichedProgressCandidates)
: enrichedProgressCandidates
if (scanToken !== latestWorkspaceCleanupScanToken) {
return
}
setState({
workspaceCleanupScan: {
scannedAt: progress.scannedAt,
candidates: enriched,
errors: progress.errors
},
workspaceCleanupProgress: { ...progress, candidates: enriched }
setState((state) => {
if (
state.workspaceCleanupProgress?.scanId === progress.scanId &&
state.workspaceCleanupProgress.scannedWorktreeCount > progress.scannedWorktreeCount
) {
return {}
}
return {
workspaceCleanupScan: {
scannedAt: progress.scannedAt,
candidates,
errors: progress.errors
},
workspaceCleanupProgress: { ...progress, candidates }
}
})
}
async function enrichWorkspaceCleanupCandidatesForScan(
candidates: readonly WorkspaceCleanupCandidate[],
state: AppState,
scanToken: number
): Promise<WorkspaceCleanupCandidate[]> {
if (workspaceCleanupEnrichmentCache?.scanToken !== scanToken) {
workspaceCleanupEnrichmentCache = { scanToken, entries: new Map() }
}
return enrichWorkspaceCleanupCandidatesWithCache(
candidates,
state,
workspaceCleanupEnrichmentCache.entries
)
}
function appendWorkspaceCleanupProgressCandidates(
previousCandidates: readonly WorkspaceCleanupCandidate[],
nextCandidates: readonly WorkspaceCleanupCandidate[]
): WorkspaceCleanupCandidate[] {
if (nextCandidates.length === 0) {
return [...previousCandidates]
}
const indexesByWorktreeId = new Map(
previousCandidates.map((candidate, index) => [candidate.worktreeId, index])
)
const merged = [...previousCandidates]
for (const candidate of nextCandidates) {
const existingIndex = indexesByWorktreeId.get(candidate.worktreeId)
if (existingIndex === undefined) {
indexesByWorktreeId.set(candidate.worktreeId, merged.length)
merged.push(candidate)
continue
}
merged[existingIndex] = candidate
}
return merged
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
@@ -404,6 +480,106 @@ export async function enrichWorkspaceCleanupCandidates(
)
}
async function enrichWorkspaceCleanupCandidatesWithCache(
candidates: readonly WorkspaceCleanupCandidate[],
state: AppState,
cache: Map<string, WorkspaceCleanupEnrichmentCacheEntry>,
options: EnrichOptions = {}
): Promise<WorkspaceCleanupCandidate[]> {
return Promise.all(
candidates.map(async (candidate) => {
const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate)
const localSignature = getWorkspaceCleanupLocalStateSignature(
candidate.worktreeId,
state,
options
)
const cached = cache.get(candidate.worktreeId)
if (cached?.inputSignature === inputSignature && cached.localSignature === localSignature) {
return cached.candidate
}
const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, options)
cache.set(candidate.worktreeId, {
inputSignature,
localSignature,
candidate: enriched
})
return enriched
})
)
}
function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupCandidate): string {
return JSON.stringify({
fingerprint: candidate.fingerprint,
blockers: candidate.blockers,
reasons: candidate.reasons,
git: candidate.git,
lastActivityAt: candidate.lastActivityAt,
createdAt: candidate.createdAt,
path: candidate.path,
branch: candidate.branch
})
}
function getWorkspaceCleanupLocalStateSignature(
worktreeId: string,
state: AppState,
options: EnrichOptions
): string {
const tabs = state.tabsByWorktree[worktreeId] ?? []
const tabIds = tabs.map((tab) => tab.id)
const tabIdSet = new Set(tabIds)
const openFiles = state.openFiles
.filter((file) => file.worktreeId === worktreeId)
.map((file) => ({
id: file.id,
isDirty: file.isDirty,
hasDraft: state.editorDrafts[file.id] !== undefined
}))
const retainedDoneAgentPaneKeys = Object.entries(state.retainedAgentsByPaneKey)
.filter(([, entry]) => entry.worktreeId === worktreeId && entry.entry.state === 'done')
.map(([paneKey]) => paneKey)
.sort()
const agentStatuses = Object.values(state.agentStatusByPaneKey)
.filter((entry) => tabIdSet.has(getPaneKeyTabId(entry.paneKey)))
.map((entry) => ({
paneKey: entry.paneKey,
state: entry.state,
updatedAt: entry.updatedAt
}))
.sort((a, b) => a.paneKey.localeCompare(b.paneKey))
const ptyIdsByTabId = Object.fromEntries(
tabIds.map((tabId) => [tabId, state.ptyIdsByTabId[tabId] ?? []])
)
const runtimePaneTitlesByTabId = Object.fromEntries(
tabIds.map((tabId) => [tabId, state.runtimePaneTitlesByTabId[tabId] ?? {}])
)
const terminalLayoutsByTabId = Object.fromEntries(
tabIds.map((tabId) => [tabId, state.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {}])
)
const dismissal =
options.applyDismissals === false
? null
: (state.workspaceCleanupDismissals[worktreeId] ?? null)
return JSON.stringify({
active: state.activeWorktreeId === worktreeId,
tabs: tabs.map((tab) => ({ id: tab.id, title: tab.title })),
ptyIdsByTabId,
runtimePaneTitlesByTabId,
terminalLayoutsByTabId,
openFiles,
browserTabCount: (state.browserTabsByWorktree[worktreeId] ?? []).length,
retainedDoneAgentPaneKeys,
agentStatuses,
lastVisitedAt: state.lastVisitedAtByWorktreeId[worktreeId] ?? 0,
viewed: state.workspaceCleanupViewedCandidates[worktreeId] ?? null,
dismissal
})
}
async function enrichWorkspaceCleanupCandidate(
candidate: WorkspaceCleanupCandidate,
state: AppState,
+1
View File
@@ -99,6 +99,7 @@ export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & {
scanId: string
scannedWorktreeCount: number
totalWorktreeCount: number
candidateMode?: 'append' | 'snapshot'
}
export type WorkspaceCleanupLocalProcessResult = {