Skip to content

Routines: standing instructions a Bot runs on a schedule - #278

Merged
guidovizoso merged 46 commits into
mainfrom
guido/routines
Aug 27, 2026
Merged

Routines: standing instructions a Bot runs on a schedule#278
guidovizoso merged 46 commits into
mainfrom
guido/routines

Conversation

@guidovizoso

Copy link
Copy Markdown
Contributor

What this is

A Bot can now be asked to do something on a schedule — "every weekday at nine, post the standup notes here" — and the routine runs under its creator's grants, replying into the channel as an ordinary Bot message. The feature spans the server (store, cron scheduling, sweep, headless turn runner, internal handoff endpoint), a worker process that fires due routines, the app's Routines page, tool surface via the plugin catalogue (Routines is the first builtin, in-process catalogue entry), Helm chart wiring, and start.sh.

Two tables arrive via migration 0021. A new process fires due routines: scripts/start.sh runs it locally, the Helm chart schedules it with routines.enabled, and WORKER_SHARED_SECRET is the credential it presents.

⚠️ This PR also removes the Attention inbox (#255)

Deliberate and discussed: the revert of e8aa344 rides in this branch, plus migration 0022 dropping attention_resolutions explicitly (the feature never shipped in a release; the alternative was a snapshot/schema divergence that armed a silent DROP TABLE inside the next unrelated migration). If the team wants Attention back, that conversation should happen on this PR.

Review

A high-effort multi-angle review ran over the full branch diff (8 finder angles, adversarial verification of every correctness candidate). Confirmed and fixed here:

  • The 15-minute schedule floor sampled only the next two occurrences, so acceptance was time-of-call dependent — 45,55 8 * * * created at 08:50 was accepted, fired once, then wedged forever on ScheduleRefusedError inside advanceNextRun (reproduced by execution). Validation now walks the expression's cycle, and a sweep-time refusal switches the routine off visibly instead of wedging.
  • failOpenRuns was unreachable at shipped Helm cadence (5-min sweep vs 10-min grace vs 5 attempts), so dispatch failures leaked status IS NULL run rows rendering as "Running…" forever — and where reachable it would have closed a genuinely in-flight run as failed. Replaced with an age-scoped reaper that closes abandoned rows as skipped, which also reaps runs stranded by a server dying mid-turn and keeps infra failures out of the fatigue streak.
  • A stale clock drained one occurrence per pass — a month of worker downtime meant ~15 days of silence. It now catches up in one pass.
  • The 20-enabled-routines cap was check-then-write; two racing creates at 19 both landed. Now counted and written under a per-owner advisory lock (covered by a real concurrency test).
  • routines had no owner index; 0023 adds (owner_user_id, enabled).
  • start.sh kept a surviving pre-routines server that could never accept worker handoffs (401 on every dispatch, invisibly). It now probes the handoff route and restarts on 401/404.
  • Deduplications: shared relative-time module in the app, RoutineTools as Pick<RoutineStore, …>, the store's MAX_RUN_ERROR reused, one dead compat branch removed, timezone validation memoized off the sweep hot path.

One reviewer claim was refuted and deliberately not applied: the run-turn chunk-collector fallback is load-bearing (chunks are durably acknowledged before the fallback can run; real empty-diff-with-chunks paths exist).

Known follow-ups (found in review, out of scope here)

  • run-turn.ts hand-copies the runtime's private turn engine (~660 lines pinned to 1.69.0 dist paths) — the real fix is exporting a headless-turn API from @copilotkit/runtime.
  • Worker and fire-routines.ts duplicate the dispatch/owner/purge wiring; extract a shared createRoutineSweep.
  • Transport layer: builtin transports get their store via a mutable module global, and call identity rides optional fields (actorId?) — wants a constructed registry and a required CallContext.
  • createApp is at 13 trailing positional optionals; convert to an options object.
  • Sweep N+1s (advanceNextRun re-SELECT, per-item routineForFiring), describeWritten full-list read, truncation-helper and MCP result-cap consolidation.
  • routine_runs would benefit from a scheduled_for column (identity-scoped closing) and a partial index on open runs.

Verification

Format, lint, typecheck (server/app/worker) all green; 1893 tests pass, 0 fail; full build succeeds; migrations 0022/0023 applied cleanly to a dev database holding 0000–0021 and re-run as a no-op.

main took idx 20 while this branch was built, so 0020_routines becomes 0021_routines: regenerated with drizzle-kit (SQL byte-identical to the original), snapshot chained off main's 0020, journal appended. The last_run_at column comment now says what the sweep actually writes there — the stamp advanced past, fired or drained — rather than implying a run history the routine_runs table owns.
biome 2.5.10 (the version bun.lock has pinned throughout) rejects eight files as merged by #241 and #255 — main's CI is red on both. Formatting them here keeps this branch's whole-tree gate honest; if main fixes itself first, this commit rebases away to nothing.
main inserted attentionStore into createApp's positional signature before pageFrames; the endpoint test's hand-built tuple put the runner one slot early, so the route never mounted and all seven cases 404ed.
…ation

Reverts e8aa344 (#255) code on guido/routines only: the API routes, store,
view, schema, app wiring, sidebar entry and /attention page go; the 0020
migration, journal entry and snapshot stay, because the local database has
already applied them and an unused table is cheaper than a broken chain.
The routine endpoint test loses the positional attentionStore slot it had
gained for main's signature.
…inish

Four lifecycle fixes from review. The 15-minute floor now walks the
expression's cycle instead of sampling the next two occurrences, so a
cron like '45,55 8 * * *' is refused whenever it is created rather than
accepted at 08:50 and wedged after its first firing; a refusal that
still surfaces at sweep time switches the routine off with its reason
on a run row instead of silently burning a due slot every pass forever.
The enabled cap counts and writes under a per-owner advisory lock, so
two racing creates at nineteen admit exactly one. A stale clock is
drained current in one pass by computing the next occurrence from now
while the CAS still compares the old stamp. And failOpenRuns — whose
only caller was unreachable at the shipped five-minute cadence, and
which would have closed a genuinely in-flight run as failed — is
replaced by an age-scoped reaper that closes abandoned rows as skipped,
which also mops up runs stranded open by a server dying mid-turn.
The attention revert kept migration 0020 while removing the schema
declaration, which left drizzle's latest snapshot claiming a table the
schema files no longer knew: the next unrelated db:generate silently
emitted DROP TABLE attention_resolutions CASCADE inside whatever
migration somebody happened to be writing. Migration 0022 makes that
drop explicit and reviewable — the feature never shipped in a release —
and restores the invariant that the latest snapshot matches the schema.
Migration 0023 gives routines the (owner_user_id, enabled) index that
listFor, countEnabled, the owner-scoped writes and the users cascade
were all sequential-scanning without.
start.sh leaves an answering server alone, and that philosophy kept a
server started before WORKER_SHARED_SECRET existed: the worker then got
401 for every handoff and routines never fired, with nothing at start
time saying why. Now the script probes /internal/routines/run with this
run's secret — 400 means the secret was accepted and only the empty
body refused, 401 or 404 means the server cannot take handoffs and is
restarted into this run's environment.
# Conflicts:
#	server/src/app.ts
#	server/src/attention/view.ts
#	server/src/db/schema/attention.ts
#	server/tests/attention-view.test.ts

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the full branch closely across three angles (governed-path/security, horizontal scale, and the runtime-copy plus template-fit), reading the actual code rather than the description. This is strong work. Approving.

Governed path and worker auth: sound

  • The internal handoff is authenticated constant-time (timingSafeEqual via sameToken), fail-closed when WORKER_SHARED_SECRET is unset (byte-identical 401), and the refusal is itself audited as routines.dispatch_refused, so a stale secret cannot silently stop every routine.
  • A firing runs under the creator's real resolved identity through the same grant -> CEL policy -> audit -> act tool boundary as an interactive turn. Enforcement lives in the tool closure baked into the agent, so the headless path cannot skip it. No escalation, and firings are audited.
  • No identity spoofing: owner/agent/channel are read server-side from the stored routine row; the handoff body carries only routineRunId, and an ownerUserId in the model's args is never read. Error columns are code-point-capped with no secret material.

Horizontal scale: multi-worker-safe

  • Exactly-once firing rides three independent Postgres guarantees: work_items (kind,key) PK with onConflictDoNothing keyed on routineId:minute, a CAS advance on next_run_at, and SELECT ... FOR UPDATE SKIP LOCKED claims. No in-process lease anywhere in the firing path, and there are real race tests.
  • The sub-floor schedule wedge is fixed by cycle-walking from a fixed leap-year start (tested at multiple clocks), and a sweep-time refusal disables the routine visibly instead of wedging.
  • The reaper closes only status IS NULL rows older than 2x the turn timeout as skipped, so it never closes live work and keeps infra deaths out of the fatigue streak. Stale-clock catch-up happens in one pass. The 20-enabled cap is counted and written under a per-owner advisory lock with a real concurrency test. Migrations 0021/0023 are lock-safe on the new/empty table and correctly chained.

Template-fit: appropriate

The builtin in-process catalogue entry is a genuinely generic seam, not a Routines special-case: it reuses the existing VendorTransport interface and the closed TransportKind union, so a forker adds a second in-process capability the same way (a new kind string plus a module exporting listTools/callTool). The worker package already existed; the Helm CronJob mirrors the culler pattern; routines.enabled defaults off behind a required secret. No new product surface.

One follow-up worth tracking (not blocking)

run-turn.ts hand-copies ~660 lines of the runtime's private turn engine. I diffed it line-for-line against 1.69.0: it is faithful, the dep is exactly pinned (not a caret range), and it is covered by the 882-line test. The live risk is upgrade-time drift: a renamed runtime method fails loudly at compile, but a renamed field would drift silently. The right long-term fix is the one already noted here, a headless-turn API exported from @copilotkit/runtime; until then, treat a runtime bump as requiring a re-read of this file against the package. The chunk-collector fallback refutation is correct: those chunks come from the durably-acknowledged event stream, so the fallback does not violate the report-only-what-persisted invariant.

The other deferred items listed in the description (shared createRoutineSweep, constructed transport registry, createApp options object, the sweep N+1s, a scheduled_for column) are all reasonable as follow-ups.

On the Attention inbox removal

Acknowledged and accepted. The revert is mechanically clean and complete (no dangling references), and migration 0022 is correctly chained; it also defuses the silent DROP that a schema-only deletion would have armed in the next unrelated migration. Good call to make it explicit.

LGTM.

@guidovizoso
guidovizoso merged commit 8f69e61 into main Aug 27, 2026
13 checks passed
@guidovizoso
guidovizoso deleted the guido/routines branch August 27, 2026 15:44
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.

2 participants