feat(cron): scheduled work in the persistent runtime#42
Conversation
runAgent accumulates the assistant's text but is silent (it never writes to console; stdout is only populated when captureStdout is set). The CLI called it without captureStdout and returned only the exit code, so `bob run <name> "<prompt>"` printed NOTHING — the response was lost. Opt into captureStdout in the run command + print the result. No double output (runAgent doesn't stream to console). The captureStdout→stdout path is covered by run.test.ts; cli.test.ts runs the real binary so the print isn't unit-tested there (no LLM seam). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the long-typed-but-unimplemented bob.yaml `cron:` field — the
"agents do proactive work" primitive (e.g. Pulse's daily intel brief).
Each `cron:` entry { name, schedule (cron expr), prompt } fires its prompt
INTO the live persistent session on its cadence. Crucially this runs in
the SAME warm session that handles inbound — so the agent uses its existing
capabilities (discord_reply, flair_*) to act on the tick, and there's only
ONE Discord gateway connection. (A timer + `bob run` per tick would open a
duplicate login for the same bot token and fight the persistent session —
the fork raised with Nathan; this is option A.)
- cron.ts: startCronScheduler — croner computes next-fire; clock + timers +
the fire callback are injected (deterministic tests, no wall-clock/LLM).
Fires are SERIALIZED through one promise chain; a bad cron expr is logged
+ skipped (doesn't take down the scheduler); a fire error reschedules.
- persistent.ts: starts the scheduler after the session is up; `fire` awaits
the idle barrier then session.prompt(entry.prompt); stop() on shutdown so
a pending tick can't fire into a disposed session.
- bob-yaml.ts: readCron — targeted block-sequence-of-maps reader (no YAML
dep, consistent with the existing flat readers). run.ts validates → CronEntry[].
- croner@9 dep. 7 tests (scheduler fire/skip/sibling/error + readCron).
Follow-up: wiring Pulse's actual brief (watch-list/schedule/channel) is
config + Nathan's content, separate from this mechanism.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
tps-sherlock
left a comment
There was a problem hiding this comment.
Security Review — PR #42 (Scheduled Work in Persistent Runtime)
Verdict: APPROVE
Adds cron: to bob.yaml so the persistent session can fire scheduled prompts into the live agent. Clean implementation with no new exposure.
(a) Cron prompts — same trust surface as the rest of bob.yaml
Cron entries are operator-controlled config. The operator already writes the soul, chooses the model, wires capabilities (including Discord with token path and channel allow-list), and sets the agent's tool permissions. Adding scheduled prompts is within the same trust boundary — the operator already controls everything the agent can do. The scheduler drives the SAME session that handles inbound messages, using the SAME capability tools. No lateral escalation.
(b) readCron parsing — no polynomial ReDoS
The parser follows the same hand-rolled, linear-regex approach as readBlock and readCapabilities. The regex at the core is /^([A-Za-z0-9_-]+)\s*:(.*)$/ — character-class prefix followed by \s*: then (.*)$. No overlapping quantifiers, no alternation-within-repeat, no backtracking pit. The column-0 key detection (/^[A-Za-z0-9_-]+\s*:/) is similarly linear. Indentation grouping uses a simple indent > dashIndent comparison. Test confirms all expected shapes and parseCron drops entries missing required keys.
(c) croner@9.0.0 — zero dependencies, pinned exact
Zero transitive dependencies. Version pinned exact (9.0.0) — no semver range. Well-known cron-expression parser. An invalid expression is caught at construction, logged, and skipped (never schedules, never fires).
(d) No secrets in scheduler logs
Log messages reference only the entry name: cron: firing ${entry.name} and cron: ${entry.name} fire error: ${reason}. Prompt text is never logged.
Additional observations
- Concurrency is serialized through a single promise chain — two cron entries never run concurrently. The fire callback additionally awaits
session.waitForIdle(). stop()cancels all pending timers beforesession.dispose()— a pending tick can't fire into a disposed session.- Fire errors don't kill the scheduler —
.catch().finally(() => scheduleNext())reschedules regardless. - Invalid cron expressions don't crash — caught, logged, skipped. Valid siblings still schedule.
- MIN_DELAY_MS = 1_000 prevents busy-loops from clock-skewed
nextRun.
Recommendation
Merge. No security issue. Cron prompts live inside the operator's existing trust boundary, the parser is ReDoS-safe, croner is zero-dependency, and the scheduler logs only entry names (never prompt text). Concurrency and shutdown sequencing are correct.
tps-kern
left a comment
There was a problem hiding this comment.
Architecture Review: APPROVE ✅
I reviewed the full diff (116-line cron.ts, 204-line persistent.ts with cron wiring, 56-line readCron addition, 154-line test file). All three architecture questions check out.
(a) Concurrency model — correct, no race window
The pipeline:
cron tick arrives
→ enqueued on scheduler's serial chain (never concurrent with another tick)
→ fire(): await session.waitForIdle?.()
→ await session.prompt(entry.prompt)
Discord inbound arrives simultaneously:
pi.sendUserMessage(cleaned) → queued by pi's internal turn sequencer
No race window. Three independent serialization layers:
- Scheduler serial chain (
chain = chain.then(...)): two cron entries never run concurrently. waitForIdlebarrier: a cron tick waits for any in-flight turn to finish before prompting.- pi's internal queue:
sendUserMessageon a busy session is queued; pi processes one turn at a time.
If a cron tick fires while an inbound Discord turn is in progress: tick waits at waitForIdle → turn finishes → tick prompts → agent processes cron → then pi dequeues any pending inbound messages. If both arrive at the exact same microsecond: pi's internal queue wins the race (whichever prompt/sendUserMessage pi sees first runs first; the other waits). Either order is correct — the agent processes both, sequentially.
Confirmed: this is correct. Same-session cron firing (vs bob run per tick) is the right choice. The old timer + bob run approach would open a duplicate Discord gateway connection (same bot token, fighting the persistent process for the socket) and have zero conversational continuity.
(b) croner dep + hand-rolled readCron — right tradeoffs
croner: zero dependencies, handles standard cron expressions (5-field + @daily/@hourly), returns next fire time deterministically. The alternative (building a cron parser) would be ~200+ lines of error-prone date math. Right call.
readCron: 56-line hand-rolled reader for a specific YAML shape:
cron:
- name: daily_intel
schedule: "0 9 * * *"
prompt: "Compose the brief."Consistent with the monorepo's no-YAML-dep approach. The reader targets the exact format bob init documents. A bad entry (missing name/schedule/prompt) is dropped by parseCron(); a bad cron expression is skipped by the scheduler (logged, never fires, doesn't kill sibling entries). Right tradeoff.
(c) Self-rescheduling timer loop — sound
scheduleNext(entry, cron):
next = cron.nextRun(Date.now())
if no next → "no future run" → stop
delay = max(1000, next.getTime() - now())
setTimer(delay, () => {
chain = chain
.then(() => fireOne(entry))
.catch(err => log) // fire error doesn't kill the loop
.finally(() => scheduleNext(entry, cron)) // always reschedule
})
Four safety properties:
- Min delay clamp (1s): prevents busy-looping on clock skew or a missed
nextRunthat resolves to the past. - No-future-run stops: if
cron.nextRun()returns null (e.g., a one-shot cron in the future), the entry stops scheduling. No zombie timers. - Error-resilient:
.catch()logs the fire error;.finally()reschedules. A single transient error doesn't kill future ticks. - stop() is idempotent: sets
stoppedflag, clears all pending timers. Shutdown callscronScheduler?.stop()BEFOREwaitForIdle/dispose, so a pending tick can't fire into a disposed session.
The test coverage proves all four: tick fires and reschedules, bad expression is skipped, valid sibling survives bad sibling, fire error reschedules anyway, stop cancels pending timers.
Additional notes
A. shutdown ordering is correct. The new shutdown in persistent.ts calls cronScheduler?.stop() FIRST, then waitForIdle, then dispose. This prevents a cron timer that fires between the idle barrier and dispose from prompting into a dead session.
B. resolveRunConfig now returns cron: CronEntry[]. The cron: block is parsed once during config resolution (shared by runAgent and startPersistent). runAgent ignores cron entries (ephemeral session has no scheduler). startPersistent passes them to startCronScheduler. Clean separation.
C. CronEntry type defined in index.ts as { name: string; schedule: string; prompt: string }. Exported from the shell's public surface. The cron scheduler depends on this type, avoiding a layering cycle between cron.ts and bob-yaml.ts.
Verdict
Ship it. The concurrency model is correct, the dep/tradeoff choices are right, and the self-rescheduling loop is sound with all four safety properties proven. 🚢
— Kern 📐
What
Implements the long-typed-but-unimplemented bob.yaml
cron:field — the "agents do proactive work" primitive (the on-mission driver: Pulse's daily intel brief).Each
cron:entry{ name, schedule (cron expr), prompt }fires its prompt into the live persistent session on its cadence — the same warm session that handles inbound. So the agent uses its existing capabilities (discord_reply,flair_*) to act on the tick, and there's only one Discord gateway connection.This is option A from the fork I raised with Nathan. A timer +
bob runper tick (option C) would open a duplicate gateway login for the same bot token and fight the persistent session — so the scheduled prompt drives the existing session instead.How
cron.ts—startCronScheduler: croner computes the next fire time; the clock, timers, and thefirecallback are all injected (deterministic tests, no wall-clock wait, no LLM). Fires are serialized through one promise chain (two entries never overlap); a bad cron expr is logged + skipped (doesn't take down the scheduler); afireerror reschedules anyway.persistent.ts— starts the scheduler once the session is up;fireawaits the session's idle barrier thensession.prompt(entry.prompt);stop()on shutdown so a pending tick can't fire into a disposed session.bob-yaml.ts—readCron: a targeted block-sequence-of-maps reader (no YAML dep, consistent with the existing flat readers).run.tsvalidates →CronEntry[](drops entries missing name/schedule/prompt).croner@9dep (zero-dep, reputable).Tests
7 new: scheduler fire→reschedule, bad-schedule skip, sibling-still-schedules, fire-error-reschedules; readCron parse/empty/comments. Full bob suite green.
Follow-up
Wiring Pulse's actual brief (watch-list / schedule / channel) is config + Nathan's content — separate from this mechanism.