fix(supervisor): make unobserved daemon deaths retryable after a crash - #657
Conversation
Startup orphan cleanup and the interval reconciler disagreed about the same situation: a daemon whose recorded PID is dead (or recycled) was cleared to Stopped by startup but marked Errored(-1) by the reconciler. Startup runs first on every restart, so its answer always won and a daemon that died while its supervisor was crashed never became eligible for its configured retries. Both paths now share one decision function. A daemon recorded Running whose exit nobody observed becomes Errored(-1) so retries apply, with two exceptions that stay Stopped: records from an earlier boot, whose processes died with the machine (reviving every retry-configured daemon after a reboot is what boot_start is for), and any other status — in practice Stopping, an intentional stop that completed while the supervisor was gone. The kill policy still records Stopped, since we terminated that process ourselves. Distinguishing a reboot from a supervisor crash needs a wall-clock value: start_time is a platform-specific high-resolution token (Linux clock ticks since boot, macOS microseconds since epoch, Windows FILETIME), so it cannot be compared against a boot time. Daemons now record the system boot time alongside their PID, compared with a 60s tolerance to absorb platform jitter. State files written before this change lack the field and fail closed to the previous behavior. These transitions also stop fabricating last_exit_success. The exits were never observed, and inventing a result corrupts the cron retrigger = "success" | "fail" decisions that read the field: a one-shot that actually succeeded during the crash window would be blocked from its next run. The adopted-daemon poll monitor still records it, because that monitor does observe the death. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughChangesBoot-aware daemon lifecycle
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Supervisor
participant StateFile
participant DaemonProcess
Supervisor->>StateFile: Read daemon boot metadata
Supervisor->>DaemonProcess: Check liveness and process identity
Supervisor->>StateFile: Write retryable or stopped status
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f9b2f91. Configure here.
Greptile SummaryThis PR aligns startup and runtime reconciliation of unobserved daemon exits.
Confidence Score: 5/5The PR appears safe to merge. No blocking failures remain in the fixes associated with the previous review threads. Important Files Changed
Reviews (4): Last reviewed commit: "docs(supervisor): record why an unobserv..." | Re-trigger Greptile |
…emons Review follow-ups, plus a Windows CI failure the first revision exposed. A deliberate `pitchfork supervisor stop` can leave daemon records in the running state: the stop signal is delivered and handled on Unix, where close() stops each daemon, but Windows has no POSIX signals so the supervisor is force-terminated and never runs its shutdown path. Mapping every unobserved death to Errored(-1) therefore reported intentionally stopped daemons as failures — and would have auto-restarted the ones with retry configured. Marking a daemon errored now requires positive evidence that the previous supervisor exited uncleanly, which its own state entry already provides: a clean shutdown removes that entry, while a crash, an external kill, or a --force replacement leaves it behind. The stop command now removes the entry itself on the platform where the supervisor cannot, so both behave the same, and a test pins that invariant directly since it is what the platforms disagreed about. The boot-time tolerance drops from 60s to 2s. It exists only for Windows, which recomputes boot time as now - GetTickCount64() and can drift about a second between samples; Linux (/proc/stat btime) and macOS (kern.boottime) are exact. At a minute wide it could span a genuine reboot when the previous session was short, e.g. a device in a reboot loop, which would resurrect daemons a reboot should have left stopped. These transitions now also clear last_exit_success instead of preserving it. Keeping the previous value attributes an earlier run's outcome to this one, so a cron daemon whose latest run died during the crash window would have its retrigger = "success" | "fail" decision made from the run before it. None restores the "no result yet" reading those retriggers already handle. Clearing it needs a direct state write, since upsert_daemon's merge preserves the existing value — which also makes the startup and reconciler paths structurally identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
src/supervisor/mod.rs (1)
1482-1508: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDoc comment omits the
supervisor_exited_uncleanlycondition.The list of "two cases stay
Stopped" is now three: a clean prior shutdown also yieldsStoppedregardless of boot alignment. That third condition is the one callers hardcode (adopt.rspassestrue), so it deserves to be spelled out here.📝 Suggested doc addition
/// - any other status (in practice `Stopping`), i.e. an intentional stop that /// completed while the supervisor was gone +/// - a *clean* previous shutdown, where the daemon's disappearance was +/// deliberate even though no monitor recorded it (see +/// `supervisor_exited_uncleanly`)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/supervisor/mod.rs` around lines 1482 - 1508, Update the doc comment for unobserved_exit_status to document the additional Stopped case when supervisor_exited_uncleanly is false, regardless of boot alignment. Clarify that a clean prior supervisor shutdown represents an intentional stop and remains Stopped, matching callers such as adopt.rs that supply this condition.src/cli/supervisor/stop.rs (1)
28-32: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winSurface failures to clear the marker.
Both the read and the write are silently discarded. On Windows this is the only code path that clears the supervisor entry, so a parse error or a failed write leaves the marker behind and the next
supervisor startclassifies these intentionally stopped daemons asErrored(-1)— the exact behavior this PR sets out to prevent, with no trace in the logs.🔎 Log the failure paths
- if let Ok(mut sf) = StateFile::read(&*env::PITCHFORK_STATE_FILE) - && sf.daemons.remove(&DaemonId::pitchfork()).is_some() - { - let _ = sf.write(); - } + match StateFile::read(&*env::PITCHFORK_STATE_FILE) { + Ok(mut sf) => { + if sf.daemons.remove(&DaemonId::pitchfork()).is_some() + && let Err(e) = sf.write() + { + warn!("failed to clear supervisor state entry: {e}"); + } + } + Err(e) => warn!("failed to read state file to clear supervisor entry: {e}"), + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/cli/supervisor/stop.rs` around lines 28 - 32, Update the supervisor cleanup logic around StateFile::read and sf.write to log failures instead of silently discarding them. Preserve removal and writing on success, and include enough error context to identify whether reading or writing the state file failed so marker-clearance problems are visible.src/supervisor/adopt.rs (1)
213-228: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFunction rustdoc is now stale. The summary above (Lines 167-168) still promises
dead PID → mark Errored(-1)andrecycled PID → ... mark Errored(-1), but both paths now delegate tounobserved_exit_status, which returnsStoppedfor previous-boot records and for legacy records with noboot_time. Please update those two bullets to describe the boot-aware classification.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/supervisor/adopt.rs` around lines 213 - 228, Update the rustdoc bullets above the adoption logic to describe the boot-aware classification performed by unobserved_exit_status instead of promising Errored(-1) for dead or recycled PIDs. Document that previous-boot records and legacy records without boot_time are classified as Stopped, while preserving the existing descriptions for other outcomes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/supervisor/adopt.rs`:
- Around line 326-333: Restore an outcome parameter on finalize_if_pid so
deliberate terminations preserve the known-good marker: pass None from the two
unobserved_exit_status callers around the startup/adoption paths, and pass
Some(true) from the kill-policy termination caller around Line 301, matching
Supervisor::stop and the adopted-monitor behavior. Keep the existing
unobserved-exit handling and rustdoc aligned with this distinction.
In `@test/supervisor.bats`:
- Around line 328-333: Replace the fixed sleep after supervisor start in the
stop_marker test with the existing wait_for_status polling helper, waiting until
the marker reaches “stopped” before invoking pitchfork status. Preserve the
current success and partial-output assertions.
- Around line 346-364: Update the crafted state in the supervisor boot-time test
to include a [daemons."global/pitchfork"] marker with a dead PID, ensuring
supervisor_exited_uncleanly reports an unclean shutdown. Keep the prevboot/svc
record’s boot_time = 1 and assert startup succeeds because the previous-boot
comparison, rather than the clean-shutdown path, keeps the daemon stopped.
---
Nitpick comments:
In `@src/cli/supervisor/stop.rs`:
- Around line 28-32: Update the supervisor cleanup logic around StateFile::read
and sf.write to log failures instead of silently discarding them. Preserve
removal and writing on success, and include enough error context to identify
whether reading or writing the state file failed so marker-clearance problems
are visible.
In `@src/supervisor/adopt.rs`:
- Around line 213-228: Update the rustdoc bullets above the adoption logic to
describe the boot-aware classification performed by unobserved_exit_status
instead of promising Errored(-1) for dead or recycled PIDs. Document that
previous-boot records and legacy records without boot_time are classified as
Stopped, while preserving the existing descriptions for other outcomes.
In `@src/supervisor/mod.rs`:
- Around line 1482-1508: Update the doc comment for unobserved_exit_status to
document the additional Stopped case when supervisor_exited_uncleanly is false,
regardless of boot alignment. Clarify that a clean prior supervisor shutdown
represents an intentional stop and remains Stopped, matching callers such as
adopt.rs that supply this condition.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Central YAML (base), Organization UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: 90646c5f-f4f7-462c-9a0d-8558d1ce89eb
📒 Files selected for processing (7)
src/cli/supervisor/stop.rssrc/daemon.rssrc/procs.rssrc/supervisor/adopt.rssrc/supervisor/mod.rssrc/supervisor/state.rstest/supervisor.bats
Clearing last_exit_success was applied to every reset, including the orphan kill policy's own terminations. Those exits are not a mystery — we stopped the process ourselves — and reading as "no result yet" diverged from the convention Supervisor::stop already uses. An ExitObservation now names the two cases at each call site: Unobserved clears the field, while Terminated records Some(true) exactly as a deliberate stop does. Killing an orphan therefore no longer looks like a run whose outcome was lost. The previous-boot test was also passing for the wrong reason. Its crafted state file had no supervisor entry, so the unclean-shutdown check short-circuited to Stopped before boot_time was ever consulted — the test would have passed with the boot-time rule deleted. It now plants a leftover supervisor entry so the shutdown reads as unclean, leaving boot_time as the only reason the daemon stays stopped. Verified by disabling the rule and confirming the test fails. The clean-shutdown test polls for the expected status instead of sleeping for a fixed interval, since it is one of the few that runs on Windows, where startup and state persistence are slowest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewers have raised this field three times with a different preferred value each time, so the tradeoff belongs in the code rather than in a PR thread. Spells out why None is chosen over fabricating either outcome or keeping a stale one, and names the consequence: an unknown result satisfies both cron retrigger modes, so such a daemon fires once at its next scheduled time either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 🤖 New release * `pitchfork-cli`: 2.17.0 -> 2.18.0 <details><summary><i><b>Changelog</b></i></summary><p> <blockquote> ## [2.18.0](v2.17.0...v2.18.0) - 2026-07-25 ### Added - *(supervisor)* re-adopt orphaned daemons by default after a crash ([#633](#633)) - *(project)* add project enter/leave/list for IDE session management ([#619](#619)) - *(config)* add top-level env with tera template injection ([#618](#618)) - *(daemons)* add --global flag to daemons add ([#621](#621)) - full Windows support with CI ([#602](#602)) ### Fixed - *(logs)* tolerate concurrent opens of the log store ([#660](#660)) - *(supervisor)* make child-monitor exit finalization atomic ([#659](#659)) - *(supervisor)* make unobserved daemon deaths retryable after a crash ([#657](#657)) - *(deps)* update rust crate clx to v3 ([#656](#656)) - *(supervisor)* verify process identity before killing orphaned daemons ([#632](#632)) - *(web)* resolve spa assets from app root on nested routes ([#603](#603)) - *(tui)* hoist shared namespace into the daemons panel title ([#622](#622)) ### Other - *(procs)* use div_ceil for the process-exit poll count ([#658](#658)) - *(deps)* update rust crate tokio to v1.53.0 ([#653](#653)) - *(deps)* update rust crate uuid to v1.24.0 ([#651](#651)) - *(deps)* update rust crate http-body-util to v0.1.4 ([#643](#643)) - *(deps)* update rust crate rustls to v0.23.42 ([#646](#646)) - *(deps)* update rust crate syn to v2.0.119 ([#647](#647)) - *(deps)* update rust crate tokio to v1.52.4 ([#648](#648)) - *(deps)* update rust crate mdns-sd to v0.20.2 ([#644](#644)) - *(deps)* update rust crate regex to v1.13.1 ([#645](#645)) - *(deps)* update rust crate globset to v0.4.19 ([#642](#642)) - *(deps)* update rust crate clx to v2.1.1 ([#641](#641)) - *(deps)* update rust crate clap to v4.6.2 ([#640](#640)) - *(deps)* update rust crate xx to v2.6.1 ([#611](#611)) - *(deps)* update rust crate ratatui to v0.30.2 ([#609](#609)) - *(deps)* update rust crate rcgen to v0.14.8 ([#610](#610)) - raise MSRV to Rust 1.88 ([#624](#624)) </blockquote> </p></details> --- This PR was generated with [release-plz](https://github.com/release-plz/release-plz/). <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Version and documentation-only release PR with no runtime code changes in the diff. > > **Overview** > **Release v2.18.0** — bumps `pitchfork-cli` from **2.17.0** to **2.18.0** across `Cargo.toml`, `Cargo.lock`, `pitchfork.usage.kdl`, and generated CLI docs (`docs/cli/commands.json`, `docs/cli/index.md`). > > **CHANGELOG** gains a dated **[2.18.0]** section (2026-07-25) documenting what ships in this tag; the **[Unreleased]** section stays empty for the next cycle. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 74b9e9c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->

Context
Follow-up to #633, closing a divergence Bugbot flagged during its review (thread) that I deliberately deferred rather than fold into that PR.
Startup orphan cleanup and the runtime reconciler disagreed about the same situation. When a daemon's recorded PID is dead (or has been recycled), startup cleared the record to
stoppedwhile the interval reconciler marked iterrored(-1). Since startup runs first on every restart, the startup answer always won — so a daemon that died while its supervisor was crashed never became eligible for its configured retries, and stayed silentlystoppedinstead.Why not just mark it errored
Two hazards make the one-line version wrong, and both shape this change:
Neither fabricating nor keeping an exit result is safe.
CronRetrigger::SuccessandFailreadlast_exit_success(watchers.rs). Writingfalsefor an exit nobody observed would block a one-shot cron daemon that actually succeeded during the crash window from its nextretrigger = "success"run. But keeping the previous value is wrong too — it attributes an earlier run's outcome to this one. These transitions now clear the field:Nonerestores the "no result yet" reading both retriggers already handle.A reboot, and a deliberate stop, are both indistinguishable from a crash by PID alone. After a hard power-off every record is
runningwith a stale PID. And on Windows a deliberatepitchfork supervisor stopleaves the same footprint: the stop signal is delivered and handled on Unix, whereclose()stops each daemon, but Windows has no POSIX signals so the supervisor is force-terminated and never runs its shutdown path. A blanketerroredwould report intentionally stopped daemons as failures and auto-restart the ones withretryconfigured. (Windows CI caught this on the first revision.)So erroring a daemon requires two pieces of positive evidence, not an assumption:
--forcereplacement leaves it. Thesupervisor stopcommand now removes the entry itself on the platform where the supervisor cannot, so both behave identically — and a test pins that invariant, since it is exactly what the platforms disagreed about.start_timecan't answer this: fix(supervisor): verify process identity before killing orphaned daemons #632 replaced it with a platform-specific high-resolution token (Linux clock ticks since boot, macOS µs since epoch, Windows FILETIME), so it isn't comparable to a wall-clock boot time — and on Linux a token from a previous boot looks plausibly current. Hence a small, explicitly wall-clock companion field.Changes
Daemon.boot_time: system boot time (epoch seconds, viasysinfo::System::boot_time()) recorded alongside the PID at spawn.skip_serializing_ifso it's absent for daemons with no live process, and old state files simply lack it.unobserved_exit_status(status, recorded_boot_time, current_boot_time, supervisor_exited_uncleanly): one pure function deciding the terminal status for an exit nobody observed.Running+ same boot + unclean shutdown →Errored(-1)(retryable). Previous boot, or a clean shutdown →Stopped. Any other status, in practiceStopping→Stopped(an intentional stop that completed while the supervisor was gone). OnlyRunningandStoppingcan carry a PID, so that's the whole space.Stopped— we terminated that process ourselves, so its exit was observed and intentional.now - GetTickCount64()and can drift about a second between samples; Linux (/proc/statbtime) and macOS (kern.boottime) are exact. Kept deliberately tight so a genuine reboot can never fall inside it — a wider window would misread a short-lived previous boot (a device in a reboot loop) as the current one.boot_time(state written before this change) →Stopped, i.e. exactly today's behavior. Fail-closed, consistent with the identity handling fix(supervisor): verify process identity before killing orphaned daemons #632/feat(supervisor): re-adopt orphaned daemons by default after a crash #633 settled on.No hooks fire from these transitions, unchanged from before: we don't know the daemon failed, and firing
on_fail/on_exitfor events that happened while the supervisor was dead would be a larger semantic change. Retries still fireon_retrynormally throughcheck_retry.Tests
daemon that dies during a supervisor crash is retried on restart— the user-visible win: crash the supervisor, kill the daemon while nothing is watching, restart, and the daemon comes back on its own with a new PID.daemon record from a previous boot is not retried— a crafted record with an ancientboot_timestaysstoppedand is left alone despiteretry = 3.unobserved_exit_statuscovering same-boot, previous-boot, the jitter boundary, short-previous-boot gaps (5/30/59/60s), a missing field,Stopping, and clean shutdown.supervisor stop clears the supervisor record on every platform— pins the clean-shutdown invariant the two platforms disagreed about.stopped— its crafted record has noboot_time, which is now exactly the legacy fail-closed case; comment updated to say so.Compatibility
User-visible: after a supervisor crash, an affected daemon now reads
erroredrather thanstoppedinpitchfork list/status, and daemons withretryconfigured come back by themselves. That's the intended fix, but it's worth a changelog line. Downgrade is safe — an older binary ignores the unknownboot_timekey.🤖 Generated with Claude Code
Note
Medium Risk
Changes core supervisor recovery, daemon terminal status, retry eligibility, and cron
last_exit_successsemantics after crashes; behavior is heavily tested but affects restart paths on all platforms.Overview
Fixes a mismatch where startup orphan cleanup always cleared dead PIDs to
stoppedwhile the interval reconciler usederrored(-1)—startup ran first, so daemons that died during a supervisor crash never became retry-eligible.Daemon.boot_timeis recorded at spawn (epoch seconds viaPROCS.boot_time()).unobserved_exit_statuspicks the terminal status when nobody observed the exit:Running+ same boot + unclean supervisor shutdown →Errored(-1); previous boot, clean shutdown, missingboot_time, orStopping→Stopped. Startup orphan scan and runtime reconciler both use this logic.ExitObservationdriveslast_exit_success: unobserved exits clear it toNone(avoids lying to cronretrigger); supervisor-initiated orphan kills set success like a deliberate stop.supervisor stopremoves theglobal/pitchforkstate entry on Windows after force-kill so the next start does not treat a deliberate stop as a crash.Bats and unit tests cover crash→retry, previous-boot no-retry, clean stop marker, and boot-time jitter.
Reviewed by Cursor Bugbot for commit e66e910. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit