Skip to content

feat: routines — scheduled and API-triggered agent runs with a zero-cost check gate and real budgets - #382

Merged
saucam merged 14 commits into
mainfrom
feat/routines-54
Oct 16, 2026
Merged

saucam merged 14 commits into
mainfrom
feat/routines-54

Conversation

@saucam

@saucam saucam commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #54.

What this adds

Routines: saved agent tasks that codeoid runs on a cron or interval schedule, once at a set time, from their own API endpoint, or on demand.

codeoid routine add ci-watch -w ~/src/repo --every 15m \
  --check 'gh run list --branch main --limit 1 --json conclusion --jq ".[] | select(.conclusion==\"failure\")"' \
  --prompt 'CI on main is failing. Find the cause and summarise it in three lines. {{check}}' \
  --guarded --max-cost 1

The design goal is that most scheduled runs cost nothing:

  • A routine can have a check, a shell command that runs first. If it prints nothing, the run ends silent: no agent starts and nothing is spent.
  • An agent that finds nothing worth reporting replies [SILENT], and you hear nothing.

Every run is an ordinary session. You can open it from the terminal, the web UI or your phone, then approve, continue or fork it.

The daemon enforces the limits, not the model:

  • guarded approvals, or an autonomous tool budget;
  • a wall-clock limit;
  • per-run and per-day spend ceilings (on Claude, for fresh sessions, also a hard stop through the Agent SDK's maxBudgetUsd);
  • overlap set to skip or queue;
  • catch-up after downtime;
  • auto-pause after repeated failures;
  • optionally, a git worktree per run.

API trigger. POST /routines/<id>/fire takes a per-routine rt_… token, which can be revoked, is rate-limited, and is stored only as a hash. Its body (up to 16,000 characters) reaches the agent as {{payload}}, delimited as data.

Outcomes:

  • Pushes go to devices per the routine's notify policy and carry no content.
  • The new routine_end hook fires for every run.

Full guide: docs/routines.md. It covers usage, the safety model, how this compares with Claude Code routines, Cursor Automations and Codex automations, and known limits.

What it does that the cloud products don't:

  • runs on your own machine, with your repos and credentials;
  • works with any backend;
  • a 60-second minimum interval instead of 1 hour;
  • a zero-cost check gate;
  • real approvals in unattended runs;
  • in-flight spend and time limits;
  • every run is a live session you can pick up.

Authority model

  • A routine is its owner's alone. Names are unique per owner. Anyone else in the tenant gets "not found", by id or by name.
  • Scopes: no new one. Routines use the scopes a routine itself exercises:
    • Every routine command except preview needs session:create and session:send, reading included, so read-only watchers can't see prompts, checks or payloads.
    • Saving, firing, re-tokening or resuming a routine that pre-approves work also needs session:approve. That means an autonomous routine, or one with a check (a host shell command nobody approves at run time). Pausing never needs it.
    • Delegated tokens can't change routines. A teammate's share token arrives delegated whatever scopes it carries, so this is what keeps standing authority out of shared hands; no ZeroID scope-profile change is needed.
  • Runs act as the owner, and are attributed routine:<name>. Each one starts autonomous only for the run itself: the session steps down to guarded the moment the run ends or parks.
  • The check gets a scrubbed environment, the same policy as hooks and backends, and never the daemon's secrets.
  • Run sessions are ordinary tenant sessions. The docs say so plainly: watchers can read them, and send-holders can prompt them.
Code tour
  • src/daemon/routines/:
    • schedule.ts: croner 10.0.1 (exact pin, MIT, no deps), IANA zones, phase-anchored intervals, stable jitter, strict ISO for --at.
    • store.ts: SQLite tables. A compare-and-set on next_fire_at is the tick lock. History is trimmed, but never today's runs, the last summary, or the last handled run.
    • service.ts: scheduler, admission, check gate, run loop, retention, management API, behind a RoutineHost seam.
    • http.ts: the fire endpoint.
    • wire.ts.
  • src/daemon/session-manager.ts:
    • #routineHandle: all authorization.
    • #makeRoutineHost: sessions, worktrees, push, hooks.
  • Session.lastAssistantMessage and CanonicalHistoryAccumulator.lastTextBlock: the run summary is the agent's final message.
  • maxBudgetUsd is plumbed through TurnOpts to the Claude provider; error_max_budget_usd maps to BUDGET_STOP_MESSAGE.
  • Protocol (additive):
    • routine.* messages and results;
    • SessionInfo.managedBy: "routine";
    • push kind "routine".
  • CLI: codeoid routine add|edit|ls|show|runs|run [--wait]|pause|resume|rm|cancel|token|preview (src/cli.ts, src/terminal/routine-format.ts).
  • Config: routines.*, CODEOID_ROUTINES_ENABLED, CODEOID_ROUTINES_MAX_CONCURRENT, plus a Settings group.

Verification

Tests:

  • The full suite passes: 3249 pass, 0 fail, 19 skipped.

  • bun run typecheck (root, protocol, core), the web typecheck and biome check are all clean.

  • Routine suites: 92 tests.

    • routine-schedule: cron/DST, intervals, once.
    • routine-service: a scripted host with a fake clock, covering every scheduling, gating, budget, outcome, retention and authority-left-behind rule.
    • routine-manager: a real SessionManager on a mock backend, covering scopes, ownership, delegation, the HTTP trigger, worktrees and the hook.
    • routine-format: CLI parsing and output.
  • Protocol and schema cases for every routine message, in addition to those 92.

  • Mutation-checked guards. I broke each of these in turn and confirmed a test fails:

    • the claimFire CAS;
    • native cap for fresh sessions only;
    • process-group kill;
    • rowid tiebreak;
    • unnamed-park grace;
    • the approve-scope gate;
    • parked spend follow-up;
    • Origin reject;
    • hooks on every run.

    The CAS and process-group guards first survived mutation; I added tests that catch them.

Run live against real Claude, through the real codeoid routine CLI, on an isolated local-mode daemon:

  • an empty check → silent, $0;
  • a check with output → succeeded with a correct summary;
  • a [SILENT] reply → silent;
  • an --every 1m schedule firing on its own;
  • curl to the API endpoint → 202; a bad token → 401; an Origin header → 403;
  • a guarded write → needs_attention and the file not created. After codeoid approve, the file was written and the run's cost and summary updated;
  • --max-cost 0.01 → over_budget;
  • the routine_end hook payload;
  • a past --at rejected;
  • the cron default timezone.

The live runs found five fixes, all included:

  • hooks only fired when a push would;
  • parked runs stayed at $0;
  • token counts excluded cache reads;
  • summaries included mid-turn narration;
  • summaries mentioned unrelated claude.ai connectors.

Dogfooded on this repo. These routines ran against main:

Dogfooding also found that the 16 KB check-output cap was too small; it is now 64 KB, with an honest truncation note.

Audit rounds:

  • Round 1 (correctness, security, cross-repo impact):

    • security H1: the check inherited daemon secrets;
    • security H2: teammates could steer others' routines;
    • correctness H1–H3: persistent session reused while parked or busy, prune destroying sessions in use, the since-window dropping items;
    • about 20 Medium and Low items.

    All fixed in 18156d5. Cross-repo: no consumer breaks (details below).

  • Round 2:

    • Security High: run sessions are tenant-visible. Resolved by documenting the boundary; the session visibility model is unchanged.
    • Mediums: autonomy left on after a run, reply lost on context rotation, trimming losing the summary and today's spend, a stale abort listener, prune leaks.

    All fixed in d9c9f96.

  • Round 3: correctness was clean ("no Critical, High or Medium I would fix before merge"); security found no merge blockers. Its Lows and the send-only docs were folded into 98aad5f.

  • Final audit (whole PR, every claim in this body and the docs checked against the code): nothing to change in the code; "ready for human review once" 4 doc and comment fixes went in. Those, plus 20 polish items, are in bd76bb0.

What did not run:

  • The web UI against a routine-driven daemon. There is no routine screen yet; run sessions show as ordinary sessions marked routine-managed.
  • Push delivery to a real device. The push path is unit-tested only; no device was registered.
  • Non-Claude backends live. Codex, Gemini and Qwen routines are covered by the mock-provider tests only.
  • A ZeroID-mode daemon. The live runs were local-mode. ZeroID scope and ownership behaviour is covered by tests with ZeroID-shaped AuthContexts.
  • The mobile app's handling of kind: "routine". Cross-repo review found the app routes by sessionId and ignores kind.
  • Oracle: n/a. codeoid is not in Oracle's enabled_repos.

Follow-ups (not in this PR)

  1. A web UI routines screen, and routine tools on /mcp/control and the conductor: feat: routines in the web UI, the conductor, and /mcp/control #383.
  2. GitHub and Slack event triggers: feat: event triggers for routines — GitHub, Slack, and generic webhooks #384.
  3. Send-only messages should never run autonomously (fix: a message from a token without session:approve never runs autonomously #385). In any autonomous session, a message from a token without session:approve should have its tool calls run guarded. This is not new with this PR, but routines make autonomous sessions more common.
  4. Routine ownership (feat: key routine ownership on the human, not the credential's identity #386) keys on the token's subject, which for a codeoid login key is the key's identity. Consider keying on the human principal, if ZeroID carries it.
  5. Smaller items:

🤖 Generated with Claude Code

saucam and others added 10 commits October 16, 2026 10:54
…t gate and real budgets

A routine is a saved agent job: a prompt, a workdir, a backend, and the
policy that makes running it unattended safe. It fires on a schedule (cron
in an IANA timezone, an interval, or once), on its own API endpoint, or on
demand.

Each run is bounded four ways and recorded:

- a check command runs first; no output means nothing to do, so the run
  ends `silent` without starting an agent and costs nothing — minute-level
  monitors are practical;
- autonomous within a tool budget, or guarded so every non-read tool
  waits for the owner; a run that parks ends `needs_attention` and stays
  parked for them;
- a wall-clock limit with a hard interrupt;
- a per-run spend ceiling (the Claude SDK's native maxBudgetUsd on fresh
  run sessions, and the daemon's own check between turns everywhere), and a
  per-routine daily cap.

Runs record outcome, cost, tokens and the agent's summary; [SILENT] means
nothing to report. The routine pauses itself after repeated failures,
notifies per its policy (content-blind push, and a new `routine_end` hook
that can post to Slack), keeps the last N run sessions, and feeds the last
summary to the next run. Fresh sessions per run (optionally in their own
git worktree) or one persistent session; overlap skip/queue; catch-up after
downtime; stable jitter. A fire is claimed by compare-and-set on the next
fire time, so it can never run twice.

Authority: routine:manage is a new owner-tier scope; changing a routine
also needs session:create + session:send, and pre-approving work
(autonomous, or a check command) needs session:approve. Runs act as the
owner, delegated and named routine:<name>; audit rows name the routine.

Surfaces: routine.* protocol messages, `codeoid routine add|edit|ls|show|
runs|run|pause|resume|rm|cancel|token|preview`, POST /routines/<id>/fire
with a per-routine token, and a `routines` config block with a kill switch.

Refs #54

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
… tokens count cache reads

The live run showed three gaps: routine_end only fired when a push would,
a needs_attention run stayed at $0 after the owner approved and the agent
finished (so the daily cap undercounted), and input tokens excluded cache
reads.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…h tests

Both guards survived mutation: the claim test raced two ticks that ran in
sequence, and the timeout test passed whether or not the shell's children
died.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…TION, COMPARISON, hooks and CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…it cut more

Dogfooding found the 16 KB cap too small for a real check (open issues plus
history is 34 KB), and the truncation note understated what was dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…ole turn

Dogfooding: a docs-audit run's summary opened with "Checking the config…"
narration from between its tool calls. Like claude -p, the result is now the
last text block of the turn.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…safe session reuse and retention

Security: a routine is its owner's alone (names unique per owner, others get
not-found); every routine read needs routine:manage; delegated tokens can't
change routines; the check runs with a built environment, never the
daemon's secrets; edits are audited with what changed; run start and cancel
are audited.

Correctness: a persistent session that is parked or busy is not prompted (the
fire is skipped); retention spares sessions that are attached, working or
parked; CODEOID_ROUTINE_SINCE (was LAST_RUN_AT) is the start of the last run
that handled its input, so a since-check never loses items; a turn with no
text never reuses an older reply; hooks fire for skips, queued cancels and
boot interruptions; a daemon stop ends runs interrupted and keeps queued runs
queued across restart; cancel and stop kill a running check; pause/resume
don't revalidate the spec; only resuming resets the failure streak; a paused
routine's queued scheduled run is skipped; one-off times must be future ISO
8601 with a zone; a cron without --tz takes the daemon's zone (or keeps its
own on edit); run history is capped at 500; one broken schedule can't stall
the tick; --wait follows its run by id; an overlap-queue fire right behind a
starting run is no longer skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…fe replies, trimming keeps what runs read

Security: a run's session is set back to guarded when the run ends (and when
a parked run settles), so nobody prompting it later gets pre-approved tool
calls; firing or re-tokening a routine that pre-approves work needs
session:approve; check commands are audited by digest, never text; qwen
routines can't take an API trigger. Docs state the boundary plainly: the
routine is owner-only, its run sessions are ordinary tenant sessions, and
ownership follows the credential's identity.

Correctness: a run recognises its reply by turn id, so context rotation
shrinking history mid-run no longer drops it; history trimming never removes
today's runs (the daily cap's sum), the newest summary, or the newest handled
run; an abort after a check settled no longer signals a reused pid; prune
re-examines every kept session and spares a former persistent one; a busy
skip doesn't point a push at the session; a schedule that can't be evaluated
pauses the routine instead of erroring every tick; the accumulator forgets
its last text block when history is replaced.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…n't need

Live runs on Claude ended every summary with a paragraph about unrelated
claude.ai connectors needing authorization.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…nd qwen refused as the default too

A run's session steps down from autonomous the moment the run ends or parks
(and only from autonomous, so a mode the owner chose is kept), before any
store write that could fail. An API trigger is refused for a routine that
would run on qwen as the daemon's default backend, and an API run fails if
the default has since become qwen. Only a schedule that can't be evaluated
pauses a routine (other tick errors are logged), and resuming one gives a
clear error. Prune scans every kept session. Pausing no longer needs
session:approve. Docs name what send-only tokens can do in an autonomous run
and warn against persistent + autonomous where they can reach it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@socket-security

socket-security Bot commented Oct 16, 2026 •

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@​10.0.11001009995100

View full report

@saucam

saucam commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd: CI is green on 98aad5f (iteration 1). All 4 checks passed: daemon-check, web-check, Socket Security: Project Report, Socket Security: Pull Request Alerts. Nothing needed fixing and no commits were pushed. The branch is up to date with main.

Comments: #routineHandle's scope rules, routine_end firing for every run,
needs_attention meaning a tool approval, the spend-ceiling contract, the full
placeholder list and --at's zone requirement in CLI help. Guide: the
16,000-character payload limit and 409 for a paused routine, the 4,000-char
failed-check excerpt, what history trimming keeps, the complete audit list,
the overlap-queue skip, and the native cap being fresh-session-only. Also
marks P4.5 shipped in the conductor plan, and tidies a duplicate assignment
and an indent.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
saucam and others added 3 commits October 16, 2026 15:49
Drop routine:manage. Holding routines takes the scopes a routine itself
uses — session:create + session:send (reading included, so read-only
watchers still can't see prompts, checks or payloads), plus session:approve
for one that pre-approves work. Delegated tokens (teammates' share tokens,
agents) still can't change routines, which is what kept standing authority
out of shared hands. No ZeroID scope-profile change is needed, so Studio's
embedded sign-in works with routines as is.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Remove RetryManager / src/daemon/retry.ts: deleted in af22d81; retry is the
  Claude Agent SDK's fallbackModel (session.fallbackModel / CODEOID_FALLBACK_MODEL).
- Rewrite "Retry with fallback": no daemon-side backoff, error categorization,
  3-attempt fallback, or indefinite unattended retry exists.
- Scopes: 17, not 8; canonical source is packages/protocol/src/scopes.ts
  (src/protocol/* are re-export shims). List the missing scopes in Auth Model.
- Backends: add llamacpp to the ProviderRegistry list, providers/ dir list,
  Tech Stack, and Sessions.
- Scrollback buffer defaults: 5000 entries / 20MB, not 500 / 1MB.
- `codeoid new <name> [workdir]`: workdir is optional when --worktree is given.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The docs-drift routine's count was one short; its list was complete.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
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.

feat: Schedules — cron, interval, and once-off triggers for sessions

2 participants