Files
kilocode/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts
T
kirillk 91040bcff4 fix(agent-manager): report worktree problems accurately and recover from them
Agent Manager could tell a user "Git is not installed or not found in PATH"
while git was installed and working, show a worktree as having no changes when
the status check had actually failed, and keep polling worktrees whose folder
was long gone. One unresponsive worktree was enough to slow down status updates
for every other row.

Three causes, all of them the same mistake in different places: a failure was
reported as a fact about something else.

- A failed process launch reports ENOENT whether the program or the working
  directory is missing, and the code read that as "git is missing".
- A failed or timed-out status check returned zero counts, which is
  indistinguishable from a clean worktree.
- A timed-out GitHub CLI call was recorded as a success, which reset the
  backoff that was supposed to stop retrying it.

Nothing reconciled the three views of a worktree either (the row, git's own
registration, the folder on disk), so stale rows accumulated and were polled
forever, and a timed-out `gh pr view` was retried on every cycle.

Approach: name each state and never guess between them. A worktree is healthy,
restorable, gone, not-a-worktree, or unmeasurable, and each state gets the
message and the recovery action that actually applies. Startup reconciles the
three views and repairs metadata only; deleting files is always a user's
click. Repeated failures park one worktree instead of the whole panel, and
git/gh calls get budgets sized to the work they do.

Both clients implement the same states, wording, and recovery actions.
2026-09-14 13:42:40 -04:00

453 lines
15 KiB
TypeScript

import * as fs from "fs"
import * as path from "path"
import { remoteRef, type Worktree } from "./WorktreeStateManager"
import type { GitOps } from "./GitOps"
import type { Semaphore } from "./semaphore"
import { findTrackedBranch } from "./project/paths"
import {
GitStatsSnapshot,
refOID,
shortRef,
type DiffStats,
type RefSnapshot,
type GitStatsSource,
} from "./git-stats-snapshot"
export interface WorktreeStats {
worktreeId: string
files: number
additions: number
deletions: number
ahead: number
behind: number
}
export interface LocalStats {
branch: string
files: number
additions: number
deletions: number
ahead: number
behind: number
}
export interface WorktreePresence {
worktreeId: string
missing: boolean
/**
* Why the worktree is missing, so the UI can say something true. `absent` means the directory is
* gone; `unregistered` means it is still on disk but git no longer tracks it, which is a different
* problem with a different fix.
*/
reason?: "absent" | "unregistered"
/** Current branch from `git worktree list`, if available. */
branch?: string
}
export interface WorktreePresenceResult {
worktrees: WorktreePresence[]
degraded: boolean
}
interface GitStatsPollerOptions {
getWorktrees: () => Worktree[]
getWorkspaceRoot: () => string | undefined
getHotWorktreeIds?: () => Set<string>
/** Override the real Git source in scheduler and failure-path tests. */
source?: GitStatsSource
git: GitOps
onStats: (stats: WorktreeStats[]) => void
onLocalStats: (stats: LocalStats) => void
onWorktreePresence?: (result: WorktreePresenceResult) => void
log: (...args: unknown[]) => void
intervalMs?: number
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
hiddenIntervalMs?: number
dormantIntervalMs?: number
/** True for worktrees the health reconcile says cannot answer; they are skipped, not measured. */
isUnhealthy?: (worktreeId: string) => boolean
}
export class GitStatsPoller {
private timer: ReturnType<typeof setTimeout> | undefined
private active = false
private busy = false
private lastHash: string | undefined
private lastLocalHash: string | undefined
private lastLocalStats: LocalStats | undefined
private lastStats: Record<string, WorktreeStats> = {}
private readonly intervalMs: number
private readonly hiddenIntervalMs: number
private readonly dormantIntervalMs: number
private readonly git: GitOps
private readonly snapshots: GitStatsSource
private readonly cache = new Map<string, CachedStats>()
private localCache: CachedStats | undefined
private skipWorktreeIds = new Set<string>()
private visible = true
private generation = 0
private cursor = 0
constructor(private readonly options: GitStatsPollerOptions) {
this.intervalMs = options.intervalMs ?? 5000
this.hiddenIntervalMs = options.hiddenIntervalMs ?? 60000
this.dormantIntervalMs = options.dormantIntervalMs ?? 30000
this.git = options.git
this.snapshots = options.source ?? new GitStatsSnapshot(options.git)
}
setVisible(visible: boolean): void {
if (this.visible === visible) return
this.visible = visible
if (this.active && this.timer) {
clearTimeout(this.timer)
this.timer = undefined
this.schedule(this.visible ? this.intervalMs : this.hiddenIntervalMs)
}
}
/** Replace the entire skip set with the given IDs. */
syncSkips(ids: Set<string>): WorktreeStats[] | undefined {
this.skipWorktreeIds = ids
const stats = Object.values(this.lastStats).filter((item) => !ids.has(item.worktreeId))
if (stats.length === 0) return undefined
const hash = this.hash(stats)
if (hash === this.lastHash) return undefined
this.lastHash = hash
return stats
}
/** Pre-emptively exclude a single worktree (e.g. before deletion). */
skipWorktree(id: string): void {
this.skipWorktreeIds.add(id)
}
unskipWorktree(id: string): void {
this.skipWorktreeIds.delete(id)
}
setEnabled(enabled: boolean): void {
if (enabled) {
if (this.active) return
this.active = true
void this.poll()
return
}
this.stop()
}
stop(): void {
this.generation++
this.active = false
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
}
this.busy = false
this.lastHash = undefined
this.lastLocalHash = undefined
this.lastLocalStats = undefined
this.lastStats = {}
this.cache.clear()
this.localCache = undefined
this.cursor = 0
}
async snapshot(refresh = false): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> {
if (refresh && !this.busy) {
this.busy = true
const refs = await this.fetchRefs()
await Promise.all([
this.fetchWorktreeStats(true, this.generation, refs),
this.fetchLocalStats(this.generation, refs, true),
]).finally(() => {
this.busy = false
})
}
return {
worktrees: Object.values(this.lastStats),
...(this.lastLocalStats ? { local: this.lastLocalStats } : {}),
}
}
private currentInterval(): number {
return this.visible ? this.intervalMs : this.hiddenIntervalMs
}
private schedule(delay: number): void {
if (!this.active) return
this.timer = setTimeout(() => {
void this.poll()
}, delay)
}
private poll(): Promise<void> {
if (!this.active) return Promise.resolve()
if (this.busy) return Promise.resolve()
this.busy = true
const generation = this.generation
return this.fetch(generation).finally(() => {
// stop() already reset busy and bumped the generation, so a stale
// fetch must not touch busy: a restarted poll may own it right now.
if (generation !== this.generation) return
this.busy = false
this.schedule(this.currentInterval())
})
}
private async fetch(generation = this.generation): Promise<void> {
const refs = await this.fetchRefs()
await Promise.all([this.fetchWorktreeStats(false, generation, refs), this.fetchLocalStats(generation, refs)])
}
private async fetchWorktreeStats(
includeSkipped = false,
generation = this.generation,
refs?: RefSnapshot,
): Promise<void> {
const worktrees = this.options.getWorktrees()
if (worktrees.length === 0) return
const presence = await this.probeWorktreePresence(worktrees, refs)
if (generation !== this.generation) return
this.options.onWorktreePresence?.(presence)
const missing = new Set(
presence.degraded ? [] : presence.worktrees.filter((item) => item.missing).map((item) => item.worktreeId),
)
const available = worktrees.filter((wt) => !missing.has(wt.id) && this.options.isUnhealthy?.(wt.id) !== true)
const ids = new Set(available.map((wt) => wt.id))
for (const id of Object.keys(this.lastStats)) {
if (!ids.has(id)) {
delete this.lastStats[id]
this.cache.delete(id)
}
}
const candidates = includeSkipped ? available : available.filter((wt) => !this.skipWorktreeIds.has(wt.id))
const active = includeSkipped ? candidates : this.select(candidates)
if (active.length === 0) {
if (available.length > 0) return
if (this.lastHash === "") return
this.lastHash = ""
this.lastStats = {}
this.options.onStats([])
return
}
const stats = await this.fetchOptimized(active, refs, includeSkipped)
if (generation !== this.generation) return
for (const item of stats) this.lastStats[item.worktreeId] = item
const visible = Object.values(this.lastStats).filter((item) => !this.skipWorktreeIds.has(item.worktreeId))
if (visible.length === 0) return
const hash = this.hash(visible)
if (hash === this.lastHash) return
this.lastHash = hash
this.options.onStats(visible)
}
private async fetchOptimized(worktrees: Worktree[], refs: RefSnapshot | undefined, refresh: boolean) {
const rows = await Promise.all(
worktrees.map(async (wt) => {
const base = remoteRef(wt)
const baseOID = refOID(refs, base)
try {
const status = await this.snapshots.status(wt.path)
const cached = this.cache.get(wt.id)
const same =
!refresh &&
!!baseOID &&
cached?.base === base &&
cached.baseOID === baseOID &&
cached.fingerprint === status.fingerprint
const diff = same ? cached.diff : await this.snapshots.diff(wt.path, base, status.untracked)
const ahead =
!refresh && baseOID && cached?.head === status.head && cached.baseOID === baseOID
? cached.ahead
: await this.git.aheadBehind(wt.path, base)
return { wt, base, baseOID, status, diff, ahead }
} catch (err) {
this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err)
return { wt, prior: this.lastStats[wt.id] }
}
}),
)
return rows
.map((row) => {
if ("prior" in row) return row.prior
const stats = { worktreeId: row.wt.id, ...row.diff, ...row.ahead }
this.cache.set(row.wt.id, {
base: row.base,
baseOID: row.baseOID,
head: row.status.head,
fingerprint: row.status.fingerprint,
diff: row.diff,
dirty: row.status.dirty,
clean: row.status.dirty ? 0 : (this.cache.get(row.wt.id)?.clean ?? 0) + 1,
ahead: row.ahead,
})
return stats
})
.filter((item): item is WorktreeStats => !!item)
}
private select(worktrees: Worktree[]): Worktree[] {
if (!this.visible || worktrees.length === 0) return worktrees
const hot = this.options.getHotWorktreeIds?.() ?? new Set<string>()
const selected = worktrees.filter((wt) => {
const cached = this.cache.get(wt.id)
return hot.has(wt.id) || !cached || cached.dirty || cached.clean < 2
})
const ids = new Set(selected.map((wt) => wt.id))
const dormant = worktrees.filter((wt) => !ids.has(wt.id))
if (dormant.length === 0) return selected
const ticks = Math.max(1, Math.ceil(this.dormantIntervalMs / this.intervalMs))
const count = Math.max(1, Math.ceil(dormant.length / ticks))
for (let i = 0; i < count; i++) {
selected.push(dormant[(this.cursor + i) % dormant.length]!)
}
this.cursor = (this.cursor + count) % dormant.length
return selected
}
private hash(stats: WorktreeStats[]): string {
return stats
.map(
(item) => `${item.worktreeId}:${item.files}:${item.additions}:${item.deletions}:${item.ahead}:${item.behind}`,
)
.join("|")
}
private async probeWorktreePresence(worktrees: Worktree[], refs?: RefSnapshot): Promise<WorktreePresenceResult> {
const root = this.options.getWorkspaceRoot()
if (!root) {
return { worktrees: [], degraded: true }
}
const paths = refs?.worktreePaths
if (paths) {
const items = await Promise.all(worktrees.map((wt) => this.presence(wt, root, paths)))
if (items.every((item) => !item.missing)) return { worktrees: items, degraded: false }
}
const tracked = await this.git.listWorktreePaths(root).catch((err) => {
this.options.log("Failed to list worktree paths:", err)
return undefined
})
if (!tracked) {
return { worktrees: [], degraded: true }
}
const worktreeStatuses = await Promise.all(worktrees.map((wt) => this.presence(wt, root, tracked)))
return { worktrees: worktreeStatuses, degraded: false }
}
private async presence(wt: Worktree, root: string, paths: Map<string, string>): Promise<WorktreePresence> {
const abs = path.isAbsolute(wt.path) ? wt.path : path.join(root, wt.path)
const exists = await fs.promises.access(abs).then(
() => true,
() => false,
)
const branch = exists ? findTrackedBranch(paths, abs) : undefined
if (!exists) return { worktreeId: wt.id, missing: true, reason: "absent" }
if (branch === undefined) return { worktreeId: wt.id, missing: true, reason: "unregistered" }
return { worktreeId: wt.id, missing: false, branch }
}
private async fetchLocalStats(generation = this.generation, refs?: RefSnapshot, refresh = false): Promise<void> {
const root = this.options.getWorkspaceRoot()
if (!root) return
try {
const status = await this.snapshots.status(root)
const branch = status.branch
if (!branch || branch === "HEAD") return
const stats = await this.local(root, branch, status, refs, refresh).catch((err) => {
this.options.log("Failed to fetch local diff stats:", err)
return undefined
})
if (!stats) return
if (generation !== this.generation) return
const hash = `local:${branch}:${stats.files}:${stats.additions}:${stats.deletions}:${stats.ahead}:${stats.behind}`
if (hash === this.lastLocalHash) {
this.options.log(`Local stats: unchanged (${hash})`)
return
}
this.lastLocalHash = hash
this.options.log(
`Local stats: emitting files=${stats.files} +${stats.additions} -${stats.deletions}${stats.ahead}${stats.behind}`,
)
this.lastLocalStats = stats
this.options.onLocalStats(stats)
} catch (err) {
this.options.log("Failed to fetch local stats:", err)
}
}
private async local(
root: string,
branch: string,
status: Awaited<ReturnType<GitStatsSource["status"]>>,
refs: RefSnapshot | undefined,
refresh: boolean,
): Promise<LocalStats> {
const trackingRef = refs?.upstreams.get(`refs/heads/${branch}`)
const tracking = trackingRef ? shortRef(trackingRef) : await this.git.resolveTrackingBranch(root, branch)
const base = tracking ?? (await this.git.resolveDefaultBranch(root, branch))
if (!base) {
const stats = await this.git.workingTreeStats(root)
return { branch, ...stats, ahead: 0, behind: 0 }
}
const baseOID = refOID(refs, base)
const cached = this.localCache
const same =
!refresh &&
!!baseOID &&
cached?.base === base &&
cached.baseOID === baseOID &&
cached.fingerprint === status.fingerprint
const diff = same ? cached.diff : await this.snapshots.diff(root, base, status.untracked)
const ahead =
!refresh && baseOID && cached?.head === status.head && cached.baseOID === baseOID
? cached.ahead
: await this.git.aheadBehind(root, base)
this.localCache = {
base,
baseOID,
head: status.head,
fingerprint: status.fingerprint,
diff,
dirty: status.dirty,
clean: status.dirty ? 0 : (cached?.clean ?? 0) + 1,
ahead,
}
return { branch, ...diff, ...ahead }
}
private async fetchRefs(): Promise<RefSnapshot | undefined> {
const root = this.options.getWorkspaceRoot()
if (!root) return undefined
return this.snapshots.refs(root).catch((err) => {
this.options.log("Failed to read project refs:", err)
return undefined
})
}
}
interface CachedStats {
base: string
baseOID?: string
head: string
fingerprint: string
diff: DiffStats
dirty: boolean
clean: number
ahead: { ahead: number; behind: number }
}