Skip to main content

Scheduled Sessions

A cron job in Bossanova is a pre-configured session-creation trigger that fires on a schedule. When the cron tick arrives, the daemon spawns a session as if you had pressed n from the home view: same prompt, same worktree machinery, same plugin pipeline, except no human is sitting there to type the prompt. Useful for the kind of work that wants to happen on a predictable cadence: weekly dependency scans, nightly stale-issue triage, periodic docs sweeps.

Open the cron list

From the home view, press s to open Settings, then c (or press ? from any screen for the keymap). The list shows every job the daemon knows about, one row per job, sorted by next fire time. The columns are:

ColumnMeaning
CRONThe schedule expression (e.g. 0 9 * * 1-5).
NAMEHuman-readable name you set on creation.
REPOWhich repo the spawned session targets.
ENABLEDyes / no; disabled rows are dimmed and don't fire.
LAST RUNRelative time of the last fire (5m ago, 2h ago, never).
NEXT RUNRelative time until the next scheduled fire.
STATUSRunning (with spinner), gating, gated, failed, or idle.

The gating and gated statuses come from the optional gate command (see Gate command below): gating shows while the gate is running, and gated is remembered when the last tick was blocked because the gate ran and said no. A gate that could not run at all reads failed instead — see Outcome.

The action bar shows the available keys: [n]ew, [e/enter]dit, [d]elete, [space] toggle, [r]un now. esc returns to Settings. Both e and Enter open the highlighted job in the edit form.

Add a cron job

Press n from the cron list. The form's fields all come from services/boss/internal/views/cron_form.go:

FieldRequired?Notes
NameyesLetters, digits, spaces, hyphens, underscores. Up to 80 chars.
RepoyesPick from a select of all configured repos.
PromptyesSingle-turn prompt the agent runs. Must be self-contained; see the warning below.
Scheduleyes5-field cron expression or one of @daily / @hourly / @weekly / @monthly.
TimezonenoIANA name (e.g. America/New_York). Empty = the daemon's local zone.
Gate commandnoOptional command run before each fire; a non-zero exit skips the run. See Gate command.
Run setup commandyesDefaults to on. Turn off to skip the repo setup script for light jobs. See Run setup command.
Zero outputyesDefaults to off. Turn on for jobs that change nothing in the repo — no worktree, branch, or PR. See Zero output.
EnabledyesDefaults to on. Disabled rows persist but don't fire.

The form renders a live next-fire preview under the schedule field. As you type a valid expression, it shows the literal next wall-clock time the job would fire in the chosen timezone. Invalid expressions show a red error inline.

Single-turn prompts

Cron sessions only listen for the main agent's Stop hook. Subagents are ignored, and there is no follow-up loop. Whatever the prompt does in one shot is the run. Keep the prompt self-contained: don't write it as "ask me first" or "if X, ping me." If you need interaction, start a regular session instead.

Schedule format

The schedule field accepts either of:

  • Standard 5-field cron. Minute, hour, day-of-month, month, day-of-week. Examples:
    • 0 9 * * 1-5: every weekday at 09:00.
    • */15 * * * *: every 15 minutes.
    • 0 3 1 * *: 03:00 on the first of every month.
  • Predefined macros. @hourly, @daily, @weekly, @monthly, @yearly. (Source: bossalib/cronutil parser used by both the form validator and the scheduler.)

Cron's smallest granularity is one minute, so */30 * * * * * and similar second-level expressions are rejected.

Gate command

Some cron jobs run often and carry heavy, token-expensive prompts, but only actually need to run when a cheap mechanical condition holds — for example, "there is an open PR with label needs-triage." The gate command is that cheap check. It runs before the job fires; a zero exit lets the run proceed, and a non-zero exit blocks it. Because the gate runs before any worktree is created, a blocked tick costs nothing beyond the gate command itself — no worktree, no setup script, no agent session, no tokens.

Leave the field empty for no gate (the default — every tick fires).

Command vs. path

The command is interpreted one of two ways:

  • Path to an executable when it starts with /, ./, or ../. The command is split on whitespace and run directly, without a shell. Relative paths resolve against the repo clone (the working directory).
  • Shell command otherwise: run as sh -c "<command>", so pipes, &&, environment expansion, and other shell features work.

Outcome

Anything other than exit 0 blocks the fire — the gate contract deliberately fails closed, because an unverifiable condition should skip the run rather than burn tokens. But blocking is not one thing, and the two ways of blocking are recorded differently:

Gate resultOutcomeSTATUSMeaning
Exit 0The job fires normally (worktree, setup, agent session).
Exit 1 (or any other non-zero code)gatedgatedThe gate ran and decided there is no work. Healthy.
Exit 127 — command not foundgate_failedfailedThe gate could not be found. Nothing was evaluated.
Exit 126 — found but not executablegate_failedfailedThe gate could not be executed. Nothing was evaluated.
Timeout (default 60s)gate_failedfailedThe gate never finished. The slot is released so a hung gate can't wedge the runner.
Launch error (binary missing, bad cwd, …)gate_failedfailedThe gate process never started.
Killed by a signal (OOM, kill, crash)gate_failedfailedThe gate died before it exited, so it never chose anything.
Empty gate command reaching the runnergate_failedfailedMisconfiguration; nothing to evaluate.

Both outcomes block the fire, create no session, and still advance next_run_at. What differs is what an operator sees. gated is a warning-styled "waiting, healthy" row: the gate is doing its job. gate_failed is a red failed row: the gate is broken and nothing is being checked.

That split exists because collapsing the two hid a real outage. When a daemon host lost its node from PATH, every node …/gate.mjs gate exited 127, every tick recorded gated, and the cron list looked like a quiet, healthy backlog sweep while eighteen pull requests piled up behind it. A failed row and a warn-level daemon log line — carrying the gate's own output, its classified failure, and its exit code — now name the problem instead.

Signal "no work" with exit 1

Exit 126 and 127 are the shell's own convention for "could not run what you asked", so a gate that exits with either is reported as broken rather than as a skip. If your gate wants to say "there is nothing to do right now", exit 1 — which is also what a bare grep, test, or [ … ] already does, and what the shipped example gates below use. Any non-zero code other than 126 and 127 is treated the same way.

Both are remembered — the outcome is written to last_run_outcome, so it survives a daemon restart and stays visible in the list until a later fire supersedes it. A manual Run now reports them distinctly too: blocked by gate command for a real gate decision, and gate command could not run — check the daemon log when the gate never ran.

The gate runs inside the shared cron concurrency slot (see Overlap and concurrency), so keep gate commands fast: a gate that runs near the 60s timeout holds one of the (default 3) slots for that whole time. This is why the gate should be a cheap mechanical check, not heavy work.

Environment

The gate runs on the daemon host (not in a worktree — none exists yet), with its working directory set to the repo's main clone. The following environment variables are provided so a gate script can talk to the repo's services without hard-coding anything:

VariableValueWhy it's provided
REPO_DIRThe repo's main clone pathLets the gate inspect the checkout (run git, read files, etc.).
WORKTREE_DIRSame as REPO_DIRThe standard variable setup scripts receive. No per-run worktree exists at gate time, so it points at the repo clone.
BOSS_WORKTREE_DIRSame as REPO_DIRAlias of WORKTREE_DIR for gate scripts that expect the BOSS_-prefixed name.
LINEAR_API_KEYThe repo's Linear API key (or blank)So a gate can query Linear (e.g. "is there an issue labelled X?") without storing the key itself. Blank when the repo has none.
SENTRY_API_KEYThe repo's Sentry API key (or blank)So a gate can query Sentry. Blank when unset.
SENTRY_ORGThe repo's Sentry org (or blank)Companion to SENTRY_API_KEY for Sentry API calls. Blank when unset.

These keys come from the repo's settings (the same trust boundary as the setup script). The gate's combined stdout+stderr is captured and logged by the daemon — don't print secret values from your gate script.

Worktree variables point at the repo clone

The gate deliberately runs before a per-run worktree exists, so WORKTREE_DIR / BOSS_WORKTREE_DIR both point at the repo's main clone rather than an isolated worktree. Write gate scripts that only read from the checkout; don't expect a clean per-run worktree.

Example

A gate that only lets a triage job run when there's at least one open PR labelled needs-triage (exits non-zero — gating the job — when there is nothing to do):

test -n "$(gh pr list --label needs-triage --state open --json number -q '.[].number')"

Run setup command

Before the agent starts, a cron fire normally runs the repo's setup script in the fresh worktree to install dependencies, generate code, etc. Some jobs failed in the past because setup hadn't run; others are light enough that paying the full setup cost on every tick is wasteful.

Run setup command is the per-job toggle for this. It defaults to on (so the safe default is preserved — setup always runs unless you opt out). Turn it off to keep a light job fast: the fire skips the setup script entirely and starts the agent in the bare worktree.

Internally this maps to the session's SkipSetupScript option (run_setup_command off ⇒ SkipSetupScript true).

Zero output

Some jobs only ever report somewhere else — they post to Slack, probe a health endpoint, or file a ticket — and are never expected to change a line of this repository. Provisioning a worktree, a branch and a draft PR for those is pure overhead.

Zero output is the per-job toggle for that shape. It defaults to off — the only toggle on this form that does, because running with no worktree is never a safe default. Turn it on and the fire runs with no worktree, no branch and no PR: the agent runs in the repository checkout itself, the setup script is skipped regardless of Run setup command, and the run finalizes as a benign no-op recorded with the zero_output outcome.

Because the agent runs in your real checkout rather than an isolated worktree, a zero-output job that does write files leaves them there — nothing cleans them up and no PR is opened. Keep the prompt to jobs that genuinely produce no repository changes.

It is settable from every surface: the TUI and web cron forms, boss cron add --zero-output / boss cron update <id> --zero-output=false, and the create_cron_job / update_cron_job MCP tools' zero_output argument.

What happens at fire time

When the schedule's next tick arrives, the daemon's scheduler (services/bossd/internal/cron/scheduler.go) runs fire():

  1. Re-fetch the job. A job that was disabled or deleted between the tick scheduling and the actual fire is skipped (skip reasons disabled and db_fetch_error are logged but not surfaced).
  2. Overlap check. If the job's last spawned session is still active and not archived, the fire is skipped with overlap_prev_active. See the next section.
  3. Concurrency cap. A counting semaphore limits simultaneous fires across all cron jobs to 3 by default (DefaultMaxConcurrent in scheduler.go). Extra fires block until a slot frees up.
  4. Gate command. If a gate command is set, it runs now — before any worktree exists. A zero exit lets the fire proceed; anything else blocks it, with next_run_at still advancing and no session created. A non-zero exit records gated (STATUS gated); a gate that could not run at all — timeout, launch error, shell exit 126/127 — records gate_failed (STATUS failed). See Outcome. The gate runs on every fire — scheduled and manual Run now alike — so a manual run is also blocked when the gate fails.
  5. Spawn. A worktree is created on a fresh branch; your repo's setup script runs unless Run setup command is off; and the agent plugin starts the agent inside it with the cron job's prompt as the first turn. When Zero output is on, none of that provisioning happens: the agent starts in the repository checkout with no worktree, branch or PR.
  6. Persist. last_run_session_id, last_run_at, and next_run_at are written to the cron job row, so the list view's LAST RUN and NEXT RUN columns update on the next poll.

Branch naming

Every fire produces a unique branch named:

cron-<name-slug>-<unix-timestamp>

The slug is the job's name lower-cased with non-[a-z0-9] characters replaced by hyphens, truncated to 40 chars. The unix timestamp suffix guarantees consecutive fires (which are at least one minute apart by cron's minimum granularity) don't collide on a previously-merged or SIGTERM'd branch. (Source: cronBranchName in scheduler.go:510.)

Overlap and concurrency

Cron fires interact with parallel sessions in two places:

  • Per-job overlap. If the job's previous fire is still running (the spawned session is in a non-terminal, non-archived state), the next fire is skipped with overlap_prev_active. This is what prevents a slow weekly job from stacking up on itself across runs. Once the previous session reaches Merged, Closed, or is archived, the next fire goes through.
  • Cross-job concurrency. All cron fires share a global semaphore capped at 3. If you have a dozen jobs that all happen to fire at 09:00, three start immediately and the rest queue until a slot frees up. Tune by setting MaxConcurrent on the scheduler (today this is wired internally, not exposed in settings.json).

For broader patterns on running many sessions at once, see Worktrees → Multiple sessions.

Inspecting cron history

The cron list is the dashboard. It refreshes every 2 seconds while open, so LAST RUN and STATUS stay live without manual refresh. Specifically:

  • LAST RUN shows when the most recent fire actually spawned a session. A skipped fire (overlap, disabled-between-tick-and-fire) does not update this column.
  • NEXT RUN is computed from the parsed schedule and the job's timezone, so it reflects the runner's current decision: not a snapshot from when you last edited the row.
  • STATUS reflects the spawned session's state: Running while the agent is active, gating while a gate command is running, gated if the last tick was blocked because the gate ran and said no, failed if the last fire's session ended in failure or the gate could not run at all (outcome gate_failed), idle otherwise. Both gate outcomes are remembered across restarts; a later successful fire supersedes them. A failed row on a job that has a gate command is worth checking first — see Outcome.

There is no separate "history view". Every fire produces a normal session, so to drill into a specific run, find its session in the home view (it will have a cron-… branch name) or via boss ls.

Run a job ad-hoc

Press r on the highlighted row in the cron list. This calls RunCronJobNow and spawns a session immediately, regardless of schedule. The same overlap, concurrency, and gate rules apply. If the previous fire is still running, you'll see a Skipped: previous run still active toast at the bottom of the list.

Run now honors the gate command. A manual run is gated exactly like a scheduled fire: if the gate exits non-zero the run is blocked and you'll see a Skipped: blocked by gate command toast — so pressing r is the way to test a gated job on demand.

Use this when you want to manually re-trigger a cron job without waiting for the next scheduled fire (handy when you've just edited the prompt and want to see how the new version behaves).

Failure handling

If CreateSession itself returns an error (out of disk, repo gone, worktree dir not writable), the job's last_run_outcome is set to fire_failed and next_run_at is cleared. The cron runner still ticks on its own schedule for the next fire. A single failed spawn does not disable the job.

A gate that could not be evaluated records gate_failed and also shows failed (see Outcome); the daemon log carries the gate's captured output, the classified failure, and the exit code at warn level, which is where to look first.

If the spawned session itself fails (the agent crashes, the prompt errors out), the cron job row's STATUS cell shows failed until the next successful fire. Repair plugin behaviour applies to cron sessions exactly as it does to manual sessions. See PR Lifecycle.

See also

  • Worktrees: Multiple sessions: the cross-job concurrency cap and how cron fits into the bigger parallelism story.
  • Setup scripts: what runs in the worktree before the cron prompt does.
  • your repo's settings (open Settings with s from home, then r for the Repos screen, then enter on the repo): per-repo automation flags that apply to cron-spawned PRs same as manual ones.