From 59661f2bb885dd4a2fbda69c4884abf6ce6b91fd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 1 Sep 2026 19:25:16 +0200 Subject: [PATCH] Eliminate recurring cross-platform CI flakes (#4171) * fix(ci): eliminate recent cross-platform flakes Synchronize tests on owned state instead of elapsed time or transient identities. Preserve file rename collision safety when platform file IDs are unavailable. * fix(ci): close follow-up synchronization gaps Keep fetches in flight through buffered-event classification, scope mock agents to navigation tests, and wait for live workspace hydration before opening agent routes. * fix(ci): synchronize browser lifecycle boundaries --- .../browser/agent-message-submission.spec.ts | 9 +- .../browser/viewed-agent-timelines.spec.ts | 10 +- .../workspace-navigation-regression.spec.ts | 13 +-- .../support/helpers/agent-bottom-anchor.ts | 96 +++++++++++-------- .../app/e2e/support/helpers/archive-tab.ts | 42 ++++++-- .../support/helpers/daemon-websocket-gate.ts | 11 +++ .../app/e2e/support/helpers/workspace-ui.ts | 8 -- .../app/src/git/diff-document/surface.web.tsx | 4 +- packages/desktop/e2e/browser-tabs.e2e.mjs | 16 +++- .../src/server/file-explorer/service.test.ts | 5 + .../src/server/file-explorer/service.ts | 30 ++++-- .../workspace-git-service.observation.test.ts | 4 +- .../src/server/workspace-git-service.ts | 42 ++++---- 13 files changed, 200 insertions(+), 90 deletions(-) diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index d2c836b615..295e07ba88 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -39,7 +39,10 @@ import { expectResumeOverflowFallsBackToOneTail, rememberTimelineRequestCounts, } from "../support/helpers/timeline-resume"; -import { workspaceDeckEntryLocator } from "../support/helpers/workspace-ui"; +import { + waitForWorkspaceInSidebar, + workspaceDeckEntryLocator, +} from "../support/helpers/workspace-ui"; import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork"; import { scrollTimelineToNewestLoadedEdge, @@ -468,6 +471,10 @@ async function expectHiddenStreamingSubmissionOrderAfterWorkspaceEviction( await target.client.waitForFinish(target.agentId, 30_000); const requestsBeforeReturn = rememberTimelineRequestCounts(gate); + await waitForWorkspaceInSidebar(page, { + serverId: getServerId(), + workspaceId: target.workspaceId, + }); await openAgentRoute(page, target); await expectComposerVisible(page); await subscriptions.waitForSubscribedAgents([target.agentId]); diff --git a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts index 5dc98dc82c..4a0d2755d6 100644 --- a/packages/app/e2e/browser/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/browser/viewed-agent-timelines.spec.ts @@ -13,7 +13,6 @@ import { expectTurnCopyButton, } from "../support/helpers/agent-stream"; import { - expectReconnectingToastDebounced, expectReconnectingToastGone, expectReconnectingToastVisible, } from "../support/helpers/workspace-ui"; @@ -181,13 +180,17 @@ test.describe("Viewed agent timelines", () => { "true", ); await gate.drop(); - await expectReconnectingToastDebounced(page); - await expectReconnectingToastVisible(page); + await gate.waitForBlockedConnection(); await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects."); await expect( page.getByText("Committed while the chat reconnects.", { exact: true }), ).toHaveCount(0); + // Hold the first authoritative catch-up response so the assertion observes + // the reconnect boundary instead of racing a socket that has not reopened yet. + gate.holdNextServerMessage("fetch_agent_timeline_response"); gate.restore(); + await gate.waitForHeldServerMessage("fetch_agent_timeline_response"); + gate.releaseHeldServerMessage("fetch_agent_timeline_response"); await expectReconnectingToastGone(page); const recoveredMessage = page.getByText("Committed while the chat reconnects.", { exact: true, @@ -209,6 +212,7 @@ test.describe("Viewed agent timelines", () => { await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); await selectAgent(page, "First viewed chat"); await gate.drop(); + await gate.waitForBlockedConnection(); await expectReconnectingToastVisible(page); await selectAgent(page, "Second viewed chat"); diff --git a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts index d60d2081a5..08a4604aec 100644 --- a/packages/app/e2e/browser/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/browser/workspace-navigation-regression.spec.ts @@ -6,7 +6,7 @@ import { import { expect, test, type Page } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { - createIdleAgent, + createMockIdleAgent, expectWorkspaceTabHidden, expectWorkspaceTabVisible, openWorkspaceWithAgents, @@ -159,7 +159,7 @@ test.describe("Workspace navigation regression", () => { const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" }); try { - const agent = await createIdleAgent(workspace.client, { + const agent = await createMockIdleAgent(workspace.client, { cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title: `workspace-reconnect-${Date.now()}`, @@ -180,6 +180,7 @@ test.describe("Workspace navigation regression", () => { await expectWorkspaceTabVisible(page, agent.id); await daemonGate.drop(); + await daemonGate.waitForBlockedConnection(); await expectReconnectingToastVisible(page); await expectWorkspaceHeader(page, { title: workspace.workspaceName, @@ -217,12 +218,12 @@ test.describe("Workspace navigation regression", () => { try { await Promise.all([ - createIdleAgent(primaryWorkspace.client, { + createMockIdleAgent(primaryWorkspace.client, { cwd: primaryWorkspace.repoPath, workspaceId: primaryWorkspace.workspaceId, title: "Active host agent", }), - createIdleAgent(secondaryWorkspace.client, { + createMockIdleAgent(secondaryWorkspace.client, { cwd: secondaryWorkspace.repoPath, workspaceId: secondaryWorkspace.workspaceId, title: "Inactive host agent", @@ -319,12 +320,12 @@ test.describe("Workspace navigation regression", () => { const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" }); try { - const firstAgent = await createIdleAgent(firstWorkspace.client, { + const firstAgent = await createMockIdleAgent(firstWorkspace.client, { cwd: firstWorkspace.repoPath, workspaceId: firstWorkspace.workspaceId, title: `workspace-nav-a-${Date.now()}`, }); - const secondAgent = await createIdleAgent(secondWorkspace.client, { + const secondAgent = await createMockIdleAgent(secondWorkspace.client, { cwd: secondWorkspace.repoPath, workspaceId: secondWorkspace.workspaceId, title: `workspace-nav-b-${Date.now()}`, diff --git a/packages/app/e2e/support/helpers/agent-bottom-anchor.ts b/packages/app/e2e/support/helpers/agent-bottom-anchor.ts index d8c036d638..d881878595 100644 --- a/packages/app/e2e/support/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/support/helpers/agent-bottom-anchor.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; const NEAR_BOTTOM_THRESHOLD_PX = 72; const DEFAULT_SCROLL_TOLERANCE_PX = 24; @@ -14,6 +14,31 @@ function getVisibleChatScroll(page: Page) { return page.locator('[data-testid="agent-chat-scroll"]:visible').first(); } +interface PositionedToolCall { + locator: Locator; + bounds: { x: number; y: number; width: number; height: number }; +} + +async function readRenderedToolCalls(toolCalls: Locator): Promise { + const entries = await Promise.all( + Array.from({ length: await toolCalls.count() }, async (_, index) => ({ + locator: toolCalls.nth(index), + bounds: await toolCalls.nth(index).boundingBox(), + })), + ); + return entries.filter( + (entry): entry is PositionedToolCall => entry.bounds !== null && entry.bounds.width > 0, + ); +} + +function closestToolCallToY(toolCalls: PositionedToolCall[], targetY: number) { + return toolCalls.toSorted( + (left, right) => + Math.abs(left.bounds.y + left.bounds.height / 2 - targetY) - + Math.abs(right.bounds.y + right.bounds.height / 2 - targetY), + )[0]; +} + export async function readScrollMetrics(page: Page): Promise { return getVisibleChatScroll(page).evaluate((root: Element) => { const candidates = [root, ...Array.from(root.querySelectorAll("*"))] @@ -180,58 +205,53 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi const visibleButtonBounds = buttonBounds!; const toolCalls = page.locator('[data-testid="tool-call-badge"] [role="button"]'); - const toolCallBounds = await Promise.all( - Array.from({ length: await toolCalls.count() }, async (_, index) => ({ - index, - bounds: await toolCalls.nth(index).boundingBox(), - })), - ); const buttonCenterY = visibleButtonBounds.y + visibleButtonBounds.height / 2; - const candidate = toolCallBounds - .filter( - (entry): entry is { index: number; bounds: NonNullable } => - entry.bounds !== null && entry.bounds.width > 0, - ) - .sort( - (left, right) => - Math.abs(left.bounds.y + left.bounds.height / 2 - buttonCenterY) - - Math.abs(right.bounds.y + right.bounds.height / 2 - buttonCenterY), - )[0]; + const initialToolCalls = await readRenderedToolCalls(toolCalls); + const initialCandidate = closestToolCallToY(initialToolCalls, buttonCenterY); expect( - candidate, + initialCandidate, `Expected at least one rendered tool-call badge: ${JSON.stringify({ buttonBounds, scrollMetrics: await readScrollMetrics(page), - toolCallBounds, + toolCallBounds: initialToolCalls.map(({ bounds }) => bounds), })}`, ).toBeDefined(); - const visibleToolCall = candidate!; - const initialToolCallCenterY = visibleToolCall.bounds.y + visibleToolCall.bounds.height / 2; - await getVisibleChatScroll(page).evaluate((scroll, deltaY) => { - (scroll as HTMLElement).scrollTop += deltaY; - }, initialToolCallCenterY - buttonCenterY); - - const alignedToolCall = toolCalls.nth(visibleToolCall.index); await expect .poll(async () => { - const [currentButtonBounds, currentToolCallBounds] = await Promise.all([ - scrollToBottomButton.boundingBox(), - alignedToolCall.boundingBox(), - ]); - if (!currentButtonBounds || !currentToolCallBounds) { - return false; - } - const toolCallCenterY = currentToolCallBounds.y + currentToolCallBounds.height / 2; - return ( + const currentButtonBounds = await scrollToBottomButton.boundingBox(); + if (!currentButtonBounds) return false; + const currentButtonCenterY = currentButtonBounds.y + currentButtonBounds.height / 2; + const closest = closestToolCallToY( + await readRenderedToolCalls(toolCalls), + currentButtonCenterY, + ); + if (!closest) return false; + const toolCallCenterY = closest.bounds.y + closest.bounds.height / 2; + if ( toolCallCenterY >= currentButtonBounds.y && toolCallCenterY <= currentButtonBounds.y + currentButtonBounds.height - ); + ) { + return true; + } + const deltaY = Math.max(-200, Math.min(200, toolCallCenterY - currentButtonCenterY)); + await getVisibleChatScroll(page).evaluate((scroll, delta) => { + (scroll as HTMLElement).scrollTop += delta; + }, deltaY); + return false; }) .toBe(true); + const currentButtonBounds = await scrollToBottomButton.boundingBox(); + expect(currentButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull(); + const currentButtonCenterY = currentButtonBounds!.y + currentButtonBounds!.height / 2; + const alignedToolCall = closestToolCallToY( + await readRenderedToolCalls(toolCalls), + currentButtonCenterY, + )?.locator; + expect(alignedToolCall, "Expected an aligned tool-call badge").toBeDefined(); const [alignedButtonBounds, visibleToolCallBounds] = await Promise.all([ scrollToBottomButton.boundingBox(), - alignedToolCall.boundingBox(), + alignedToolCall!.boundingBox(), ]); expect(alignedButtonBounds, "Expected scroll-to-bottom button to remain visible").not.toBeNull(); expect( @@ -245,7 +265,7 @@ export async function clickToolCallBesideScrollToBottomButton(page: Page): Promi x: finalToolCallBounds.x + 24, y: finalToolCallBounds.y + finalToolCallBounds.height / 2, }; - const toolCallReceivesPointer = await alignedToolCall.evaluate((toolCall, point) => { + const toolCallReceivesPointer = await alignedToolCall!.evaluate((toolCall, point) => { const hit = document.elementFromPoint(point.x, point.y); return hit !== null && toolCall.contains(hit); }, clickPoint); diff --git a/packages/app/e2e/support/helpers/archive-tab.ts b/packages/app/e2e/support/helpers/archive-tab.ts index c33414412a..cf9c43fcf3 100644 --- a/packages/app/e2e/support/helpers/archive-tab.ts +++ b/packages/app/e2e/support/helpers/archive-tab.ts @@ -52,18 +52,18 @@ export interface IdleAgentSeedClient { ): Promise<{ status: string }>; } -export async function createIdleAgent( +async function seedIdleAgent( client: IdleAgentSeedClient, input: { cwd: string; workspaceId: string; title: string }, + provider: { + provider: string; + model: string; + modeId: string; + featureValues?: Record; + }, ): Promise { const created = await client.createAgent({ - provider: "opencode", - model: "opencode/gpt-5-nano", - // OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with - // auto_accept for unattended full access — mode validation now rejects modes - // the provider doesn't define. - modeId: "build", - featureValues: { auto_accept: true }, + ...provider, cwd: input.cwd, workspaceId: input.workspaceId, title: input.title, @@ -84,6 +84,32 @@ export async function createIdleAgent( }; } +export async function createIdleAgent( + client: IdleAgentSeedClient, + input: { cwd: string; workspaceId: string; title: string }, +): Promise { + return seedIdleAgent(client, input, { + provider: "opencode", + model: "opencode/gpt-5-nano", + // OpenCode has no "bypassPermissions" mode (that's Claude's). Use build with + // auto_accept for unattended full access — mode validation now rejects modes + // the provider doesn't define. + modeId: "build", + featureValues: { auto_accept: true }, + }); +} + +export async function createMockIdleAgent( + client: IdleAgentSeedClient, + input: { cwd: string; workspaceId: string; title: string }, +): Promise { + return seedIdleAgent(client, input, { + provider: "mock", + model: "e2e-fast-stream", + modeId: "load-test", + }); +} + export async function archiveAgentFromDaemon( client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> }, agentId: string, diff --git a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts index 83edf569bf..2f3d6b22c6 100644 --- a/packages/app/e2e/support/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/support/helpers/daemon-websocket-gate.ts @@ -315,6 +315,9 @@ export async function installDaemonWebSocketGate(page: Page) { const suppressedAgentStreamEventTypes = new Set(); const suppressedAgentStreamItemTypes = new Set(); const activeSockets = new Set(); + let blockedConnectionCount = 0; + let blockedConnectionCountAtDrop = 0; + const blockedConnectionWaiters = new Set<() => void>(); let latestServer: WebSocketRoute | null = null; const directoryStarts: DirectoryRequestStartCounts = { subscribed: { agents: 0, workspaces: 0 }, @@ -443,6 +446,9 @@ export async function installDaemonWebSocketGate(page: Page) { await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { + blockedConnectionCount += 1; + for (const resolve of blockedConnectionWaiters) resolve(); + blockedConnectionWaiters.clear(); void ws.close({ code: 1008, reason: "Blocked by reconnect test." }); return; } @@ -583,6 +589,7 @@ export async function installDaemonWebSocketGate(page: Page) { forward?.(); }, async drop(): Promise { + blockedConnectionCountAtDrop = blockedConnectionCount; acceptingConnections = false; const sockets = Array.from(activeSockets); activeSockets.clear(); @@ -592,6 +599,10 @@ export async function installDaemonWebSocketGate(page: Page) { ), ); }, + async waitForBlockedConnection(): Promise { + if (blockedConnectionCount > blockedConnectionCountAtDrop) return; + await new Promise((resolve) => blockedConnectionWaiters.add(resolve)); + }, restore(): void { acceptingConnections = true; }, diff --git a/packages/app/e2e/support/helpers/workspace-ui.ts b/packages/app/e2e/support/helpers/workspace-ui.ts index 3bf5e5c466..2daa58bf69 100644 --- a/packages/app/e2e/support/helpers/workspace-ui.ts +++ b/packages/app/e2e/support/helpers/workspace-ui.ts @@ -112,14 +112,6 @@ export async function expectReconnectingToastGone( }); } -export async function expectReconnectingToastDebounced( - page: Page, - options?: { durationMs?: number }, -): Promise { - await page.waitForTimeout(options?.durationMs ?? 750); - await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, { timeout: 100 }); -} - export async function expectHostConnectingOrOffline( page: Page, options?: { timeout?: number }, diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index 055a335c11..fb58eef938 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -171,6 +171,7 @@ export function DiffSurface(props: DiffSurfaceProps) { const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight; if (canvas.width !== pixelWidth) canvas.width = pixelWidth; if (canvas.height !== pixelHeight) canvas.height = pixelHeight; + canvas.style.width = `${currentModel.viewportWidth}px`; canvas.style.top = `${canvasTop}px`; canvas.style.height = `${canvasHeight}px`; const context = canvas.getContext("2d"); @@ -535,11 +536,10 @@ export function DiffSurface(props: DiffSurfaceProps) { const canvasStyle = useMemo( () => ({ ...CANVAS_STYLE, - width: model.viewportWidth, fontFamily: (loadedTypography ?? desiredTypography).family, fontSize: (loadedTypography ?? desiredTypography).size, }), - [desiredTypography, loadedTypography, model.viewportWidth], + [desiredTypography, loadedTypography], ); return ( diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 30eeb5efcc..3aa05a4b49 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -232,6 +232,20 @@ async function callBrowserTool(client, name, args = {}) { return mcpPayload(await client.callTool({ name, args }), name); } +async function callBrowserToolUntilReady(client, name, args = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await client.callTool({ name, args }); + const payload = result.structuredContent; + if (payload?.ok === true) return payload.result; + if (payload?.ok !== false || payload.error?.retryable !== true) { + return mcpPayload(result, name); + } + await delay(100); + } + throw new Error(`${name} remained unavailable for ${timeoutMs}ms`); +} + async function waitForGuestSelector(client, browserId) { const deadline = Date.now() + 5_000; while (Date.now() < deadline) { @@ -568,7 +582,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click(); await page.waitForTimeout(500); try { - await callBrowserTool(client, "browser_screenshot", { browserId }); + await callBrowserToolUntilReady(client, "browser_screenshot", { browserId }); } catch (error) { failures.push(`inactive browser remains captureable: ${String(error)}`); } diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index a191a5fabf..ea22ac24b9 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -1,6 +1,7 @@ import { appendFile, chmod, + link, mkdir, mkdtemp, readFile, @@ -547,10 +548,14 @@ describe("file explorer service", () => { try { await writeFile(path.join(root, "source.txt"), "source", "utf8"); await writeFile(path.join(root, "existing.txt"), "existing", "utf8"); + await link(path.join(root, "source.txt"), path.join(root, "source-link.txt")); await expect( renameExplorerEntry({ root, relativePath: "source.txt", name: "existing.txt" }), ).resolves.toEqual({ status: "error", error: '"existing.txt" already exists' }); + await expect( + renameExplorerEntry({ root, relativePath: "source.txt", name: "source-link.txt" }), + ).resolves.toEqual({ status: "error", error: '"source-link.txt" already exists' }); await expect( renameExplorerEntry({ root, relativePath: "source.txt", name: "../outside.txt" }), ).resolves.toEqual({ status: "error", error: "Name cannot contain path separators" }); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 72e45286da..5aa4efe974 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -1,4 +1,4 @@ -import { constants, promises as fs, type BigIntStats } from "fs"; +import { constants, promises as fs, type BigIntStats, type Stats } from "fs"; import type { FileHandle } from "fs/promises"; import path from "path"; import { randomUUID } from "crypto"; @@ -707,11 +707,10 @@ export async function renameExplorerEntry({ } throw error; }); - if ( - targetStats && - (targetStats.dev !== sourceStats.dev || targetStats.ino !== sourceStats.ino) - ) { - return { status: "error", error: `"${trimmedName}" already exists` }; + if (targetStats) { + if (!(await isCaseOnlyRename(source, targetPath, sourceStats, targetStats))) { + return { status: "error", error: `"${trimmedName}" already exists` }; + } } const sourcePath = normalizeRelativePath({ root, targetPath: source.requestedPath }); @@ -782,6 +781,25 @@ function isEntryExistsError(error: unknown): boolean { ); } +async function isCaseOnlyRename( + source: ScopedPath, + targetPath: string, + sourceStats: Stats, + targetStats: Stats, +): Promise { + const hasStableFileIds = + sourceStats.dev !== 0 || + sourceStats.ino !== 0 || + targetStats.dev !== 0 || + targetStats.ino !== 0; + const isSameEntry = hasStableFileIds + ? targetStats.dev === sourceStats.dev && targetStats.ino === sourceStats.ino + : !sourceStats.isSymbolicLink() && + !targetStats.isSymbolicLink() && + (await fs.realpath(targetPath)) === source.resolvedPath; + return isSameEntry && source.requestedPath.toLowerCase() === targetPath.toLowerCase(); +} + async function resolveScopedPath({ root, relativePath = ".", diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index ad80acb71f..c12c076e30 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -899,7 +899,9 @@ describe("WorkspaceGitService checkout observation", () => { { path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" }, ]); releaseFetch.resolve(); - await flushPromises(); + await vi.waitFor(() => { + expect(service.getMetrics().fetchInFlightCount).toBe(0); + }); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 5304de646d..fc1d211f33 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -3141,26 +3141,36 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { let result: WorkspaceGitFetchResult | null = null; const eventsBeforeFetchSnapshot: FileChange[] = []; try { - result = await this.deps.runGitFetch( - target.cwd, - { - onRefSnapshot: (phase) => { - const events = target.bufferedFetchMetadataEvents.splice(0); - if (phase === "before") { - eventsBeforeFetchSnapshot.push(...events); - } + try { + result = await this.deps.runGitFetch( + target.cwd, + { + onRefSnapshot: (phase) => { + const events = target.bufferedFetchMetadataEvents.splice(0); + if (phase === "before") { + eventsBeforeFetchSnapshot.push(...events); + } + }, }, - }, - createRunGitCommand("background-fetch"), - ); - } catch (error) { - this.logger.warn( - { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd }, - "Background git fetch failed", - ); + createRunGitCommand("background-fetch"), + ); + } catch (error) { + this.logger.warn( + { err: error, repoGitRoot: target.repoGitRoot, cwd: target.cwd }, + "Background git fetch failed", + ); + } + this.applyRepoFetchResult(target, result, eventsBeforeFetchSnapshot); } finally { target.fetchInFlight = false; } + } + + private applyRepoFetchResult( + target: RepoGitTarget, + result: WorkspaceGitFetchResult | null, + eventsBeforeFetchSnapshot: FileChange[], + ): void { this.flushFetchMetadataEvents(target, eventsBeforeFetchSnapshot); if (!result || result.changes === null) { target.recentFetchRemoteRefChanges.clear();