Cron Jobs
Every agent runs a set of scheduled prompts — crons — that pick up work, review and patch PRs, deploy, and run periodic maintenance without a human in the loop. This page covers how crons work end to end, and how to investigate a specific run as an operator.
How crons work
Schedule model
Each cron is an AgentCronJob row with a standard cron expression in its schedule field (e.g.
* * * * * for every minute, 0 6 * * * for daily at 06:00). The agent process doesn’t hold a
static schedule in memory — it polls the admin API every 60 seconds and (re)schedules jobs via
node-cron based on whatever it fetches. That means enabling, disabling, or editing a cron’s
schedule from the admin console takes effect on the agent within about a minute, with no restart
required. A background sync also cancels the node-cron task for any job that’s been disabled or
removed since the last poll.
Silent-by-default posting
Crons run with silent: true by default. A silent cron only posts to Slack when there’s a result
worth surfacing or when the run errors — most ticks that find nothing to do produce no visible
output at all. On top of that, Claude’s own response can carry a [silent] marker, which suppresses
a channel post even for a non-silent cron. The one exception: DMs always get a reply — a
[silent] marker is ignored when the delivery target is a user rather than a channel, so a
cron configured to DM a human never goes quiet on them.
Seeing no Slack message from a cron is therefore normal, not a bug — it usually means the cron ran and had nothing to report.
preCheck scripts
Most crons carry a preCheck script. Rather than sending the cron’s static prompt straight to
Claude every tick, the agent first runs the preCheck script and uses its stdout as the actual
prompt. This lets a cron skip spending a Claude turn entirely when there’s no real work ready:
- Exit code 0 with non-empty stdout — the script’s output becomes the prompt, and the tick proceeds to a full Claude run.
- Exit code 1, or empty stdout — the tick is skipped silently. No Claude turn is spent, and nothing is posted.
- Exit code 2 or higher — treated as a crash. The run is recorded as skipped with reason
preCheck:crash, and if an alerts channel is configured, a short alert is posted there so a broken preCheck script doesn’t fail invisibly forever.
A preCheck value is resolved either as a file path (relative to the agent’s workspace) or as a
plugin:script reference, resolved against the installed plugin cache or installed_plugins.json.
Reconcile
System crons — the ones seeded automatically for every agent — are created and kept in sync by a
reconcile pass (POST /agents/:id/crons/reconcile), which runs at agent startup and can also be
triggered on demand. Reconcile is a two-pass strategy over the cron list declared by the agent’s
type manifest (agent-types/{typeName}/manifest.yaml):
- Pass 1 creates or updates each manifest cron entry by name. If a matching row already
exists, it’s updated in place — schedule, prompt,
silent, andpreCheckare refreshed, but the row’sidis preserved (so itsAgentCronRunhistory survives) and, importantly, theenabledtoggle is never touched on update. A human’s enable/disable decision survives every reconcile. - Pass 2 links parent/child crons by resolving each entry’s declared
parentCronname against the id map built in Pass 1. This self-heals in both directions: a child whose parent link was missing gets it set, and a child whoseparentCrondeclaration was removed getsparentCronIdcleared back tonull.
A final orphan pass deletes any existing system cron whose name is no longer present in the manifest, so removed crons clean themselves up on the next reconcile.
Because of this, system crons can’t be edited directly via the admin API or console — they’re
read-only after creation except for the enabled toggle. Anything else (schedule, prompt,
preCheck) is owned by the agent type manifest and only changes when the manifest changes and a
reconcile runs.
Loop-driven vs standalone crons
Not every cron discovers its own work. The four pipeline crons — shipwright-dev-task,
shipwright-review, shipwright-patch, and shipwright-deploy — are children of
shipwright-loop (linked via parentCronId). They are loop-driven, item-addressed executors,
not self-discovering standalone crons:
shipwright-loop’s in-process candidate providers evaluate every enabled phase each tick and pick exactly one winning candidate.- The loop orchestrator then dispatches the matching command with an explicit task id or
org/repo#numberembedded directly in the prompt. - None of the four commands scans for its own work. Each one responds
[silent]and exits immediately if it’s ever invoked with no target.
The practical consequence: a reconciled pipeline cron never gets its own independent node-cron
schedule at all. classifyCronJobsForScheduling excludes any job with a non-null parentCronId
from scheduling unconditionally — before it even looks at that job’s own enabled flag or whether
shipwright-loop itself is enabled. So enabling, say, shipwright-dev-task in isolation doesn’t
make it fire on its own schedule and fall through to a no-op; it simply never fires on its own at
all. The only way a pipeline cron dispatches is indirectly: shipwright-loop’s own tick reads the
child row’s enabled toggle (via resolveLoopPhaseToggles), and if true, dispatches that phase
directly as part of the loop’s own run. Re-enabling shipwright-loop is what restores real
dispatch — not the phase cron’s own schedule kicking back in.
By contrast, the maintenance crons — shipwright-test-readiness, shipwright-docs-freshness,
learn-dream, and the entropy/error/security/consolidation patrol crons —
are standalone. Each is fully self-contained: its own preCheck does its own discovery (e.g.
scanning repos for stale docs, or querying Sentry for unresolved errors), with no dependency on
shipwright-loop at all.
Default system crons
| Cron | Schedule | Command | Skill | Default | What it does |
|---|---|---|---|---|---|
shipwright-dev-task |
every minute | /shipwright:dev-task |
— | on | Builds and ships a PR for the task shipwright-loop selects. Inert if shipwright-loop is disabled. |
shipwright-review |
every minute | /shipwright:review |
— | on | Reviews the PR shipwright-loop selects. Inert if shipwright-loop is disabled. |
shipwright-patch |
every minute | /shipwright:patch |
— | on | Patches the PR shipwright-loop selects. Inert if shipwright-loop is disabled. |
shipwright-deploy |
every minute | /shipwright:deploy |
— | off | Merges and deploys the PR shipwright-loop selects. Inert if shipwright-loop is disabled. |
shipwright-loop |
every minute | internal | — | off | Drives the four phases above — merges their candidates and dispatches exactly one winning item per phase, per tick, until the queue is dry. |
shipwright-test-readiness |
daily 06:00 | /shipwright:test-readiness --full --publish |
test-readiness | off | Full test-readiness audit for repos with stale or missing artifacts. |
shipwright-docs-freshness |
daily 07:00 | /shipwright:research-docs --auto |
— | off | Refreshes docs that have drifted from the code. |
learn-dream |
daily 03:00 | /shipwright:learn-dream --since 1d --review |
— | off | Mines the last day of merged PRs for durable learnings. |
entropy-patrol-maintenance |
weekly Mon 04:00 | /shipwright:entropy-scan → /shipwright:entropy-fix |
entropy-scan, entropy-fix | off | Scans for code entropy and fixes what’s PR-worthy. |
error-patrol-maintenance |
daily 04:00 | /shipwright:error-scan → /shipwright:error-fix → /shipwright:error-resolve |
error-scan, error-fix, error-resolve | off | Scans for unresolved Sentry errors and fixes what’s PR-worthy. |
security-patrol-maintenance |
weekly Mon 06:00 | /shipwright:security-scan → /shipwright:security-fix |
security-scan, security-fix | off | Scans for security vulnerabilities and fixes what’s PR-worthy. |
consolidation-patrol-maintenance |
weekly Mon 05:00 | /shipwright:consolidation-scan → /shipwright:consolidation-fix |
consolidation-scan, consolidation-fix | off | Proposes consolidation for duplicate patterns that have stabilized over time. |
Only three ship enabled by default: shipwright-dev-task, shipwright-review, and
shipwright-patch. Everything else — including shipwright-loop itself — is opt-in, toggled from
the agent’s Cron Jobs card in the admin console.
Investigating a cron
Sometimes a cron does something surprising — it posts nothing when you expected a result, or it posts something that looks wrong. Here’s what to check as an operator, using the admin console and Slack.
Start from the cron run history
Open the agent’s detail page in the admin console and find the cron in its Cron Jobs card, or
jump straight to Cron Logs from the agent list. The Cron Logs view lists every run across every
cron the agent owns, filterable by Cron and Outcome (posted / dm / silent / skipped
/ error). Each row shows:
- Outcome — what actually happened on that tick
- Started and Duration — when it ran and how long it took
- Tokens and Model — token counts and per-model cost, if a Claude turn was actually spent
- Phase — for
shipwright-loopruns, which phase (dev-task/review/patch/deploy) it dispatched - Item — the specific Task or PR the run was dispatched against, if any
If you’re chasing a specific PR or task, filter or scan for its Item badge rather than trying to line up timestamps by eye — the run history is keyed to the exact item a dispatch targeted.
Read what the session actually did
The run history tells you that a cron fired and what it was dispatched against, but not why it
decided what it decided. For that, look at the Slack thread the run posted into (if any) — the
posted message is the cron’s own summary of its conclusion. If the outcome was silent or
skipped, there is no Slack post to read, which is itself informative (see below).
For a deeper narrative — the actual prompt, Claude’s reasoning, and the specific commands it ran —
ask the agent to investigate on your behalf via Slack chat. The agent has a dedicated
/shipwright:investigate-cron tool for exactly this: given a cron name and approximate time, or a
specific PR/task id, it resolves the matching run and reads the underlying session transcript to
explain what happened in plain language. You don’t need transcript or log access yourself — just
ask the agent to run it, and it will report back the prompt, the key steps taken, and its
conclusion.
Two outcomes that are expected, not bugs
Use this table when a cron produces no output or shows an unexpected outcome. Arrive with a symptom (what you’re observing), then follow the check and meaning columns to confirm whether it’s a normal state or a real issue.
| Symptom | Check | Meaning |
|---|---|---|
A cron run shows outcome silent in Cron Logs |
Look at the run details — this is normal. The run completed successfully and simply had no qualifying work to report. | Most cron ticks find no work and end silently by design. This is the system working correctly, not a bug. |
A cron run shows outcome skipped in Cron Logs |
Look at the run’s outcome in Cron Logs. A skipped outcome means the cron’s preCheck script exited non-zero or produced empty output. The tick was suppressed before a Claude turn even started. |
This is the expected shape for a cron with no work ready. The preCheck script found nothing to act on and correctly short-circuited the expensive Claude run. |
A cron never appears in Cron Logs at all (no recent runs, no silent, no skipped) |
Check the enabled/disabled badge on the cron’s row in the agent’s Cron Jobs card in the admin console. | If the badge shows disabled, the cron doesn’t run at all — node-cron never scheduled it in the first place. Re-enable it if you want it to fire. |
A pipeline cron (shipwright-dev-task, shipwright-review, shipwright-patch, or shipwright-deploy) shows as enabled but has no runs in Cron Logs |
Check whether shipwright-loop itself is enabled in the agent’s Cron Jobs card (see “Loop-driven vs standalone crons” section above). |
A reconciled pipeline cron is a child of shipwright-loop and never gets its own independent schedule. Its enabled toggle only takes effect once shipwright-loop reads it and dispatches that phase directly. If shipwright-loop is disabled, the pipeline cron will not fire on its own — there’s no standalone tick. Re-enable shipwright-loop to restore pipeline dispatch. |
Next steps
For the mechanics of the loop that drives the four pipeline crons — candidate selection, dispatch order, and drain-until-dry behavior — see The Shipwright Loop next.