fix: auto-skip stale instruction-conflict tasks

This commit is contained in:
Mikael Hugo 2026-04-29 20:33:06 +02:00
parent 46174c1183
commit d6fc1211b7
4 changed files with 98 additions and 11 deletions

View file

@ -32,7 +32,10 @@ import {
checkNeedsRunUat,
} from "./auto-prompts.js";
import { hasImplementationArtifacts } from "./auto-recovery.js";
import { getExecuteTaskInstructionConflict } from "./execution-instruction-guard.js";
import {
getExecuteTaskInstructionConflict,
skipExecuteTaskForInstructionConflict,
} from "./execution-instruction-guard.js";
import {
extractUatType,
loadActiveOverrides,
@ -921,6 +924,17 @@ export const DISPATCH_RULES: DispatchRule[] = [
tTitle,
);
if (instructionConflict) {
if (isDbAvailable()) {
await skipExecuteTaskForInstructionConflict(
basePath,
mid,
sid,
tid,
instructionConflict.reason,
);
logWarning("dispatch", instructionConflict.reason);
return { action: "skip" };
}
return {
action: "stop",
reason: instructionConflict.reason,

View file

@ -1,6 +1,12 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveTaskFile } from "./paths.js";
import { updateTaskStatus } from "./sf-db.js";
import { invalidateStateCache } from "./state.js";
import { appendEvent } from "./workflow-events.js";
import { logWarning } from "./workflow-logger.js";
import { writeManifest } from "./workflow-manifest.js";
import { renderAllProjections } from "./workflow-projections.js";
export interface ExecutionInstructionConflict {
reason: string;
@ -92,3 +98,39 @@ export function getExecuteTaskInstructionConflict(
"Replan or skip this stale task, and use repo-appropriate verification instead.",
};
}
export async function skipExecuteTaskForInstructionConflict(
basePath: string,
mid: string,
sid: string,
tid: string,
reason: string,
): Promise<void> {
const ts = new Date().toISOString();
updateTaskStatus(mid, sid, tid, "skipped", ts);
try {
await renderAllProjections(basePath, mid);
writeManifest(basePath);
appendEvent(basePath, {
cmd: "skip-task",
params: {
milestoneId: mid,
sliceId: sid,
taskId: tid,
reason,
},
ts,
actor: "system",
actor_name: "instruction-conflict-guard",
trigger_reason: "repo instructions conflict with planned task",
});
} catch (err) {
logWarning(
"dispatch",
`instruction-conflict skip post-mutation hook warning: ${err instanceof Error ? err.message : String(err)}`,
);
}
invalidateStateCache();
}

View file

@ -16,6 +16,14 @@ import { join } from "node:path";
import test from "node:test";
import type { DispatchContext } from "../auto-dispatch.ts";
import { resolveDispatch } from "../auto-dispatch.ts";
import {
closeDatabase,
getTask,
insertMilestone,
insertSlice,
insertTask,
openDatabase,
} from "../sf-db.ts";
import type { SFState } from "../types.ts";
function makeState(overrides: Partial<SFState> = {}): SFState {
@ -129,9 +137,14 @@ test("dispatch: present task plan proceeds to execute-task normally", async (t)
);
});
test("dispatch: stale Docker Compose staging task is blocked by current repo instructions", async (t) => {
test("dispatch: stale Docker Compose staging task is auto-skipped when DB can advance state", async (t) => {
const tmp = mkdtempSync(join(tmpdir(), "sf-stale-staging-"));
t.after(() => rmSync(tmp, { recursive: true, force: true }));
t.after(() => {
closeDatabase();
rmSync(tmp, { recursive: true, force: true });
});
mkdirSync(join(tmp, ".sf"), { recursive: true });
openDatabase(join(tmp, ".sf", "sf.db"));
writeFileSync(
join(tmp, "AGENTS.md"),
@ -146,6 +159,20 @@ test("dispatch: stale Docker Compose staging task is blocked by current repo ins
);
scaffoldSlicePlan(tmp, "M002", "S03");
scaffoldTaskPlan(tmp, "M002", "S03", "T01");
insertMilestone({ id: "M002", title: "Test Milestone" });
insertSlice({
id: "S03",
milestoneId: "M002",
title: "Third Slice",
status: "active",
});
insertTask({
id: "T01",
sliceId: "S03",
milestoneId: "M002",
title: "Validate docker-compose staging stack starts cleanly",
status: "pending",
});
writeFileSync(
join(
tmp,
@ -169,14 +196,11 @@ test("dispatch: stale Docker Compose staging task is blocked by current repo ins
const result = await resolveDispatch(makeContext(tmp));
assert.equal(result.action, "stop");
assert.match(
result.action === "stop" ? result.reason : "",
/Docker Compose staging/,
);
assert.match(
result.action === "stop" ? result.reason : "",
/deploy\/staging as historical/,
assert.equal(result.action, "skip");
assert.equal(
getTask("M002", "S03", "T01")?.status,
"skipped",
"stale staging task should be closed before skip re-derives state",
);
});

View file

@ -105,6 +105,13 @@ function replayEvents(events: WorkflowEvent[]): void {
updateTaskStatus(milestoneId, sliceId, taskId, "done", event.ts);
break;
}
case "skip_task": {
const milestoneId = p["milestoneId"] as string;
const sliceId = p["sliceId"] as string;
const taskId = p["taskId"] as string;
updateTaskStatus(milestoneId, sliceId, taskId, "skipped", event.ts);
break;
}
case "start_task": {
const milestoneId = p["milestoneId"] as string;
const sliceId = p["sliceId"] as string;