feat(FN-4371): complete remaining decision-only task handling

Fusion-Task-Id: FN-4371
Fusion-Task-Lineage: 4175ba4c-b7cb-4f3a-87c5-75e51f06c0ff
This commit is contained in:
Fusion
2026-05-14 09:52:19 -07:00
committed by gsxdsm
parent c8a2caf948
commit 77308601e5
9 changed files with 188 additions and 1 deletions

View File

@@ -5984,3 +5984,45 @@ describe("SelfHealingManager reclaimSelfOwnedBranchConflicts", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-502", "in-review");
});
});
describe("SelfHealingManager no-commits-expected audit", () => {
it("logs candidate task IDs without mutating tasks", async () => {
const store = createMockStore();
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const now = new Date().toISOString();
const candidate = {
id: "FN-900",
column: "in-review",
status: "failed",
error: "fn_task_done refused: no_commits",
noCommitsExpected: undefined,
branch: "fusion/fn-900",
baseBranch: "main",
paused: false,
steps: [{ id: "s1", name: "done", status: "done" }],
log: [],
createdAt: now,
updatedAt: now,
description: "audit",
dependencies: [],
currentStep: 1,
} as unknown as Task;
vi.mocked(store.listTasks)
.mockResolvedValueOnce([candidate])
.mockResolvedValueOnce([candidate]);
mockedExecSync.mockImplementation((command: string) => {
if (command.includes("git rev-list --count")) {
return Buffer.from("0\n");
}
return Buffer.from("ok\n");
});
const count = await manager.auditNoCommitsExpectedCandidates();
expect(count).toBe(1);
expect(getSelfHealingLogger().warn).toHaveBeenCalledWith(expect.stringContaining("FN-900"));
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
});
});

View File

@@ -412,6 +412,7 @@ export class SelfHealingManager {
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy().then(() => undefined) },
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts().then(() => undefined) },
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) },
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates().then(() => undefined) },
];
for (const step of steps) {
@@ -1114,6 +1115,7 @@ export class SelfHealingManager {
{ name: "clear-stale-blocked-by", fn: () => this.clearStaleBlockedBy() },
{ name: "reclaim-self-owned-branch-conflicts", fn: () => this.reclaimSelfOwnedBranchConflicts() },
{ name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() },
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates() },
];
for (const fn of batch2Fns) {
try {
@@ -2825,6 +2827,44 @@ export class SelfHealingManager {
}
}
async auditNoCommitsExpectedCandidates(): Promise<number> {
try {
const inReviewTasks = await this.store.listTasks({ column: "in-review", slim: true });
const allTasks = await this.store.listTasks({ slim: true });
const failedTasks = allTasks.filter((task) => task.status === "failed");
const candidateMap = new Map<string, Task>();
for (const task of [...inReviewTasks, ...failedTasks]) {
candidateMap.set(task.id, task);
}
const candidates = [...candidateMap.values()].filter((task) => {
if (task.noCommitsExpected === true) return false;
if (task.steps.length === 0 || !task.steps.every((step) => step.status === "done" || step.status === "skipped")) return false;
const noCommitsError = typeof task.error === "string" && /no_commits/i.test(task.error);
return task.column === "in-review" || noCommitsError;
});
if (candidates.length === 0) return 0;
const taskIds: string[] = [];
for (const task of candidates) {
const ahead = await isBranchAheadOfBase(task, this.options.rootDir, task.baseBranch || "main");
if (ahead && ahead.aheadCount === 0) {
taskIds.push(task.id);
}
}
if (taskIds.length > 0) {
log.warn(`no-commits-expected audit candidates: ${JSON.stringify({ taskIds })}`);
}
return taskIds.length;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`No-commits-expected audit failed: ${errorMessage}`);
return 0;
}
}
/**
* Recover executor tasks stranded in `in-progress` before a real session was
* established, typically when the scheduler reserved a worktree path but the