Files
TORCH/docs/scheduler-cycle-overview.md
T
2026-02-20 12:51:17 -05:00

263 lines
12 KiB
Markdown

# 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)
├─ <handoffCommand> (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 <runStart>
→ 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 <daily|weekly> [--platform <p>] [--model <m>]
# 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/<cadence>/.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/<cadence>/` | Task logs written and read here |
| `task-logs/<cadence>/.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
```