08-tooling

cron loops to background agents architecture

2026-05-31·tooling-architecture·status: proposal (awaiting founder decisions)·by Ray

Cron loops → background agents: architecture change

One-line: Stop hosting the scheduled-loop suite inside the always-on channels-agent session. Keep ONE manifest as the source of truth, compile it to the OS scheduler, and have each scheduled fire spawn a fresh ephemeral claude --bg agent that runs one loop and exits. The OS does the timing; background agents do the work.

Why (the problem the founder named, 2026-05-31)

Three overlapping scheduling strategies had accreted:

  1. SessionStart auto-arm hook (emit-cron-rearm.sh) reads the manifest and arms all 14 loops as session-scoped CronCreate jobs on every session start.
  2. Restart script /loop block (claude-channels-restart.sh lines ~100-111) ALSO tmux-send-keys /loop <raw-cron> <command> for the same loops — and it never worked, because /loop expects interval shorthand (5m/1d), not a raw 5-field cron.
  3. Now a third option is in play: background agents (claude --bg).

Two concrete bugs:

The original good intent — one centralized list of loops — should be preserved. The delivery mechanism is what changes.

Grounded findings (verified this session, not from memory)

CLI: claude v2.1.158 on the Mac Mini.

Proposed architecture

manifest → compiled to OS scheduler → fresh claude --bg per fire.

  1. One source of truth (kept). scheduled-jobs.txt stays the single place loops are managed. Extend format to <cron> | <mode> | <command>, mode ∈ bg / in-session / split.
  2. Compile, don't duplicate. A generator (sync-loops.sh) turns the manifest into the user crontab (manifest is already cron syntax; the ip-drift cron proves crontab is restart-proof here). This single path REPLACES both the auto-arm hook (for offloaded loops) and the restart-script /loop block.
  3. Per-fire background agent. Each bg crontab line → run-loop.sh <command>claude --bg --name <loop> --permission-mode bypassPermissions "<command>". Hardening: 1Password CLAUDE_CODE_OAUTH_TOKEN; flock per shared resource (overnight Notion/ledger writers can't collide); a success-sentinel the run-log validates (exit-0 lies — the 2026-05-26 silent-outage class); explicit "do NOT arm the cron suite" instruction. bypassPermissions (NOT acceptEdits, which silently denies un-allowlisted Bash like yt-dlp/ffmpeg).
  4. Channel-coupled exceptions stay in the channels-agent (TCC reality). open-threads-check (needs live --channels inbound) stays in-session. iMessage-delivery loops (morning-prep, board re-prompts) use the split: bg agent gathers headless → writes brief to disk → tmux-rooted channels-agent does the iMessage send. (Or route those to Discord and they fire fully from cron.)
  5. Auto-arm hook: retired for the bulk, name-gated for the rest. Only ever arms the 2-3 in-session loops, and only inside the channels-agent (gated on session name / marker env) → can never again arm a duplicate suite in a random bg session.
  6. Restart script slims down. Delete the broken /loop send-keys block; its only scheduling job becomes "run sync-loops.sh."

De-risking: the morning-prep canary (do FIRST, ~1 day, reversible)

One temporary crontab line firing claude --bg /morning-prep. Settles the two open unknowns: (a) does a cron-launched bg agent inherit the TCC grant to send iMessage? (b) does the OAuth-token export survive a locked keychain? Validates or kills the approach before any fan-out. Do NOT canary a local-only job (it would pass while leaving the real risk untested). If iMessage send DOES work from cron lineage, the split in #4 is unnecessary and more loops go fully-bg.

Open decisions (founder)

  1. Scheduler: crontab (rec — reuses proven substrate) vs launchd (more macOS-native; re-runs jobs missed while asleep).
  2. Canary: green-light the 1-day morning-prep test?
  3. Quota: per-fire sessions are an independent-quota multiplier vs today's single shared session. OK? Heavy jobs on Sonnet to dampen?
  4. Cutover pace: keep the existing in-session arming live in parallel until the new substrate has a proven multi-week track record (slow, reversible).

Migration outline (after canary passes)

  1. Snapshot rollback: crontab -l > ~/.claude/state/crontab.bak-<date>; back up manifest.
  2. Write run-loop.sh (wrapper: profile + OAuth-token export + flock + bypassPermissions + sentinel + JSON tee to ~/.claude/state/loop-output/).
  3. Validate the morning-prep split end-to-end; then one connector-only job (/check-board → Discord) and one local job (/vault-health). Grep per-run JSON for permission/denied, not just exit 0.
  4. Add the ~9 non-channel loops to crontab via sync-loops.sh; stagger the overnight cluster (deep-research 1:00 vs sync-contacts 1:30) + flock shared writers.
  5. Handle the 2 iMessage-delivery loops via split (or Discord-route). Keep OFF bare cron.
  6. Trim the manifest to ONLY the in-session loops; name-gate the auto-arm hook to the channels-agent.
  7. Delete the broken /loop block in the restart script (keep .bak).
  8. Add a watchdog cron: scan run-log for fired-but-no-sentinel loops; ping founder via the channels-agent. Probe claude mcp list for connector OAuth drift.
  9. Verify a FULL cycle incl. a weekend (finance-pulse/self-review Sun, improve Mon) before declaring done.
  10. Land script changes via branch + PR (crontab/LaunchAgents are local machine state).

References