fix(FN-1600): complete async conversion of hasRecoverableGitWork

- Convert hasRecoverableGitWork from sync execSync to async execAsync
- Update recoverNoProgressNoTaskDoneFailures caller to await the method
- Update self-healing tests to work with async method and execAsync mock
- Add exec and promisify imports to self-healing.ts
- Fix mock to include exec export for promisify compatibility
This commit is contained in:
gsxdsm
2026-04-12 12:24:54 -07:00
parent 52dad6741f
commit 31815e0fba
2 changed files with 50 additions and 22 deletions

View File

@@ -1,8 +1,39 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
// Mock node modules
// Route async `exec` through the `execSync` mock so existing tests that set up
// mockedExecSync.mockImplementation for verification keep working unchanged.
vi.mock("node:child_process", async () => {
const { promisify: utilPromisify } = await import("node:util");
const execSyncFn = vi.fn();
const execFn: any = vi.fn((cmd: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "object" && opts !== null ? opts : {};
try {
const out = execSyncFn(cmd, { ...options, stdio: ["pipe", "pipe", "pipe"] });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
// Mirror real child_process.exec: promisify resolves to { stdout, stderr }.
execFn[utilPromisify.custom] = (cmd: any, opts?: any) =>
new Promise((resolve, reject) => {
execFn(cmd, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn };
});
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
@@ -429,7 +460,7 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("treats dirty worktrees as recoverable git work", () => {
it("treats dirty worktrees as recoverable git work", async () => {
const task = {
id: "FN-1473",
worktree: "/tmp/test-project/.worktrees/fn-1473",
@@ -443,7 +474,7 @@ describe("SelfHealingManager", () => {
return "" as any;
});
expect((manager as any).hasRecoverableGitWork(task)).toBe(true);
expect(await (manager as any).hasRecoverableGitWork(task)).toBe(true);
mockedExecSync.mockClear();
});
});

View File

@@ -13,7 +13,8 @@
* by cleaning oldest idle worktrees when count exceeds 2× maxWorktrees.
*/
import { execSync } from "node:child_process";
import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { getTaskMergeBlocker, type TaskStore, type Settings, type Task } from "@fusion/core";
@@ -21,6 +22,7 @@ import { createLogger } from "./logger.js";
import { scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
const log = createLogger("self-healing");
const execAsync = promisify(exec);
export interface SelfHealingOptions {
/** Project root directory (parent of .worktrees/) */
@@ -708,7 +710,7 @@ export class SelfHealingManager {
let recovered = 0;
for (const task of candidates) {
try {
if (this.hasRecoverableGitWork(task)) {
if (await this.hasRecoverableGitWork(task)) {
log.log(`${task.id} has recoverable git work — leaving in-progress for inspection`);
continue;
}
@@ -739,16 +741,14 @@ export class SelfHealingManager {
}
}
private hasRecoverableGitWork(task: Task): boolean {
private async hasRecoverableGitWork(task: Task): Promise<boolean> {
if (task.worktree && existsSync(task.worktree)) {
try {
const status = execSync("git status --porcelain", {
const { stdout: status } = await execAsync("git status --porcelain", {
cwd: task.worktree,
stdio: "pipe",
encoding: "utf-8",
timeout: 30_000,
}).trim();
if (status.length > 0) return true;
});
if (status.trim().length > 0) return true;
} catch {
// If we cannot inspect an existing worktree, preserve it.
return true;
@@ -757,9 +757,8 @@ export class SelfHealingManager {
const branchName = task.branch || `fusion/${task.id.toLowerCase()}`;
try {
execSync(`git rev-parse --verify "${branchName}"`, {
await execAsync(`git rev-parse --verify "${branchName}"`, {
cwd: this.options.rootDir,
stdio: "pipe",
timeout: 30_000,
});
} catch {
@@ -767,13 +766,11 @@ export class SelfHealingManager {
}
try {
const uniqueCommits = execSync(`git rev-list --count HEAD.."${branchName}"`, {
cwd: this.options.rootDir,
stdio: "pipe",
encoding: "utf-8",
timeout: 30_000,
}).trim();
return Number.parseInt(uniqueCommits, 10) > 0;
const { stdout: uniqueCommits } = await execAsync(
`git rev-list --count HEAD.."${branchName}"`,
{ cwd: this.options.rootDir, timeout: 30_000 },
);
return Number.parseInt(uniqueCommits.trim(), 10) > 0;
} catch {
// If the branch exists but cannot be compared, preserve it.
return true;