fix(auto): replan inactive milestones with DB context
This commit is contained in:
parent
ecf6af92e8
commit
a2a6ab767c
4 changed files with 125 additions and 22 deletions
|
|
@ -423,9 +423,7 @@ async function handleAllSlicesDone(
|
|||
// happened. Route those to reassessment instead so the milestone
|
||||
// gets real slices planned.
|
||||
const hasRealWork = Array.isArray(activeMilestoneSlices)
|
||||
? activeMilestoneSlices.some(
|
||||
(s) => s.status === "complete" || s.status === "done",
|
||||
)
|
||||
? hasRealWorkSlice(activeMilestoneSlices)
|
||||
: true; // fall back to old behaviour if caller didn't pass slices
|
||||
if (!hasRealWork) {
|
||||
// Route into the pre-planning ladder. The dispatcher decides:
|
||||
|
|
@ -440,25 +438,13 @@ async function handleAllSlicesDone(
|
|||
// planner has the purpose it needs. No operator action
|
||||
// required - autonomous mode will pick it up on the next
|
||||
// dispatch tick.
|
||||
return {
|
||||
return buildNoRealWorkPrePlanningState(
|
||||
activeMilestone,
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
phase: "pre-planning",
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction:
|
||||
`Milestone ${activeMilestone.id} has no slice carrying real ` +
|
||||
`work (every slice is skipped). Route into the pre-planning ` +
|
||||
`ladder so the dispatcher picks discuss/research/plan based ` +
|
||||
`on which artifacts are missing. Use ` +
|
||||
`\`sf headless complete-milestone ${activeMilestone.id}\` ` +
|
||||
`only if you want to intentionally defer the work without ` +
|
||||
`planning it.`,
|
||||
registry,
|
||||
requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
milestoneProgress,
|
||||
sliceProgress,
|
||||
);
|
||||
}
|
||||
const { terminal: validationTerminal, verdict } =
|
||||
await readMilestoneValidationVerdict(
|
||||
|
|
@ -553,6 +539,39 @@ function resolveSliceDependencies(activeMilestoneSlices) {
|
|||
}
|
||||
return { activeSlice: null, activeSliceRow: null };
|
||||
}
|
||||
|
||||
function hasRealWorkSlice(slices) {
|
||||
return slices.some((s) => s.status === "complete" || s.status === "done");
|
||||
}
|
||||
|
||||
function buildNoRealWorkPrePlanningState(
|
||||
activeMilestone,
|
||||
registry,
|
||||
requirements,
|
||||
milestoneProgress,
|
||||
sliceProgress,
|
||||
) {
|
||||
return {
|
||||
activeMilestone,
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
phase: "pre-planning",
|
||||
recentDecisions: [],
|
||||
blockers: [],
|
||||
nextAction:
|
||||
`Milestone ${activeMilestone.id} has no slice carrying real ` +
|
||||
`work (every slice is skipped, cancelled, or otherwise inactive). ` +
|
||||
`Route into the pre-planning ladder so the dispatcher picks ` +
|
||||
`discuss/research/plan based on which artifacts are missing. Use ` +
|
||||
`\`sf headless complete-milestone ${activeMilestone.id}\` ` +
|
||||
`only if you want to intentionally defer the work without ` +
|
||||
`planning it.`,
|
||||
registry,
|
||||
requirements,
|
||||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
|
||||
async function reconcileSliceTasks(basePath, milestoneId, sliceId, planFile) {
|
||||
const tasks = getSliceTasks(milestoneId, sliceId);
|
||||
if (tasks.length === 0 && planFile) {
|
||||
|
|
@ -753,6 +772,18 @@ export async function deriveStateFromDb(basePath) {
|
|||
progress: { milestones: milestoneProgress, slices: sliceProgress },
|
||||
};
|
||||
}
|
||||
if (
|
||||
activeMilestoneSlices.length > 0 &&
|
||||
!hasRealWorkSlice(activeMilestoneSlices)
|
||||
) {
|
||||
return buildNoRealWorkPrePlanningState(
|
||||
activeMilestone,
|
||||
registry,
|
||||
requirements,
|
||||
milestoneProgress,
|
||||
sliceProgress,
|
||||
);
|
||||
}
|
||||
return {
|
||||
activeMilestone,
|
||||
activeSlice: null,
|
||||
|
|
|
|||
|
|
@ -154,6 +154,56 @@ describe("resolveDispatch canonical milestone plan", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("pre_planning_when_db_has_milestone_context_dispatches_plan_milestone", async () => {
|
||||
const base = makeTempDir("sf-dispatch-db-context-plan-");
|
||||
try {
|
||||
mkdirSync(join(base, ".sf"), { recursive: true });
|
||||
openDatabase(join(base, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: "M325",
|
||||
title: "DB context replan",
|
||||
status: "active",
|
||||
planning: {
|
||||
vision: "Replan from DB-backed milestone purpose.",
|
||||
successCriteria: ["Planner receives enough context from DB."],
|
||||
},
|
||||
});
|
||||
insertSlice({
|
||||
milestoneId: "M325",
|
||||
id: "S01",
|
||||
title: "Cancelled stale slice",
|
||||
status: "cancelled",
|
||||
goal: "Old slice still records what the milestone was trying to do.",
|
||||
sequence: 1,
|
||||
});
|
||||
|
||||
const result = await resolveDispatch({
|
||||
state: {
|
||||
phase: "pre-planning",
|
||||
activeMilestone: { id: "M325", title: "DB context replan" },
|
||||
activeSlice: null,
|
||||
activeTask: null,
|
||||
},
|
||||
mid: "M325",
|
||||
midTitle: "DB context replan",
|
||||
basePath: base,
|
||||
prefs: { phases: {} },
|
||||
session: {},
|
||||
pipelineVariant: "standard",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
action: "dispatch",
|
||||
unitType: "plan-milestone",
|
||||
unitId: "M325",
|
||||
prompt: "plan milestone",
|
||||
});
|
||||
} finally {
|
||||
closeDatabase();
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("completing_milestone_when_validation_needs_attention_without_plan_dispatches_remediation", async () => {
|
||||
const base = makeTempDir("sf-dispatch-validation-attention-");
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ 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 () => {
|
||||
test("deriveState_when_all_slices_cancelled_replans_without_selecting_cancelled_slice", async () => {
|
||||
const project = mkdtempSync(join(tmpdir(), "sf-db-runtime-state-"));
|
||||
tmpDirs.push(project);
|
||||
mkdirSync(join(project, ".sf", "milestones", "M779", "slices", "S01"), {
|
||||
|
|
@ -156,10 +156,11 @@ test("deriveState_when_all_slices_cancelled_does_not_select_cancelled_slice", as
|
|||
|
||||
const state = await deriveState(project);
|
||||
|
||||
assert.equal(state.phase, "blocked");
|
||||
assert.equal(state.phase, "pre-planning");
|
||||
assert.equal(state.activeMilestone?.id, "M779");
|
||||
assert.equal(state.activeSlice, null);
|
||||
assert.deepEqual(state.blockers, ["No slice eligible — check dependency ordering"]);
|
||||
assert.deepEqual(state.blockers, []);
|
||||
assert.match(state.nextAction, /pre-planning ladder|cancelled|inactive/i);
|
||||
});
|
||||
|
||||
test("deriveState_when_db_has_no_tasks_refuses_runtime_plan_file_import", async () => {
|
||||
|
|
|
|||
|
|
@ -127,6 +127,24 @@ function missingSliceStop(mid, phase) {
|
|||
level: "error",
|
||||
};
|
||||
}
|
||||
|
||||
function hasDbMilestonePlanningContext(mid) {
|
||||
if (!isDbAvailable()) return false;
|
||||
const milestone = getMilestone(mid);
|
||||
if (
|
||||
typeof milestone?.vision === "string" &&
|
||||
milestone.vision.trim().length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return getMilestoneSlices(mid).some(
|
||||
(slice) =>
|
||||
(typeof slice.goal === "string" && slice.goal.trim().length > 0) ||
|
||||
(typeof slice.success_criteria === "string" &&
|
||||
slice.success_criteria.trim().length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
async function readMilestoneValidationForDispatch(basePath, mid) {
|
||||
if (isDbAvailable()) {
|
||||
const assessment = getMilestoneValidationAssessment(mid);
|
||||
|
|
@ -716,6 +734,7 @@ export const DISPATCH_RULES = [
|
|||
// Only on first dispatch: when phase is pre-planning AND no roadmap exists yet
|
||||
// This ensures roadmap meeting happens BEFORE discuss/research/plan
|
||||
if (state.phase !== "pre-planning") return null;
|
||||
if (hasDbMilestonePlanningContext(mid)) return null;
|
||||
// resolveMilestoneFile returns path string if file exists, null if not
|
||||
const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
|
||||
if (roadmapFile && existsSync(roadmapFile)) return null; // roadmap already exists
|
||||
|
|
@ -1005,6 +1024,7 @@ export const DISPATCH_RULES = [
|
|||
name: "pre-planning (no context) → discuss-milestone",
|
||||
match: async ({ state, mid, midTitle, basePath }) => {
|
||||
if (state.phase !== "pre-planning") return null;
|
||||
if (hasDbMilestonePlanningContext(mid)) return null;
|
||||
const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
|
||||
const hasContext = !!(contextFile && (await loadFile(contextFile)));
|
||||
if (hasContext) return null; // fall through to next rule
|
||||
|
|
@ -1027,6 +1047,7 @@ export const DISPATCH_RULES = [
|
|||
pipelineVariant,
|
||||
}) => {
|
||||
if (state.phase !== "pre-planning") return null;
|
||||
if (hasDbMilestonePlanningContext(mid)) return null;
|
||||
// Phase skip: skip research when preference or profile says so
|
||||
if (prefs?.phases?.skip_research) return null;
|
||||
// #4781 phase 2: trivial-scope milestones skip dedicated milestone research
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue