diff --git a/src/resources/extensions/sf/auto-dispatch.js b/src/resources/extensions/sf/auto-dispatch.js index fc9d6bd3d..f109c86d1 100644 --- a/src/resources/extensions/sf/auto-dispatch.js +++ b/src/resources/extensions/sf/auto-dispatch.js @@ -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 diff --git a/src/resources/extensions/sf/tests/schedule-dispatch.test.mjs b/src/resources/extensions/sf/tests/schedule-dispatch.test.mjs new file mode 100644 index 000000000..f6d182f03 --- /dev/null +++ b/src/resources/extensions/sf/tests/schedule-dispatch.test.mjs @@ -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); + }); +});