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,