feat(scheduler): add local time-window guard to prevent within-window re-runs

Adds a cadence-aware time-window check that runs after the Nostr relay lock
check to exclude agents that already ran within the current window (24h daily,
7d weekly). This closes the cross-midnight edge case where the relay's
date-scoped d-tag rolls over but the agent ran minutes ago.

Changes:
- scheduler-utils.mjs: export log-parsing helpers (parseDateValue,
  parseTimestampFromFilename, parseAgentFromFilename, isStrictSchedulerLogFilename,
  parseFrontmatterCreatedAt, parseFrontmatterAgent, parseFrontmatterValue) plus
  CADENCE_WINDOW_MS constant and buildRecentlyRunExclusionSet(). Helpers are
  exported so they can be unit-tested independently. Adds 'node:path' import.
- run-scheduler-cycle.mjs: import the moved helpers from utils (removes ~45 lines
  of duplicate definitions), call buildRecentlyRunExclusionSet after excludedSet
  is built from relay data, merge results, log newly-excluded agents. Adds cycle
  saturation detection: when all roster agents are covered by the time-window
  guard, exit 0 with a clear "Cycle complete" message instead of a failure.
- test/scheduler-time-window.test.mjs: 30 new scenario-first unit tests covering
  SCN-TWG-01..11 (in-window exclusion, out-of-window pass-through, cross-midnight,
  frontmatter precedence, weekly threshold, empty dir, missing dir, deferred
  log passthrough, mixed rosters, anti-cheat for non-log files).

All 30 new tests pass. Existing scheduler-ratchet tests (7) still pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Sonnet 4.6 committed 2026-02-20 11:05:52 -05:00
1 parent 8a9080e7c3
commit a5f08ff082
3 files changed
+467 -49

No files matched your search

+36 -49
View File
@@ -15,6 +15,13 @@ import {
excerptText,
getRunDateKey,
toYamlScalar,
buildRecentlyRunExclusionSet,
parseDateValue,
parseFrontmatterCreatedAt,
parseFrontmatterAgent,
parseTimestampFromFilename,
isStrictSchedulerLogFilename,
parseAgentFromFilename,
} from './scheduler-utils.mjs';
import {
@@ -125,55 +132,6 @@ async function verifyMemoryStep({ name, markers, artifacts, outputText, sinceMs
};
}
function parseFrontmatterAgent(markdown) {
const lines = markdown.split(/\r?\n/);
if (lines[0]?.trim() !== '---') return null;
for (let i = 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '---') break;
const match = line.match(/^agent\s*:\s*(.+)$/i);
if (match?.[1]) {
return match[1].trim().replace(/^['"]|['"]$/g, '');
}
}
return null;
}
function parseFrontmatterCreatedAt(markdown) {
const lines = markdown.split(/\r?\n/);
if (lines[0]?.trim() !== '---') return null;
for (let i = 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '---') break;
const match = line.match(/^created_at\s*:\s*(.+)$/i);
if (match?.[1]) {
return match[1].trim().replace(/^['"]|['"]$/g, '');
}
}
return null;
}
function parseDateValue(value) {
if (!value || typeof value !== 'string') return null;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : null;
}
function parseTimestampFromFilename(filename) {
const match = String(filename).match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)__[^_]+__(completed|failed)\.md$/);
if (!match?.[1]) return null;
const iso = match[1].replace(/T(\d{2})-(\d{2})-(\d{2})Z$/, 'T$1:$2:$3Z');
return parseDateValue(iso);
}
function isStrictSchedulerLogFilename(filename) {
return /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)__[^_]+__(completed|failed)\.md$/.test(String(filename));
}
function parseAgentFromFilename(filename) {
const match = String(filename).match(/^.+__([^_]+?)__(completed|failed)\.md$/);
return match?.[1] || null;
}
async function getLatestFile(logDir) {
await fs.mkdir(logDir, { recursive: true });
@@ -486,6 +444,19 @@ async function main() {
])];
const excludedSet = new Set(excluded);
// Time-window guard: exclude agents that already ran within the cadence window
// (24 h for daily, 7 d for weekly). This catches the cross-midnight edge case
// where the relay's date-scoped lock has rolled over but the run was recent.
const windowLabel = cadence === 'weekly' ? '7-day' : '24-hour';
const recentlyRunSet = await buildRecentlyRunExclusionSet(logDir, cadence);
const newlyTimeExcluded = [...recentlyRunSet].filter((agent) => !excludedSet.has(agent));
for (const agent of recentlyRunSet) {
excludedSet.add(agent);
}
if (newlyTimeExcluded.length > 0) {
console.log(`[scheduler] Time-window guard (${windowLabel}): ${newlyTimeExcluded.join(', ')} excluded — ran within window`);
}
const latestFile = await getLatestFile(logDir);
let previousAgent = null;
@@ -506,6 +477,22 @@ async function main() {
});
if (!selectedAgent) {
// Cycle saturation: every roster agent has a recent local log within the
// window. This is the expected steady state once a full rotation completes.
// Exit 0 so the triggering process (cron, CI) does not treat it as an error.
const allCycleSaturated = roster.every((agent) => recentlyRunSet.has(agent));
if (allCycleSaturated) {
const reason = `Cycle complete: all ${roster.length} agents ran within the ${windowLabel} window`;
console.log(`[scheduler] ${reason}. Nothing to do.`);
await exitWithSummary(0, {
status: 'skipped',
agent: 'scheduler',
promptPath: null,
reason,
platform,
});
}
await writeLog({
cadence,
agent: 'scheduler',
+121
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
const ALLOWED_ENV_KEYS = new Set([
@@ -198,3 +199,123 @@ export function toYamlScalar(value) {
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ---------------------------------------------------------------------------
// Time-window guard helpers
// Exported so they can be unit-tested independently of run-scheduler-cycle.mjs
// ---------------------------------------------------------------------------
/** Window sizes for the per-cadence duplicate-run guard. */
export const CADENCE_WINDOW_MS = {
daily: 24 * 60 * 60 * 1000, // 24 hours
weekly: 7 * 24 * 60 * 60 * 1000, // 7 days
};
/** Parse a date string to milliseconds, or null if invalid. */
export function parseDateValue(value) {
if (!value || typeof value !== 'string') return null;
const ms = Date.parse(value);
return Number.isFinite(ms) ? ms : null;
}
/** Returns true when a filename matches the canonical scheduler log pattern. */
export function isStrictSchedulerLogFilename(filename) {
return /^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)__[^_]+__(completed|failed)\.md$/.test(String(filename));
}
/** Extract agent name from a canonical log filename, or null. */
export function parseAgentFromFilename(filename) {
const match = String(filename).match(/^.+__([^_]+?)__(completed|failed)\.md$/);
return match?.[1] || null;
}
/** Extract the ISO timestamp embedded in a canonical log filename as ms, or null. */
export function parseTimestampFromFilename(filename) {
const match = String(filename).match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z)__[^_]+__(completed|failed)\.md$/);
if (!match?.[1]) return null;
const iso = match[1].replace(/T(\d{2})-(\d{2})-(\d{2})Z$/, 'T$1:$2:$3Z');
return parseDateValue(iso);
}
/** Read a single YAML frontmatter key from a markdown string, or null. */
export function parseFrontmatterValue(markdown, key) {
const lines = String(markdown).split(/\r?\n/);
if (lines[0]?.trim() !== '---') return null;
const keyPattern = new RegExp(`^${key}\\s*:\\s*(.+)$`, 'i');
for (let i = 1; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '---') break;
const match = line.match(keyPattern);
if (match?.[1]) {
return match[1].trim().replace(/^['"]|['"]$/g, '');
}
}
return null;
}
/** Read `created_at` from YAML frontmatter. */
export function parseFrontmatterCreatedAt(markdown) {
return parseFrontmatterValue(markdown, 'created_at');
}
/** Read `agent` from YAML frontmatter. */
export function parseFrontmatterAgent(markdown) {
return parseFrontmatterValue(markdown, 'agent');
}
/**
* Scans task-log files for a cadence and returns a Set of agent names that
* completed or failed within the cadence's time window (24 h daily, 7 d weekly).
*
* This is the local-filesystem companion to the Nostr relay lock check. It
* catches the cross-midnight edge case where the relay's date-scoped lock has
* rolled over but the agent ran less than a full window ago.
*
* Only `completed` and `failed` logs are considered. `deferred` logs are
* intentionally skipped so the scheduler retries lock-backend failures.
*
* Non-fatal: if the log directory cannot be read, an empty Set is returned.
*
* @param {string} logDir - Absolute or relative path to task-logs/<cadence>/.
* @param {'daily'|'weekly'} cadence
* @param {number} [now] - Current epoch ms (injectable for tests).
* @returns {Promise<Set<string>>} Agent names that ran within the window.
*/
export async function buildRecentlyRunExclusionSet(logDir, cadence, now = Date.now()) {
const windowMs = CADENCE_WINDOW_MS[cadence] ?? CADENCE_WINDOW_MS.daily;
const cutoff = now - windowMs;
const recentlyRun = new Set();
let entries;
try {
await fs.mkdir(logDir, { recursive: true });
entries = await fs.readdir(logDir, { withFileTypes: true });
} catch {
return recentlyRun;
}
const logFiles = entries
.filter((e) => e.isFile() && isStrictSchedulerLogFilename(e.name))
.map((e) => e.name);
await Promise.all(logFiles.map(async (filename) => {
const agentName = parseAgentFromFilename(filename);
if (!agentName) return;
// Prefer frontmatter created_at (more precise) over the filename timestamp.
let effectiveMs = parseTimestampFromFilename(filename);
try {
const content = await fs.readFile(path.join(logDir, filename), 'utf8');
const frontmatterMs = parseDateValue(parseFrontmatterCreatedAt(content));
if (frontmatterMs !== null) effectiveMs = frontmatterMs;
} catch {
// Read failure: fall back to filename timestamp.
}
if (effectiveMs !== null && effectiveMs >= cutoff) {
recentlyRun.add(agentName);
}
}));
return recentlyRun;
}