feat(server): add background git fetch manager

Periodically fetch from remotes in the background so workspace git
watch targets pick up upstream changes without user intervention.
The manager deduplicates subscriptions per repo and triggers a
workspace refresh callback after each successful fetch.
This commit is contained in:
Mohamed Boudra
2026-04-07 11:40:43 +07:00
parent 7b1144dafe
commit 638c208609
5 changed files with 618 additions and 0 deletions
@@ -0,0 +1,248 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const execFileMock = vi.hoisted(() =>
vi.fn(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(null, "", "");
},
),
);
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: execFileMock,
};
});
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
async function flushPromises(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
warn: vi.fn(),
};
return logger;
}
describe("BackgroundGitFetchManager", () => {
beforeEach(() => {
vi.useFakeTimers();
execFileMock.mockReset();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(null, "", "");
},
);
});
afterEach(() => {
vi.useRealTimers();
});
test("creates a fetch timer for a repo with an origin remote", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
expect(target).toBeDefined();
expect(target.intervalId).toBeTruthy();
expect(execFileMock).toHaveBeenNthCalledWith(
1,
"git",
["remote", "get-url", "origin"],
expect.objectContaining({
cwd: "/tmp/repo",
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
}),
expect.any(Function),
);
expect(execFileMock).toHaveBeenNthCalledWith(
2,
"git",
["fetch", "origin", "--prune"],
expect.objectContaining({
cwd: "/tmp/repo",
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: "0" }),
}),
expect.any(Function),
);
subscription.unsubscribe();
manager.dispose();
});
test("dedupes multiple subscribers for the same repo root behind one timer", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listenerOne = vi.fn();
const listenerTwo = vi.fn();
const subscriptionOne = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
listenerOne,
);
const subscriptionTwo = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo-worktree" },
listenerTwo,
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
expect(managerAny.targets.size).toBe(1);
expect(target.listeners).toEqual(new Set([listenerOne, listenerTwo]));
expect(execFileMock.mock.calls.filter((call) => call[1][0] === "remote")).toHaveLength(1);
subscriptionOne.unsubscribe();
subscriptionTwo.unsubscribe();
manager.dispose();
});
test("cleans up the timer when the last subscriber unsubscribes", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
await flushPromises();
const managerAny = manager as any;
const target = managerAny.targets.get("/tmp/repo/.git");
const intervalId = target.intervalId;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
subscription.unsubscribe();
expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId);
expect(managerAny.targets.size).toBe(0);
clearIntervalSpy.mockRestore();
manager.dispose();
});
test("logs fetch errors without crashing", async () => {
const logger = createLogger();
execFileMock.mockImplementation(
(
_file: string,
args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
if (args[0] === "remote") {
callback(null, "", "");
return;
}
callback(new Error("fetch failed"));
},
);
const manager = new BackgroundGitFetchManager({ logger: logger as any });
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, vi.fn());
await flushPromises();
expect(logger.debug).toHaveBeenCalledWith(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
"Running background git fetch",
);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.any(Error),
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo",
},
"Background git fetch failed",
);
manager.dispose();
});
test("calls listeners when a fetch completes", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listener = vi.fn();
await manager.subscribe({ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" }, listener);
await flushPromises();
expect(listener).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(180_000);
await flushPromises();
expect(listener).toHaveBeenCalledTimes(2);
manager.dispose();
});
test("does not create a timer when the repo has no origin remote", async () => {
const logger = createLogger();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback: (error: Error | null, stdout?: string, stderr?: string) => void,
) => {
callback(new Error("missing origin"));
},
);
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const subscription = await manager.subscribe(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
vi.fn(),
);
expect((manager as any).targets.size).toBe(0);
subscription.unsubscribe();
manager.dispose();
});
test("dispose clears timers and listeners", async () => {
const logger = createLogger();
const manager = new BackgroundGitFetchManager({ logger: logger as any });
const listener = vi.fn();
await manager.subscribe({ repoGitRoot: "/tmp/repo-one/.git", cwd: "/tmp/repo-one" }, listener);
await manager.subscribe({ repoGitRoot: "/tmp/repo-two/.git", cwd: "/tmp/repo-two" }, vi.fn());
await flushPromises();
const managerAny = manager as any;
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
manager.dispose();
expect(clearIntervalSpy).toHaveBeenCalledTimes(2);
expect(managerAny.targets.size).toBe(0);
clearIntervalSpy.mockRestore();
});
});
@@ -0,0 +1,150 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type pino from "pino";
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
const execFileAsync = promisify(execFile);
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
type BackgroundGitFetchTarget = {
repoGitRoot: string;
cwd: string;
listeners: Set<() => void>;
intervalId: NodeJS.Timeout | null;
fetchInFlight: boolean;
};
export class BackgroundGitFetchManager {
private readonly logger: pino.Logger;
private readonly targets = new Map<string, BackgroundGitFetchTarget>();
constructor(options: { logger: pino.Logger }) {
this.logger = options.logger.child({ module: "background-git-fetch-manager" });
}
async subscribe(
params: { repoGitRoot: string; cwd: string },
listener: () => void,
): Promise<{ unsubscribe: () => void }> {
const existingTarget = this.targets.get(params.repoGitRoot);
if (existingTarget) {
existingTarget.listeners.add(listener);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
const hasOrigin = await this.hasOriginRemote(params.cwd);
if (!hasOrigin) {
return { unsubscribe: () => {} };
}
const targetAfterProbe = this.targets.get(params.repoGitRoot);
if (targetAfterProbe) {
targetAfterProbe.listeners.add(listener);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
const target: BackgroundGitFetchTarget = {
repoGitRoot: params.repoGitRoot,
cwd: params.cwd,
listeners: new Set([listener]),
intervalId: setInterval(() => {
void this.runFetch(target);
}, BACKGROUND_GIT_FETCH_INTERVAL_MS),
fetchInFlight: false,
};
this.targets.set(params.repoGitRoot, target);
void this.runFetch(target);
return {
unsubscribe: () => {
this.removeListener(params.repoGitRoot, listener);
},
};
}
dispose(): void {
for (const target of this.targets.values()) {
this.closeTarget(target);
}
this.targets.clear();
}
private closeTarget(target: BackgroundGitFetchTarget): void {
if (target.intervalId) {
clearInterval(target.intervalId);
target.intervalId = null;
}
target.listeners.clear();
}
private removeListener(targetKey: string, listener: () => void): void {
const target = this.targets.get(targetKey);
if (!target) {
return;
}
target.listeners.delete(listener);
if (target.listeners.size > 0) {
return;
}
this.closeTarget(target);
this.targets.delete(targetKey);
}
private async hasOriginRemote(cwd: string): Promise<boolean> {
try {
await execFileAsync("git", ["remote", "get-url", "origin"], {
cwd,
env: {
...READ_ONLY_GIT_ENV,
GIT_TERMINAL_PROMPT: "0",
},
});
return true;
} catch {
return false;
}
}
private async runFetch(target: BackgroundGitFetchTarget): Promise<void> {
if (target.fetchInFlight) {
return;
}
target.fetchInFlight = true;
this.logger.debug(
{ repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Running background git fetch",
);
try {
await execFileAsync("git", ["fetch", "origin", "--prune"], {
cwd: target.cwd,
env: {
...process.env,
GIT_TERMINAL_PROMPT: "0",
},
});
} catch (error) {
this.logger.warn(
{ err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd },
"Background git fetch failed",
);
} finally {
target.fetchInFlight = false;
for (const listener of target.listeners) {
listener();
}
}
}
}
+23
View File
@@ -65,6 +65,7 @@ import {
import { experimental_createMCPClient } from "ai";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js";
@@ -398,6 +399,7 @@ export type SessionOptions = {
scheduleService: ScheduleService;
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
backgroundGitFetchManager: BackgroundGitFetchManager;
createAgentMcpTransport: AgentMcpTransportFactory;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
@@ -583,6 +585,7 @@ export class Session {
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
@@ -611,6 +614,7 @@ export class Session {
private peakInflightRequests = 0;
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
private readonly registerVoiceSpeakHandler?: (
agentId: string,
@@ -649,6 +653,7 @@ export class Session {
scheduleService,
loopService,
checkoutDiffManager,
backgroundGitFetchManager,
createAgentMcpTransport,
stt,
tts,
@@ -677,6 +682,7 @@ export class Session {
this.scheduleService = scheduleService;
this.loopService = loopService;
this.checkoutDiffManager = checkoutDiffManager;
this.backgroundGitFetchManager = backgroundGitFetchManager;
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
this.providerSnapshotManager = providerSnapshotManager ?? null;
@@ -4199,6 +4205,9 @@ export class Session {
if (!target) {
return;
}
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(workspaceId);
unsubscribeFetch?.();
this.workspaceGitFetchSubscriptions.delete(workspaceId);
this.closeWorkspaceGitWatchTarget(target);
this.workspaceGitWatchTargets.delete(workspaceId);
}
@@ -4329,6 +4338,16 @@ export class Session {
}
this.workspaceGitWatchTargets.set(workspaceId, target);
const subscription = await this.backgroundGitFetchManager.subscribe(
{ repoGitRoot: refsRoot, cwd: workspaceId },
() => {
const activeTarget = this.workspaceGitWatchTargets.get(workspaceId);
if (activeTarget) {
this.scheduleWorkspaceGitWatchRefresh(activeTarget);
}
},
);
this.workspaceGitFetchSubscriptions.set(workspaceId, subscription.unsubscribe);
}
private async syncWorkspaceGitWatchTarget(
@@ -7284,6 +7303,10 @@ export class Session {
}
this.checkoutDiffSubscriptions.clear();
for (const unsubscribe of this.workspaceGitFetchSubscriptions.values()) {
unsubscribe();
}
this.workspaceGitFetchSubscriptions.clear();
for (const target of this.workspaceGitWatchTargets.values()) {
this.closeWorkspaceGitWatchTarget(target);
}
@@ -43,7 +43,14 @@ vi.mock("node:fs", async () => {
});
vi.mock("./checkout-git-utils.js", () => ({
READ_ONLY_GIT_ENV: {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
},
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
toCheckoutError: vi.fn((error: unknown) => ({
message: error instanceof Error ? error.message : String(error),
})),
}));
import { Session } from "./session.js";
@@ -51,10 +58,31 @@ import { Session } from "./session.js";
function createSessionForWorkspaceGitWatchTests(): {
session: Session;
emitted: Array<{ type: string; payload: unknown }>;
backgroundGitFetchManager: {
subscribe: ReturnType<typeof vi.fn>;
subscriptions: Array<{
params: { repoGitRoot: string; cwd: string };
listener: () => void;
unsubscribe: ReturnType<typeof vi.fn>;
}>;
};
logger: {
child: () => unknown;
trace: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
} {
const emitted: Array<{ type: string; payload: unknown }> = [];
const projects = new Map<string, any>();
const workspaces = new Map<string, any>();
const backgroundGitFetchSubscriptions: Array<{
params: { repoGitRoot: string; cwd: string };
listener: () => void;
unsubscribe: ReturnType<typeof vi.fn>;
}> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
@@ -63,6 +91,19 @@ function createSessionForWorkspaceGitWatchTests(): {
warn: vi.fn(),
error: vi.fn(),
};
const backgroundGitFetchManager = {
subscribe: vi.fn(
async (params: { repoGitRoot: string; cwd: string }, listener: () => void) => {
const unsubscribe = vi.fn();
backgroundGitFetchSubscriptions.push({
params,
listener,
unsubscribe,
});
return { unsubscribe };
},
),
};
const session = new Session({
clientId: "test-client",
@@ -140,6 +181,7 @@ function createSessionForWorkspaceGitWatchTests(): {
}),
dispose: () => {},
} as any,
backgroundGitFetchManager: backgroundGitFetchManager as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
@@ -153,6 +195,11 @@ function createSessionForWorkspaceGitWatchTests(): {
return {
session,
emitted,
backgroundGitFetchManager: {
subscribe: backgroundGitFetchManager.subscribe,
subscriptions: backgroundGitFetchSubscriptions,
},
logger,
};
}
@@ -316,4 +363,147 @@ describe("workspace git watch targets", () => {
await session.cleanup();
}
});
test("subscribes to the background fetch manager when a git watch target is created", async () => {
const { session, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: path.basename(cwd),
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledWith(
{ repoGitRoot: "/tmp/repo/.git", cwd: "/tmp/repo" },
expect.any(Function),
);
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
await session.cleanup();
});
test("stores separate background fetch subscriptions per workspace and unsubscribes removed targets", async () => {
const { session, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: path.basename(cwd),
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) =>
cwd === "/tmp/repo" ? "/tmp/repo/.git" : "/tmp/repo/.git/worktrees/feature",
);
sessionAny.resolveWorkspaceGitRefsRoot = vi.fn(async () => "/tmp/repo/.git");
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
await sessionAny.ensureWorkspaceRegistered("/tmp/repo-feature");
expect(backgroundGitFetchManager.subscribe).toHaveBeenCalledTimes(2);
expect(backgroundGitFetchManager.subscriptions[0]?.params).toEqual({
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo",
});
expect(backgroundGitFetchManager.subscriptions[1]?.params).toEqual({
repoGitRoot: "/tmp/repo/.git",
cwd: "/tmp/repo-feature",
});
sessionAny.removeWorkspaceGitWatchTarget("/tmp/repo");
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
expect(backgroundGitFetchManager.subscriptions[1]?.unsubscribe).not.toHaveBeenCalled();
expect(sessionAny.workspaceGitFetchSubscriptions.size).toBe(1);
await session.cleanup();
});
test("refreshes the workspace when the background fetch manager callback fires and unsubscribes on cleanup", async () => {
const { session, emitted, backgroundGitFetchManager } = createSessionForWorkspaceGitWatchTests();
const sessionAny = session as any;
sessionAny.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: path.basename(cwd),
checkout: {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
worktreeRoot: cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
sessionAny.workspaceUpdatesSubscription = {
subscriptionId: "sub-1",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
let descriptor = {
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: { additions: 1, deletions: 0 },
};
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor];
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
descriptor = {
...descriptor,
name: "updated-after-fetch",
};
backgroundGitFetchManager.subscriptions[0]?.listener();
await vi.advanceTimersByTimeAsync(500);
const workspaceUpdates = emitted.filter(
(message) => message.type === "workspace_update",
) as any[];
expect(workspaceUpdates).toHaveLength(1);
expect(workspaceUpdates[0]?.payload).toMatchObject({
kind: "upsert",
workspace: {
id: "/tmp/repo",
name: "updated-after-fetch",
},
});
await session.cleanup();
expect(backgroundGitFetchManager.subscriptions[0]?.unsubscribe).toHaveBeenCalledTimes(1);
});
});
@@ -13,6 +13,7 @@ import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js";
import {
type ServerInfoStatusPayload,
type WSHelloMessage,
@@ -237,6 +238,7 @@ export class VoiceAssistantWebSocketServer {
private readonly loopService: LoopService;
private readonly scheduleService: ScheduleService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly backgroundGitFetchManager: BackgroundGitFetchManager;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly pushTokenStore: PushTokenStore;
@@ -338,6 +340,9 @@ export class VoiceAssistantWebSocketServer {
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
}
this.checkoutDiffManager = checkoutDiffManager;
this.backgroundGitFetchManager = new BackgroundGitFetchManager({
logger: this.logger,
});
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.createAgentMcpTransport = createAgentMcpTransport;
@@ -507,6 +512,7 @@ export class VoiceAssistantWebSocketServer {
await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
this.backgroundGitFetchManager.dispose();
this.checkoutDiffManager.dispose();
this.pendingConnections.clear();
this.sessions.clear();
@@ -653,6 +659,7 @@ export class VoiceAssistantWebSocketServer {
loopService: this.loopService,
scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager,
backgroundGitFetchManager: this.backgroundGitFetchManager,
createAgentMcpTransport: this.createAgentMcpTransport,
stt: () => this.speech?.resolveStt() ?? null,
tts: () => this.speech?.resolveTts() ?? null,