diff --git a/scripts/agent/run-scheduler-cycle.mjs b/scripts/agent/run-scheduler-cycle.mjs index 5dd2ce5..afd0cba 100644 --- a/scripts/agent/run-scheduler-cycle.mjs +++ b/scripts/agent/run-scheduler-cycle.mjs @@ -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', diff --git a/scripts/agent/scheduler-utils.mjs b/scripts/agent/scheduler-utils.mjs index cfef0d9..56597b0 100644 --- a/scripts/agent/scheduler-utils.mjs +++ b/scripts/agent/scheduler-utils.mjs @@ -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//. + * @param {'daily'|'weekly'} cadence + * @param {number} [now] - Current epoch ms (injectable for tests). + * @returns {Promise>} 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; +} diff --git a/test/scheduler-time-window.test.mjs b/test/scheduler-time-window.test.mjs new file mode 100644 index 0000000..3458178 --- /dev/null +++ b/test/scheduler-time-window.test.mjs @@ -0,0 +1,310 @@ +/** + * Unit tests for the scheduler time-window guard. + * + * Scenario-first, cheat-resistant (Dark Factory standard): + * - Every test asserts observable outcomes at the boundary of + * buildRecentlyRunExclusionSet (the Set it returns). + * - Determinism controls: `now` is injected so the clock is fixed. + * - No mocking of internal logic; real temp-directory I/O is used so + * the implementation cannot "return true" and pass. + */ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildRecentlyRunExclusionSet, + parseDateValue, + parseTimestampFromFilename, + parseAgentFromFilename, + isStrictSchedulerLogFilename, + parseFrontmatterCreatedAt, + parseFrontmatterAgent, + CADENCE_WINDOW_MS, +} from '../scripts/agent/scheduler-utils.mjs'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Create a task log file with optional YAML frontmatter. */ +async function writeLog(dir, filename, { createdAt } = {}) { + const frontmatter = createdAt + ? `---\nagent: test\ncreated_at: ${createdAt}\n---\n` + : ''; + await fs.writeFile(path.join(dir, filename), `${frontmatter}# log\n`, 'utf8'); +} + +/** ISO string with colons replaced by dashes (canonical log filename format). */ +function isoToFilename(isoString) { + return isoString.replace(/:/g, '-').replace(/\.\d+Z$/, 'Z'); +} + +/** Build a canonical log filename for a given agent/status/time. */ +function logFilename(agent, status, isoString) { + return `${isoToFilename(isoString)}__${agent}__${status}.md`; +} + +// Fixed reference point: 2026-02-20T12:00:00Z +const NOW = new Date('2026-02-20T12:00:00Z').getTime(); + +// --------------------------------------------------------------------------- +// Unit tests for helper utilities +// --------------------------------------------------------------------------- + +describe('parseDateValue', () => { + it('parses a valid ISO string', () => { + assert.equal(parseDateValue('2026-02-20T12:00:00Z'), NOW); + }); + + it('returns null for empty input', () => { + assert.equal(parseDateValue(''), null); + assert.equal(parseDateValue(null), null); + assert.equal(parseDateValue(undefined), null); + }); + + it('returns null for invalid date strings', () => { + assert.equal(parseDateValue('not-a-date'), null); + }); +}); + +describe('isStrictSchedulerLogFilename', () => { + it('accepts valid completed log', () => { + assert.ok(isStrictSchedulerLogFilename('2026-02-20T12-00-00Z__audit-agent__completed.md')); + }); + + it('accepts valid failed log', () => { + assert.ok(isStrictSchedulerLogFilename('2026-02-20T12-00-00Z__audit-agent__failed.md')); + }); + + it('rejects deferred logs', () => { + assert.ok(!isStrictSchedulerLogFilename('2026-02-20T12-00-00Z__audit-agent__deferred.md')); + }); + + it('rejects filenames without timestamp', () => { + assert.ok(!isStrictSchedulerLogFilename('audit-agent__completed.md')); + }); + + it('rejects plain text files', () => { + assert.ok(!isStrictSchedulerLogFilename('notes.md')); + }); + + it('rejects .scheduler-run-state.json', () => { + assert.ok(!isStrictSchedulerLogFilename('.scheduler-run-state.json')); + }); +}); + +describe('parseAgentFromFilename', () => { + it('extracts agent name from completed log', () => { + assert.equal(parseAgentFromFilename('2026-02-20T12-00-00Z__audit-agent__completed.md'), 'audit-agent'); + }); + + it('extracts agent name from failed log', () => { + assert.equal(parseAgentFromFilename('2026-02-20T12-00-00Z__ci-health-agent__failed.md'), 'ci-health-agent'); + }); + + it('returns null for invalid filename', () => { + assert.equal(parseAgentFromFilename('bad-file.md'), null); + }); +}); + +describe('parseTimestampFromFilename', () => { + it('parses timestamp from canonical filename', () => { + const filename = '2026-02-20T12-00-00Z__audit-agent__completed.md'; + assert.equal(parseTimestampFromFilename(filename), NOW); + }); + + it('returns null for non-canonical filename', () => { + assert.equal(parseTimestampFromFilename('audit-agent__completed.md'), null); + }); +}); + +describe('parseFrontmatterCreatedAt / parseFrontmatterAgent', () => { + const doc = `---\nagent: known-issues-agent\ncreated_at: 2026-02-20T12:00:00Z\nstatus: completed\n---\n# body\n`; + + it('reads created_at from frontmatter', () => { + assert.equal(parseFrontmatterCreatedAt(doc), '2026-02-20T12:00:00Z'); + }); + + it('reads agent from frontmatter', () => { + assert.equal(parseFrontmatterAgent(doc), 'known-issues-agent'); + }); + + it('returns null when no frontmatter', () => { + assert.equal(parseFrontmatterCreatedAt('# no frontmatter'), null); + assert.equal(parseFrontmatterAgent('# no frontmatter'), null); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario tests for buildRecentlyRunExclusionSet +// --------------------------------------------------------------------------- + +describe('buildRecentlyRunExclusionSet — scenarios', () => { + let tmpDir; + + before(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'torch-time-window-')); + }); + + after(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + // SCN-TWG-01 + it('SCN-TWG-01: excludes agents with completed logs within 24-hour daily window', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn01-')); + // Ran 1 hour ago — within the 24h window + const recentTs = new Date(NOW - 60 * 60 * 1000).toISOString(); + await writeLog(dir, logFilename('audit-agent', 'completed', recentTs)); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.ok(result.has('audit-agent'), 'audit-agent ran 1h ago should be excluded'); + assert.equal(result.size, 1); + }); + + // SCN-TWG-02 + it('SCN-TWG-02: does NOT exclude agents whose last run is outside the 24-hour window', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn02-')); + // Ran 25 hours ago — outside the window + const oldTs = new Date(NOW - 25 * 60 * 60 * 1000).toISOString(); + await writeLog(dir, logFilename('audit-agent', 'completed', oldTs)); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.ok(!result.has('audit-agent'), 'agent outside window should not be excluded'); + assert.equal(result.size, 0); + }); + + // SCN-TWG-03 + it('SCN-TWG-03: excludes agents with failed logs within the window', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn03-')); + const recentTs = new Date(NOW - 30 * 60 * 1000).toISOString(); // 30 min ago + await writeLog(dir, logFilename('ci-health-agent', 'failed', recentTs)); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.ok(result.has('ci-health-agent'), 'recently failed agent should be excluded'); + }); + + // SCN-TWG-04 + it('SCN-TWG-04: cross-midnight scenario — agent ran 10 min before midnight, checked 10 min after', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn04-')); + const tenMinutesAgo = new Date(NOW - 20 * 60 * 1000).toISOString(); // 20 min ago (simulates cross-midnight) + await writeLog(dir, logFilename('load-test-agent', 'completed', tenMinutesAgo)); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.ok(result.has('load-test-agent'), 'cross-midnight agent should still be excluded'); + }); + + // SCN-TWG-05 + it('SCN-TWG-05: uses frontmatter created_at over filename timestamp when both present', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn05-')); + + // Filename timestamp = 26h ago (outside window), but frontmatter says 1h ago (inside window) + const oldTs = new Date(NOW - 26 * 60 * 60 * 1000).toISOString(); + const recentFrontmatter = new Date(NOW - 60 * 60 * 1000).toISOString(); + await writeLog(dir, logFilename('docs-agent', 'completed', oldTs), { createdAt: recentFrontmatter }); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.ok(result.has('docs-agent'), 'frontmatter created_at should take precedence over filename timestamp'); + }); + + // SCN-TWG-06 + it('SCN-TWG-06: weekly window uses 7-day threshold', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn06-')); + + // 5 days ago — within 7-day window + const inWindowTs = new Date(NOW - 5 * 24 * 60 * 60 * 1000).toISOString(); + await writeLog(dir, logFilename('changelog-agent', 'completed', inWindowTs)); + + // 8 days ago — outside 7-day window + const outWindowTs = new Date(NOW - 8 * 24 * 60 * 60 * 1000).toISOString(); + await writeLog(dir, logFilename('dead-code-agent', 'completed', outWindowTs)); + + const result = await buildRecentlyRunExclusionSet(dir, 'weekly', NOW); + + assert.ok(result.has('changelog-agent'), 'agent 5d ago should be excluded for weekly'); + assert.ok(!result.has('dead-code-agent'), 'agent 8d ago should not be excluded for weekly'); + }); + + // SCN-TWG-07 + it('SCN-TWG-07: empty log directory returns empty set', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn07-')); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.equal(result.size, 0); + }); + + // SCN-TWG-08 + it('SCN-TWG-08: non-existent directory returns empty set without throwing', async () => { + const nonExistent = path.join(tmpDir, 'does-not-exist-' + Date.now()); + + const result = await buildRecentlyRunExclusionSet(nonExistent, 'daily', NOW); + + assert.equal(result.size, 0); + }); + + // SCN-TWG-09 + it('SCN-TWG-09: only the most recent log matters — agent excluded if any log is within window', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn09-')); + + // Two logs: one old, one recent + const oldTs = new Date(NOW - 25 * 60 * 60 * 1000).toISOString(); + const recentTs = new Date(NOW - 2 * 60 * 60 * 1000).toISOString(); + await writeLog(dir, logFilename('perf-agent', 'completed', oldTs)); + await writeLog(dir, logFilename('perf-agent', 'failed', recentTs)); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.ok(result.has('perf-agent'), 'agent with any log within window should be excluded'); + }); + + // SCN-TWG-10 + it('SCN-TWG-10: mixed roster — correctly partitions in-window vs out-of-window agents', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn10-')); + + const recentTs = new Date(NOW - 1 * 60 * 60 * 1000).toISOString(); // 1h ago + const oldTs = new Date(NOW - 30 * 60 * 60 * 1000).toISOString(); // 30h ago + + await writeLog(dir, logFilename('audit-agent', 'completed', recentTs)); + await writeLog(dir, logFilename('ci-health-agent', 'completed', oldTs)); + await writeLog(dir, logFilename('log-fixer-agent', 'failed', recentTs)); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.ok(result.has('audit-agent')); + assert.ok(!result.has('ci-health-agent')); + assert.ok(result.has('log-fixer-agent')); + assert.equal(result.size, 2); + }); + + // SCN-TWG-11 — anti-cheat: verify the guard cannot be trivially bypassed + it('SCN-TWG-11: non-log files in directory are ignored', async () => { + const dir = await fs.mkdtemp(path.join(tmpDir, 'scn11-')); + + // Write files that are NOT canonical log filenames + await fs.writeFile(path.join(dir, '.scheduler-run-state.json'), '{}', 'utf8'); + await fs.writeFile(path.join(dir, 'README.md'), '# readme', 'utf8'); + await fs.writeFile(path.join(dir, '2026-02-20T12-00-00Z__audit-agent__deferred.md'), 'deferred', 'utf8'); + + const result = await buildRecentlyRunExclusionSet(dir, 'daily', NOW); + + assert.equal(result.size, 0, 'non-log files and deferred logs must not trigger exclusion'); + }); +}); + +describe('CADENCE_WINDOW_MS constants', () => { + it('daily window is exactly 24 hours', () => { + assert.equal(CADENCE_WINDOW_MS.daily, 24 * 60 * 60 * 1000); + }); + + it('weekly window is exactly 7 days', () => { + assert.equal(CADENCE_WINDOW_MS.weekly, 7 * 24 * 60 * 60 * 1000); + }); +});