fix(gateway): a mode waiting for its scheduled end leaves no watchdog record each minute (#1722)

A mode applied with an end time is watched by a job that runs every 60 seconds. While the mode was still waiting to end, each pass wrote a watchdog history row and an event, about 480 of each for an eight-hour mode.

The waiting skip now returns its own reason, `mode_end_not_yet`, which the engine treats as quiet, the same way the quiet-hours release already does with its own reason. A notice still owed after the mode has ended keeps its recorded skip, because an operator may need to see it. The job's last-evaluation record still updates, so this removes the per-minute history rows and events, not every write.

One test runs the real engine and policy: two waiting passes leave no history row and no event, while an owed notice still records one.

Source +6/-1, tests +30/-1.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-10-21 13:55:18 -07:00
1 parent 616cecea08
commit 2f001ad044
3 files changed
+36 -2

No files matched your search

@@ -158,13 +158,16 @@ function noticeFor(ended: ModeApplication, scheduledEnd: string, qitemId: string
};
}
/** The waiting skip: a quiet reason in the engine, so a mode waiting to end leaves no history row or event each minute. */
export const MODE_END_NOT_YET = "mode_end_not_yet";
/** Watchdog-engine wrapper: the scheduler's cadence drives evaluation; the application's own record owns the timing. */
export function makeModeScheduledEndPolicy(deps: Omit<ModeScheduledEndInput, "jobId" | "now">): Policy {
return {
name: MODE_SCHEDULED_END_POLICY,
async evaluate(job: PolicyJob): Promise<PolicyEvaluation> {
const r = await runModeScheduledEnd({ ...deps, jobId: job.jobId });
if (r.state === "not-yet") return { action: "skip", reason: "before its scheduled end" };
if (r.state === "not-yet") return { action: "skip", reason: MODE_END_NOT_YET };
if (r.state === "notice-pending") return { action: "skip", reason: `ended; the notice is still to be created${r.why ? `: ${r.why}` : ""}` };
return { action: "terminal", reason: r.state === "ended" ? "mode ended at its scheduled time" : "mode had already ended" };
},
@@ -175,6 +175,8 @@ const QUIET_SKIP_REASONS = new Set<string>([
"not_due",
// Quiet hours: the window's release job while quiet is still on. Nothing changed, so no history row or event per minute.
"quiet_hours_still_on",
// A person's mode before its scheduled end (mode-scheduled-end): the same, for every minute it waits.
"mode_end_not_yet",
"queue_wait_already_presented",
"queue_wait_owner_working",
"workflow_healthy_deadline_gated",
@@ -15,7 +15,9 @@ import { addHumanFragment, loadHumanRegistry, readHumanAvailability, removeHuman
import { HumanModeService, registryAvailability } from "../src/domain/gateway/human-operating-modes.js";
import { makeTopologyDestinationCheck } from "../src/domain/queue-destination-check.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { armModeScheduledEnd, KERNEL_OPERATOR_SESSION, MODE_SCHEDULED_END_POLICY, runModeScheduledEnd } from "../src/domain/policies/mode-scheduled-end.js";
import { armModeScheduledEnd, KERNEL_OPERATOR_SESSION, makeModeScheduledEndPolicy, MODE_SCHEDULED_END_POLICY, runModeScheduledEnd } from "../src/domain/policies/mode-scheduled-end.js";
import { WatchdogPolicyEngine } from "../src/domain/watchdog-policy-engine.js";
import { WatchdogHistoryLog } from "../src/domain/watchdog-history-log.js";
const homes: string[] = [];
afterEach(() => { for (const h of homes.splice(0)) rmSync(h, { recursive: true, force: true }); });
@@ -156,4 +158,31 @@ describe("a mode's scheduled end", () => {
expect(f.modes.get(app.applicationId)!.endReason).toBe("back");
expect(f.availability()).toBe("off");
});
it("while a mode waits to end, the watchdog records nothing each minute; a notice still owed is recorded", async () => {
const f = fixture();
const bus = new EventBus(f.db);
const events: Array<{ type: string }> = [];
bus.subscribe((e) => events.push(e as { type: string }));
const jobsRepo = new WatchdogJobsRepository(f.db);
const historyLog = new WatchdogHistoryLog(f.db);
const engine = new WatchdogPolicyEngine({ jobsRepo, historyLog, eventBus: bus, deliver: async () => ({ ok: true }) as never,
additionalPolicies: [makeModeScheduledEndPolicy({ db: f.db, queueRepo: new QueueRepository(f.db, new EventBus(f.db), {
validateRig: makeTopologyDestinationCheck({ findRigsByName: (name) => new RigRepository(f.db).findRigsByName(name), loadHumanRegistry: () => loadHumanRegistry(f.home) }),
}), jobsRepo, home: f.home })] } as never);
const later = f.modes.apply("alex", "asleep", { scheduledEnd: new Date("2099-01-01T07:00:00Z") }, "orch@rig").application!;
const waiting = f.jobFor(later.applicationId)!;
await engine.evaluate(waiting);
await engine.evaluate(jobsRepo.getById(waiting.jobId)!);
expect(historyLog.countForJob(waiting.jobId)).toBe(0);
expect(events.filter((e) => e.type.startsWith("watchdog."))).toEqual([]);
f.modes.end("alex", "back", "alex");
const app = f.modes.apply("alex", "asleep", { scheduledEnd: END }, "op").application!; // END has passed by the wall clock
const owed = f.jobFor(app.applicationId)!;
writeFileSync(join(f.home, "gateway", "humans", "alex.yaml"), "entityId: [unclosed\n"); // the notice can't be addressed yet
await engine.evaluate(owed);
expect(historyLog.countForJob(owed.jobId)).toBe(1);
expect(events.some((e) => e.type === "watchdog.evaluation_skipped")).toBe(true);
});
});