From 8e85a6e673c02df253e71970d2169028706237b2 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 20:24:11 +0200 Subject: [PATCH] fix(state): skip cancelled slices during dispatch --- src/resources/extensions/sf/state-db.js | 7 ++--- src/resources/extensions/sf/status-guards.js | 6 ++-- .../sf/tests/db-driven-runtime-state.test.mjs | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/resources/extensions/sf/state-db.js b/src/resources/extensions/sf/state-db.js index 89edda447..fbb78252a 100644 --- a/src/resources/extensions/sf/state-db.js +++ b/src/resources/extensions/sf/state-db.js @@ -41,7 +41,7 @@ import { readMilestoneValidationVerdict, stripMilestonePrefix, } from "./state-shared.js"; -import { isClosedStatus, isDeferredStatus } from "./status-guards.js"; +import { isClosedStatus, isInactiveStatus } from "./status-guards.js"; import { logWarning } from "./workflow-logger.js"; // ─── DB-backed State Derivation ──────────────────────────────────────────── @@ -502,7 +502,7 @@ function resolveSliceDependencies(activeMilestoneSlices) { const sliceLock = process.env.SF_SLICE_LOCK; if (sliceLock) { const lockedSlice = activeMilestoneSlices.find((s) => s.id === sliceLock); - if (lockedSlice) { + if (lockedSlice && !isInactiveStatus(lockedSlice.status)) { return { activeSlice: { id: lockedSlice.id, title: lockedSlice.title }, activeSliceRow: lockedSlice, @@ -519,8 +519,7 @@ function resolveSliceDependencies(activeMilestoneSlices) { let bestFallback = null; let bestFallbackSatisfied = -1; for (const s of activeMilestoneSlices) { - if (isStatusDone(s.status)) continue; - if (isDeferredStatus(s.status)) continue; + if (isInactiveStatus(s.status)) continue; if (s.depends.every((dep) => doneSliceIds.has(dep))) { return { activeSlice: { id: s.id, title: s.title }, activeSliceRow: s }; } diff --git a/src/resources/extensions/sf/status-guards.js b/src/resources/extensions/sf/status-guards.js index dfc359cfd..e78116179 100644 --- a/src/resources/extensions/sf/status-guards.js +++ b/src/resources/extensions/sf/status-guards.js @@ -17,8 +17,10 @@ export function isDeferredStatus(status) { } /** * Returns true when a slice should be skipped during active-slice selection. - * This includes both closed (complete/done) and deferred slices. + * This includes closed, deferred, and cancelled slices. */ export function isInactiveStatus(status) { - return isClosedStatus(status) || isDeferredStatus(status); + return ( + isClosedStatus(status) || isDeferredStatus(status) || status === "cancelled" + ); } diff --git a/src/resources/extensions/sf/tests/db-driven-runtime-state.test.mjs b/src/resources/extensions/sf/tests/db-driven-runtime-state.test.mjs index 7adf452e3..e89552c8d 100644 --- a/src/resources/extensions/sf/tests/db-driven-runtime-state.test.mjs +++ b/src/resources/extensions/sf/tests/db-driven-runtime-state.test.mjs @@ -134,6 +134,34 @@ test("deriveState_when_generated_projections_are_stale_uses_db_slice_and_task_se assert.equal(state.activeTask?.id, "T02"); }); +test("deriveState_when_all_slices_cancelled_does_not_select_cancelled_slice", async () => { + const project = mkdtempSync(join(tmpdir(), "sf-db-runtime-state-")); + tmpDirs.push(project); + mkdirSync(join(project, ".sf", "milestones", "M779", "slices", "S01"), { + recursive: true, + }); + openDatabase(join(project, ".sf", "sf.db")); + insertMilestone({ + id: "M779", + title: "Cancelled slice selection", + status: "active", + }); + insertSlice({ + milestoneId: "M779", + id: "S01", + title: "Cancelled work", + status: "cancelled", + sequence: 1, + }); + + const state = await deriveState(project); + + assert.equal(state.phase, "blocked"); + assert.equal(state.activeMilestone?.id, "M779"); + assert.equal(state.activeSlice, null); + assert.deepEqual(state.blockers, ["No slice eligible — check dependency ordering"]); +}); + test("deriveState_when_db_has_no_tasks_refuses_runtime_plan_file_import", async () => { const project = mkdtempSync(join(tmpdir(), "sf-db-runtime-state-")); tmpDirs.push(project);