feat: restart accounting on the Linux runtime - #134
Conversation
Seedling records nothing about individual restarts: crash_loop is filed only when systemd reports it has given up, so a container that flaps below StartLimitBurst is completely invisible to an operator. Plan recording restart attempts in the database and deriving crash_loop from the recorded rate, with systemd's start limit demoted to a secondary trigger. Linux first, because it is where this can be tested, and because the Windows container runtime has no systemd equivalent and will have to own restart outright — settling the portable shape here means that runtime conforms to a proven rule rather than one invented alongside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgdfXBAnSEj5s24gbJyXHi
The touch-points table abbreviated the second path in each cell into a relative continuation, so half the entries were not real paths. Spell them out. Also note that the wcr[...] rule cited under "what Windows inherits" lands with #107 and cannot be resolved before then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgdfXBAnSEj5s24gbJyXHi
…g sink Seedling writes its own rt.* call records into the journal with the same SEEDLING_APP/RESOURCE/INSTANCE tags container output carries, so one field-matched query returns both interleaved in time order. That is the property that makes `apps logs` useful for debugging — the closure's call sequence sits against the output it produced — and nothing in any spec said so. A second runtime could build a correct container-output store, satisfy every log rule, and silently drop it. State it, along with the fresh-execution-only rule that keeps a barrier-suspended operation from flooding the log on every replay pass, and annotate the two implementation sites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgdfXBAnSEj5s24gbJyXHi
Record every container restart per instance with its exit status and initiator, derive crash_loop from the recorded rate over an operator-settable window, and demote systemd's start limit to a secondary trigger. Adds the restart operator-interface surface and its retention rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
v54 adds instance_restarts (one row per restart, with exit status and initiator), instance_restart_counters (the supervisor counter baseline to diff against), and restart_settings (the operator-settable crash-loop threshold and window). Records are bounded per instance on write rather than by rate-limiting recording: a crash loop produces rows fastest exactly when the per-attempt exit codes are the diagnostic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
NRestarts lives on org.freedesktop.systemd1.Service rather than the Unit interface already proxied, so this adds a second proxy against the same object path, reading the counter plus ExecMainCode/ExecMainStatus. A restart that completes inside one observe interval leaves the unit active at both ends and is invisible to state polling; the counter is what catches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
Each tick diffs the observed restart counter against the stored baseline and records what moved, treating a decrease as a counter reset to re-baseline against rather than a negative delta. Starts the reconciler issues for an instance it has already run are recorded as runtime-initiated and excluded from the rate, so a rolling update does not read as a crash burst. crash_loop is now filed when the rate reaches the operator-set threshold, with systemd's start limit as a secondary trigger; the fault description names which fired. Auto-restart suppression is re-derived from the fault table each tick, so it covers rate-derived loops and lifts as soon as an operator clears the fault. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
/restarts/list returns records most recent first, filterable by app and
instance; /restarts/settings/{get,set} reads and changes the threshold
and window. app.describe gains a per-instance restart summary, omitted
for instances with no history so a resource that has never restarted
does not read as one that restarted zero times just now.
`seedling-ctl restarts` covers all three.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
Lists records most recent first, filterable by app and — when reached from the restart chip on an app's resource table — by instance, with runtime-initiated restarts chipped apart from supervisor ones since only the latter move the rate. The crash-loop threshold and window are shown and editable, in minutes rather than the wire's seconds. useOiQuery kept its effect off the params, so a view that narrowed its query never refetched. Key it on the params' serialisation, which the cache path already computed, so identity churn from a fresh object literal each render still does not refetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
The runtime overview said crash-loop detection was derived from the autonomous-operations log; it is derived from the restart record now, so that becomes a fourth history alongside the other three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
Code Coverage OverviewLanguages: TypeScript, Rust TypeScript / code-coverage/vitestThe overall coverage in commit 840013b in the Show a code coverage summary of the most impacted files.
Rust / code-coverage/rustThe overall coverage in commit 840013b in the Show a code coverage summary of the most impacted files.
Updated |
passcod
left a comment
There was a problem hiding this comment.
Reviewing this from the Windows runtime side (#107), since the portable rules added here are what that runtime will have to conform to. The implementation follows the plan closely and the spec rules read better than the ones the plan sketched. One thing is worth changing before merge, because it is operator-visible and gets expensive afterwards.
initiator splits on actor where the meaning is cause
i[restart.record] defines initiator as "supervisor" or "runtime", and r[autonomous.restart.rate] counts supervisor-actioned restarts toward the threshold, excluding runtime-initiated ones so rolling updates do not read as crash bursts.
On Linux that is exactly right, and the two meanings line up perfectly: systemd actions crash restarts, seedlingd actions deliberate ones. They line up because of a Linux implementation detail, not because actor and cause are the same thing.
The Windows container runtime has no platform supervisor actioning restarts. containerd has no restart policy and the runhcs shim does not restart tasks, so seedlingd actions every restart there — that is why wcr[shim.ownership] has to drop its restart clause. Under this taxonomy that means:
- every Windows restart is
initiator: "runtime" r[autonomous.restart.rate]counts only supervisor-actioned restarts- the rate never reaches the threshold, and the primary crash-loop trigger is dead
The secondary trigger does not cover for it either: r[autonomous.restart.start-limit-hit] keys on systemd's failed/start-limit-hit, which has no Windows equivalent. So the second runtime would end up with no crash-loop detection at all — the precise blind spot this PR exists to close.
The alternative is for Windows to report crash restarts as "supervisor" when no supervisor was involved, which empties the field of meaning.
Suggested change
Split on cause rather than actor: recovery from an unexpected exit, versus a deliberate runtime action. Something like cause: "recovery" | "deliberate" in place of initiator: "supervisor" | "runtime", with the rate counting recovery restarts.
On Linux this is a pure rename — systemd-actioned becomes recovery, deploys and replacements become deliberate, identical records and identical numbers. On Windows it works, because seedlingd is both the actor and the thing that knows why it is restarting something.
Worth noting that r[autonomous.restart.backoff] was already generalised in this PR to "where the platform's supervisor actions restarts", so the PR does anticipate a runtime where it does not. The initiator field simply did not get the same treatment, which reads as an oversight rather than a decision.
Why now
Pre-merge this is a field rename across i[restart.record], r[autonomous.restart.record], r[autonomous.restart.rate], i[restart.settings]'s wording, a DB column, a CLI column and a web column. Post-merge it is a breaking change to a published operator-interface field.
Nothing else blocking from here — the NRestarts baseline handling (adopt on first sighting, re-baseline on decrease, exclude runtime-initiated starts) is the part I would most have expected to get subtly wrong, and it looks right.
Generated by Claude Code
Splits the database side of file_crash_loop_faults out the same way the counter reconciliation was, so the policy can be exercised without standing up a tick: the rate trigger files and observed-healthy clears, start-limit-hit files below the rate threshold, a persisting loop is not filed twice, and clearing is scoped to the instance that recovered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
Per repo convention the plan goes at the end of its implementation PR; the spec rules it called for stay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
initiator (supervisor | runtime) named who performed a restart, which on Linux happens to coincide with why: systemd actions crash recovery and seedling actions deploys. The coincidence is a property of this platform, not of the record. Where there is no service supervisor the runtime performs both kinds, so every restart would be initiator=runtime, the rate would count none of them, and the primary crash-loop trigger would be dead on that runtime — with no start-limit state to fall back on. cause (recovery | deliberate) says why instead. On Linux it is a pure rename: identical records, identical numbers. Elsewhere it still means something. v54 is edited in place rather than superseded: it is introduced in this unmerged branch and has never been on main, so no shipped database has a schema_version row for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBJbZDHZAfLiRWhZFs2wL
|
Agreed, and done in 840013b. You're right that it's an oversight rather than a decision — The failure mode you describe is the decisive part: not that the taxonomy reads oddly on Windows, but that it leaves that runtime with no working crash-loop trigger.
Both specs now say the split is about why rather than who, so the next reader does not have to rediscover this:
Renamed across Two things worth flagging: The v54 migration is edited in place rather than superseded. The plan is deleted in 9263ff6, per the convention that it goes at the end of its implementation PR. That takes the "What Windows inherits" section with it — the I also filled in the two tests the plan listed that I'd missed: the rate trigger filing and observed-healthy clearing the fault, and start-limit-hit filing below the rate threshold. Both needed the same DB-side extraction I'd already done for the counter reconciliation. Generated by Claude Code |
wcr[shim.ownership] claimed the runhcs shim restarts the workload per policy. It does not — containerd has no restart policy and restart is a client concern — and with restart accounting landed, the portable spec now places crash-loop detection in the reconciler outright. Drop the clause and say where restart actually lives. Three Windows-specific consequences come with it. Restarts are recorded at the point the reconciler actions them, so the counter inference the Linux runtime needs is not needed here — and the recorded cause is recovery versus deliberate, not who acted, because seedlingd performs both kinds and an actor-shaped field would leave the crash-loop rate counting nothing. A terminated container's exit status must be read before its record is reaped, or the record is a tally with nothing to diagnose from. And a workload that exits while seedlingd is down stays down until it returns, which is a stated property of owning restart in the control plane rather than something to be found later. r[autonomous.restart.record] and r[autonomous.restart.rate] land with #134; the references resolve once both are on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CgdfXBAnSEj5s24gbJyXHi
There was a problem hiding this comment.
Pull request overview
Adds durable restart accounting to the Linux runtime (observing systemd’s NRestarts/last exit), derives crash_loop from a configurable restart-rate threshold/window, and exposes the resulting history + settings through OI, CLI, and the web UI.
Changes:
- Persist restart records and per-instance baselines in the DB, compute rate-derived crash loops, and GC retained restart history.
- Extend reconciliation to observe systemd restart counters and file
crash_loopfaults from either restart-rate or start-limit-hit triggers (with suppression derived from the fault table each tick). - Add operator surfaces:
/restarts/*OI endpoints,seedling-ctl restarts …, and a/restartsweb route plus per-instance restart summary inapp.describe.
Reviewed changes
Copilot reviewed 38 out of 38 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/spec/web.md | Specifies the new /restarts web route requirements. |
| docs/spec/runtime.md | Defines restart recording, rate-based crash-loop detection, settings, and GC expectations. |
| docs/spec/interface.md | Defines restart record/list/settings interfaces and adds restarts summary to app.describe. |
| docs/runtime-overview.md | Updates conceptual model to include “Restart History” as a fourth persistent history. |
| crates/web/frontend/src/routes/Restarts.tsx | Implements the /restarts UI: list + filters + editable rate settings. |
| crates/web/frontend/src/routes/Restarts.test.tsx | Frontend tests for the restarts route behaviour and settings mutation payload. |
| crates/web/frontend/src/routes/AppDetail.tsx | Adds per-instance restart summary chip linking to /restarts?instance=…. |
| crates/web/frontend/src/routes/AppDetail.resources.test.tsx | Tests restart chip rendering/linking behaviour in AppDetail resources table. |
| crates/web/frontend/src/lib/types.ts | Adds frontend types for restart records/settings/summary. |
| crates/web/frontend/src/hooks/useOi.ts | Fixes useOiQuery to refetch when params change (value-based keying). |
| crates/web/frontend/src/components/Navbar.tsx | Adds navbar link/icon to /restarts. |
| crates/web/frontend/src/App.tsx | Registers the new restarts route. |
| crates/ctl/src/restarts.rs | Adds `seedling-ctl restarts list |
| crates/ctl/src/main.rs | Wires the new restarts subcommand into the CLI. |
| crates/core/src/system/types.rs | Extends unit state and observation facts to carry restart counter + last exit. |
| crates/core/src/system/systemd.rs | Adds systemd Service proxy reads for NRestarts/ExecMain* and parsing. |
| crates/core/src/system/stub.rs | Extends stub process manager to simulate restarts/counter resets and last exits. |
| crates/core/src/system/reconcile/restarts/tests.rs | Unit tests for counter delta logic, resets, deliberate exclusion, threshold, and GC. |
| crates/core/src/system/reconcile/restarts.rs | Implements baseline reconciliation, recording, rate evaluation, and restart GC. |
| crates/core/src/system/reconcile/pods.rs | Threads restart counters through pods phase and adds crash-loop cause modelling. |
| crates/core/src/system/reconcile/phases.rs | Plumbs crash_looped suppression set into pods phase execution. |
| crates/core/src/system/reconcile/faults/crash_loop_tests.rs | Tests crash-loop fault filing/clearing for both triggers and deduping. |
| crates/core/src/system/reconcile/faults.rs | Updates crash-loop fault handling to support multiple triggers and clearer descriptions. |
| crates/core/src/system/reconcile.rs | Adds per-tick derivation of crash_looped from the fault table and runs bookkeeping. |
| crates/core/src/system/observer.rs | Emits UnitRestartCounter observation facts for deployment/job units. |
| crates/core/src/system/breadcrumb.rs | Adds tracey annotation for breadcrumb emission requirement. |
| crates/core/src/runtime/restarts/tests.rs | Tests DB restart recording, filtering, rate-window logic, caps, summaries, and settings validation. |
| crates/core/src/runtime/restarts.rs | Implements DB schema operations for restart records, summaries, baselines, and settings. |
| crates/core/src/runtime/gc.rs | Hooks restart-record GC into the periodic GC cycle. |
| crates/core/src/runtime/db/tests.rs | Bumps expected schema version to 54. |
| crates/core/src/runtime/db/migrations/v54.sql | Adds tables for restart records, baselines, and restart-rate settings. |
| crates/core/src/runtime/db.rs | Registers migration v54. |
| crates/core/src/runtime/barrier/replay.rs | Adds tracey annotation for breadcrumb replay-boundary behaviour. |
| crates/core/src/runtime.rs | Exposes the new runtime::restarts module. |
| crates/core/src/oi/handler/restarts/tests.rs | Tests OI restart list/settings endpoints end-to-end. |
| crates/core/src/oi/handler/restarts.rs | Adds OI handlers for restart list and settings get/set. |
| crates/core/src/oi/handler/apps.rs | Adds per-instance restart summary into app.describe responses. |
| crates/core/src/oi/handler.rs | Registers /restarts/* routes in the OI dispatcher. |
Suppressed comments (1)
crates/core/src/oi/handler/restarts.rs:61
- As above in
list_restarts,get_settingsmaps DB failures toErrorCode::NotFound. This should beErrorCode::Internalso clients can distinguish missing resources from server/database failures.
.map_err(|e| OiError::new(ErrorCode::NotFound, format!("db query: {e}")))?;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "transition_time": transition_time.and_then(|t| { | ||
| jiff::Timestamp::try_from(t).ok().map(|ts| ts.to_string()) | ||
| }), | ||
| "restarts": restart_summary, | ||
| }) |
| pods::CrashLoopCause::RestartRate { count, window_secs } => format!( | ||
| "{} restarted {count} times in the last {} minutes. \ | ||
| Auto-recovery is paused until this fault is cleared.", | ||
| instance.display_name, | ||
| window_secs / 60, | ||
| ), |
| > The navbar's held-volumes badge must reflect the current count of held volumes without requiring a page reload, both when new held volumes are created and when the operator confirms their deletion. | ||
|
|
||
| > w[routes.restarts] | ||
| > The web interface must expose container restart history at `/restarts`, listing [restart records](interface.md#i--restart.record) most recent first with their app, instance, time, initiator, and exit status. |
| let _ = db.conn.execute( | ||
| "UPDATE restart_settings | ||
| SET threshold = ?1, window_secs = ?2, updated_at = ?3 | ||
| WHERE singleton = 1", | ||
| rusqlite::params![next.threshold, next.window_secs, now_ms()], | ||
| ); |
| let records = state | ||
| .db | ||
| .call(move |db| restarts::list(db, app.as_ref(), instance.as_deref(), limit)) | ||
| .map_err(|e| OiError::new(ErrorCode::NotFound, format!("db query: {e}")))?; |
Implements the plan from #133.
Seedling now records every container restart and derives
crash_loopfrom the recorded rate. systemd still actions restarts on Linux; seedling keeps the books. Sub-threshold flapping — a container that crashes a couple of times a day forever, never exhaustingStartLimitBurstinside its window — is no longer silent.The two open decisions
The plan left two open; both are recorded in its
## Decidedsection (in history, since the plan is removed by this PR).restart_settings, read and changed through/restarts/settings/{get,set}, and re-read by the reconciler each tick so a change takes effect without a restart. Default is 5 recovery restarts within 30 minutes;thresholdfloors at 2,window_secsat 60./restarts/list, plus a per-instance summary folded intoapp.describeso the count is visible without going looking.Cause, not actor
Following review: a restart record's
causeisrecoveryordeliberate— why the restart happened, not which component performed it. On Linux the two coincide, since systemd actions recovery and seedling actions deploys, but only as a property of this platform. A runtime with no service supervisor performs both kinds, so a field naming the actor would classify everything there identically, leave the rate counting nothing, and kill the primary crash-loop trigger on the very runtime that made this requirement portable.Spec first
docs/spec/runtime.md: newautonomous.restart.record,autonomous.restart.rate,autonomous.restart.rate.settings,gc.restarts;autonomous.restart.backoffbecomes the Linux pacing mechanism rather than the definition of a crash loop;autonomous.restart.start-limit-hitis demoted to a secondary trigger;fault.crash-loopgains both triggers and must say which fired.docs/spec/interface.md:restart.record,restart.list,restart.settings, and theapp.describeamendment.docs/spec/web.md:routes.restarts.All four specs are at 100% implementation coverage with zero stale references.
How restarts are observed
Polling container state cannot see a restart that completes inside one observe interval — the unit is
activeat both ends. So the runtime reads systemd's ownNRestarts, which lives onorg.freedesktop.systemd1.Servicerather than theUnitinterface already proxied, alongsideExecMainCode/ExecMainStatusfor the last exit. Two extra property reads on a path already fetchingActiveStateandSubState; failure is non-fatal, since the interface only exists on.serviceunits.Each tick diffs the counter against a stored per-instance baseline:
reset-failed, or a recreated unit), not a negative delta. Re-baseline, recording whatever accrued after the reset.A start the reconciler issues for an instance it has already run is recorded as deliberate and re-baselines to zero, so a rolling update never reads as a crash burst — and the zeroed counter on the next tick is expected state rather than a reset.
Fault behaviour
crash_loopis filed on the rate reaching the threshold, or on start-limit-hit, and the description names which. Auto-restart suppression is re-derived from the fault table each tick, so it now covers rate-derived loops, survives a daemon restart, and lifts on the next tick when an operator clears the fault.Retention
Bounded per instance (50 records) on write rather than by rate-limiting recording: a crash loop produces rows fastest exactly when the per-attempt exit codes are the diagnostic. The GC pass drops records and counters for instances that have left the registry.
Operator surfaces
seedling-ctl restarts list|settings|set-settings, and a/restartsweb route with an app filter, an instance filter reachable from a per-instance chip on an app's resource table, and the threshold/window editable in minutes.One incidental fix:
useOiQuerykept its effect off the params, so any view that narrowed its query never refetched. It is now keyed on the params' serialisation, which the cache path already computed — object-identity churn from a fresh literal each render still does not refetch.Note on the migration
v54 is edited in place by the cause rename rather than superseded by a v55.
AGENTS.mdforbids editing a shipped migration because itsschema_versionrow exists in real databases; v54 is introduced in this unmerged branch and has never been on main, so no such row exists. Anyone who has run this branch locally will need to drop their dev database.Tests
43 tests covering all five the plan listed: counter deltas, bursts, resets and re-baselining, deliberate-restart exclusion, threshold crossing and the operator setting, the rate trigger filing and observed-healthy clearing the fault, start-limit-hit filing below the rate threshold, per-instance cap under sustained crash-looping, GC of orphans, the observer fact end to end against a stub whose counter moves behind its back, the OI endpoints, and the web route.
cargo clippy --workspace --all-targetsis clean,cargo fmt --checkpasses,traceyvalidates with 0 errors and 0 stale references, and all 47 frontend test files (319 tests) pass.Five
oi::servertests fail in the dev sandbox withAddress family not supported by protocol— they need QUIC/IPv6. Verified as pre-existing by running them at the base commit, where they fail identically; they pass in CI.🤖 Generated with Claude Code