diff --git a/.changeset/thin-rocks-post.md b/.changeset/thin-rocks-post.md new file mode 100644 index 0000000000..7408a397ce --- /dev/null +++ b/.changeset/thin-rocks-post.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Wait for task store secret database handles to close before cleanup. +category: fix +dev: Awaits the async secrets store close path during TaskStore shutdown to avoid teardown races. diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 6967624988..3065333344 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -261,6 +261,7 @@ vi.mock("@fusion/core", async (importOriginal) => { const { mockExec, mockExecSync, + mockExecFileSync, mockFindPrForBranch, mockCreatePr, mockGetPrMergeStatus, @@ -282,6 +283,16 @@ const { }; }), mockExecSync: vi.fn(() => ""), + mockExecFileSync: vi.fn((file: string, args: readonly string[] = [], options?: { cwd?: string }) => { + if (file === "git" && args.join(" ") === "remote get-url origin") { + const effectiveCwd = options?.cwd ?? process.cwd(); + if (effectiveCwd !== "/repo" && effectiveCwd !== process.cwd()) { + throw new Error(`unexpected repository cwd: ${options?.cwd ?? ""}`); + } + return "https://github.com/owner/repo.git\n"; + } + return ""; + }), mockFindPrForBranch: vi.fn(), mockCreatePr: vi.fn(), mockGetPrMergeStatus: vi.fn(), @@ -307,6 +318,7 @@ vi.mock("node:child_process", async (importOriginal) => { exec: mockExec, execSync: mockExecSync, execFile: mockExecFile, + execFileSync: mockExecFileSync, }; }); @@ -861,6 +873,18 @@ function resetGitHubMocks() { mockCreatePr.mockReset(); mockGetPrMergeStatus.mockReset(); mockMergePr.mockReset(); + mockExecFileSync.mockReset(); + + mockExecFileSync.mockImplementation((file: string, args: readonly string[] = [], options?: { cwd?: string }) => { + if (file === "git" && args.join(" ") === "remote get-url origin") { + const effectiveCwd = options?.cwd ?? process.cwd(); + if (effectiveCwd !== "/repo" && effectiveCwd !== process.cwd()) { + throw new Error(`unexpected repository cwd: ${options?.cwd ?? ""}`); + } + return "https://github.com/owner/repo.git\n"; + } + return ""; + }); mockFindPrForBranch.mockResolvedValue(null); mockCreatePr.mockResolvedValue({ @@ -1006,6 +1030,36 @@ describe("processPullRequestMergeTask", () => { expect.objectContaining({ number: 42, status: "open" }), ); expect(store.updateTask).toHaveBeenCalledWith("FN-093", { status: "awaiting-pr-checks" }); + expect(mockExecFileSync).toHaveBeenCalledWith( + "git", + ["remote", "get-url", "origin"], + expect.objectContaining({ cwd: "/repo" }), + ); + }); + + it("keeps the production missing-repository error when no project remote is resolvable", async () => { + mockExecFileSync.mockImplementationOnce(() => { + throw new Error("not a git repository"); + }); + const store = makeMockStore(); + store.getTask.mockResolvedValue({ + id: "FN-093", + title: "Task", + description: "Description", + column: "in-review", + paused: false, + log: [], + }); + + await expect(processPullRequestMergeTask(store as any, "/repo", "FN-093", { + findPrForBranch: mockFindPrForBranch, + createPr: mockCreatePr, + getPrMergeStatus: mockGetPrMergeStatus, + mergePr: mockMergePr, + } as any, () => undefined)).rejects.toThrow("processPullRequestMergeTask: could not determine repository"); + + expect(mockCreatePr).not.toHaveBeenCalled(); + expect(mockGetPrMergeStatus).not.toHaveBeenCalled(); }); it("links an existing PR instead of creating a duplicate", async () => { @@ -1050,6 +1104,7 @@ describe("processPullRequestMergeTask", () => { } as any, mockGetTaskMergeBlocker); expect(mockCreatePr).not.toHaveBeenCalled(); + expect(mockGetPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 7); expect(store.logEntry).toHaveBeenCalledWith( "FN-093", "Linked existing PR", @@ -1114,6 +1169,7 @@ describe("processPullRequestMergeTask", () => { } as any, mockGetTaskMergeBlocker); expect(result).toBe("merged"); + expect(mockGetPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 42); expect(mockMergePr).toHaveBeenCalledWith({ number: 42, method: "squash" }); expect(store.moveTask).toHaveBeenCalledWith("FN-093", "done"); // Check that exec was called with the expected commands (options object and callback may follow) @@ -1177,6 +1233,7 @@ describe("processPullRequestMergeTask", () => { } as any, mockGetTaskMergeBlocker); expect(result).toBe("waiting"); + expect(mockGetPrMergeStatus).toHaveBeenCalledWith("owner", "repo", 42); expect(mockMergePr).not.toHaveBeenCalled(); expect(store.moveTask).not.toHaveBeenCalled(); expect(store.updateTask).toHaveBeenCalledWith("FN-093", { status: "awaiting-pr-checks" }); @@ -1234,6 +1291,11 @@ describe("runDashboard — PR-first auto-merge queue", () => { head: "fusion/fn-093", base: "main", }); + expect(mockExecFileSync).toHaveBeenCalledWith( + "git", + ["remote", "get-url", "origin"], + expect.objectContaining({ cwd: process.cwd() }), + ); expect(aiMergeTask).not.toHaveBeenCalled(); }); @@ -1263,6 +1325,11 @@ describe("runDashboard — PR-first auto-merge queue", () => { head: "fusion/fn-093", base: "main", }); + expect(mockExecFileSync).toHaveBeenCalledWith( + "git", + ["remote", "get-url", "origin"], + expect.objectContaining({ cwd: process.cwd() }), + ); expect(aiMergeTask).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index cc05a95a0c..fcaec8b29d 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -16135,8 +16135,18 @@ ${stepsSection}`; this._archiveDb = null; } if (this.secretsCentralCore) { - void this.secretsCentralCore.close(); + /** + * FNXC:TaskStoreShutdown 2026-06-29-13:04: + * TaskStore.close() must deterministically await the cached secrets CentralCore close before temp-root cleanup and test teardown continue. + * CentralCore.close() is currently synchronous internally, but awaiting the async contract prevents unhandled rejections and preserves shutdown safety if the central secrets handle gains asynchronous cleanup. + */ + const secretsCentralCore = this.secretsCentralCore; this.secretsCentralCore = null; + try { + await secretsCentralCore.close(); + } catch (err) { + console.warn(`[fusion] Could not close secrets central core on TaskStore close:`, err); + } } this.secretsStore = null; if (this.pluginStore) {