From 90756f92b0ae0ca50998765c8ee0f21f40c19ca2 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Mon, 29 Jun 2026 12:36:52 -0700 Subject: [PATCH 1/5] fix(core): await secrets store close during TaskStore shutdown --- .changeset/thin-rocks-post.md | 7 +++++++ packages/core/src/store.ts | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/thin-rocks-post.md 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/core/src/store.ts b/packages/core/src/store.ts index cc05a95a0c..e3814e957e 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -16135,8 +16135,17 @@ ${stepsSection}`; this._archiveDb = null; } if (this.secretsCentralCore) { - void this.secretsCentralCore.close(); + // Await the secrets central core close: CentralCore.close() is async, so a + // fire-and-forget call would let close() resolve while the secrets SQLite + // handle is still draining on a later microtask, racing temp-root cleanup + // under the loaded package test lane. Await it like the other handles above. + 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) { From af99a353f09e1998fb9875cba31c79e983a27435 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Mon, 29 Jun 2026 12:41:20 -0700 Subject: [PATCH 2/5] test(engine): align workflow gate expectations --- packages/core/src/store.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index e3814e957e..fcaec8b29d 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -16135,10 +16135,11 @@ ${stepsSection}`; this._archiveDb = null; } if (this.secretsCentralCore) { - // Await the secrets central core close: CentralCore.close() is async, so a - // fire-and-forget call would let close() resolve while the secrets SQLite - // handle is still draining on a later microtask, racing temp-root cleanup - // under the loaded package test lane. Await it like the other handles above. + /** + * 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 { From bdd0a1cc2c3d70c9ef7c9d5c9eaab49c9eb3c5e1 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Mon, 29 Jun 2026 13:45:15 -0700 Subject: [PATCH 3/5] test(cli): stabilize dashboard pr repository fixtures --- .../src/commands/__tests__/dashboard.test.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 6967624988..41b5566711 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,15 @@ const { }; }), mockExecSync: vi.fn(() => ""), + mockExecFileSync: vi.fn((file: string, args: readonly string[] = [], options?: { cwd?: string }) => { + if (file === "git" && args.join(" ") === "remote get-url origin") { + if (options?.cwd !== "/repo" && options?.cwd !== 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 +317,7 @@ vi.mock("node:child_process", async (importOriginal) => { exec: mockExec, execSync: mockExecSync, execFile: mockExecFile, + execFileSync: mockExecFileSync, }; }); @@ -913,6 +924,16 @@ beforeEach(() => { resetGitHubMocks(); mockExecSync.mockReset(); mockExecSync.mockReturnValue(""); + mockExecFileSync.mockReset(); + mockExecFileSync.mockImplementation((file: string, args: readonly string[] = [], options?: { cwd?: string }) => { + if (file === "git" && args.join(" ") === "remote get-url origin") { + if (options?.cwd !== "/repo" && options?.cwd !== process.cwd()) { + throw new Error(`unexpected repository cwd: ${options?.cwd ?? ""}`); + } + return "https://github.com/owner/repo.git\n"; + } + return ""; + }); mockExec.mockClear(); mockListen.mockReset(); mockListen.mockImplementation((port: number) => { @@ -1006,6 +1027,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 +1101,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 +1166,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 +1230,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 +1288,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 +1322,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(); }); }); From 6763584f642571c5218fb7988fd2392cc054cac3 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Mon, 29 Jun 2026 14:34:10 -0700 Subject: [PATCH 4/5] fix: align dashboard PR fixture cwd mock --- packages/cli/src/commands/__tests__/dashboard.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 41b5566711..132fc17f97 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -285,7 +285,8 @@ const { mockExecSync: vi.fn(() => ""), mockExecFileSync: vi.fn((file: string, args: readonly string[] = [], options?: { cwd?: string }) => { if (file === "git" && args.join(" ") === "remote get-url origin") { - if (options?.cwd !== "/repo" && options?.cwd !== process.cwd()) { + 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"; @@ -927,7 +928,8 @@ beforeEach(() => { mockExecFileSync.mockReset(); mockExecFileSync.mockImplementation((file: string, args: readonly string[] = [], options?: { cwd?: string }) => { if (file === "git" && args.join(" ") === "remote get-url origin") { - if (options?.cwd !== "/repo" && options?.cwd !== process.cwd()) { + 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"; From 4c31441f58899ac2326fc5f98c715bf77133290f Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Mon, 29 Jun 2026 15:24:35 -0700 Subject: [PATCH 5/5] fix: reset PR fixture git remote mock consistently --- .../src/commands/__tests__/dashboard.test.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 132fc17f97..3065333344 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -873,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({ @@ -925,17 +937,6 @@ beforeEach(() => { resetGitHubMocks(); mockExecSync.mockReset(); mockExecSync.mockReturnValue(""); - 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 ""; - }); mockExec.mockClear(); mockListen.mockReset(); mockListen.mockImplementation((port: number) => {