fix(state): skip cancelled slices during dispatch

This commit is contained in:
Mikael Hugo 2026-05-15 20:24:11 +02:00
parent 62d63f111e
commit 8e85a6e673
3 changed files with 35 additions and 6 deletions

View file

@ -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 };
}

View file

@ -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"
);
}

View file

@ -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);