Skip to main content

Scheduling Recurring Work (Cron)

The go-code harness includes a built-in cron system for scheduling recurring shell commands or agent tasks on a calendar. You describe a job with a standard five-field cron expression, and the scheduler fires it at the right time, records the execution, and makes the results queryable — no external cron daemon required.

The system is useful for things like periodic benchmark runs, nightly cleanup, recurring agent tasks that check external services, or any work you would otherwise manage with crontab or a cloud scheduler.


What cron provides

Embedded scheduler vs. remote cronsd

By default, when harnessd starts up it spins an embedded cron scheduler backed by a SQLite database at <workspace>/.harness/cron.db. No extra process needed. Jobs are loaded from the store at startup, registered with the scheduler, and fired according to their schedule for as long as harnessd runs.

When you need to share a cron database across multiple harnessd instances, or you want the scheduler to outlive any single harness process, you can run cronsd — the standalone cron daemon — and point harnessd at it:

export HARNESS_CRON_URL=http://localhost:9090

When HARNESS_CRON_URL is set, the embedded scheduler is not started. harnessd instead proxies all cron operations over HTTP to the remote cronsd. The two modes are mutually exclusive.

cronsd listens on :9090 by default (CRONSD_ADDR) and stores its database at ~/.go-harness/cronsd.db (CRONSD_DB_PATH). These are distinct from the embedded scheduler's .harness/cron.db path inside the workspace.

Five-field UTC cron expressions

All schedules use standard five-field UTC cron syntax: Minute Hour DayOfMonth Month DayOfWeek. The scheduler is built on github.com/robfig/cron/v3 configured for exactly this five-field format — no seconds prefix.

FieldRangeExamples
Minute0–59*/5, 0, 30
Hour0–239, */2
Day of month1–31*, 1
Month1–12*, 6
Day of week0–6 (Sun=0)1-5, *
# Every 5 minutes
*/5 * * * *

# Weekdays at 09:00 UTC
0 9 * * 1-5

# First day of each month at midnight
0 0 1 * *

Six-field expressions (with a leading seconds field) are not supported and will fail validation when you try to create a job. Keep it to five fields.


Managing jobs

There are three ways to manage cron jobs, depending on your workflow.


Job structure

Every job has the following fields:

{
"id": "job_abc123",
"name": "daily-report",
"schedule": "0 9 * * 1-5",
"execution_type": "shell",
"execution_config": "{\"command\": \"./scripts/report.sh\"}",
"status": "active",
"timeout_seconds": 60,
"tags": "reports,nightly",
"next_run_at": "2026-06-29T09:00:00Z",
"last_run_at": "2026-06-28T09:04:17Z",
"created_at": "2026-06-01T00:00:00Z",
"updated_at": "2026-06-01T00:00:00Z"
}

execution_type is either "shell" (run a headless shell command via sh -c) or "harness" (continue the creating run's conversation with an assistant prompt). execution_config is a JSON blob whose shape depends on the type: shell jobs contain a non-empty "command"; harness jobs contain a non-empty "prompt". Agent-created harness jobs inherit their tenant, agent, and conversation from immutable run metadata rather than model-supplied scope fields.

Job status values: "active", "paused", "deleted"

Execution status values: "queued", "starting", "running", "succeeded", "failed", "timeout", "skipped".

Harness executions include a structured run_id as soon as the harness accepts the scheduled run. Do not infer that ID from output_summary: the summary is for display and changes again when terminal output is available. When a second cron fire targets the same tenant, agent, and conversation while an earlier cron execution remains active, the scheduler records a terminal "skipped" history row whose error text is the stable overlap reason. Different conversations remain independently concurrent. Older installations can still contain the legacy "pending" and "success" history values.


Jitter and gotchas

Jitter: why your jobs are never exactly on time

Every scheduled job is delayed by a random offset — called jitter — after the cron clock fires. The default range is 1 to 5 minutes (60–300 seconds). This intentional delay exists to prevent multiple jobs from stampeding simultaneously at minute-mark boundaries.

How it works:

  1. When a job is registered, a deterministic base jitter offset is computed from a hash of the job's ID and schedule. The same job always gets the same base offset across restarts.
  2. At fire time, the scheduler walks the offset forward (one second at a time, up to 120 additional seconds) to avoid landing on any of the configured "avoided minute marks" — by default :00 and :30.
  3. The scheduler sleeps for the final adjusted offset before dispatching the job.

Every job fires 1–5 minutes after its scheduled cron time by default. If you write a test that creates a cron job and immediately checks for an execution, you will be surprised by this delay. Disable jitter in test environments or use a short window.

Configuring jitter

Output truncation

Shell job output (stdout + stderr combined) is truncated to 4096 bytes in the execution record. Jobs that produce more output will have their output_summary field silently cut off. If you need full output, redirect to a file from within the shell command.

Soft delete and name reuse

DELETE /v1/cron/jobs/{id} does not immediately free the job's name. Instead it:

  1. Sets status to "deleted".
  2. Appends _deleted_<UnixNano> to the job name.

This frees the original name so a new job can use it — but only after the rename completes. If you delete a job and immediately create one with the same name, the create will succeed.

Execution concurrency

The scheduler caps concurrent job executions at MaxConcurrent: 5 (both the embedded scheduler and the default cronsd setup). Jobs that would exceed this limit are queued but may be delayed.


External triggers

POST /v1/external/trigger is a normalized webhook endpoint that routes one-off webhook events from external services (GitHub, Slack, Linear) to start, steer, or continue a harness run. It is separate from the cron scheduler — think of it as a push-based complement to cron's pull-based polling.

Actions

ActionBehavior
"start"Always starts a new run
"steer"Injects a message into an existing running or queued run
"continue"Starts a new run continuing from a completed or failed run

Request shape

{
"source": "github",
"source_id": "<delivery-id>",
"repo_owner": "myorg",
"repo_name": "myrepo",
"thread_id": "42",
"action": "start",
"message": "Run the eval suite on PR #42",
"tenant_id": "",
"agent_id": "",
"signature": "<hmac>"
}

The signature can be provided either in the "signature" JSON field or in the X-Trigger-Signature HTTP header. The header takes precedence.

Signature validators

Each source uses a different HMAC format:

SourceSignature format
"github""sha256=<hex>" (HMAC-SHA256)
"slack""<unix_ts>:v0=<hex>" (±5 min freshness check)
"linear"Raw hex HMAC-SHA256

The corresponding secrets are set via GITHUB_WEBHOOK_SECRET, SLACK_SIGNING_SECRET, and LINEAR_WEBHOOK_SECRET. When a secret is set, the handler for that source is registered automatically.

Response codes

CodeMeaning
202Accepted
400Invalid JSON or missing required fields
401Signature validation failed, or no validator configured for the source
404steer or continue action but no existing run for the thread
409Run state mismatch (e.g. steer on a completed run)
501Run store not configured

There are also source-specific webhook routes — POST /v1/webhooks/github, POST /v1/webhooks/slack, and POST /v1/webhooks/linear — that bypass Bearer auth and use HMAC validation directly. These are convenient when a platform requires a fixed webhook URL, but the /v1/external/trigger route gives you more flexibility for multi-source setups.

Reference: cronsd environment variables

When running cronsd as a standalone daemon:

VariableDefaultDescription
CRONSD_ADDR:9090Listen address
CRONSD_DB_PATH~/.go-harness/cronsd.dbSQLite database file
CRONSD_MAX_CONCURRENT5Max simultaneous job executions
CRONSD_URLhttp://localhost:9090Used by cronctl to locate the daemon

Next steps

  • To understand how harnessd authenticates these API calls, see Authentication and Tenancy.
  • To drive cron jobs from within a running agent, call the initial-turn core cron_create tool directly; use the other seven core cron tools to inspect and manage the job lifecycle.
  • To connect external webhooks (GitHub, Slack, Linear) to run triggers, set the corresponding *_WEBHOOK_SECRET environment variables and point your webhook at the appropriate /v1/webhooks/* route or at /v1/external/trigger.