From d6fc1211b7fb5b4129c01d16ab145a562f9f96ed Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Wed, 29 Apr 2026 20:33:06 +0200 Subject: [PATCH] fix: auto-skip stale instruction-conflict tasks --- src/resources/extensions/sf/auto-dispatch.ts | 16 ++++++- .../sf/execution-instruction-guard.ts | 42 ++++++++++++++++++ .../tests/dispatch-missing-task-plans.test.ts | 44 ++++++++++++++----- .../extensions/sf/workflow-reconcile.ts | 7 +++ 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/sf/auto-dispatch.ts b/src/resources/extensions/sf/auto-dispatch.ts index d4755a8fa..acdd5b0e7 100644 --- a/src/resources/extensions/sf/auto-dispatch.ts +++ b/src/resources/extensions/sf/auto-dispatch.ts @@ -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, diff --git a/src/resources/extensions/sf/execution-instruction-guard.ts b/src/resources/extensions/sf/execution-instruction-guard.ts index c59a94056..b52c031c1 100644 --- a/src/resources/extensions/sf/execution-instruction-guard.ts +++ b/src/resources/extensions/sf/execution-instruction-guard.ts @@ -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 { + 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(); +} diff --git a/src/resources/extensions/sf/tests/dispatch-missing-task-plans.test.ts b/src/resources/extensions/sf/tests/dispatch-missing-task-plans.test.ts index fe9480f04..16d3fab1d 100644 --- a/src/resources/extensions/sf/tests/dispatch-missing-task-plans.test.ts +++ b/src/resources/extensions/sf/tests/dispatch-missing-task-plans.test.ts @@ -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 { @@ -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", ); }); diff --git a/src/resources/extensions/sf/workflow-reconcile.ts b/src/resources/extensions/sf/workflow-reconcile.ts index 15f011c53..b3d48c294 100644 --- a/src/resources/extensions/sf/workflow-reconcile.ts +++ b/src/resources/extensions/sf/workflow-reconcile.ts @@ -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;