fix(FN-7273): prevent stale step resume regressions

This commit is contained in:
gsxdsm
2026-06-30 08:07:40 -07:00
parent 94ddfe1064
commit 4441b72bbc
5 changed files with 166 additions and 10 deletions

View File

@@ -5,6 +5,7 @@ import { reviewStep as mockedReviewStepFn } from "../reviewer.js";
import {
createMockStore,
mockedCreateFnAgent,
mockedExecSync,
mockedExistsSync,
resetExecutorMocks,
} from "./executor-test-helpers.js";
@@ -151,6 +152,96 @@ describe("executor tool step numbering is 0-based", () => {
);
});
it("does not reconcile reopened steps from older complete-step commits", async () => {
const store = createMockStore();
const detail = {
id: "FN-7273",
title: "Reopened suffix",
description: "",
column: "in-progress",
dependencies: [],
baseCommitSha: "base",
steps: [
{ name: "Preflight", status: "done" },
{ name: "Implementation", status: "done" },
{ name: "Testing", status: "pending" },
],
currentStep: 2,
log: [
{ timestamp: "2026-06-30T14:59:30.110Z", action: "Step 2 (Testing) → pending" },
],
prompt: "# test\n## Steps\n### Step 0: Preflight\n### Step 1: Implementation\n### Step 2: Testing",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as any;
store.getTask.mockResolvedValue(detail);
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("git log")) {
return "1782831500\tfeat(FN-7273): complete Step 2 — old verification\n";
}
return "";
});
const executor = new TaskExecutor(store as any, "/tmp/test");
await (executor as any).reconcileStepsFromGitHistory("FN-7273", detail, "/tmp/wt");
expect(store.updateStep).not.toHaveBeenCalled();
expect(store.logEntry).not.toHaveBeenCalledWith(
"FN-7273",
expect.stringContaining("Reconciled Step 2 as done from git history"),
expect.anything(),
expect.anything(),
);
});
it("does not log git-history reconciliation when TaskStore rejects the done write", async () => {
const store = createMockStore();
const detail = {
id: "FN-7273",
title: "Out of order reconciliation",
description: "",
column: "in-progress",
dependencies: [],
baseCommitSha: "base",
steps: [
{ name: "Preflight", status: "done" },
{ name: "Fix", status: "in-progress" },
{ name: "Delivery", status: "pending" },
],
currentStep: 1,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n### Step 1: Fix\n### Step 2: Delivery",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as any;
store.getTask.mockResolvedValue(detail);
store.updateStep.mockResolvedValue({
...detail,
steps: [
{ name: "Preflight", status: "done" },
{ name: "Fix", status: "in-progress" },
{ name: "Delivery", status: "pending" },
],
} as any);
mockedExecSync.mockImplementation((cmd: string) => {
if (cmd.includes("git log")) {
return "1782832000\tfeat(FN-7273): complete Step 2 — old delivery\n";
}
return "";
});
const executor = new TaskExecutor(store as any, "/tmp/test");
await (executor as any).reconcileStepsFromGitHistory("FN-7273", detail, "/tmp/wt");
expect(store.updateStep).toHaveBeenCalledWith("FN-7273", 2, "done");
expect(store.logEntry).not.toHaveBeenCalledWith(
"FN-7273",
expect.stringContaining("Reconciled Step 2 as done from git history"),
expect.anything(),
expect.anything(),
);
});
it("pending-review loop detection matches 0-based writer strings", async () => {
const store = createMockStore();
const task = {

View File

@@ -16352,7 +16352,7 @@ You have access to the file system to review changes.${verdictBlock}`;
let logOutput: string;
try {
const { stdout } = await execAsync(
`git log "${baseCommitSha}..HEAD" --oneline`,
`git log "${baseCommitSha}..HEAD" --format=%ct%x09%s`,
{ cwd: worktreePath },
);
logOutput = stdout;
@@ -16364,17 +16364,35 @@ You have access to the file system to review changes.${verdictBlock}`;
if (!logOutput.trim()) return;
const latestPendingByStep = new Map<number, number>();
for (const entry of detail.log ?? []) {
const action = entry.action ?? "";
const match = action.match(/^Step (\d+) \(.+\) → pending$/);
if (!match) continue;
const stepIndex = Number.parseInt(match[1], 10);
const pendingAt = Date.parse(entry.timestamp);
if (!Number.isInteger(stepIndex) || !Number.isFinite(pendingAt)) continue;
latestPendingByStep.set(stepIndex, Math.max(latestPendingByStep.get(stepIndex) ?? -1, pendingAt));
}
/*
FNXC:WorkflowResume 2026-06-30-08:02:
Browser Verification and Code Review REVISE intentionally reopen the trailing implementation/verification suffix. FN-7273 showed git-history resume then found older `complete Step 5` commits from the previous attempt, tried to mark Step 5 done while Step 3 was active, and logged a false reconciliation after TaskStore rejected the out-of-order write. A reopened step may only be reconciled from a commit whose author time is newer than the latest `→ pending` transition for that step, and success is logged only after the store confirms the step is terminal.
*/
// Match: feat(FN-2978): complete Step 3 / chore(fn-2978)!: Complete step 3
const stepCommitRegex = /^(?:feat|chore|fix)\([Ff][Nn]-\d+\)(?:!)?:\s*complete\s+step\s+(\d+)/i;
const reconciledStepIndices = new Set<number>();
for (const line of logOutput.split("\n")) {
// git log --oneline format: "<sha> <message>"
const message = line.replace(/^[0-9a-f]+ /, "").trim();
const [commitSecondsRaw, ...messageParts] = line.split("\t");
const commitMs = Number.parseInt(commitSecondsRaw ?? "", 10) * 1000;
const message = messageParts.join("\t").trim();
const match = message.match(stepCommitRegex);
if (!match) continue;
const stepIndex = parseInt(match[1], 10);
if (Number.isNaN(stepIndex) || stepIndex < 0 || stepIndex >= detail.steps.length) continue;
const latestPendingAt = latestPendingByStep.get(stepIndex);
if (latestPendingAt !== undefined && (!Number.isFinite(commitMs) || commitMs <= latestPendingAt)) continue;
const step = detail.steps[stepIndex];
if (step.status === "pending" || step.status === "in-progress") {
reconciledStepIndices.add(stepIndex);
@@ -16382,7 +16400,14 @@ You have access to the file system to review changes.${verdictBlock}`;
}
for (const stepIndex of reconciledStepIndices) {
await this.store.updateStep(taskId, stepIndex, "done");
const updated = await this.store.updateStep(taskId, stepIndex, "done");
const updatedStepStatus = updated.steps?.[stepIndex]?.status;
if (updatedStepStatus !== "done" && updatedStepStatus !== "skipped") {
executorLog.warn(
`${taskId}: skipped git-history reconciliation log for Step ${stepIndex}; store kept status ${updatedStepStatus ?? "missing"}`,
);
continue;
}
await this.store.logEntry(
taskId,
`Reconciled Step ${stepIndex} as done from git history (resume)`,