# Scheduler Cycle Overview (`scripts/agent/run-scheduler-cycle.mjs`) ## What this module does `run-scheduler-cycle.mjs` is the main scheduler entry point. Given a cadence (`daily` or `weekly`), it selects the next eligible agent from the roster, acquires a distributed Nostr lock to prevent duplicate execution across agents, runs the selected agent's prompt via a configured handoff command, verifies that required run artifacts and memory evidence were produced, then publishes task completion. This script owns the full agent lifecycle for one slot per invocation: lock acquisition → prompt execution → artifact/memory verification → lock:complete. Spawned agents must **not** call `lock:complete` themselves — the scheduler does it after all validation passes. ## Where it fits ``` npm run scheduler:daily └─ scripts/agent/run-scheduler-cycle.mjs daily ├─ src/prompts/roster.json (agent list) ├─ torch-config.json (handoff command, memory policy, etc.) ├─ scripts/agent/scheduler-utils.mjs ├─ scripts/agent/scheduler-lock.mjs ├─ npm run lock:check:daily (relay query) ├─ npm run lock:lock (relay publish) ├─ (spawns the agent) ├─ scripts/agent/verify-run-artifacts.mjs └─ npm run lock:complete (relay publish, permanent) ``` ## Typical call flow ``` 1. Parse args → cadence='daily', platform='codex', model=null 2. Load roster from src/prompts/roster.json → ['audit-agent', 'docs-agent', ...] 3. Read AGENTS.md → print to stdout (best-effort; non-fatal if missing) 4. [LOOP] Read run-state → { run_date: '2026-02-20', lock_deferral: null } 5. [LOOP] Lock health preflight (if enabled): npm run lock:health -- --cadence daily → non-zero: write _deferred.md or _failed.md, exit 6. [LOOP] npm run lock:check:daily -- --json --quiet → exclusion set: { locked: ['ci-health-agent'], paused: [], completed: [] } 7. [LOOP] Time-window guard: exclude agents that ran within 24h window 8. [LOOP] Round-robin select: roster = ['audit-agent', 'ci-health-agent', 'docs-agent', ...] previous = 'ci-health-agent' (from latest log) excluded = { 'ci-health-agent' } → selected = 'docs-agent' 9. [LOOP] npm run lock:lock -- --agent docs-agent --cadence daily → exit 0: lock acquired (continue) → exit 3: race lost (restart loop at step 4) → exit 2: backend error → defer (non-strict) or fail (strict) 10. [LOOP] Validate src/prompts/daily/docs-agent.md → not readable → _failed.md (prompt_parse_error), exit 1 → bad first line → _failed.md (prompt_schema_error), exit 1 11. [LOOP] Clear deferral state. Run memory retrieve command (if configured). 12. [LOOP] Run handoff command (e.g. `claude --prompt "$SCHEDULER_PROMPT_PATH"`) → non-zero → _failed.md (execution_error), exit non-zero → missing → _failed.md, exit 1 13. [LOOP] Run memory store command (if configured). 14. [LOOP] Verify memory evidence (markers/artifacts): → mode='required' + missing → _failed.md, exit 1 → mode='optional' + missing → console.warn, continue 15. [LOOP] node scripts/agent/verify-run-artifacts.mjs --since → non-zero → _failed.md (prompt_schema_error), exit non-zero 16. [LOOP] npm run lint (or configured validationCommands) → non-zero → _failed.md (execution_error), exit non-zero 17. [LOOP] npm run lock:complete -- --agent docs-agent --cadence daily → non-zero → _failed.md, exit non-zero 18. [LOOP] Write _completed.md task log. Print run summary. Exit 0. ``` ## Public API summary This is a CLI script — it has no exported functions. The entry point is: ```bash node scripts/agent/run-scheduler-cycle.mjs [--platform

] [--model ] # or via npm: npm run scheduler:daily npm run scheduler:weekly ``` **Environment variables:** | Variable | Source | Purpose | |----------|--------|---------| | `AGENT_PLATFORM` | env | Platform identifier (codex, claude, linux) | | `AGENT_MODEL` | env | Model identifier forwarded to lock events | | `SCHEDULER_LOCK_MAX_RETRIES` | env | Override lock:lock retry count (default: 2) | | `SCHEDULER_LOCK_BACKOFF_MS` | env | Override base backoff ms (default: 250) | | `SCHEDULER_LOCK_JITTER_MS` | env | Override jitter ms (default: 75) | | `SCHEDULER_LOCK_HEALTH_PREFLIGHT` | env | Enable/disable relay health preflight | | `SCHEDULER_SKIP_LOCK_HEALTH_PREFLIGHT` | env | Skip preflight even if enabled | | `SCHEDULER_STRICT_LOCK` | env | Override strict_lock policy | | `SCHEDULER_DEGRADED_LOCK_RETRY_WINDOW_MS` | env | Override deferral window ms | | `SCHEDULER_MAX_DEFERRALS` | env | Override max deferral count | **Variables injected into child processes:** | Variable | Value | Purpose | |----------|-------|---------| | `SCHEDULER_AGENT` | selected agent name | Used by handoff/memory commands | | `SCHEDULER_CADENCE` | `daily`\|`weekly` | Used by handoff/memory commands | | `SCHEDULER_PROMPT_PATH` | absolute path | Path to prompt file | | `SCHEDULER_MEMORY_FILE` | absolute path | Agent writes learnings here | ## Key internal functions | Function | Purpose | |----------|---------| | `parseArgs(argv)` | Parses cadence, platform, model from CLI args | | `getSchedulerConfig(cadence, opts)` | Merges torch-config.json + env overrides | | `readRunState(cadence)` / `writeRunState(path, state)` | Deferral state persistence (day-scoped) | | `getLatestFile(logDir)` | Finds most recent valid task log for previous-agent detection | | `selectNextAgent({roster, excludedSet, previousAgent, firstPrompt})` | Round-robin with exclusion | | `validatePromptFile(promptPath)` | Reads and validates prompt format | | `verifyMemoryStep({name, markers, artifacts, outputText, sinceMs})` | Evidence check | | `writeLog({cadence, agent, status, ...})` | Writes YAML-frontmatter task log file | | `exitWithSummary(code, data)` | Prints summary + calls process.exit | ## Key invariants 1. **lock:complete gate**: `npm run lock:complete` is called only after memory verification (step 14), artifact verification (step 15), and all validation commands (step 16) pass. If any gate fails, the run ends without publishing completion. 2. **Completed log gate**: `_completed.md` task log is written only after lock:complete exits 0 (step 17). Earlier failures always write `_failed.md` or `_deferred.md`. 3. **Race resolution**: Lock exit code 3 (race lost) causes the scheduler to restart the loop at step 4, re-checking what agents are available. This is the normal race resolution path — not an error. 4. **Time-window guard**: Agents that appear in a recent local log (within 24h for daily, 7d for weekly) are added to the exclusion set regardless of relay state. This handles the cross-midnight edge case where the relay date-scope has rolled over. 5. **Cycle saturation**: When all roster agents are in the time-window exclusion set, the scheduler exits 0 with a "cycle complete" reason — not treated as an error by cron/CI. 6. **Deferral state is day-scoped**: `task-logs//.scheduler-run-state.json` is reset whenever `run_date` changes, so deferral budgets do not carry across days. ## Edge cases & error paths | Scenario | Behavior | |----------|----------| | No roster entries | `process.exit(1)` before loop | | Roster fully excluded (not cycle-saturated) | `_failed.md` + exit 1 | | All relays unhealthy (preflight) | `_deferred.md` + exit 0 | | Lock backend error (strict mode) | `_failed.md` + exit 2 | | Lock backend error (non-strict, within window/budget) | `_deferred.md` + exit 0; deferral state persisted | | Lock backend error (non-strict, budget exhausted) | `_failed.md` + exit 2 | | Prompt file missing or unreadable | `_failed.md (prompt_parse_error)` + exit 1 | | Prompt first line not `#` or `>` | `_failed.md (prompt_schema_error)` + exit 1 | | handoffCommand missing (non-interactive mode) | `_failed.md (execution_error)` + exit 1 | | handoffCommand exits non-zero | `_failed.md (execution_error)` + exit non-zero | | Memory evidence missing (required mode) | `_failed.md (prompt_schema_error)` + exit 1 | | Memory evidence missing (optional mode) | console.warn, continue | | Artifact verification fails | `_failed.md (prompt_schema_error)` + exit non-zero | | Validation command exits non-zero | `_failed.md (execution_error)` + exit non-zero | | lock:complete exits non-zero | `_failed.md` + exit non-zero | ## Security considerations - ⚠️ Environment variables injected into child processes (`SCHEDULER_AGENT`, `SCHEDULER_CADENCE`, `SCHEDULER_PROMPT_PATH`, `SCHEDULER_MEMORY_FILE`) are derived from the roster and config — not from user input. No sanitization issue here, but ensure `torch-config.json` is not writable by untrusted parties. - The handoff command is executed via `bash -lc` with a login shell. The command string comes from `torch-config.json`. Do not allow untrusted config sources. - No cryptographic or signing logic in this file. ## Performance & concurrency - The loop is single-threaded and sequential; no parallel lock attempts. - Each iteration makes several network calls (lock:check, lock:lock, lock:complete) that may take seconds each. Total wall-clock time per cycle depends heavily on relay latency and handoff command duration. - `getLatestFile()` reads all log filenames and file contents synchronously in memory — acceptable for typical log counts (< 1000 files) but worth monitoring if logs accumulate without rotation. ## Related files and call graph | File | Relationship | |------|-------------| | `scripts/agent/scheduler-utils.mjs` | Utility functions (runCommand, readJson, parsers, etc.) | | `scripts/agent/scheduler-lock.mjs` | Lock acquisition, error classification, preflight | | `scripts/agent/verify-run-artifacts.mjs` | Run artifact validation (called as subprocess) | | `scripts/agent/scheduler-utils.mjs:buildRecentlyRunExclusionSet` | Time-window guard | | `src/utils.mjs:detectPlatform` | Auto-detects agent platform | | `src/prompts/roster.json` | Agent roster (read at startup) | | `src/prompts/scheduler-flow.md` | Authoritative numbered steps (implemented by this script) | | `torch-config.json` | Handoff command, memory policy, lock policy | | `task-logs//` | Task logs written and read here | | `task-logs//.scheduler-run-state.json` | Deferral state (day-scoped) | | `memory-updates/` | Memory update files (written path injected as env var) | ## Why it works this way - **Infinite loop with restart on race loss**: Distributed lock systems inherently have races; the loop lets the scheduler immediately try the next available agent without requiring the caller to re-invoke the script. - **Day-scoped run-state**: Deferral tracking needs to persist across multiple rapid scheduler invocations on the same day (e.g., cron every 5 minutes) but should reset daily so a failed relay doesn't block the next day's run. - **lock:complete gated behind all validation**: Ensures a completion event on the relay (which prevents future agents from re-running the same slot) is only published after the run is genuinely finished and verified. This prevents "green-but-worthless" completions. - **Subprocess model for handoff**: Agents run in their own process with environment variables injected by the scheduler. This isolates agent crashes and allows the scheduler to check exit codes cleanly. ## When to change - **New validation gate**: Add a step between artifact check and lock:complete. Maintain the gate ordering: memory evidence → artifact check → validation → lock:complete. - **New failure category**: Add to the `FAILURE_CATEGORY` constant and document in `src/prompts/scheduler-flow.md`. - **New config option**: Add parsing in `getSchedulerConfig()` with an env var override following the existing pattern. - **Log format change**: Update `writeLog()` — also update `verify-run-artifacts.mjs` and `scheduler-utils.mjs:parseFrontmatterAgent` to match. ## Tests that validate behavior - `test/run-scheduler-cycle-memory-policy.test.mjs` — memory policy enforcement - `test/scheduler-time-window.test.mjs` — time-window guard behavior - `test/scheduler-preflight-lock.e2e.test.mjs` — preflight + lock flow ```bash # Run all scheduler tests: node --test test/run-scheduler-cycle-memory-policy.test.mjs test/scheduler-time-window.test.mjs # Or full suite (may be slow in sandbox): npm test ```