feat(schedule): auto-dispatch rule in DISPATCH_RULES

This commit is contained in:
Mikael Hugo 2026-05-05 01:34:50 +02:00
parent 94ba38bdd6
commit 7e1883844a
2 changed files with 179 additions and 0 deletions

View file

@ -8,6 +8,7 @@
* data structure that is inspectable, testable per-rule, and extensible
* without modifying orchestration code.
*/
import { createScheduleStore } from "./schedule/schedule-store.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { buildCompleteMilestonePrompt, buildCompleteSlicePrompt, buildDiscussMilestonePrompt, buildDiscussProjectPrompt, buildDiscussRequirementsPrompt, buildExecuteTaskPrompt, buildGateEvaluatePrompt, buildParallelResearchSlicesPrompt, buildPlanMilestonePrompt, buildPlanSlicePrompt, buildReactiveExecutePrompt, buildReassessRoadmapPrompt, buildRefineSlicePrompt, buildReplanSlicePrompt, buildResearchProjectPrompt, buildResearchMilestonePrompt, buildResearchSlicePrompt, buildRewriteDocsPrompt, buildRunUatPrompt, buildValidateMilestonePrompt, buildWorkflowPreferencesPrompt, checkNeedsReassessment, checkNeedsRunUat, } from "./auto-prompts.js";
@ -326,6 +327,37 @@ When done, say: "Validation attention remediated; ready for revalidation."`;
}
// ─── Rules ────────────────────────────────────────────────────────────────
export const DISPATCH_RULES = [
{
name: "schedule (auto_dispatch=true) → notify",
match: async ({ state, basePath }) => {
// Only fire when no active milestone — never pre-empt real work
if (state.activeMilestone?.id) return null;
try {
const store = createScheduleStore(basePath);
const due = store.findDue("project", new Date());
// Find entries that want auto-dispatch
const autoDispatch = due.filter(
(e) => e.auto_dispatch === true && e.kind === "reminder",
);
if (autoDispatch.length === 0) return null;
// Surface the first due entry as a notification stop
const entry = autoDispatch[0];
const msg =
entry.payload?.message ??
`Scheduled reminder ${entry.id} is due.`;
return {
action: "stop",
reason: `[schedule] ${msg} Mark done: /sf schedule done ${entry.id}`,
level: "info",
};
} catch {
// Non-fatal: never block dispatch on schedule store errors
return null;
}
},
},
{
// ADR-011 Phase 2 (gsd-2 ADR): mid-execution escalation handling.
// Auto-mode is autonomous, so by default we accept the agent's

View file

@ -0,0 +1,147 @@
/**
* Schedule Auto-Dispatch Rule tests.
*
* Purpose: verify the schedule dispatch rule in auto-dispatch.js:
* - never pre-empts active milestone work
* - returns null when no matching due entries
* - returns stop action with reminder when auto_dispatch=true reminder is due
* - non-fatal on errors
*
* Consumer: CI test runner (vitest).
*/
import assert from "node:assert/strict";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, it } from "vitest";
import { DISPATCH_RULES } from "../auto-dispatch.js";
import { createScheduleStore } from "../schedule/schedule-store.js";
import { generateULID } from "../schedule/schedule-ulid.js";
describe("schedule-dispatch", () => {
let testDir;
let originalCwd;
beforeEach(() => {
testDir = join(
tmpdir(),
`sf-dispatch-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
mkdirSync(testDir, { recursive: true });
originalCwd = process.cwd();
process.chdir(testDir);
});
afterEach(() => {
process.chdir(originalCwd);
try {
rmSync(testDir, { recursive: true });
} catch {
// ignore
}
});
function makeEntry(overrides = {}) {
const now = new Date().toISOString();
return {
id: generateULID(),
kind: "reminder",
status: "pending",
due_at: now,
created_at: now,
payload: { message: "test reminder" },
created_by: "user",
...overrides,
};
}
async function runScheduleRule(state, basePath = testDir) {
const rule = DISPATCH_RULES.find((r) => r.name === "schedule (auto_dispatch=true) → notify");
assert.ok(rule, "schedule dispatch rule not found");
return rule.match({ state, basePath });
}
it("returns null when active milestone exists (never pre-empts)", async () => {
const state = {
activeMilestone: { id: "M010" },
phase: "executing",
};
const result = await runScheduleRule(state);
assert.equal(result, null);
});
it("returns null when no due entries exist", async () => {
const state = { activeMilestone: null, phase: "idle" };
const result = await runScheduleRule(state);
assert.equal(result, null);
});
it("returns null when due entries have auto_dispatch=false", async () => {
const store = createScheduleStore(testDir);
store.appendEntry("project", makeEntry({
due_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
auto_dispatch: false,
}));
const state = { activeMilestone: null, phase: "idle" };
const result = await runScheduleRule(state);
assert.equal(result, null);
});
it("returns null when due entries are not reminders", async () => {
const store = createScheduleStore(testDir);
store.appendEntry("project", makeEntry({
due_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
auto_dispatch: true,
kind: "milestone_check",
}));
const state = { activeMilestone: null, phase: "idle" };
const result = await runScheduleRule(state);
assert.equal(result, null);
});
it("returns stop action when auto_dispatch=true reminder is due", async () => {
const store = createScheduleStore(testDir);
store.appendEntry("project", makeEntry({
due_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
auto_dispatch: true,
payload: { message: "Review adoption metrics" },
}));
const state = { activeMilestone: null, phase: "idle" };
const result = await runScheduleRule(state);
assert.ok(result);
assert.equal(result.action, "stop");
assert.ok(result.reason.includes("Review adoption metrics"));
assert.ok(result.reason.includes("/sf schedule done"));
});
it("picks first entry by due_at when multiple match", async () => {
const store = createScheduleStore(testDir);
store.appendEntry("project", makeEntry({
id: "SECOND",
due_at: "2024-01-02T00:00:00.000Z",
auto_dispatch: true,
payload: { message: "Second" },
}));
store.appendEntry("project", makeEntry({
id: "FIRST",
due_at: "2024-01-01T00:00:00.000Z",
auto_dispatch: true,
payload: { message: "First" },
}));
const state = { activeMilestone: null, phase: "idle" };
const result = await runScheduleRule(state);
assert.ok(result);
assert.ok(result.reason.includes("First"));
});
it("is non-fatal on store errors", async () => {
const state = { activeMilestone: null, phase: "idle" };
// Pass an invalid basePath that will cause store errors
const result = await runScheduleRule(state, "/dev/null/invalid-path");
assert.equal(result, null);
});
});