From 638c2086096ac6677b4716486d7250200dd2db07 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 11:40:43 +0700 Subject: [PATCH] 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. --- .../background-git-fetch-manager.test.ts | 248 ++++++++++++++++++ .../server/background-git-fetch-manager.ts | 150 +++++++++++ packages/server/src/server/session.ts | 23 ++ .../session.workspace-git-watch.test.ts | 190 ++++++++++++++ .../server/src/server/websocket-server.ts | 7 + 5 files changed, 618 insertions(+) create mode 100644 packages/server/src/server/background-git-fetch-manager.test.ts create mode 100644 packages/server/src/server/background-git-fetch-manager.ts diff --git a/packages/server/src/server/background-git-fetch-manager.test.ts b/packages/server/src/server/background-git-fetch-manager.test.ts new file mode 100644 index 000000000..aa2b1f8f9 --- /dev/null +++ b/packages/server/src/server/background-git-fetch-manager.test.ts @@ -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("node:child_process"); + return { + ...actual, + execFile: execFileMock, + }; +}); + +import { BackgroundGitFetchManager } from "./background-git-fetch-manager.js"; + +async function flushPromises(): Promise { + 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(); + }); +}); diff --git a/packages/server/src/server/background-git-fetch-manager.ts b/packages/server/src/server/background-git-fetch-manager.ts new file mode 100644 index 000000000..190220254 --- /dev/null +++ b/packages/server/src/server/background-git-fetch-manager.ts @@ -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(); + + 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 { + 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 { + 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(); + } + } + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0ea3939b5..8857aebf6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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; 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; tts: Resolvable; @@ -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 void>(); private readonly workspaceGitWatchTargets = new Map(); + private readonly workspaceGitFetchSubscriptions = new Map 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); } diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index b9b65602d..38c3a0290 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -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; + subscriptions: Array<{ + params: { repoGitRoot: string; cwd: string }; + listener: () => void; + unsubscribe: ReturnType; + }>; + }; + logger: { + child: () => unknown; + trace: ReturnType; + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; } { const emitted: Array<{ type: string; payload: unknown }> = []; const projects = new Map(); const workspaces = new Map(); + const backgroundGitFetchSubscriptions: Array<{ + params: { repoGitRoot: string; cwd: string }; + listener: () => void; + unsubscribe: ReturnType; + }> = []; 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); + }); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 00c636e34..7c719b454 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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,