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 by a non-zero gate exit.

The action bar shows the available keys: [n]ew, [e]dit, [d]elete, [space] toggle, [r]un now. esc returns to Settings.

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.
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

Gate resultWhat happens
Exit 0The job fires normally (worktree, setup, agent session).
Non-zero exitThe job is gated: no session, next_run_at advances.
Timeout (default 60s)Treated as a failure → gated. The scheduler slot is released so a hung gate can't wedge the runner.
Launch error (binary not found, etc.)Treated as a failure → gated.

The STATUS column shows gating (with a spinner) while the gate runs and gated once a tick is blocked. gated is remembered — it is written to last_run_outcome, so it survives a daemon restart and stays visible in the list until a later successful fire supersedes it.

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).

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; a non-zero exit, timeout, or launch error blocks it (STATUS becomes gated, next_run_at still advances, no session is created). 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.
  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 by the gate, failed if the last fire's session ended in failure, idle otherwise. gated is remembered across restarts; a later successful fire supersedes it.

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.

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.