Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions src/server/workspace-metadata-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -147,6 +154,7 @@ function createWorkspaceEntry(workspacePath: string): WorkspaceMetadataEntry {
summary: null,
stateToken: null,
stateVersion: 0,
repoRoot: null,
},
taskMetadataByTaskId: new Map<string, CachedTaskWorkspaceMetadata>(),
};
Expand All @@ -164,18 +172,21 @@ function buildWorkspaceMetadataSnapshot(entry: WorkspaceMetadataEntry): RuntimeW

async function loadHomeGitMetadata(entry: WorkspaceMetadataEntry): Promise<CachedHomeGitMetadata> {
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 };
}
}

Expand Down Expand Up @@ -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 &&
Expand All @@ -243,6 +257,7 @@ async function loadTaskWorkspaceMetadata(
stateVersion: Date.now(),
},
stateToken: probe.stateToken,
repoRoot: probe.repoRoot,
};
} catch {
if (current) {
Expand All @@ -263,6 +278,7 @@ async function loadTaskWorkspaceMetadata(
stateVersion: Date.now(),
},
stateToken: null,
repoRoot: null,
};
}
}
Expand Down
39 changes: 29 additions & 10 deletions src/workspace/git-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,9 @@ function parseStatusPath(line: string): string | null {
return tokens[tokens.length - 1] ?? null;
}

export async function probeGitWorkspaceState(cwd: string): Promise<GitWorkspaceProbe> {
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<GitWorkspaceProbe> {
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.");
Expand All @@ -125,6 +122,7 @@ export async function probeGitWorkspaceState(cwd: string): Promise<GitWorkspaceP
let upstreamBranch: string | null = null;
let aheadCount = 0;
let behindCount = 0;
let headCommit: string | null = null;
const fingerprintPaths: string[] = [];
const untrackedPaths: string[] = [];
let changedFiles = 0;
Expand All @@ -134,6 +132,11 @@ export async function probeGitWorkspaceState(cwd: string): Promise<GitWorkspaceP
if (!line) {
continue;
}
if (line.startsWith("# branch.oid ")) {
const oid = line.slice("# branch.oid ".length).trim();
headCommit = oid && oid !== "(initial)" ? oid : null;
continue;
}
if (line.startsWith("# branch.head ")) {
const branchName = line.slice("# branch.head ".length).trim();
currentBranch = branchName && branchName !== "(detached)" ? branchName : null;
Expand Down Expand Up @@ -174,7 +177,6 @@ export async function probeGitWorkspaceState(cwd: string): Promise<GitWorkspaceP
}
}

const headCommit = headCommitResult.ok && headCommitResult.stdout ? headCommitResult.stdout : null;
const fingerprints = await buildPathFingerprints(repoRoot, fingerprintPaths);

return {
Expand Down Expand Up @@ -208,6 +210,9 @@ async function resolveRepoRoot(cwd: string): Promise<string> {
}

async function countUntrackedAdditions(repoRoot: string, untrackedPaths: string[]): Promise<number> {
if (untrackedPaths.length === 0) {
return 0;
}
const counts = await Promise.all(
untrackedPaths.map(async (relativePath) => {
try {
Expand All @@ -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<boolean> {
const result = await runGit(repoRoot, ["show-ref", "--verify", "--quiet", ref]);
return result.ok;
Expand All @@ -231,9 +249,10 @@ export async function getGitSyncSummary(
options?: { probe?: GitWorkspaceProbe },
): Promise<RuntimeGitSyncSummary> {
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,
Expand Down
9 changes: 6 additions & 3 deletions web-ui/src/components/card-detail-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))";

Expand Down Expand Up @@ -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,
);
Expand Down
Loading