feat(jobs): retry, run history, notify, and manual runs with inputs for scheduled jobs - #156
Conversation
…ng a skip as failure Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
…he host journal Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
…rom the journal Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
…nst the timeout Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
…ment defaults Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
…d systemd 252 for them Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
…led as schedule_run Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
…query by them A process that writes one line and exits is often gone before journald reads /proc for it, so the systemd-cat record arrived with no _SYSTEMD_UNIT and journalctl -u never found it. logger --journald carries ONEBOX_UNIT and ONEBOX_JOB in the entry itself, and the history query matches on those. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
Drop the fallbacks: ob status no longer reads systemd's Result for an issue when no record exists, ob schedule logs no longer falls back to the unit log (and loses --tail), and ob schedule run --wait fails on any recorded outcome but success, a skip included. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
- A skip is the runner's own word: it records the reason and exits 0 before any container starts, the unit keeps no SuccessExitStatus remap, a job that exits 75 is a failure, and three skips in a row are an ob status issue. The application lock is honoured only within its TTL, as AcquireLock does. - Every scheduled job needs systemd 252 (TRIGGER_UNIT), checked in preflight before staging as well as in schedule reconciliation. - ob schedule run --wait matches the record by operation id, polls briefly for journald, and discards its inputs file when the start fails. - The retry validator counts the same whole seconds the runner sleeps. - The published schema constrains notify, retry, inputs and job-only fields. - ob schedule logs reports the run id it resolved; ob status drops systemd's Result in favour of the record alone. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
There was a problem hiding this comment.
🟡 Changes recommended
Confirmed issues in the new scheduling implementation can cause incorrect skip behavior under sub-minute lock TTLs and can silently hide journald read failures, which should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Implements the “scheduled-job contract” end-to-end by extending the project schema, generated systemd runner/notifier scripts, and CLI to support bounded retry, per-run journald history, per-outcome notifications, and operator-initiated manual runs with declared/validated inputs—without adding a resident host process or new store.
Changes:
- Adds
schedule.retry,schedule.notify, andinputsto the spec (validation + published JSON Schema) and renders input defaults into Compose env. - Updates generated scheduled-job runner/notifier to record one structured run record per activation into journald, support in-firing retry with capped exponential backoff, and consume manual-run inputs safely.
- Adds CLI commands (
ob schedule list|history|logs|run) and updatesob status/audit/e2e/tests to surface outcomes, attempts, durations, skip streaks, and run correlation.
File summaries
| File | Description |
|---|---|
| site/src/content/docs/status/capabilities.mdx | Documents new scheduled-job capabilities at a high level. |
| site/src/content/docs/reference/policies.mdx | Updates output-policy classification for new schedule commands. |
| site/src/content/docs/reference/fields/workloads.mdx | Adds reference docs for inputs, schedule.notify, schedule.retry. |
| site/src/content/docs/reference/errors.mdx | Documents new schedule-related error codes. |
| site/src/content/docs/reference/cli.mdx | Adds CLI reference sections for schedule subcommands. |
| site/src/content/docs/guides/schedule-a-job.mdx | Expands guide with retry, journald history, notifications, and manual runs with inputs. |
| site/public/onebox.run-v1.schema.json | Publishes updated JSON Schema for new schedule/input fields. |
| docs/onebox.run-v1.schema.json | Publishes updated JSON Schema for new schedule/input fields (docs copy). |
| internal/onebox/operation_types.go | Adds schedule_run operation kind. |
| internal/onebox/operation_errors.go | Registers schedule read/flag validation failure definitions. |
| internal/onebox/execution_types.go | Extends ExecuteRequest/OperationResult for schedule run requests/results. |
| internal/onebox/execute.go | Routes schedule_run to engine execution. |
| internal/onebox/binding.go | Includes schedule run in inspection-runtime selection. |
| internal/engine/status.go | Extends ob status schedule line output (attempts, next run, journal volatility, last outcome). |
| internal/engine/status_snapshot_test.go | Updates snapshot expectations for schedule status changes. |
| internal/engine/schedule.go | Major runner/notifier generation changes: inputs consume, skip semantics, journald records, retry loop, systemd floor. |
| internal/engine/schedule_test.go | Adds/updates unit-script/notifier contract tests and behavior tests. |
| internal/engine/schedule_status.go | Changes schedule status model to be record-driven (outcomes, streaks, next run, attempts). |
| internal/engine/schedule_run.go | Implements operator-initiated schedule run with inputs file + journaling + optional wait for record. |
| internal/engine/schedule_run_test.go | Adds tests for schedule run behavior, refusal paths, and --wait record matching. |
| internal/engine/schedule_history.go | Adds history/list/logs primitives backed by journald run records. |
| internal/engine/schedule_history_test.go | Adds tests for history parsing/list/logs behavior. |
| internal/engine/preflight.go | Enforces scheduled-job host requirements during preflight. |
| internal/engine/deploy_test.go | Updates fake host to satisfy new systemd floor check. |
| internal/engine/audit.go | Adds audit labeling for schedule run operations. |
| internal/app/validate.go | Wires inputs + retry validation into workload validation. |
| internal/app/types.go | Adds typed model fields for inputs/retry/notify. |
| internal/app/schedule.go | Resolves scheduled jobs including retry/notify defaults and inputs. |
| internal/app/schedule_test.go | Adds tests for retry/notify resolution, bounds, inputs constraints, and compose env rendering. |
| internal/app/schedule_retry.go | Implements retry policy defaults, worst-case backoff computation, and validation. |
| internal/app/schedule_inputs.go | Implements input constraints + workstation-side value validation. |
| internal/app/names.go | Adds path helper for per-job .inputs file. |
| internal/app/jsonschema.go | Adds JSON Schema constraints and marks inputs as job-only. |
| internal/app/generate.go | Renders inputs defaults into Compose environment. |
| internal/app/constraints.go | Adds enum values for schedule notify outcomes. |
| e2e/testdata/postgres/ob.yml.tmpl | Adds e2e fixtures for retry/input/manual run behaviors. |
| e2e/server_test.go | Extends e2e coverage for run history, retries, manual runs with inputs, and record-driven status clearing. |
| cmd/ob/schedule.go | Introduces `ob schedule list |
| cmd/ob/schedule_test.go | Adds tests for --input NAME=VALUE flag parsing. |
| cmd/ob/output.go | Extends CLI output matrix for new schedule commands. |
| cmd/ob/output_test.go | Updates output matrix test expectations. |
| cmd/ob/ops.go | Hooks schedule commands into ops command wiring. |
Review details
- Files reviewed: 42/43 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| ttlMinutes := int(math.Ceil(lockTTL.Minutes())) | ||
| if ttlMinutes < 1 { | ||
| ttlMinutes = 1 | ||
| } | ||
| return []string{ | ||
| "state=" + q(names.ScheduledJobRunState(job)), | ||
| "tmp=\"$state.$$\"", | ||
| // The operation and inputs of a manual request are kept on the skip | ||
| // record too, so `ob schedule run --wait` can find its own outcome. | ||
| "skip() { umask 077; printf 'skipped=%s\\noperation=%s\\ninputs=%s\\n' \"$1\" \"$operation\" \"$inputs_json\" >\"$tmp\"; mv -f \"$tmp\" \"$state\"; echo \"onebox: skipped: $1\" >&2; exit 0; }", | ||
| "exec 9>" + q(names.ScheduledJobRunLock(job)), | ||
| "/usr/bin/flock --exclusive --nonblock 9 || skip 'another run of this job is still in progress'", | ||
| "exec 8>" + q(names.ScheduleRunLock()), | ||
| "/usr/bin/flock --exclusive --nonblock 8 || skip 'an application operation is taking its lock'", | ||
| "if [ -e " + q(applicationLock) + " ] && [ -z \"$(find " + q(applicationLock) + " -mmin +" + strconv.Itoa(ttlMinutes) + " 2>/dev/null)\" ]; then skip 'an application operation holds the deploy lock'; fi", | ||
| } |
There was a problem hiding this comment.
Fixed in b4762c8. The runner now reads the lock's age with lockAgeCmd, the same shell AcquireLock uses, and compares it against the TTL in whole seconds rather than rounding up to a minute with find -mmin. That also inherits its fail-closed behaviour: a dangling symlink or an unreadable parent directory reads as age 0, so the runner still defers to a lock it cannot inspect.
| if res.ExitCode != 0 { | ||
| return result, fmt.Errorf("a manual run of %s is already pending (%s exists); wait for it, or remove the file on the host", name, path) | ||
| } |
There was a problem hiding this comment.
Fixed in b4762c8. The command now tests for the file first and exits 73 for that case, so an existing pending run and a host that refuses the write are reported separately, the latter with the shell's own stderr. Covered by TestScheduleRunTellsAPendingFileFromAWriteFailure, which also asserts neither path starts the unit.
- The runner reads the application lock's age with the same shell AcquireLock uses, in whole seconds, so a sub-minute TTL is honoured as written and an unreadable lock still fails closed. - A journalctl failure is an error, not an empty history: unreadable and never-ran are different answers. - Writing the inputs file distinguishes an already-pending run from a host that refused the write. - Range over an integer, which the pinned linter asks for. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
- A skip never clears a failure: ob status reports the newest run that actually happened and says nothing has run since. - An unreadable journal costs the status report its records, not the whole report; ob schedule history is still the command that says why. - A skipped run is notified as a run that did not happen, not as a failure. notify.Payload carries the distinction, and its reason survives redaction because it comes from Onebox's own closed vocabulary. - ob schedule run --wait only lets go of its inputs file once a record proves the runner read it, so a start that never activated, or one that merged into a timer firing, cannot strand the file. - The inputs file is written through the fence guard, like every other mutation. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
There was a problem hiding this comment.
🟡 Changes recommended
A couple of verified issues in the new journald record parsing and skipped-notification messaging can lead to incorrect status/reporting or confusing operator output.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 43/44 changed files
- Comments generated: 2
- Review effort level: Lite
| func parseScheduleRunRecords(stdout string) []ScheduleRunRecord { | ||
| var out []ScheduleRunRecord | ||
| for _, line := range strings.Split(stdout, "\n") { | ||
| line = strings.TrimSpace(line) | ||
| if !strings.HasPrefix(line, "{") { | ||
| continue | ||
| } | ||
| var record ScheduleRunRecord | ||
| if err := json.Unmarshal([]byte(line), &record); err != nil { | ||
| continue | ||
| } | ||
| out = append(out, record) | ||
| } | ||
| return out | ||
| } |
There was a problem hiding this comment.
Fixed in 5bf92a1. parseScheduleRunRecords now takes the job name and keeps a decoded line only when it names that job, carries a 32-hex systemd invocation id, and reports an outcome from the closed set (success, failure, timeout, skipped). Anything else is dropped, so a stray JSON line cannot become the newest record and clear a failure or a streak. Covered by TestParseScheduleRunRecordsRejectsLinesThatAreNotThisJobsRuns.
| // Diagnostic errors may contain provider output, query text, or credentials. | ||
| // Notifications cross the host trust boundary, so they carry only the stable | ||
| // outcome; operators use the trusted local diagnostics for details. | ||
| if p.Status != "ok" && p.Error != "" { | ||
| p.Error = "operation failed; inspect trusted local diagnostics" |
There was a problem hiding this comment.
Fixed in 5bf92a1. The skip error is now just the directive, so the line reads ⏭️ app: scheduled job X did not run on host — inspect trusted host diagnostics instead of repeating itself. The reason itself stays on the host, in the run record's reason field and in ob status, which is where it can be specific without crossing the trust boundary.
- A record has to be one: it names the job, carries a systemd invocation id, and reports an outcome from the closed set. Callers read the newest record as the job's verdict, so a stray JSON line logged under the same fields could otherwise clear a failure. - The skip notification says where to look, once. The line already says the run did not happen. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
- A firing that cannot take the job lock stands aside without writing state: that file belongs to the run in flight, and overwriting it replaced a real outcome with this activation's skip. - The container is cleared before every attempt, not once before the loop, so a corpse from one attempt cannot fail all the ones after it. - The failure notification carries the run id, which is what a text webhook sends and the only handle on ob schedule logs --run. - The systemd 252 floor is scoped back to what needs it: jobs declaring inputs, and ob schedule run. A host on an older LTS keeps running its scheduled jobs, and its records say trigger unknown rather than guessing. - ob schedule list reports a failed timer read instead of a table of dashes, and ob schedule logs fails like its siblings. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
There was a problem hiding this comment.
🟡 Changes recommended
A job-lock conflict “skip” can currently be mis-recorded (and can clobber the in-flight run’s state), so the journald history/status semantics are not reliable under overlap.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
internal/engine/schedule_history.go:141
- ScheduleList suppresses
systemctl showfailures with2>/dev/null || true, which forces an overall exit code of 0 and prevents the laterres.ExitCode != 0check from ever surfacing a real read failure. This contradicts the intent in the comment below (distinguishing “empty state” vs “host would not tell us”) and will makeob schedule listsilently print dashes when systemctl is unavailable/permission-denied/unit missing.
- Files reviewed: 44/45 changed files
- Comments generated: 1
- Review effort level: Lite
| "skip() { umask 077; printf 'skipped=%s\\noperation=%s\\ninputs=%s\\n' \"$1\" \"$operation\" \"$inputs_json\" >\"$tmp\"; mv -f \"$tmp\" \"$state\"; echo \"onebox: skipped: $1\" >&2; exit 0; }", | ||
| // No lock, no state: the run already in flight will record itself, | ||
| // and its evidence is not this activation's to overwrite. | ||
| "stand_aside() { echo \"onebox: skipped: $1\" >&2; exit 0; }", | ||
| "exec 9>" + q(names.ScheduledJobRunLock(job)), | ||
| "/usr/bin/flock --exclusive --nonblock 9 || stand_aside 'another run of this job is still in progress'", |
There was a problem hiding this comment.
Both correct, and both fixed in d8d94e9.
The stand-aside path now leaves a note keyed to its own invocation, <state>.skip.$INVOCATION_ID, carrying the reason and the manual run's operation and inputs. The notifier prefers that note over the state file: it records the skip and never reads or removes $state, so the run still holding the job lock keeps its outcome and its pinned-release evidence. That closes both halves you named, the false success and the clobbering, without per-invocation state files for the ordinary path. TestScheduledJobNotifierReadsAStandAsideNoteAndSparesTheRunningState executes the generated notifier against a laid-out directory and asserts the live state file is byte-identical afterwards.
On the suppressed note about ScheduleList: also right, and it was mine from the same commit that added the exit check. The 2>/dev/null || true forced the batch to exit 0, so the check could never fire. The probe no longer swallows its failure, and TestScheduleListSurfacesAFailedTimerRead covers it.
…s state A firing that could not take the job lock exited 0 and wrote nothing, so its own ExecStopPost saw a clean exit and recorded a success for a run that never happened — and read and removed the state file belonging to the run that was still going, destroying its outcome and its pinned-release evidence. It now leaves a note keyed to its own invocation. The notifier prefers that note, records the skip with its reason, and leaves the state file alone. ScheduleList no longer swallows its own probe failure, which had made the exit check added alongside it unreachable: a host that will not answer is reported rather than rendered as a row of dashes. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
There was a problem hiding this comment.
🔵 Needs a closer look
The published JSON Schemas define schedule.notify as an array but currently set its default as a string, which should be corrected before merge.
Review details
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
docs/onebox.run-v1.schema.json:2602
schedule.notifyis an array, but the JSON Schemadefaultis currently a string ("failure, timeout"), which is not a valid default for this type and can confuse tooling.
internal/app/types.go:287JobSchedule.Notifyis a[]string, but its struct tag setsdefault:"failure, timeout"(a single string). This likely drives the generated JSON Schemaschedule.notify.defaultto be a string too, making the schema invalid for an array type (as seen in the committed schema JSON).
site/public/onebox.run-v1.schema.json:2602schedule.notifyis typed as an array, but its JSON Schemadefaultis currently a single string ("failure, timeout"). This makes the schema internally inconsistent and can break editors/validators that apply defaults.
- Files reviewed: 44/45 changed files
- Comments generated: 0 new
- Review effort level: Lite
A JSON Schema default has to be a value of the property's own type. Every list field carried its default as the sentence the reference table prints, `failure, timeout`, so the published schema declared an array whose default was a string. An editor that applies defaults would have filled the list with that sentence. The same shape was in every list `examples` entry. Fixed where it was produced rather than per field, so `schedule.notify` and the older `notifications.on` are both correct, and the reference table still reads the way it did: the generator splits the tag, the docs join it back. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
There was a problem hiding this comment.
🟡 Changes recommended
There is at least one confirmed build-breaking bug (for attempt := range 10 in awaitScheduleRecord) and a small robustness mismatch in skip-marker naming between runner and notifier that should be corrected.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
internal/engine/schedule.go:303
- The comment above
requireScheduleHostsays the systemd 252 floor “applies to every scheduled job”, but the implementation (and tests) only enforce it when any job declaresinputs. This mismatch makes it harder to reason about host requirements and could confuse future edits.
internal/engine/schedule.go:510
- The stand-aside marker file uses
${INVOCATION_ID:-$$}in the runner, but the notifier looks for${INVOCATION_ID:-}. IfINVOCATION_IDis ever unset (e.g., script invoked outside systemd), the notifier won't find the marker and may fall back to reading/removing the shared$statefile, risking clobbering the in-flight run's evidence.
// never held the job lock, so the state file belongs to whichever run
// is still going: read the note and leave that file alone.
"skip_marker=\"$state.skip.${INVOCATION_ID:-}\"",
"if [ -f \"$skip_marker\" ]; then",
" while IFS= read -r line || [ -n \"$line\" ]; do",
- Files reviewed: 46/47 changed files
- Comments generated: 1
- Review effort level: Lite
| func (e *Engine) awaitScheduleRecord(ctx context.Context, name, operationID string) (*ScheduleRunRecord, error) { | ||
| for attempt := range 10 { | ||
| if attempt > 0 { | ||
| e.Opts.Sleep(200 * time.Millisecond) | ||
| } | ||
| records, err := e.ScheduleHistory(ctx, name, 5) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| for i := range records { | ||
| if records[i].Operation == operationID { | ||
| return &records[i], nil | ||
| } | ||
| } | ||
| } | ||
| return nil, fmt.Errorf("no run record carries operation %s for job %s: the unit did not run for this request; a timer firing may have taken the slot, or the host's notifier wrote nothing", operationID, name) | ||
| } |
The runner wrote `$state.skip.${INVOCATION_ID:-$$}` and the notifier looked
for `$state.skip.${INVOCATION_ID:-}`. Under systemd the variable is always
set, so the two agreed; anywhere else they did not, and a note the notifier
cannot see sends it back to the state file belonging to the run in flight —
the clobbering this note exists to prevent. Both sides now use one expression,
and a test holds them to it.
The comment on requireScheduleHost still described the systemd 252 floor as
applying to every scheduled job, which it stopped doing when the floor was
scoped back to jobs declaring inputs, and contradicted the comment inside the
function.
Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
|
Thanks — two of the three are real and are fixed; the headline one is not.
The skip-marker mismatch is real. The runner wrote The suppressed note on Both fixed in the commit above. |
…bout the host Five from the final review, all in the operator-initiated path. The journal said `schedule run ... started` whether or not the unit ever started. The finish is now written after the attempt and carries its real status, so `ob audit` can show a request that failed — a unit that does not exist because `ob schedule apply` was never run, or a `--wait` that saw the job fail. `ExecuteRequest.Validate` rejected every other kind's fields on the wrong kind but not `job`, `inputs` and `wait`, so a deploy carrying them was accepted and they were dropped in silence. `--wait` gave journald 1.8s to make the record queryable and then declared the run had never happened. It waits ten seconds and says what it actually knows: no record appeared yet, and where to look. A stand-aside note is removed only by the activation that wrote it, so one orphaned by a power cut stayed forever. Swept a day later, under the job lock. `hasTriggerUnit` cached a transport error as "this host is too old", which preflight then turned into a refused deploy. A failed probe is not an answer. Claude-Session: https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5
What this changes
Completes the scheduled-job contract without adding a resident process or a new store, as specified in the issue: bounded retry inside one timer firing, one run record per activation in the host journal, notifications per outcome, and declared inputs with an operator-initiated
ob schedule run.schedule.retry,schedule.notify,inputs, validated by the loader and constrained in the published JSON Schema.-earguments (never shell text), records a skip with its reason and exits 0 before any container starts, and honours the application lock only within its TTL.ExecStopPost): finalises one JSON record per run withlogger --journald, carryingONEBOX_UNIT/ONEBOX_JOBfields, then sends webhooks for the outcomes the job selected, with the run id asdeploy_id.ob schedule list,ob schedule history,ob schedule logs,ob schedule run --input NAME=VALUE --wait;ob statusreads outcome, attempts, duration, consecutive failures and skips from the records.TRIGGER_UNIT), checked in preflight and in schedule reconciliation.Closes #155
Why this is correct
loggerandcurlbinaries for every outcome (success, failure, timeout, skip, container exit 75) and the inputs-consume block runs against a real file. Each behaviour was added test-first and the test was watched to fail.ob status.systemd-catrecord arrived with no_SYSTEMD_UNIT(the process exits before journald reads/proc), andjournalctl -unever found it. The field query was probed on the guest before the e2e rerun.--wait, inputs-file cleanup on failure, skip semantics, systemd floor scope and placement, retry arithmetic parity, schema constraints,logsrun id, and dropping systemd'sResultas a verdict).just checkpasses on the final tree.Effect on the safety envelope
Changes what Onebox does on a host, deliberately and within the documented boundary:
ob statusissue. A retry sleeps under the locks the run already holds, bounded bytimeoutat validation. Nothing else about unattended firings changes.ob schedule runstarts a unit the timer already runs unattended, only fordata_effect: nonejobs, with inputs limited to the declared enum or pattern. Jobs withmigrationordestructiveeffects keep the sealed-plan gates ofob job run.ob preflightand deploy refuse older hosts before staging./status/capabilitiesgains one shipped bullet covering retry, run history, notify, inputs and manual runs. Nothing moved from "schema accepts it" to "binary does it" without landing in the binary.Checklist
just checkpasses locally.just checkverifies this).https://claude.ai/code/session_013MqwrF5NA179khDtEQdib5