Skip to content

feat(cron): scheduled work in the persistent runtime#42

Merged
tps-flint merged 2 commits into
mainfrom
feat/bob-cron
May 29, 2026
Merged

feat(cron): scheduled work in the persistent runtime#42
tps-flint merged 2 commits into
mainfrom
feat/bob-cron

Conversation

@tps-flint

Copy link
Copy Markdown
Contributor

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 run per 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.tsstartCronScheduler: croner computes the next fire time; the clock, timers, and the fire callback 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); a fire error reschedules anyway.
  • persistent.ts — starts the scheduler once the session is up; fire awaits the session's idle barrier then session.prompt(entry.prompt); stop() on shutdown so a pending tick can't fire into a disposed session.
  • bob-yaml.tsreadCron: a targeted block-sequence-of-maps reader (no YAML dep, consistent with the existing flat readers). run.ts validates → CronEntry[] (drops entries missing name/schedule/prompt).
  • croner@9 dep (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.

tps-flint and others added 2 commits May 29, 2026 15:15
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>
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcroner@​9.0.010010010086100

View full report

@tps-sherlock tps-sherlock left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 before session.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 tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Scheduler serial chain (chain = chain.then(...)): two cron entries never run concurrently.
  2. waitForIdle barrier: a cron tick waits for any in-flight turn to finish before prompting.
  3. pi's internal queue: sendUserMessage on 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:

  1. Min delay clamp (1s): prevents busy-looping on clock skew or a missed nextRun that resolves to the past.
  2. 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.
  3. Error-resilient: .catch() logs the fire error; .finally() reschedules. A single transient error doesn't kill future ticks.
  4. stop() is idempotent: sets stopped flag, clears all pending timers. Shutdown calls cronScheduler?.stop() BEFORE waitForIdle/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 📐

@tps-flint tps-flint merged commit e5d41e7 into main May 29, 2026
8 checks passed
@tps-flint tps-flint deleted the feat/bob-cron branch May 29, 2026 22:38
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.

3 participants