diff --git a/src/server/workspace-metadata-monitor.ts b/src/server/workspace-metadata-monitor.ts index aa2c24cd7..ddfdf2066 100644 --- a/src/server/workspace-metadata-monitor.ts +++ b/src/server/workspace-metadata-monitor.ts @@ -7,7 +7,12 @@ import type { import { getGitSyncSummary, probeGitWorkspaceState } from "../workspace/git-sync"; import { getTaskWorkspacePathInfo } from "../workspace/task-worktree"; -const WORKSPACE_METADATA_POLL_INTERVAL_MS = 1_000; +// The UI refreshes imperatively via updateWorkspaceState whenever the board +// changes, so this timer only needs to catch external edits (out-of-band +// commits, agent file writes). Polling more aggressively just spawns a +// continuous stream of git subprocesses (~3·(N+1) per tick) even when no +// task is running, which shows up as sustained energy impact at idle. +const WORKSPACE_METADATA_POLL_INTERVAL_MS = 10_000; interface TrackedTaskWorkspace { taskId: string; @@ -18,11 +23,13 @@ interface CachedHomeGitMetadata { summary: RuntimeGitSyncSummary | null; stateToken: string | null; stateVersion: number; + repoRoot: string | null; } interface CachedTaskWorkspaceMetadata { data: RuntimeTaskWorkspaceMetadata; stateToken: string | null; + repoRoot: string | null; } interface WorkspaceMetadataEntry { @@ -147,6 +154,7 @@ function createWorkspaceEntry(workspacePath: string): WorkspaceMetadataEntry { summary: null, stateToken: null, stateVersion: 0, + repoRoot: null, }, taskMetadataByTaskId: new Map(), }; @@ -164,18 +172,21 @@ function buildWorkspaceMetadataSnapshot(entry: WorkspaceMetadataEntry): RuntimeW async function loadHomeGitMetadata(entry: WorkspaceMetadataEntry): Promise { try { - const probe = await probeGitWorkspaceState(entry.workspacePath); + const probe = await probeGitWorkspaceState(entry.workspacePath, { + repoRoot: entry.homeGit.repoRoot ?? undefined, + }); if (entry.homeGit.stateToken === probe.stateToken) { - return entry.homeGit; + return { ...entry.homeGit, repoRoot: probe.repoRoot }; } const summary = await getGitSyncSummary(entry.workspacePath, { probe }); return { summary, stateToken: probe.stateToken, stateVersion: Date.now(), + repoRoot: probe.repoRoot, }; } catch { - return entry.homeGit; + return { ...entry.homeGit, repoRoot: null }; } } @@ -214,11 +225,14 @@ async function loadTaskWorkspaceMetadata( stateVersion: Date.now(), }, stateToken: null, + repoRoot: null, }; } try { - const probe = await probeGitWorkspaceState(pathInfo.path); + const probe = await probeGitWorkspaceState(pathInfo.path, { + repoRoot: current && current.data.path === pathInfo.path && current.repoRoot ? current.repoRoot : undefined, + }); if ( current && current.stateToken === probe.stateToken && @@ -243,6 +257,7 @@ async function loadTaskWorkspaceMetadata( stateVersion: Date.now(), }, stateToken: probe.stateToken, + repoRoot: probe.repoRoot, }; } catch { if (current) { @@ -263,6 +278,7 @@ async function loadTaskWorkspaceMetadata( stateVersion: Date.now(), }, stateToken: null, + repoRoot: null, }; } } diff --git a/src/workspace/git-sync.ts b/src/workspace/git-sync.ts index 6de0a6dfe..ca42e69e8 100644 --- a/src/workspace/git-sync.ts +++ b/src/workspace/git-sync.ts @@ -110,12 +110,9 @@ function parseStatusPath(line: string): string | null { return tokens[tokens.length - 1] ?? null; } -export async function probeGitWorkspaceState(cwd: string): Promise { - const repoRoot = await resolveRepoRoot(cwd); - const [statusResult, headCommitResult] = await Promise.all([ - runGit(repoRoot, ["status", "--porcelain=v2", "--branch", "--untracked-files=all"]), - runGit(repoRoot, ["rev-parse", "--verify", "HEAD"]), - ]); +export async function probeGitWorkspaceState(cwd: string, options?: { repoRoot?: string }): Promise { + const repoRoot = options?.repoRoot ?? (await resolveRepoRoot(cwd)); + const statusResult = await runGit(repoRoot, ["status", "--porcelain=v2", "--branch", "--untracked-files=all"]); if (!statusResult.ok) { throw new Error(statusResult.error ?? "Git status command failed."); @@ -125,6 +122,7 @@ export async function probeGitWorkspaceState(cwd: string): Promise { } async function countUntrackedAdditions(repoRoot: string, untrackedPaths: string[]): Promise { + if (untrackedPaths.length === 0) { + return 0; + } const counts = await Promise.all( untrackedPaths.map(async (relativePath) => { try { @@ -221,6 +226,19 @@ async function countUntrackedAdditions(repoRoot: string, untrackedPaths: string[ return counts.reduce((total, value) => total + value, 0); } +async function getTrackedDiffTotals(probe: GitWorkspaceProbe): Promise<{ additions: number; deletions: number }> { + // `git diff --numstat HEAD` only has output when there are tracked changes. + // `probe.changedFiles` already counts both tracked and untracked entries, so + // when those are equal (or both zero) we can skip the subprocess entirely — + // the dominant case for an idle clean repo on the metadata-monitor poll. + const trackedChangeCount = probe.changedFiles - probe.untrackedPaths.length; + if (trackedChangeCount <= 0) { + return { additions: 0, deletions: 0 }; + } + const diffResult = await runGit(probe.repoRoot, ["diff", "--numstat", "HEAD", "--"]); + return diffResult.ok ? parseNumstatTotals(diffResult.stdout) : { additions: 0, deletions: 0 }; +} + async function hasGitRef(repoRoot: string, ref: string): Promise { const result = await runGit(repoRoot, ["show-ref", "--verify", "--quiet", ref]); return result.ok; @@ -231,9 +249,10 @@ export async function getGitSyncSummary( options?: { probe?: GitWorkspaceProbe }, ): Promise { const probe = options?.probe ?? (await probeGitWorkspaceState(cwd)); - const diffResult = await runGit(probe.repoRoot, ["diff", "--numstat", "HEAD", "--"]); - const trackedTotals = diffResult.ok ? parseNumstatTotals(diffResult.stdout) : { additions: 0, deletions: 0 }; - const untrackedAdditions = await countUntrackedAdditions(probe.repoRoot, probe.untrackedPaths); + const [trackedTotals, untrackedAdditions] = await Promise.all([ + getTrackedDiffTotals(probe), + countUntrackedAdditions(probe.repoRoot, probe.untrackedPaths), + ]); return { currentBranch: probe.currentBranch, diff --git a/web-ui/src/components/card-detail-view.tsx b/web-ui/src/components/card-detail-view.tsx index b3b8be454..d58b9c118 100644 --- a/web-ui/src/components/card-detail-view.tsx +++ b/web-ui/src/components/card-detail-view.tsx @@ -32,8 +32,9 @@ import { useTerminalThemeColors } from "@/terminal/theme-colors"; import { type BoardCard, type CardSelection, getTaskAutoReviewCancelButtonLabel } from "@/types"; import { useWindowEvent } from "@/utils/react-use"; -// We still poll the open detail diff because line content can change without changing -// the overall file or line counts that drive the shared workspace metadata stream. +// We poll the open detail diff while a task is running because in-place line +// edits can change without bumping the file/additions/deletions counters that +// drive the shared workspace metadata stream's `taskWorkspaceStateVersion`. const DETAIL_DIFF_POLL_INTERVAL_MS = 1_000; const DIFF_MODE_ACTIVE_BACKGROUND = "color-mix(in srgb, var(--color-surface-3) 80%, var(--color-text-primary))"; @@ -494,7 +495,9 @@ export function CardDetailView({ selection.card.baseRef, diffMode, taskWorkspaceStateVersion, - isDocumentVisible && !gitHistoryPanel && selection.column.id !== "trash" ? DETAIL_DIFF_POLL_INTERVAL_MS : null, + isDocumentVisible && !gitHistoryPanel && selection.column.id !== "trash" && sessionSummary?.state === "running" + ? DETAIL_DIFF_POLL_INTERVAL_MS + : null, lastTurnViewKey, true, );