Skip to content

fix(cron): resilient file watcher + periodic reconciler (closes #15)#16

Merged
nyem69 merged 1 commit into
mainfrom
fix/cron-watcher-reconciler
May 28, 2026
Merged

fix(cron): resilient file watcher + periodic reconciler (closes #15)#16
nyem69 merged 1 commit into
mainfrom
fix/cron-watcher-reconciler

Conversation

@nyem69

@nyem69 nyem69 commented May 28, 2026

Copy link
Copy Markdown
Owner

Closes #15

Fixes the silent file-watcher drop where cron/jobs.json edits stopped being picked up by the live scheduler — leaving newly-enabled jobs with a sensible nextRun in /api/cron but lastRun: null forever (because the API computes nextRun via cron-parser independently of the live node-cron scheduler state).

Root cause

The single-file chokidar watch on cron/jobs.json:

  • Watched a single inode → atomic-rename writes (vim, git checkout, many editors) unlink the inode and the underlying fs handle becomes invalid; chokidar silently stops emitting events.
  • Only listened to change (atomic replaces emit unlink + add, not change).
  • No error handler → any chokidar error was swallowed.
  • Reload callback wasn't wrapped in try/catch → a throw inside would orphan the debounced chain.

Fix — two layers of defense

1. Resilient single-file watcher (gateway/watcher.ts)

New watchSingleFile() helper that watches the parent directory filtered to one file via ignored, with depth: 0. Directory watches persist across inode replacements. Listens to change and add, registers an error handler, wraps the callback in try/catch with logging. Applied to both config.yaml and cron/jobs.jsonorg/ and skills/ already watch directories and are naturally resilient.

2. Periodic reconciler (cron/reconciler.ts, new)

Every 5 min: loadJobs()signatureOfJobs(jobs) compared to getScheduledSignature() (the sig of the last successful scheduleJobs pass, tracked in scheduler.ts). On divergence → reloadScheduler(jobs). Signature hashes id + schedule + timezone + engine + model + employee + prompt of enabled jobs only.

Why prompt is in the signature: runCronJob captures the job into its closure at schedule time, so a prompt-only edit must trigger reschedule for the live closure to pick it up. The reconciler relies on this — without it, prompt changes would be invisible until the next restart.

Timer .unref()s so it doesn't keep the process alive. Wired alongside startScheduler / stopScheduler in server.ts.

Verification

  • typecheck: pnpm typecheck clean.
  • tests: 7 new unit tests in cron/__tests__/reconciler.test.ts cover signature stability, order independence, disabled-job exclusion, schedule/prompt change detection, enable/disable transitions.
  • full suite: 741/741 pass across 52 files — no regressions.
Test Files  52 passed (52)
     Tests  741 passed (741)

Files

Path Change
packages/jimmy/src/cron/scheduler.ts + signatureOfJobs(), + getScheduledSignature(), track lastScheduledSig in scheduleJobs
packages/jimmy/src/cron/reconciler.ts newstartCronReconciler() / stopCronReconciler() / tickReconciler()
packages/jimmy/src/cron/__tests__/reconciler.test.ts new — 7 unit tests
packages/jimmy/src/gateway/watcher.ts new watchSingleFile() helper; applied to config + cron watchers
packages/jimmy/src/gateway/server.ts wire start/stop of the reconciler

Operational note

The PR doesn't remove the existing PUT /api/cron/:id workaround — that's still a valid per-job lever. The reconciler is the background safety net; the watcher fix should make manual reloads rarely necessary even on macOS+atomic writes.


🤖 Generated with Claude Code

The cron file-watcher could silently die on atomic-rename writes (vim,
git checkout, many editors), leaving jobs.json edits invisible to the
live scheduler — even though GET /api/cron kept returning the latest
jobs.json with computed nextRun via cron-parser. Newly-enabled jobs
never fired and there was no visible error.

Two layers of defense (per #15):

1. Resilient single-file watcher (watcher.ts):
   - watchSingleFile() helper watches the parent directory filtered to
     the target file (depth 0, ignored-filter). The directory watch
     persists across inode replacements, where a direct file watch's
     fs handle becomes invalid.
   - Listens to both 'change' (in-place edits) and 'add' (atomic
     replace lands here, not 'change').
   - Registers a chokidar 'error' handler that logs the error instead
     of swallowing it.
   - Wraps the user callback in try/catch with logging so a throw
     can't kill the watcher.
   - Applied to BOTH config.yaml and cron/jobs.json watchers
     (org/skills already watch directories, naturally resilient).

2. Periodic reconciler (cron/reconciler.ts):
   - Every 5 min: loadJobs() -> signatureOfJobs(jobs) compared to
     getScheduledSignature() (the sig of the last scheduleJobs pass).
     On divergence -> reloadScheduler(jobs).
   - Signature hashes id+schedule+timezone+engine+model+employee+prompt
     of *enabled* jobs only. Prompt is included because runCronJob
     captures it at schedule time — a prompt edit must trigger reschedule
     for the live closure to pick it up.
   - Timer .unref()s so it doesn't keep the process alive.
   - Wired alongside startScheduler / stopScheduler in server.ts.

Tests: 7 unit tests covering signature stability, order independence,
disabled-job exclusion, schedule/prompt change detection, enable/disable
transitions. Full jimmy suite: 741/741 passing.

Closes #15.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@nyem69 nyem69 merged commit f088483 into main May 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cron scheduler silently stops reloading on jobs.json edits — newly-enabled jobs never fire

1 participant