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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user