FN-6461: block incomplete no-commits no-op finalization
Add a shared no-op finalize guard so no-commits tasks with mostly incomplete steps return to todo instead of silently completing. - Add core step-evidence evaluation for no-commits no-op finalization. - Apply the guard across AI empty merge, early empty-own-diff, legacy no-op classifier, direct no-op, and self-healing finalize lanes. - Emit run-audit/log evidence and preserve progress when demoting blocked tasks. - Cover guard behavior with core, merger, AI merge, real-git, and self-healing tests. - Document the finalize integrity invariant and add a patch changeset. Files changed: .changeset/fn-6461-no-commits-finalize-guard.md | 5 + docs/architecture.md | 2 + .../__tests__/no-commits-finalize-guard.test.ts | 58 +++++++ packages/core/src/index.ts | 2 + packages/core/src/no-commits-finalize-guard.ts | 42 +++++ packages/engine/src/__tests__/merger-ai.test.ts | 59 +++++++ .../merger-finalize-unproven.real-git.test.ts | 176 +++++++++++++++++++++ packages/engine/src/__tests__/self-healing.test.ts | 125 +++++++++++++++ packages/engine/src/merger-ai.ts | 45 ++++++ packages/engine/src/merger.ts | 130 +++++++++++++++ packages/engine/src/run-audit.ts | 6 + packages/engine/src/self-healing.ts | 41 ++++- 12 files changed, 689 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-6461 Fusion-Task-Lineage: 36647768-42da-414a-9304-d8e0ed61b99c
This commit is contained in:
5
.changeset/fn-6461-no-commits-finalize-guard.md
Normal file
5
.changeset/fn-6461-no-commits-finalize-guard.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Guard no-commits-expected tasks from being finalized as done by no-op merge/self-healing lanes when skipped or incomplete steps outweigh completed work.
|
||||
@@ -1075,6 +1075,7 @@ The run-audit system records every mutation performed by the engine across four
|
||||
- **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `<rebaseBaseSha>..HEAD` has zero attributable own commits but the source `fusion/<id>` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`.
|
||||
- **Git / `merge:no-op-attribution-mismatch-skipped`** — emitted when the FN-5304 source-tip guard cannot run because the source branch ref is unavailable (for example already pruned). `target` is the task ID; metadata includes `reason` (`"source-ref-unavailable"`).
|
||||
- **Database / `task:auto-recover-misrouted-foreign-commit`** — emitted per dropped misrouted commit during FN-4948 contamination recovery. `target` is the recovering task; metadata carries `{ droppedSha, foreignTaskId, paths }`.
|
||||
- **Database / `task:no-commits-finalize-blocked-incomplete-steps`** — emitted by no-op finalize lanes when a `noCommitsExpected` task has no net branch changes but incomplete/skipped steps outweigh done steps. Metadata includes `{ reason, doneCount, incompleteCount, lane, classification?, baseRef? }`; the accompanying task log explains that the task was demoted to `todo` with progress preserved instead of finalized as done.
|
||||
- **Database / `task:orphan-detected-no-action`** — emitted by `recoverOrphanedExecutions` (FN-5337) when row metadata looks orphaned after grace windows; annotation-only event with no lifecycle mutation (`in-progress` task stays put).
|
||||
- **Database / `task:reattach-orphaned-execution`** — emitted by `reattachOrphanedAssignedExecutions` (FN-6336) when self-healing re-dispatches an idle assigned `in-progress` task forward via `executor.resumeTaskForAgent(agentId)` after proving the assigned agent has no active heartbeat run or active execution.
|
||||
- **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`.
|
||||
@@ -1642,6 +1643,7 @@ The GitHub tracking state listener now attaches to every registered project stor
|
||||
#### Finalize integrity gate
|
||||
- Finalize-to-done now runs an ownership classifier with three outcomes: `owned-commit` (task trailer/subject commit proven landed on merge target), `proven-no-op` (zero-ahead branch plus start point reachable from target), and `unproven` (missing ownership evidence, including foreign start-point inheritance).
|
||||
- `owned-commit` and `proven-no-op` can finalize. `proven-no-op` explicitly reconciles metadata by clearing stale `task.modifiedFiles` and stamping `mergeDetails.noOpMerge=true` with `landedFiles: []`.
|
||||
- `noCommitsExpected === true` tasks have an additional no-op finalize guard (FN-6461): if a zero-net-change lane reaches finalize with step evidence showing incomplete/skipped work outweighing completed work (`incompleteCount >= doneCount`, with at least one step), the task must not move to `done`. Merger and self-healing write `task.error`, log an operator-visible reason, emit `task:no-commits-finalize-blocked-incomplete-steps`, and move the task back to `todo` with `preserveProgress: true`. All-done no-commits tasks, mostly-done tasks with only a minor skipped tail, zero-step tasks, ordinary tasks, and no-commits tasks with real landed changes keep the existing finalize behavior.
|
||||
- `unproven` no longer silently completes as done; merger/self-healing emit `task:finalize-unproven-blocked` audit events and auto-retry by requeuing to `todo` for a fresh execution pass.
|
||||
- Historical cleanup is additive: `reconcileDoneTaskIntegrity()` scans done tasks missing `mergeDetails.commitSha` but still carrying `modifiedFiles`, then either recovers owned commit metadata, clears no-op stale files, or emits `task:integrity-warning` without regressing done tasks back to review. `task:integrity-warning` is transition-only on the persisted warning reason: first warning emits once, repeated sweeps with the same `mergeDetails.integrityWarning.reason` stay silent, and a new warning reason emits again.
|
||||
- This integrity gate complements FN-4646 landed-file capture (metadata truth source) and FN-4647 dashboard labeling (UI presentation); gate enforcement is in merger/self-healing, while display semantics remain UI-owned.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateNoCommitsNoOpFinalize, type TaskStep } from "../index.js";
|
||||
|
||||
function steps(statuses: Array<TaskStep["status"]>): TaskStep[] {
|
||||
return statuses.map((status, index) => ({ name: `Step ${index}`, status }));
|
||||
}
|
||||
|
||||
describe("evaluateNoCommitsNoOpFinalize", () => {
|
||||
it("blocks the FN-6455 skipped-release shape", () => {
|
||||
const result = evaluateNoCommitsNoOpFinalize({
|
||||
noCommitsExpected: true,
|
||||
steps: steps(["done", "skipped", "skipped", "skipped", "skipped", "skipped"]),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ blocked: true, doneCount: 1, incompleteCount: 5 });
|
||||
expect(result.reason).toContain("done=1, incomplete=5");
|
||||
});
|
||||
|
||||
it("allows legitimate all-done no-op tasks", () => {
|
||||
expect(evaluateNoCommitsNoOpFinalize({
|
||||
noCommitsExpected: true,
|
||||
steps: steps(["done", "done", "done"]),
|
||||
})).toEqual({ blocked: false, doneCount: 3, incompleteCount: 0 });
|
||||
});
|
||||
|
||||
it("allows mostly-done no-op tasks with only a minor skipped tail", () => {
|
||||
expect(evaluateNoCommitsNoOpFinalize({
|
||||
noCommitsExpected: true,
|
||||
steps: steps(["done", "done", "done", "done", "done", "skipped"]),
|
||||
})).toEqual({ blocked: false, doneCount: 5, incompleteCount: 1 });
|
||||
});
|
||||
|
||||
it("blocks pending or in-progress work on no-commits tasks", () => {
|
||||
expect(evaluateNoCommitsNoOpFinalize({
|
||||
noCommitsExpected: true,
|
||||
steps: steps(["done", "pending"]),
|
||||
})).toMatchObject({ blocked: true, doneCount: 1, incompleteCount: 1 });
|
||||
expect(evaluateNoCommitsNoOpFinalize({
|
||||
noCommitsExpected: true,
|
||||
steps: steps(["in-progress"]),
|
||||
})).toMatchObject({ blocked: true, doneCount: 0, incompleteCount: 1 });
|
||||
});
|
||||
|
||||
it("preserves zero-step behavior", () => {
|
||||
expect(evaluateNoCommitsNoOpFinalize({ noCommitsExpected: true, steps: [] }))
|
||||
.toEqual({ blocked: false, doneCount: 0, incompleteCount: 0 });
|
||||
});
|
||||
|
||||
it("does not block ordinary tasks", () => {
|
||||
expect(evaluateNoCommitsNoOpFinalize({
|
||||
noCommitsExpected: false,
|
||||
steps: steps(["done", "skipped", "skipped"]),
|
||||
})).toEqual({ blocked: false, doneCount: 1, incompleteCount: 2 });
|
||||
expect(evaluateNoCommitsNoOpFinalize({
|
||||
steps: steps(["pending"]),
|
||||
})).toEqual({ blocked: false, doneCount: 0, incompleteCount: 1 });
|
||||
});
|
||||
});
|
||||
@@ -494,6 +494,8 @@ export {
|
||||
type NoOpCompletionMarker,
|
||||
type NoOpCompletionMarkerKind,
|
||||
} from "./no-op-completion-marker.js";
|
||||
export { evaluateNoCommitsNoOpFinalize } from "./no-commits-finalize-guard.js";
|
||||
export type { NoCommitsNoOpFinalizeEvaluation } from "./no-commits-finalize-guard.js";
|
||||
export {
|
||||
__getDeterministicGuardMutexSize,
|
||||
deterministicGuardLocks,
|
||||
|
||||
42
packages/core/src/no-commits-finalize-guard.ts
Normal file
42
packages/core/src/no-commits-finalize-guard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Task } from "./types.js";
|
||||
|
||||
export interface NoCommitsNoOpFinalizeEvaluation {
|
||||
blocked: boolean;
|
||||
reason?: string;
|
||||
doneCount: number;
|
||||
incompleteCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:Lifecycle 2026-06-14-19:54:
|
||||
* FN-6461/FN-6455 showed that release and ops tasks marked `noCommitsExpected` can be silently finalized as no-op after skipping substantive steps.
|
||||
* Zero-diff finalize lanes must only trust step evidence when completed work outweighs incomplete work; ties block because a todo requeue is recoverable while dropping operational work is not.
|
||||
*/
|
||||
export function evaluateNoCommitsNoOpFinalize(
|
||||
task: Pick<Task, "noCommitsExpected" | "steps">,
|
||||
): NoCommitsNoOpFinalizeEvaluation {
|
||||
const steps = task.steps ?? [];
|
||||
const doneCount = steps.filter((step) => step.status === "done").length;
|
||||
const incompleteCount = steps.length - doneCount;
|
||||
|
||||
if (
|
||||
task.noCommitsExpected === true &&
|
||||
steps.length > 0 &&
|
||||
incompleteCount > 0 &&
|
||||
// Equal counts still block: requeueing is recoverable, but silently dropping ops work is not.
|
||||
incompleteCount >= doneCount
|
||||
) {
|
||||
return {
|
||||
blocked: true,
|
||||
reason: `no-commits task skipped/incomplete work outweighs completed work (done=${doneCount}, incomplete=${incompleteCount}) with no net branch changes`,
|
||||
doneCount,
|
||||
incompleteCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: false,
|
||||
doneCount,
|
||||
incompleteCount,
|
||||
};
|
||||
}
|
||||
@@ -308,6 +308,65 @@ describe("runAiMerge", () => {
|
||||
expect(git(dir, "rev-parse main")).toBe(mainBefore);
|
||||
});
|
||||
|
||||
it("demotes a no-commits task with skipped-out work instead of AI empty-merge finalizing done", async () => {
|
||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
git(dir, "merge -q fusion/fn-1");
|
||||
const { store, task } = makeStore(dir, {
|
||||
noCommitsExpected: true,
|
||||
steps: [
|
||||
{ name: "Preflight", status: "done" },
|
||||
{ name: "Dry-run", status: "skipped" },
|
||||
{ name: "Execute", status: "skipped" },
|
||||
{ name: "Verify", status: "skipped" },
|
||||
{ name: "Testing", status: "skipped" },
|
||||
{ name: "Documentation", status: "skipped" },
|
||||
],
|
||||
});
|
||||
const mainBefore = git(dir, "rev-parse main");
|
||||
|
||||
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
|
||||
mergeAgent: vi.fn(async () => { /* nothing to do */ }),
|
||||
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||
});
|
||||
|
||||
expect(result.merged).toBe(false);
|
||||
expect(result.noOp).toBe(false);
|
||||
expect(result.error).toContain("done=1, incomplete=5");
|
||||
expect(task.column).toBe("todo");
|
||||
expect(task.error).toContain("done=1, incomplete=5");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine" }));
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-1", "done");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-1",
|
||||
expect.stringContaining("Finalize blocked (no-commits incomplete-work guard)"),
|
||||
expect.stringContaining("ai-empty-merge"),
|
||||
);
|
||||
expect(git(dir, "rev-parse main")).toBe(mainBefore);
|
||||
});
|
||||
|
||||
it("still finalizes an all-done no-commits task on the AI empty-merge path", async () => {
|
||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
git(dir, "merge -q fusion/fn-1");
|
||||
const { store, task } = makeStore(dir, {
|
||||
noCommitsExpected: true,
|
||||
steps: [
|
||||
{ name: "Preflight", status: "done" },
|
||||
{ name: "Dry-run", status: "done" },
|
||||
{ name: "Execute", status: "done" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
|
||||
mergeAgent: vi.fn(async () => { /* nothing to do */ }),
|
||||
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||
});
|
||||
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(task.column).toBe("done");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done");
|
||||
});
|
||||
|
||||
it("fails loudly when an executed, never-merged task has no branch (possible lost work)", async () => {
|
||||
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
|
||||
// branch points at a ref that doesn't exist; task was executed (baseCommitSha) and never merged.
|
||||
|
||||
@@ -209,6 +209,182 @@ describeIfGit("aiMergeTask finalize no-op unproven reproduction (real git)", ()
|
||||
expect((store.moveTask as ReturnType<typeof vi.fn>).mock.calls.some(([, column]) => column === "todo")).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
it("FN-6461: demotes no-commits proven no-op tasks when skipped work outweighs done work", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-no-commits-noop-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
git(repo, "git commit --allow-empty -m 'init'");
|
||||
const baseSha = git(repo, "git rev-parse HEAD");
|
||||
git(repo, "git checkout -b fusion/fn-no-commits");
|
||||
git(repo, "git checkout main");
|
||||
|
||||
const task = {
|
||||
id: "FN-NO-COMMITS",
|
||||
title: "FN-NO-COMMITS",
|
||||
description: "FN-NO-COMMITS",
|
||||
column: "in-review",
|
||||
branch: "fusion/fn-no-commits",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: baseSha,
|
||||
noCommitsExpected: true,
|
||||
dependencies: [],
|
||||
steps: [
|
||||
{ name: "Preflight", status: "done" },
|
||||
{ name: "Dry-run", status: "skipped" },
|
||||
{ name: "Execute", status: "skipped" },
|
||||
{ name: "Verify", status: "skipped" },
|
||||
{ name: "Testing", status: "skipped" },
|
||||
{ name: "Documentation", status: "skipped" },
|
||||
],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prompt: "# FN-NO-COMMITS",
|
||||
} as unknown as Task;
|
||||
|
||||
const store = createStore(task);
|
||||
const result = await aiMergeTask(store, repo, "FN-NO-COMMITS");
|
||||
|
||||
expect(result.merged).toBe(false);
|
||||
expect(result.noOp).toBe(false);
|
||||
expect(result.error).toContain("done=1, incomplete=5");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-NO-COMMITS", expect.objectContaining({ error: expect.stringContaining("done=1, incomplete=5") }));
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-NO-COMMITS", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine" }));
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-NO-COMMITS", "done");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-NO-COMMITS",
|
||||
expect.stringContaining("Finalize blocked (no-commits incomplete-work guard)"),
|
||||
expect.stringContaining("legacy-no-op-classifier"),
|
||||
);
|
||||
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:no-commits-finalize-blocked-incomplete-steps",
|
||||
target: "FN-NO-COMMITS",
|
||||
}));
|
||||
}, 20_000);
|
||||
|
||||
it("FN-6461: allows all-done no-commits proven no-op tasks to finalize", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-no-commits-done-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
git(repo, "git commit --allow-empty -m 'init'");
|
||||
const baseSha = git(repo, "git rev-parse HEAD");
|
||||
git(repo, "git checkout -b fusion/fn-no-commits-done");
|
||||
git(repo, "git checkout main");
|
||||
|
||||
const task = {
|
||||
id: "FN-NO-COMMITS-DONE",
|
||||
title: "FN-NO-COMMITS-DONE",
|
||||
description: "FN-NO-COMMITS-DONE",
|
||||
column: "in-review",
|
||||
branch: "fusion/fn-no-commits-done",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: baseSha,
|
||||
noCommitsExpected: true,
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "done" }, { name: "Verify", status: "done" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prompt: "# FN-NO-COMMITS-DONE",
|
||||
} as unknown as Task;
|
||||
|
||||
const store = createStore(task);
|
||||
const result = await aiMergeTask(store, repo, "FN-NO-COMMITS-DONE");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-NO-COMMITS-DONE", "done");
|
||||
}, 20_000);
|
||||
|
||||
it("FN-6461: demotes no-commits empty-own-diff fast-path before cleanup", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-no-commits-empty-own-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
git(repo, "git commit --allow-empty -m 'init'");
|
||||
const baseSha = git(repo, "git rev-parse HEAD");
|
||||
git(repo, "git checkout -b fusion/fn-empty-block");
|
||||
git(repo, "git commit --allow-empty -m 'test(FN-EMPTY-BLOCK): no content change'");
|
||||
git(repo, "git checkout main");
|
||||
|
||||
const task = {
|
||||
id: "FN-EMPTY-BLOCK",
|
||||
title: "FN-EMPTY-BLOCK",
|
||||
description: "FN-EMPTY-BLOCK",
|
||||
column: "in-review",
|
||||
branch: "fusion/fn-empty-block",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: baseSha,
|
||||
noCommitsExpected: true,
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "done" }, { name: "Execute", status: "skipped" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prompt: "# FN-EMPTY-BLOCK",
|
||||
} as unknown as Task;
|
||||
|
||||
const store = createStore(task, { mergeIntegrationWorktree: "reuse-task-worktree" as any });
|
||||
const result = await aiMergeTask(store, repo, "FN-EMPTY-BLOCK");
|
||||
|
||||
expect(result.merged).toBe(false);
|
||||
expect(result.error).toContain("done=1, incomplete=1");
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-EMPTY-BLOCK", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine" }));
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-EMPTY-BLOCK", "done");
|
||||
expect(git(repo, "git show-ref --verify --quiet refs/heads/fusion/fn-empty-block; echo $?")).toBe("0");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-EMPTY-BLOCK",
|
||||
expect.stringContaining("Finalize blocked (no-commits incomplete-work guard)"),
|
||||
expect.stringContaining("early-empty-own-diff"),
|
||||
);
|
||||
}, 20_000);
|
||||
|
||||
it("FN-6461: allows all-done no-commits empty-own-diff fast-path tasks", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-no-commits-empty-done-"));
|
||||
repos.push(repo);
|
||||
git(repo, "git init -b main");
|
||||
git(repo, 'git config user.email "test@example.com"');
|
||||
git(repo, 'git config user.name "Test User"');
|
||||
git(repo, "git commit --allow-empty -m 'init'");
|
||||
const baseSha = git(repo, "git rev-parse HEAD");
|
||||
git(repo, "git checkout -b fusion/fn-empty-done");
|
||||
git(repo, "git commit --allow-empty -m 'test(FN-EMPTY-DONE): no content change'");
|
||||
git(repo, "git checkout main");
|
||||
|
||||
const task = {
|
||||
id: "FN-EMPTY-DONE",
|
||||
title: "FN-EMPTY-DONE",
|
||||
description: "FN-EMPTY-DONE",
|
||||
column: "in-review",
|
||||
branch: "fusion/fn-empty-done",
|
||||
baseBranch: "main",
|
||||
baseCommitSha: baseSha,
|
||||
noCommitsExpected: true,
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "done" }, { name: "Verify", status: "done" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
prompt: "# FN-EMPTY-DONE",
|
||||
} as unknown as Task;
|
||||
|
||||
const store = createStore(task, { mergeIntegrationWorktree: "reuse-task-worktree" as any });
|
||||
const result = await aiMergeTask(store, repo, "FN-EMPTY-DONE");
|
||||
|
||||
expect(result.merged).toBe(true);
|
||||
expect(result.noOp).toBe(true);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-EMPTY-DONE", "done");
|
||||
}, 20_000);
|
||||
|
||||
it("blocks FN-4653 shape: foreign start-point branch with no FN-owned commits", async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), "fusion-merger-unproven-"));
|
||||
repos.push(repo);
|
||||
|
||||
@@ -3944,6 +3944,131 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("FN-6461: demotes no-commits no-op review tasks with skipped-out work", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("rev-parse --verify 'fusion/fn-6461'")) return "ok" as any;
|
||||
if (cmd.includes("rev-parse --verify 'main'")) return "ok" as any;
|
||||
if (cmd.includes("rev-list --count 'main'..'fusion/fn-6461'")) return "0\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-6461",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
worktree: "/tmp/test-project/.worktrees/fn-6461",
|
||||
branch: "fusion/fn-6461",
|
||||
noCommitsExpected: true,
|
||||
steps: [
|
||||
{ name: "Preflight", status: "done" },
|
||||
{ name: "Dry-run", status: "skipped" },
|
||||
{ name: "Execute", status: "skipped" },
|
||||
{ name: "Verify", status: "skipped" },
|
||||
{ name: "Testing", status: "skipped" },
|
||||
{ name: "Documentation", status: "skipped" },
|
||||
],
|
||||
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
|
||||
mergeDetails: undefined,
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.finalizeNoOpReviewTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-6461", expect.objectContaining({ error: expect.stringContaining("done=1, incomplete=5") }));
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-6461", "todo", expect.objectContaining({ preserveProgress: true, moveSource: "engine", recoveryRehome: true }));
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-6461", "done");
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-6461",
|
||||
expect.stringContaining("Finalize blocked (no-commits incomplete-work guard)"),
|
||||
expect.stringContaining("self-healing-finalize-no-op-review"),
|
||||
);
|
||||
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
mutationType: "task:no-commits-finalize-blocked-incomplete-steps",
|
||||
target: "FN-6461",
|
||||
}));
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("FN-6461: still finalizes all-done no-commits no-op review tasks", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
});
|
||||
mockedExecSync.mockImplementation((command) => {
|
||||
const cmd = String(command);
|
||||
if (cmd.includes("rev-parse --verify 'fusion/fn-6462'")) return "ok" as any;
|
||||
if (cmd.includes("rev-parse --verify 'main'")) return "ok" as any;
|
||||
if (cmd.includes("rev-list --count 'main'..'fusion/fn-6462'")) return "0\n" as any;
|
||||
return "" as any;
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-6462",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
worktree: "/tmp/test-project/.worktrees/fn-6462",
|
||||
branch: "fusion/fn-6462",
|
||||
noCommitsExpected: true,
|
||||
steps: [{ name: "Preflight", status: "done" }, { name: "Verify", status: "done" }],
|
||||
workflowStepResults: [{ id: "ws-1", status: "passed", phase: "pre-merge" }],
|
||||
mergeDetails: undefined,
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.finalizeNoOpReviewTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-6462", "done");
|
||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-6462", "todo", expect.anything());
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("FN-6461: stranded todo recovery does not promote skipped-to-completion no-commits tasks", async () => {
|
||||
const recoverCompletedTask = vi.fn().mockResolvedValue(true);
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
recoverCompletedTask,
|
||||
});
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-6463",
|
||||
column: "todo",
|
||||
paused: false,
|
||||
status: null,
|
||||
noCommitsExpected: true,
|
||||
steps: [{ name: "Preflight", status: "done" }, { name: "Execute", status: "skipped" }],
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks();
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(recoverCompletedTask).not.toHaveBeenCalled();
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("blocks unproven no-op finalize candidates and emits audit", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
|
||||
@@ -38,6 +38,7 @@ import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import {
|
||||
buildTaskLineageTrailer,
|
||||
evaluateNoCommitsNoOpFinalize,
|
||||
getPrimaryPrInfo,
|
||||
getTaskMergeBlocker,
|
||||
resolveAgentPrompt,
|
||||
@@ -1125,6 +1126,50 @@ export async function runAiMerge(
|
||||
if (!squashSha) {
|
||||
// Branch had no net changes vs the tip — nothing to land.
|
||||
await audit.git({ type: "merge:ai-empty", target: integrationBranch, metadata: { taskId, tipSha } });
|
||||
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
|
||||
if (noCommitsFinalize.blocked) {
|
||||
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
|
||||
/*
|
||||
* FNXC:Lifecycle 2026-06-14-20:02:
|
||||
* FN-6461/FN-6455 requires the AI empty-merge lane to demote no-commits tasks whose skipped/incomplete steps outweigh done steps instead of finalizing the operational work as done.
|
||||
*/
|
||||
await store.updateTask(taskId, { error: reason });
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
|
||||
JSON.stringify({
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
branch,
|
||||
integrationBranch,
|
||||
lane: "ai-empty-merge",
|
||||
}, null, 2),
|
||||
);
|
||||
await audit.database({
|
||||
type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters<typeof audit.database>[0]["type"],
|
||||
target: taskId,
|
||||
metadata: {
|
||||
reason,
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
branch,
|
||||
integrationBranch,
|
||||
lane: "ai-empty-merge",
|
||||
},
|
||||
});
|
||||
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters<TaskStore["moveTask"]>[2]);
|
||||
return {
|
||||
task,
|
||||
branch,
|
||||
merged: false,
|
||||
noOp: false,
|
||||
ok: true,
|
||||
reason,
|
||||
error: reason,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
};
|
||||
}
|
||||
await log(`AI merge: ${branch} had no net changes vs ${integrationBranch} — finalizing as no-op`);
|
||||
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, tipSha, audit, log, { empty: true });
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ import { isBranchAuthoritativeForTask } from "./branch-conflicts.js";
|
||||
import { hostname } from "node:os";
|
||||
import {
|
||||
buildTaskLineageTrailer,
|
||||
evaluateNoCommitsNoOpFinalize,
|
||||
getTaskMergeBlocker,
|
||||
normalizeMergeConflictStrategy,
|
||||
normalizeMergeStrategyOverlapBehavior,
|
||||
@@ -7370,6 +7371,51 @@ async function tryEarlyEmptyOwnDiffFinalize(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
|
||||
if (noCommitsFinalize.blocked) {
|
||||
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
|
||||
/*
|
||||
* FNXC:Lifecycle 2026-06-14-20:06:
|
||||
* FN-6461/FN-6455 requires the early empty-own-diff fast-path to block before mergeDetails writes or branch/worktree cleanup so incomplete release/ops work remains recoverable.
|
||||
*/
|
||||
await store.updateTask(taskId, { error: reason });
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
|
||||
JSON.stringify({
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
branch,
|
||||
mergeTargetBranch,
|
||||
lane: "early-empty-own-diff",
|
||||
}, null, 2),
|
||||
);
|
||||
await audit.database({
|
||||
type: "task:no-commits-finalize-blocked-incomplete-steps" as Parameters<typeof audit.database>[0]["type"],
|
||||
target: taskId,
|
||||
metadata: {
|
||||
reason,
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
branch,
|
||||
mergeTargetBranch,
|
||||
lane: "early-empty-own-diff",
|
||||
},
|
||||
});
|
||||
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
|
||||
return {
|
||||
task,
|
||||
branch,
|
||||
merged: false,
|
||||
noOp: false,
|
||||
ok: true,
|
||||
reason,
|
||||
error: reason,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const noOpReason = `early fast-path: branch ${branch} has ${aheadCount} own commit(s) but zero net diff vs merge-base of ${mergeTargetBranch}`;
|
||||
const mergedAt = new Date().toISOString();
|
||||
const mergeDetails: MergeDetails = {
|
||||
@@ -8382,6 +8428,52 @@ export async function aiMergeTask(
|
||||
// — NOT a legitimate no-op. Demote to the unproven-recovery path which
|
||||
// moves the task back to todo with progress preserved instead of
|
||||
// clearing modifiedFiles to [].
|
||||
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
|
||||
if (noCommitsFinalize.blocked) {
|
||||
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
|
||||
/*
|
||||
* FNXC:Lifecycle 2026-06-14-20:08:
|
||||
* FN-6461/FN-6455 extends the FN-5490 no-op demotion pattern to no-commits tasks whose skipped/incomplete steps outweigh completed work.
|
||||
*/
|
||||
await store.updateTask(taskId, { error: reason });
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
|
||||
JSON.stringify({
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
classification: classification.kind,
|
||||
baseRef: classification.baseRef,
|
||||
lane: "legacy-no-op-classifier",
|
||||
}, null, 2),
|
||||
);
|
||||
await (store as any).recordRunAuditEvent?.({
|
||||
domain: "database",
|
||||
mutationType: "task:no-commits-finalize-blocked-incomplete-steps",
|
||||
target: taskId,
|
||||
metadata: {
|
||||
reason,
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
classification: classification.kind,
|
||||
baseRef: classification.baseRef,
|
||||
lane: "legacy-no-op-classifier",
|
||||
},
|
||||
});
|
||||
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
|
||||
await releaseReuseHandoffEarly("no-commits-incomplete-blocked");
|
||||
return {
|
||||
task,
|
||||
branch,
|
||||
merged: false,
|
||||
noOp: false,
|
||||
ok: true,
|
||||
reason,
|
||||
worktreeRemoved: false,
|
||||
branchDeleted: false,
|
||||
error: reason,
|
||||
};
|
||||
}
|
||||
if (task.modifiedFiles && task.modifiedFiles.length > 0) {
|
||||
const reason = `lost-work-detected: ${task.modifiedFiles.length} modifiedFiles claimed but no commit landed`;
|
||||
await store.updateTask(taskId, { error: reason });
|
||||
@@ -8641,6 +8733,44 @@ export async function aiMergeTask(
|
||||
result.mergeTargetSource = mergeTarget.source;
|
||||
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${classification.commit.sha.slice(0, 8)}`);
|
||||
} else {
|
||||
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
|
||||
if (noCommitsFinalize.blocked) {
|
||||
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
|
||||
/*
|
||||
* FNXC:Lifecycle 2026-06-14-20:10:
|
||||
* FN-6461/FN-6455 applies the same no-commits incomplete-work guard when branch-missing classification would otherwise finalize a zero-change task.
|
||||
*/
|
||||
result.error = reason;
|
||||
result.reason = reason;
|
||||
result.noOp = false;
|
||||
await store.updateTask(taskId, { error: reason });
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
|
||||
JSON.stringify({
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
classification: classification.kind,
|
||||
baseRef: classification.baseRef,
|
||||
lane: "legacy-branch-missing-no-op",
|
||||
}, null, 2),
|
||||
);
|
||||
await (store as any).recordRunAuditEvent?.({
|
||||
domain: "database",
|
||||
mutationType: "task:no-commits-finalize-blocked-incomplete-steps",
|
||||
target: taskId,
|
||||
metadata: {
|
||||
reason,
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
classification: classification.kind,
|
||||
baseRef: classification.baseRef,
|
||||
lane: "legacy-branch-missing-no-op",
|
||||
},
|
||||
});
|
||||
await store.moveTask(taskId, "todo", { preserveProgress: true, moveSource: "engine" } as any);
|
||||
return result;
|
||||
}
|
||||
const noOpReason = `branch has zero commits ahead of ${classification.baseRef}`;
|
||||
const mergedAt = new Date().toISOString();
|
||||
await store.updateTask(taskId, {
|
||||
|
||||
@@ -565,6 +565,12 @@ export type DatabaseMutationType =
|
||||
* Metadata: { modifiedFilesCount, classification, baseRef? }
|
||||
*/
|
||||
| "task:finalize-lost-work-blocked"
|
||||
/**
|
||||
* FNXC:Lifecycle 2026-06-14-20:16:
|
||||
* FN-6461 records every no-op finalize lane that refuses to mark a no-commits task done because incomplete/skipped steps outweigh completed work.
|
||||
* Metadata: { reason, doneCount, incompleteCount, classification?, baseRef?, lane }
|
||||
*/
|
||||
| "task:no-commits-finalize-blocked-incomplete-steps"
|
||||
| "task:integrity-reconcile-modified-files"
|
||||
| "task:integrity-warning"
|
||||
/** FN-5092 watchdog: stale `status: "merging"` / `"merging-pr"` cleared on a done/archived task. Metadata: { previousColumn, previousStatus, ageMs, mergeConfirmed?: boolean } */
|
||||
|
||||
@@ -30,7 +30,7 @@ import { setImmediate as setImmediateCb } from "node:timers";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
|
||||
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
|
||||
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
|
||||
import { createLogger, schedulerLog } from "./logger.js";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
@@ -2267,6 +2267,11 @@ export class SelfHealingManager {
|
||||
if (task.column !== "todo" || task.paused) return false;
|
||||
if (executingIds.has(task.id)) return false;
|
||||
if (task.steps.length === 0 || !task.steps.every((s) => s.status === "done" || s.status === "skipped")) return false;
|
||||
/*
|
||||
* FNXC:Lifecycle 2026-06-14-20:12:
|
||||
* FN-6461 keeps skipped-to-completion no-commits tasks out of the stranded-todo promoter so a finalize guard demotion cannot loop back into in-review before an operator fixes the incomplete work.
|
||||
*/
|
||||
if (evaluateNoCommitsNoOpFinalize(task).blocked) return false;
|
||||
if (task.error) return false;
|
||||
if (task.status && STRANDED_COMPLETED_TODO_ACTIVE_STATUSES.has(task.status)) return false;
|
||||
if (task.reviewState?.refreshStatus === "refreshing") return false;
|
||||
@@ -4943,7 +4948,7 @@ export class SelfHealingManager {
|
||||
return recovered;
|
||||
}
|
||||
|
||||
private async recordIntegrityAudit(taskId: string, mutationType: "task:finalize-unproven-blocked" | "task:finalize-lost-work-blocked" | "task:integrity-reconcile-modified-files" | "task:integrity-warning" | "task:auto-recover-stale-merger-status", metadata: Record<string, unknown>): Promise<void> {
|
||||
private async recordIntegrityAudit(taskId: string, mutationType: "task:finalize-unproven-blocked" | "task:finalize-lost-work-blocked" | "task:no-commits-finalize-blocked-incomplete-steps" | "task:integrity-reconcile-modified-files" | "task:integrity-warning" | "task:auto-recover-stale-merger-status", metadata: Record<string, unknown>): Promise<void> {
|
||||
const auditor = createRunAuditor(this.store, {
|
||||
runId: generateSyntheticRunId("self-healing-integrity", taskId),
|
||||
agentId: "self-healing",
|
||||
@@ -5131,6 +5136,38 @@ export class SelfHealingManager {
|
||||
// the audit trail of the lost work. Now we refuse to finalize and
|
||||
// move the task back to todo with progress preserved so the next
|
||||
// executor run can re-attempt.
|
||||
const noCommitsFinalize = evaluateNoCommitsNoOpFinalize(task);
|
||||
if (noCommitsFinalize.blocked) {
|
||||
const reason = noCommitsFinalize.reason ?? "no-commits task has incomplete work with no net branch changes";
|
||||
/*
|
||||
* FNXC:Lifecycle 2026-06-14-20:14:
|
||||
* FN-6461/FN-6455 requires self-healing no-op finalization to demote no-commits tasks with incomplete/skipped work and set an error so stranded-todo recovery will not immediately re-promote them.
|
||||
*/
|
||||
await this.store.updateTask(task.id, { error: reason });
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
`Finalize blocked (no-commits incomplete-work guard): ${reason} — moving back to todo with progress preserved`,
|
||||
JSON.stringify({
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
classification: "proven-no-op",
|
||||
baseRef: classification.baseRef,
|
||||
lane: "self-healing-finalize-no-op-review",
|
||||
}, null, 2),
|
||||
);
|
||||
await this.recordIntegrityAudit(task.id, "task:no-commits-finalize-blocked-incomplete-steps", {
|
||||
reason,
|
||||
doneCount: noCommitsFinalize.doneCount,
|
||||
incompleteCount: noCommitsFinalize.incompleteCount,
|
||||
classification: "proven-no-op",
|
||||
baseRef: classification.baseRef,
|
||||
lane: "self-healing-finalize-no-op-review",
|
||||
});
|
||||
// #1411: backward recovery — skip order-derived adjacency.
|
||||
await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true });
|
||||
recovered++;
|
||||
continue;
|
||||
}
|
||||
if (task.modifiedFiles && task.modifiedFiles.length > 0) {
|
||||
await this.store.logEntry(
|
||||
task.id,
|
||||
|
||||
Reference in New Issue
Block a user