feat(FN-4935): complete Step 1 — structured liveness classifier
Ref: Runfusion/Fusion#601 Fusion-Task-Id: FN-4935 Fusion-Task-Lineage: 8c842b69-1427-47be-9ba5-8ec66449cc7c
This commit is contained in:
committed by
gsxdsm
parent
eba4eb5161
commit
0f69412c20
@@ -111,6 +111,7 @@ vi.mock("../worktree-pool.js", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
classifyTaskWorktree: vi.fn().mockResolvedValue({ ok: true }),
|
||||
describeRegisteredWorktrees: vi.fn().mockResolvedValue({ rawOutput: "", canonicalized: [] }),
|
||||
isUsableTaskWorktree: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
@@ -247,7 +248,7 @@ import { withRateLimitRetry } from "../rate-limit-retry.js";
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import { hydrateWorktreeDb } from "../worktree-db-hydrate.js";
|
||||
import { classifyTaskWorktree, isUsableTaskWorktree } from "../worktree-pool.js";
|
||||
import { classifyTaskWorktree, describeRegisteredWorktrees, isUsableTaskWorktree } from "../worktree-pool.js";
|
||||
import { classifyStaleLock, tryRemoveStaleLock } from "../worktree-stale-lock.js";
|
||||
import { executingTaskLock } from "../active-session-registry.js";
|
||||
|
||||
@@ -263,6 +264,7 @@ export const mockedExistsSync = vi.mocked(existsSync);
|
||||
export const mockedRealpathSync = vi.mocked(realpathSync);
|
||||
export const mockedHydrateWorktreeDb = vi.mocked(hydrateWorktreeDb);
|
||||
export const mockedClassifyTaskWorktree = vi.mocked(classifyTaskWorktree);
|
||||
export const mockedDescribeRegisteredWorktrees = vi.mocked(describeRegisteredWorktrees);
|
||||
export const mockedIsUsableTaskWorktree = vi.mocked(isUsableTaskWorktree);
|
||||
export const mockedClassifyStaleLock = vi.mocked(classifyStaleLock);
|
||||
export const mockedTryRemoveStaleLock = vi.mocked(tryRemoveStaleLock);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execSync, spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { hasRequiredWorktreeFiles, isUsableTaskWorktree } from "../worktree-pool.js";
|
||||
import { classifyTaskWorktree, hasRequiredWorktreeFiles, isUsableTaskWorktree } from "../worktree-pool.js";
|
||||
|
||||
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
|
||||
const describeIfGit = hasGit ? describe : describe.skip;
|
||||
@@ -81,6 +81,87 @@ describeIfGit("worktree liveness gating (FN-4682)", () => {
|
||||
await expect(isUsableTaskWorktree(rootDir, worktreePath)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "ok",
|
||||
setup: () => {
|
||||
const rootDir = track(makeRepo((dir) => {
|
||||
git(dir, 'git commit --allow-empty -m "init"');
|
||||
}));
|
||||
const worktreePath = track(makeWorktree(rootDir, "ok"));
|
||||
return { rootDir, worktreePath };
|
||||
},
|
||||
expected: { ok: true } as const,
|
||||
},
|
||||
{
|
||||
name: "missing",
|
||||
setup: () => {
|
||||
const rootDir = track(makeRepo((dir) => {
|
||||
git(dir, 'git commit --allow-empty -m "init"');
|
||||
}));
|
||||
const worktreePath = join(tmpdir(), `fn-4682-missing-${Date.now()}`);
|
||||
return { rootDir, worktreePath };
|
||||
},
|
||||
expected: {
|
||||
ok: false,
|
||||
classification: "missing",
|
||||
reason: "worktree directory does not exist",
|
||||
} as const,
|
||||
},
|
||||
{
|
||||
name: "incomplete",
|
||||
setup: () => {
|
||||
const rootDir = track(makeRepo((dir) => {
|
||||
git(dir, 'git commit --allow-empty -m "init"');
|
||||
}));
|
||||
const worktreePath = track(mkdtempSync(join(tmpdir(), "fn-4682-incomplete-")));
|
||||
return { rootDir, worktreePath };
|
||||
},
|
||||
expected: {
|
||||
ok: false,
|
||||
classification: "incomplete",
|
||||
reason: "missing .git metadata",
|
||||
} as const,
|
||||
},
|
||||
{
|
||||
name: "unregistered",
|
||||
setup: () => {
|
||||
const rootDir = track(makeRepo((dir) => {
|
||||
git(dir, 'git commit --allow-empty -m "init"');
|
||||
}));
|
||||
const worktreePath = track(makeRepo((dir) => {
|
||||
git(dir, 'git commit --allow-empty -m "standalone"');
|
||||
}));
|
||||
return { rootDir, worktreePath };
|
||||
},
|
||||
expected: {
|
||||
ok: false,
|
||||
classification: "unregistered",
|
||||
reason: "not registered in git worktree list",
|
||||
} as const,
|
||||
},
|
||||
{
|
||||
name: "outside-work-tree",
|
||||
setup: () => {
|
||||
const rootDir = track(makeRepo((dir) => {
|
||||
git(dir, 'git commit --allow-empty -m "init"');
|
||||
}));
|
||||
const worktreePath = track(makeWorktree(rootDir, "outside-work-tree"));
|
||||
rmSync(join(worktreePath, ".git"), { recursive: true, force: true });
|
||||
writeFileSync(join(worktreePath, ".git"), "gitdir: /tmp/nonexistent\n", "utf-8");
|
||||
return { rootDir, worktreePath };
|
||||
},
|
||||
expected: {
|
||||
ok: false,
|
||||
classification: "outside-work-tree",
|
||||
reason: "git rev-parse --is-inside-work-tree returned false",
|
||||
} as const,
|
||||
},
|
||||
])("FN-4935: classifyTaskWorktree %s", async ({ setup, expected }) => {
|
||||
const { rootDir, worktreePath } = setup();
|
||||
await expect(classifyTaskWorktree(rootDir, worktreePath)).resolves.toEqual(expected);
|
||||
});
|
||||
|
||||
it("FN-4682: rejects missing worktree directory", async () => {
|
||||
const rootDir = track(makeRepo((dir) => {
|
||||
git(dir, 'git commit --allow-empty -m "init"');
|
||||
|
||||
@@ -91,28 +91,34 @@ export async function isGitRepository(dir: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<string>> {
|
||||
export async function describeRegisteredWorktrees(rootDir: string): Promise<{ rawOutput: string; canonicalized: string[] }> {
|
||||
try {
|
||||
const result = await execAsync("git worktree list --porcelain", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
timeout: 10_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const stdout = getExecStdout(result);
|
||||
|
||||
const paths = new Set<string>();
|
||||
const canonicalized: string[] = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
paths.add(canonicalizePath(line.slice("worktree ".length)));
|
||||
canonicalized.push(canonicalizePath(line.slice("worktree ".length)));
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
worktreePoolLog.warn(`Failed to list registered worktrees: ${errorMessage}`);
|
||||
return new Set();
|
||||
|
||||
return { rawOutput: stdout, canonicalized };
|
||||
} catch {
|
||||
return { rawOutput: "", canonicalized: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRegisteredWorktreePaths(rootDir: string): Promise<Set<string>> {
|
||||
const { canonicalized } = await describeRegisteredWorktrees(rootDir);
|
||||
return new Set(canonicalized);
|
||||
}
|
||||
|
||||
export async function isRegisteredGitWorktree(rootDir: string, worktreePath: string): Promise<boolean> {
|
||||
return (await getRegisteredWorktreePaths(rootDir)).has(canonicalizePath(worktreePath));
|
||||
}
|
||||
@@ -135,9 +141,11 @@ export async function isInsideGitWorkTree(worktreePath: string): Promise<boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskWorktreeClassification = "missing" | "incomplete" | "unregistered" | "outside-work-tree";
|
||||
|
||||
export type TaskWorktreeClassificationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; classification: "missing" | "incomplete" | "unregistered"; reason: string };
|
||||
| { ok: false; classification: TaskWorktreeClassification; reason: string };
|
||||
|
||||
/**
|
||||
* Language-agnostic liveness/classification gate for task worktrees.
|
||||
@@ -146,12 +154,15 @@ export async function classifyTaskWorktree(rootDir: string, worktreePath: string
|
||||
if (!existsSync(worktreePath)) {
|
||||
return { ok: false, classification: "missing", reason: "worktree directory does not exist" };
|
||||
}
|
||||
if (!hasRequiredWorktreeFiles(worktreePath) || !await isInsideGitWorkTree(worktreePath)) {
|
||||
return { ok: false, classification: "incomplete", reason: "missing or invalid .git metadata" };
|
||||
if (!hasRequiredWorktreeFiles(worktreePath)) {
|
||||
return { ok: false, classification: "incomplete", reason: "missing .git metadata" };
|
||||
}
|
||||
if (!await isRegisteredGitWorktree(rootDir, worktreePath)) {
|
||||
return { ok: false, classification: "unregistered", reason: "not registered in git worktree list" };
|
||||
}
|
||||
if (!await isInsideGitWorkTree(worktreePath)) {
|
||||
return { ok: false, classification: "outside-work-tree", reason: "git rev-parse --is-inside-work-tree returned false" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user