Skip to content

Default scheduler routines

This page lists the default routines and the work each routine performs.

Default Routines

The scheduler ships with nine default routines.

Moved to Scheduler housekeeping.

hooks (5-second interval)

High-frequency hook lifecycle management:

Job Description
hook_checks Complete finished hooks, start stale ones
mentor_checks Start mentors once hook prerequisites are met
workflow_checks Complete/start CRS and fix-hook workflows
pending_checks_poll Poll background check results
comment_zombie_checks Mark old comment threads as ZOMBIE
suffix_transforms Strip stale suffixes, update mail-readiness
orphan_cleanup Release workspace claims for dead processes
stale_running_cleanup Release claims and proc rows of dead processes

Every job above except stale_running_cleanup ships with an fs trigger so an idle tick costs a handful of stat() calls instead of a subprocess spawn: the six Patch-driven jobs watch every enabled project's ProjectSpec file (paths: [{path: projects, glob: "*/*.sase"}, {path: projects, glob: "*/*.gp"}]), and pending_checks_poll watches the sharded ~/.sase/checks/ output directory. Each carries max_quiet: "120s", so a missed observation only delays a fire by up to two minutes. stale_running_cleanup keeps the default always trigger: its actual input is process liveness (a claim's owning PID dying), which has no filesystem proxy — a dead PID does not touch any project, claim, or artifact file.

waits (10-second interval)

Bead-claim reconciliation and epic-launch flushing:

Job Description
bead_claim_checks Acquire/release bead claims for pre-launch agents
epic_launch_flush Flush planner completions orphaned by unsettled epic launches
agent_auto_restart Sweep update-skew recoveries; submit the healer as a proc

agent_waits (2-second interval)

Fast-polling agent dependency resolution:

Job Description
wait_checks Resolve successful agent and closed-bead waits; write ready.json

wait_checks runs in its own 2-second routine so a slow sidecar_auto_sync pass does not convoy it behind a shared 10-second tick.

sidecar_sync (30-second interval)

Primary sidecar convergence, isolated from wait resolution:

Job Description
sidecar_auto_sync Fetch/fast-forward opted-in primary sidecar clones (plans, beads, ...)

The routine interval provides the 30-second cadence. The per-job 2m timeout stays. After a beads-role refresh, the job touches that project's completion pulse so agent_waits re-evaluates bead waits on its next 2-second tick.

bead_claim_checks and wait_checks both ship with an fs trigger watching the per-project completion pulse (paths: [{path: projects, glob: "*/artifacts/.ace_refresh_pulse"}], max_quiet: "120s"), so an idle tick only re-scans when something touches that project's artifacts/.ace_refresh_pulse file. Agent completions (via write_done_marker_and_update_index and the hook-runner done writer), gate/monitor turn settlement, handoff and repeat-stop finalization, a dependency-carrying waiting.json write, and TUI wait-target edits all touch the pulse; pure runner-slot queue republishes deliberately do not, and per-agent pulses written inside run directories never match the project-level glob. Anything the pulse cannot observe — such as the dead-owner claim release, where no live process writes anything — still resolves on the max_quiet backstop. epic_launch_flush is untouched by that guard; it already throttles via run_every: "30s". sidecar_auto_sync lives in its own 30-second routine now.

agent_auto_restart sweeps update-skew recoveries (see Agent auto-restart for the full feature guide): it enumerates recovery doorbells, recent failed rows, and stale, deferred, and launched ledger records, then submits sase agent auto-restart run -p -j as a durable proc (concurrency key agent-auto-restart, one at a time) when work exists. It settles launched records from their replacement's outcome, re-surfaces stale pending rows, and — when the feature is off or paused — re-surfaces pending failures loudly instead of healing them. Its fs trigger watches agent_auto_restart/doorbell and the code-swap lock file with max_quiet: "60s"; idle ticks cost a handful of stat() calls and the full artifact scan runs at most once a minute.

wait_checks unblocks a named dependency when the newest matching agent, or the newest matching workflow root and all of its children, has a successful terminal done.json outcome: "completed", "noop", "epic_approved", or "plan_committed". An ordinary wait on an epic-approved planner releases when that planner finishes. An ordinary wait on an epic-approved planner follows it into the epic it launched: %wait(planner) also waits until that epic is closed (opt out with %wait(planner, for_epic=false)). The runner initial check, the parked-runner fallback, wait_checks, and kill/dismiss share that decision. The default is true for user-authored agent targets; --plan rows never follow. "noop" agents can also satisfy waits even though they are hidden from normal done-agent lists. Failed, killed, stopped, crashed, still-running, malformed, or missing done.json artifacts do not satisfy %wait; the dependent agent remains parked until a later successful run of the same dependency name appears. "plan_rejected" is deliberately identity-terminal for exact artifact waits but does not satisfy a named %wait.

Each tick walks every project's ace-run directories for waiting.json markers and classifies each pending waiter (marker present, ready.json absent) by runner liveness: alive for a running recorded pid, dead for a stopped_at stamp or a provably gone pid (PID reuse guarded by process_identity and boot time), and unknown when no pid was recorded. Unknown waiters resolve as if live; dead waiters are skipped entirely — no resolution, no notifications — and counted in the dead_waiting backlog counter alongside live_waiting and unknown_liveness. With no live or unknown waiter pending, the tick emits its summary without building any dependency view, and unresolved counts only live/unknown waiters. The resolving view itself is built from filesystem agent_meta.json rows rather than the artifact index. Named-agent and bead-only waiters resolve from a scoped view: only rows whose identity keys intersect the live waiters' dependency names, plus each scopable waiter's own directory. Confirmation re-lists those projects fresh and keeps the same per-waiter key set, so a member that appears between the two views still defers release. Unscopable shapes — @tribe names, hoods, fork sources, identity (wait_for_artifacts) deps, and armed epic follows — keep today's full view, built at most once per tick and only when a live waiter needs it.

The wait_checks summary includes walk_ms for marker scanning and liveness classification, view_ms for dependency view construction, resolve_ms for the per-waiter loop, view_rows for the rows loaded into views during the tick, scoped_waiters for live waiters resolved from the scoped view, and full_view_waiters for live waiters that used the full view. Progress lines report the waiter counts after classification and the resolving view's row count; chop log lines flush immediately so they remain available if the job is killed.

If an unresolved dependency already has a terminal done.json outcome that wait resolution does not recognize, wait_checks increments unknown_outcome and logs the artifact directory plus the offending outcome. The job also emits a bounded sample of waiters blocked by terminal dependencies so permanent stalls are diagnosable without spamming ordinary live waiters.

An armed epic follow moves through launching, following, and blocked stages (none is never persisted). launching means the planner finished but no epic has appeared yet: the launch is still in flight, or it ended inside a short settle window (2 minutes) that absorbs monitor/proc handoff lag. following means the launched epics were pinned into wait_for_beads and the wait now tracks them like ordinary bead waits. blocked means no epic will appear without operator action: the launch was skipped, it ended without an epic, the target was dismissed mid-launch, or the follow would cycle back onto the waiter. A launching follow stays quiet until its since is older than the 10-minute grace period; only then does wait_checks raise one deduplicated inbox entry per waiter and target, and likewise one entry per blocked follow (naming the reason with its resume command, jumping to the waiter) and one per followed epic whose land agent failed terminally (naming the waiter, the epic, and the land agent). Repeat ticks corroborate the single entry with +1 instead of duplicating it, and the entry is dismissed once its state resolves or the waiter's runner is classified dead (a dead runner never releases, so its follow entries are stale). Nothing about a follow ever releases on a timeout: an overdue or blocked waiter stays parked until an epic appears, the follow is cleared, or the wait is relaunched.

Two guards keep a follow from deadlocking the epic machinery. The deadlock guard drops any launched epic that already contains the waiter: the waiter's own bead equals the epic or is a phase bead nested under it. The cycle guard blocks when a live agent in the launched epic's clan already waits on the waiter — through waiting_for, wait_for, or the approval "Wait for" field — because each side would otherwise wait out the other forever.

Before writing ready.json, wait_checks confirms an agent-shaped release against a fresh on-disk membership view taken after the resolving marker read. If a session gained a member in between — for example, a coder just launched its monitor or gate — the waiter remains parked for a later tick. The waiting runner applies the same confirmation for its startup and periodic fallback path. Such ordinary deferrals are counted in the deferred_unconfirmed summary field rather than treated as errors.

The runner's startup check and 60-second fallback build that view from the waiter's project only. Named waits and bead-only waits use a scoped index: rows whose identity keys intersect the marker's dependency names, plus the waiter's own artifact directory. Unscopable shapes — @tribe names, hoods, fork sources, identity deps, and armed epic follows — still load the full project index. Release decisions stay the same as the full view; scoping only drops rows that those queries never consult.

Row selection itself goes through a persistent scope-key cache (~/.sase/cache/wait_scope_keys.json), so a live tick stats each artifact's agent_meta.json instead of reading it. Each entry maps an ace-run artifact directory to its meta (st_mtime_ns, st_size, st_ino) fingerprint plus its sorted scope keys. Only wait_checks writes the file — atomically, and only when an entry changed — while runners read it read-only and read the meta themselves on a miss. An unreadable, corrupt, or wrong-schema cache counts as empty, so everything is read; it is never read as "no rows". Selected rows always read their meta fresh, so the cache can only affect which rows are selected, never their content, and new artifact directories are cache misses that still read meta (which keeps the confirmation pass's new-member signal). Deleting the cache file is always safe: the next tick rebuilds it.

ready.json is published atomically — via a temp file plus a no-clobber link, so the first writer wins — and is skipped entirely once the waiter is gone. The waiting runner treats an unreadable or malformed marker as not ready and keeps polling (bounded by its periodic fallback, which never reads ready.json).

Wait release telemetry

Each crossed wait barrier records how it was released and how long each segment took, in agent_meta.json:

Key Meaning
wait_release_source startup (resolved before parking), ready_json (wait_checks marker), manual (marker with unwait: true, i.e. TUI run-now), runner_fallback, or timer (duration/until-only waits)
wait_dependencies_satisfied_at Epoch seconds when the last relevant dependency member finished, when known
wait_release_latency_s Dependency-release instant minus wait_dependencies_satisfied_at, clamped ≥ 0; only for ready_json / runner_fallback releases with a known satisfied time and no bead dependencies
admission_latency_s run_started_at − wait_completed_at, for runs that crossed a wait barrier
runner_slot_wait_s run_started_at minus the instant the runner entered wait_for_runner_slot

wait_checks publishes released_by: "wait_checks" plus dependencies_satisfied_at (when known and the waiter has no bead dependencies) in ready.json; the runner carries those into its own stamp. To tally release sources over recent runs:

find ~/.sase/projects/*/artifacts/ace-run -name agent_meta.json -newermt '7 days ago' \
  -exec python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("wait_release_source", "unknown"))' {} \; \
  | sort | uniq -c | sort -rn

A waiter whose resolution raises stays parked, is counted in waiter_errors, and marks the tick check_error without blocking other waiters.

Markers may also carry wait_for_beads, emitted by %wait(bead=<bead-id>). wait_checks reads the waiting agent's project bead store once per cycle and releases the marker only when every named bead is closed as well as every agent or artifact dependency being satisfied. Missing beads, unavailable stores, and read failures deliberately fail closed and leave the agent parked; sase's TUI run-now action remains the manual escape hatch. While live bead waits are outstanding, sidecar_auto_sync hints their projects' beads role every tick — even when that role has not opted into auto_sync — so the one conservative fetch/fast-forward sync policy converges it promptly instead of a competing managed-integration refresh path. The waiting runner also marks the same hint on a coarser ten-minute cadence as an outage backstop, in case a job failure ever leaves the tick-driven hint unconsumed. Setting sdd.bead_refresh.mode: off disables both hint paths.

A parked runner with agent dependencies stats ready.json every 0.5 seconds; the 60-second direct-resolution fallback cadence is unchanged.

The agent_waits and sidecar_sync routines activate on the scheduler restart sase update performs. The moved jobs start with fresh per-routine state: the wait_checks trigger checkpoint fires once, and sidecar_sync re-checks every role once because its backoff schedule file lives in the routine state dir.

checks (5-minute interval)

Lower-frequency status checks:

Job Description
bead_task_triage Reconcile the one pending gate each task bead owns
plugins_required Raise one PluginsRequired gate per project missing plugins
pr_submitted_checks Start PR submission status checks
stale_running_cleanup Backstop dead-process claim and proc-row cleanup
orphan_agent_scope_reap Reap orphaned agent scopes whose runner died

A live task bead has at most one pending gate, and bead_task_triage is the single owner of that invariant. It scans enabled non-home projects for task beads and derives one of three gate kinds from that one issue type: a ready task gets a TaskTriage gate, a snoozed one gets a BeadSnooze gate, and a task bead of type flag whose status is open and whose date and release removal thresholds have both passed gets a FlagTriage gate. All three kinds are reconciled in the same pass, under one lock and one lane state, so no second job can race this one into giving a bead two gates. The bead-to-request mapping — including which kind each bead currently holds — lives in the checks routine's state directory. This scan does not call the dependency-aware sase bead ready query, so a stored-ready task with an active blocker still receives a gate. A flag task bead's due-ness is derived through the one shared flag_removal_due predicate, never recomputed here.

A ready task bead additionally needs at least its effective +1 bar of independent +1 reports before it earns a TaskTriage gate: its own task type's triage.min_plus_ones (0 for ci, feature, and memory; 1 for bug; 3 for flake), or bead.task_triage.min_plus_ones when the bead is untyped or its type is not registered on this machine. A sub-threshold bead is withheld from triage without any change to its stored status — it stays ready and stays visible to sase bead list, sase bead ready, sase's TUI Beads panel, and its bead page — and a TaskTriage gate already raised for a bead that later falls below the bar is canceled (reason task_bead_below_plus_one_threshold) and its notification dismissed on the job's next tick. Snoozed beads and flag task beads are never subject to this bar.

A still-pending gate is skipped on later ticks, preventing repeated notifications. If a bead leaves its gateable status or type through a launch, close, extension, or manual retraction, the job cancels its pending gate. If a gate becomes terminal or its bundle disappears while the bead is still gateable, the next tick replaces it, except while that bead has an active detached launch in flight. A persistent generation counter gives each replacement a new deterministic request ID, whether the bead kept its status or left and came back.

After project discovery succeeds and returns a non-empty inventory, the same reconciliation also cancels pending gates when their project leaves the active inventory, and cancels producer-owned gates that are absent from the routine's lane state. An unavailable inventory fails closed without sweeping anything. A project whose bead store is temporarily unreadable is likewise preserved for retry rather than being mistaken for an inactive project and swept.

Two things also force a replacement. A gate of the wrong kind for the bead's current status is canceled as bead_status_changed and replaced in the same tick — a bead that was snoozed while its triage gate was pending is asking a different question now, so that check outranks the presentation comparison below. Otherwise the job compares a presentation and gate-contract fingerprint over every stored field the gate renders: status, the whole snooze record, title, description, notes, size, creation time, refs, +1 evidence, close history, and (for a flag task bead) its key, kind, thresholds, and due state, plus explicit renderer and option-contract versions. A mismatch cancels the gate as task_triage_presentation_changed and re-raises it, so an edited description, a re-snooze, an extended threshold, or an obsolete interaction contract never leaves a gate advertising stale content, the old wake time, or superseded controls. While a BeadSnooze gate stays pending and unchanged, the job also re-snoozes its notification to match the bead's wake time (whenever that wake time is still in the future), keeping a snoozed bead's notification snoozed alongside it even after a crash or a manual unmute.

The TaskTriage gate presents the task title, description, and notes, and offers three options. Launch (the primary branch) accepts optional feedback and submits a deduplicated global unattributed proc for sase bead work <task-id> --yes-to-all; Close requires a reason and closes the bead as canceled; Snooze collects one required duration line and defers the task, moving it to snoozed so the next tick reconciles it into a BeadSnooze gate instead. The line takes the same "<wake-time> [+<N>]" vocabulary sase's TUI snooze modal takes — for example 3d, 2026-08-09T09:00:00-04:00, or 3d +2 — combining the CLI's -u duration and -p +1 target into one expression. See TaskTriage notifications, the snooze workflow for what a BeadSnooze gate then asks, and the standalone task workflow for the human-facing lifecycle.

The FlagTriage gate presents the flag's key, kind, both-branch prose, remove_when, both removal thresholds, its countdown, and the registry definition's description (or a callout when no definition names the key), and offers four options. Remove (the primary branch) deletes the Off branch and makes the On branch unconditional; it collects a required winner choice (enabled or disabled) for the worker brief and submits the same deduplicated sase bead work <flag-id> --yes-to-all proc; Extend requires a reason plus a new date and release line, pushes both thresholds out, and leaves the bead open so the next tick finds it is not due and cancels the gate as stale; Keep requires a reason and launches a worker to convert the behavior into an ordinary config field, then close the bead — it was never a feature flag; Close requires a reason and abandons the removal by closing the bead as canceled, leaving tools/check_feature_flags' closed-bead-with-surviving-definition check to catch the orphan if the flag itself survives.

The plugins_required job is the human install offer that agent and non-interactive contexts deliberately do not get. It scans enabled non-home projects, compares each project's plugins.required list against installed distributions, and raises at most one PluginsRequired gate per project per distinct missing or version-mismatched set. Install performs one combined install for every missing requirement from the answering surface. The combined planner shares a bounded public-index probe, resolves each plugin from the index unless public PyPI returns a definitive 404, and uses git only for those definitive misses; when planning or the uv mutation fails, including when sase is not a uv tool install, the gate stays pending with the same actionable message sase plugin install already prints where applicable. A successful install restarts the scheduler service proc. Dismiss records the decision so the same missing set is not re-offered until it changes. The job cancels the gate when the set becomes satisfied. Lane state holds the pending request, a generation counter, and a fingerprint over the missing set, so a re-run does not duplicate a notification. Run sase axe job run plugins_required to raise or refresh those gates without waiting for the next five-minute checks tick.

The orphan_agent_scope_reap job is the five-minute backstop for agent scopes whose runner died without sweeping (SIGKILL, OOM, or crash). It scans sase-agent-* scopes under the user manager and terminates leaked processes in scopes with no live runner, using the same spare-process selection rule as the runner-exit sweep: scopes holding a live runner or a systemd-run pre-exec window, scopes younger than agent_scope_teardown.reaper_min_scope_age_seconds, and scopes whose every member is a spared shared daemon (ssh-agent, gpg-agent, tmux server, ssh ControlMaster) are left untouched. It keeps the default always trigger because process death leaves no filesystem event to watch. To spot leaks by hand, run systemctl --user list-units 'sase-agent-*'; to reap one pass immediately, run sase axe job run orphan_agent_scope_reap.

usage (60-second interval)

Subscription-usage collection, owned by the scheduler:

Job Description
usage_refresh Refresh due subscription-usage windows

The usage_refresh job checks machine-local due and backoff state and probes the admitted providers inline in the job process, under a non-proc operation ID, so periodic collection creates no proc rows. It does not poll per agent or per project. Collection stays gated by llm_provider.usage_metrics.enabled; see Subscription usage extension.

A scheduled tick performs a due-only refresh. A manual run — the Services-tab r key or sase axe job run usage_refresh — performs an explicit refresh instead, still subject to the 60-second cooldown and any Retry-After. A host that shares an account with another machine can opt out per provider with llm_provider.usage_metrics.providers.<name>.enabled: false in a machine overlay.

external_mirror (15-minute interval)

Isolated remote-tracker polling:

Job Description
external_issue_mirror Mirror external tracker issues into task beads
external_pr_mirror Adopt remote pull requests as local Patches

Both jobs are the same class of work — one bounded remote poll per project — so they share a single generously paced lane instead of two. A healthy full pass is 1–3.5 seconds, so the 900-second interval leaves wide headroom, and a 5-minute per-job timeout means the worst-case cycle (interval + job_timeout) stays a bounded 20 minutes without ever delaying the faster checks lane's PR-submission and workspace-claim work.

external_issue_mirror expands to one instance per enabled project via for_each: {source: projects, vcs: [git, gh]} (external_issue_mirror[<project>]), the first production use of for_each. Each pass diffs that project's tracker against local beads on external_ref and creates explicitly small, open (never ready) task beads for uncovered issues, so no TaskTriage gate fires on a first-pass backlog. The issue-listing seam has no page cursor or ordering guarantee, so every pass lists the tracker's full inventory (state="all", limit=0); the per-pass bound instead caps local writes — at most 25 bead creations and 50 notes per pass, within a wall-clock work budget derived from the lane's configured job_timeout. A pass that hits the creation cap does not advance its watermark, so a large first backlog converges over several 15-minute passes; run sase bead sync-external to accelerate it manually. Persistent exponential backoff (capped at one hour) keeps one unreachable tracker from stalling every pass.

When an issue linked by external_ref closes or reopens upstream, the mirror closes or reopens the mirrored bead and appends one attributed note. It leaves status unchanged and appends the note only when the local bead merely references the issue with a bug: ref, an agent is working or claiming the bead, the bead has unclosed descendants, or the bead already matches the upstream state. Disappearances also remain note-only because there is no safe status target. Once a transition is recorded in durable upstream_states, the same transition is never re-noted. The Beads pane's drift badge therefore narrows to unreconciled cases: guard-skipped mirrored links, referenced-only links, and title drift.

The external_mirror.issues.filters surface (empty by default) excludes tracker issues from mirroring by author, label, title, or state; a non-empty filter means the bead list is not a strict superset of the issue list. Filters gate creation only — clearing a filter re-examines issues the filter excluded, but a filter never deletes a bead that already exists. Records a filter drops count toward sase bead sync-external's filtered=<n> per-project summary.

Two machines reconciling stale copies of a hosted bead sidecar can independently import the same issue before either has seen the other's copy. The local partial-unique index on external_ref prevents a single store from ever holding two beads for one issue; the canonical Rust bead event reducer additionally collapses a genuine cross-machine duplicate deterministically at integration/read time (keeping the earliest-created bead, by created_at then id) rather than making the merged store unreadable. Direct local create/update/import conflicts still fail atomically — only the very rare cross-machine race collapses.

Run sase doctor -C axe.external_mirror to check detached tracker auth: the AXE daemon's environment is not the interactive TUI's, and a silent gh auth failure there would look exactly like "no issues." The check reports the job's own persisted evidence rather than attempting an interactive provider call.

See Builtin external_pr_mirror below for that job's own behavior, including where its cursor and backoff state live.

comments (1-minute interval)

Comment polling:

Job Description
comment_checks Start critique comment checks

_[project]: A sase project is a named unit of work registered with SASE. _[workspace]: A sase workspace is a numbered clone of a project's primary repo, managed by the workspace store and tracked in that project's registry.json. _[agent]: A sase agent is an agent session or a single agent that does not belong to a session. _[prompt]: A prompt is the text SASE hands to a provider to start an agent turn. _[macro]: A macro is a reusable prompt triggered with #name in agent prompts. _[Patch]: A Patch is SASE's local unit of change. _[bead]: A bead is one tracked work item in SASE's git-native issue tracker. _[plan]: A plan is a persisted, approved artifact recording the intent behind agent work. _[TUI]: The TUI is SASE's terminal interface for running agents and reviewing results. _[epic]: An epic is an executable multi-phase plan run with sase bead work. _[gate]: A sase gate is a durable, command-backed request for one user decision, created when work must pause for the user. _[monitor]: A sase monitor is a monitor turn that runs one long command under a detached supervisor, so the command outlives the agent that started it. *[stitch]: A stitch is the lightweight ordered change record inside a Patch's STITCHES: section.