feat(schedule): auto-dispatch rule in DISPATCH_RULES
This commit is contained in:
parent
94ba38bdd6
commit
7e1883844a
2 changed files with 179 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
147
src/resources/extensions/sf/tests/schedule-dispatch.test.mjs
Normal file
147
src/resources/extensions/sf/tests/schedule-dispatch.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue