feat(supervisor): re-adopt orphaned daemons by default after a crash - #633
Conversation
📝 WalkthroughWalkthroughSupervisor orphan handling now verifies process identity and applies an ChangesOrphan reconciliation
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Supervisor
participant OrphanedDaemon
participant StateStore
participant LifecycleHooks
Supervisor->>StateStore: Find running daemon without monitor
Supervisor->>OrphanedDaemon: Verify PID and start time
Supervisor->>OrphanedDaemon: Adopt and poll liveness
OrphanedDaemon-->>Supervisor: Terminate or fail identity check
Supervisor->>StateStore: Record stopped or errored state
Supervisor->>LifecycleHooks: Run applicable lifecycle hooks
Possibly related PRs
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 |
Greptile SummaryRe-adopts verified orphaned daemons by default after supervisor crashes.
Confidence Score: 5/5The PR appears safe to merge. No blocking failures remain in the fixes associated with the previous ownership and successor-state threads. Important Files Changed
Reviews (8): Last reviewed commit: "fix(supervisor): register monitors befor..." | Re-trigger Greptile |
When the supervisor dies uncleanly, its daemons survive re-parented to init. Previously the restarted supervisor killed them (or, before the identity fix, could leave them running unsupervised). Now, daemons whose recorded identity (pid + kernel start time) matches a live process are re-adopted by default: their state, resolved ports, and proxy routing stay intact and supervision resumes. An adopted process is no longer a child of the supervisor, so wait() monitoring is impossible; a poll monitor takes over, anchored to the verified start time so a recycled PID is never mistaken for the daemon. It mirrors the regular monitor's exit path (stop -> stopped with on_stop/on_exit, unexpected death -> errored with unknown exit code, feeding the existing retry machinery — a retried daemon respawns as a normal child). Its stdout/stderr capture cannot be restored; log capture resumes on the daemon's next restart. - new setting supervisor.orphan_policy: "adopt" (default) or "kill" (pre-adoption behavior); cleanup_orphans=false still skips orphan handling entirely - adoption requires the same fail-closed identity verification as termination: unverifiable identity retains running state untouched - legacy title-matched records get their start_time persisted at adoption so future scans have a durable identity - new monitored-daemon registry so the interval watcher can reconcile state-running daemons that lost their monitor, applying the same fail-closed checks and orphan policy as the startup scan: dead or recycled pids are marked errored(-1), live verified ones are adopted or terminated per policy - the poll monitor re-verifies record ownership before mutating state so a stale monitor can never clobber a successor process's state, and it fires on_stop/on_exit even when stop() finalizes the record first - registry entries are added synchronously before monitor spawn with restart-overlap-safe RAII removal, so supervised daemons never appear unmonitored - bats coverage: adoption across crash/restart (same pid, no duplicate, stop works), adopted-death detection, stop hooks on adopted daemons, and the kill policy pinned via PITCHFORK_ORPHAN_POLICY=kill Closes the re-adoption request from #631. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ef6d050 to
f93d8a1
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
docs/public/schema.json (1)
1084-1090: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winConstrain
orphan_policyto supported values.The runtime only accepts
adoptandkill; schema validation currently accepts any string and tooling can validate a config that silently falls back toadopt.Proposed schema constraint
"orphan_policy": { "description": "What to do with live orphaned daemons on supervisor startup: adopt or kill", "type": [ "string", "null" - ] + ], + "enum": ["adopt", "kill", null] }🤖 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 `@docs/public/schema.json` around lines 1084 - 1090, Constrain the orphan_policy property in the schema to the supported values “adopt” and “kill” by adding an enum constraint while preserving its nullable behavior.
🤖 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/mod.rs`:
- Around line 1349-1356: Update the supervisor decision flow around
process_identity_matches() so adopt and kill actions require a persisted
daemon.start_time that matches the current process start time; title-only legacy
records must retain their running state and receive neither action. Apply the
same start-time requirement in the reconciliation logic in adopt.rs, using its
existing identity and state-handling symbols.
---
Nitpick comments:
In `@docs/public/schema.json`:
- Around line 1084-1090: Constrain the orphan_policy property in the schema to
the supported values “adopt” and “kill” by adding an enum constraint while
preserving its nullable behavior.
🪄 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: 21b355e5-ee10-4d97-9537-96b72eb2d5f2
📒 Files selected for processing (6)
docs/public/schema.jsonsettings.tomlsrc/supervisor/adopt.rssrc/supervisor/lifecycle.rssrc/supervisor/mod.rstest/supervisor.bats
Review follow-ups on the re-adoption feature: - preserve active_port when persisting identity for legacy title-matched records at adoption; upsert_daemon intentionally never inherits it, so the previous upsert wiped a live daemon's port and broke proxy routing until restart - gate all exit-path state writes strictly on still owning the record's PID; a successor that reached Stopping/Stopped before the recheck was previously mutated (pid cleared, active_port dropped) and could get duplicate stop hooks - snapshot stop-in-flight status at the moment death is observed (the poll-monitor analog of the child monitor's pre-drain snapshot) so stop intent is tied to the observation rather than later state reads Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ted exits A successor's complete start+stop between two poll ticks leaves the record PID-less and stopped, which the previous ownership check misattributed to the adopted process — firing its stop hooks a second time. The monitored registry already acts as a generation marker: run_once overwrites the daemon's entry synchronously before any successor spawns, and each monitor's guard removes only its own value. The poll monitor now stands down whenever the entry no longer names its PID, covering successors that already finished. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… check Re-validate registry and PID ownership inside the same state-lock critical section that clears the active port and writes the terminal status, so a successor starting between the earlier ownership snapshot and the mutation can never have its record overwritten — its upsert serializes after ours and the recheck stands down. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
adopt_daemon registered its poll monitor after an await (the legacy identity upsert), so two overlapping reconciliation passes could both pass the advisory is_monitored check and spawn duplicate monitors for the same process. Registration is now a synchronous check-and-insert (try_register) performed before any await: adoption never displaces a live monitor, while run_once keeps its unconditional overwrite so a spawned successor still supersedes a stale adopted monitor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| ); | ||
|
|
||
| let policy = orphan_policy(); | ||
|
|
There was a problem hiding this comment.
Startup dead PID skips retries
Medium Severity
After a supervisor crash, startup orphan cleanup clears a still-running record whose PID is already dead to stopped, while the new interval reconciliation marks the same situation errored(-1). Because cleanup runs first on every restart, an adopted daemon that exits before the poll monitor persists state never becomes errored, so configured retries and fail hooks may not run.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 98df44c. Configure here.
There was a problem hiding this comment.
The divergence is real but deliberate, and I'd keep it in this PR:
- Startup's dead-PID →
stoppedis merged fix(supervisor): verify process identity before killing orphaned daemons #632 behavior (and predates it) — changing it here would silently alter semantics jdx already shipped, and the merged test suite pins it (orphaned daemons are cleaned up on supervisor restartassertsstopped). - The two paths answer different questions. At startup, a dead PID means "this daemon did not survive the supervisor outage" — historically treated as a clean slate. At runtime, the reconciler finding a dead unmonitored PID means "a supervised daemon died and nobody recorded it" —
errored(-1)is what feeds retries there. - The scenario where this bites (adopted daemon dies, then the supervisor crashes again within the poll monitor's 2s window, so the death is only ever seen by the next startup scan) is a narrow double-crash race, and its outcome —
stopped, no retry — matches what every pre-adoption version did for all crash-window deaths.
That said, I think there's a fair argument that startup should also mark previously-running dead daemons errored so retries fire after a supervisor crash — that would be a behavior change best made deliberately (and tested) in a follow-up rather than folded into this one. Happy to file it.
This comment was generated by Claude Code.
Two successor-safety refinements to orphan adoption: - registry entries now carry a unique registration token in addition to the PID. Guards unregister only their own token and monitors detect supersession by token, so a successor that recycled the same numeric PID can neither be unregistered by a stale guard nor be mistaken for the stale monitor's own process - the reconciler's terminal-state writes (unmonitored death, recycled PID, kill-policy success) re-validate that the record still names the snapshot PID inside the same state-lock critical section that performs the write, matching the poll monitor's atomic finalization; a concurrently installed successor is never overwritten Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 0f4bf18. Configure here.
run_once persisted the Running state (with PID) before registering the monitor, leaving a cross-thread window in which the orphan reconciler saw a freshly started daemon as an unmonitored orphan — spuriously adopting it, or terminating it under the kill policy. Registration now happens before the upsert, so any visible running record always has a live monitor entry; the RAII guard still unregisters on early-error paths. finalize_if_pid additionally re-checks the registry inside its state-lock critical section: with registration-before-visibility guaranteed, a successor that recycled the snapshot's numeric PID is detected by its monitor entry and never overwritten. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The supervisor held the read end of every daemon's output pipe, which put it in the data path. Killing it left the pipe with no reader, so the daemon's next write took SIGPIPE and the daemon usually died with it — undermining the re-adoption added in jdx#633, since a daemon that logs to stdout rarely survived long enough to be adopted. Anything written but not yet read was lost as well. Capture now happens in a sibling process: `pitchfork log-sink`, this binary re-executed, holding the read end and writing to the log store. A supervisor crash is invisible to logging — the daemon keeps writing, the sink keeps recording, and no line is dropped. This is the arrangement runit uses, where runsv starts a service alongside its own log service and stays out of the stream. The supervisor retains a spare read end, which does two things: a sink that dies cannot leave the pipe readerless and kill the daemon, and its replacement can be handed the same pipe. It deliberately keeps no write end, which would stop the sink from ever seeing end of file. A sink that exits while its daemon is still monitored is replaced, mirroring runsv restarting a log service; one that exits cleanly has reached end of file and is not. Failed starts report what the daemon printed by querying the log store, which the in-process path guaranteed by flushing synchronously before signalling. That ordering is restored by waiting for the sink's final write, bounded so a daemon whose descendants hold the pipe open cannot stall the caller. Daemons using ready_output, an on_output hook, or pty = true keep the in-process path, because the supervisor itself has to read the stream to serve them, and so remain vulnerable to a supervisor crash. Teaching the sink to evaluate those and report matches back is left for a follow-up. 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
Implements the re-adoption half of #631 (rebased onto main now that #632 is merged): the reporter asked that a restarted supervisor either re-adopt orphaned daemons or stop them before considering them startable. #632 made the kill path safe; this PR makes re-adoption the default, built on its fail-closed identity machinery.
Behavior
On supervisor startup, for each state daemon whose recorded PID is alive and whose identity (PID + kernel start time) is verified:
orphan_policy = "adopt"(new default): the process keeps running and supervision resumes — state, resolved ports, and proxy routing stay intact. Since the process is no longer a child of the supervisor,child.wait()monitoring is impossible; a poll monitor (2s) takes over, anchored to the verified start time so a later PID recycle is never mistaken for the daemon. It mirrors the regular monitor's exit path:stoptransitions tostoppedand fireson_stop/on_exit(even whenstop()finalizes the record before the poll monitor wakes); unexpected death markserroredwith unknown exit code (exit codes of non-children are unobservable), feeding the existing retry machinery — a retried daemon respawns as a normal child again. Legacy title-matched records get theirstart_timepersisted at adoption.orphan_policy = "kill": pre-adoption behavior — the pinned, fail-closed termination from fix(supervisor): verify process identity before killing orphaned daemons #632.cleanup_orphans = falsestill skips orphan handling entirely (legacy escape hatch; docs updated to point atorphan_policy).Interval-watcher reconciliation
A new
monitoredregistry (daemon → PID under a live monitor task) lets the 10s interval watcher catch state-runningdaemons that nothing is watching, applying the same fail-closed checks and orphan policy as the startup scan: dead or recycled PIDs are markederrored(-1)(previously a stalerunningrecord could persist forever when startup cleanup was skipped), live verified ones are adopted or terminated per policy. Registration happens synchronously before monitor spawn, with restart-overlap-safe RAII removal, so supervised daemons never appear unmonitored. The poll monitor re-verifies record ownership before mutating state, so a stale monitor can never clobber a successor process's state.Known limitation (follow-up planned)
Log capture cannot be restored for an adopted process — its stdout/stderr pipes died with the old supervisor. Output capture resumes on the daemon's next restart. A follow-up will explore re-attaching via
/proc/<pid>/fdon Linux (reopening the surviving pipe inode recovers capture and up to 64KB of gap output), and possibly FIFO-based capture for a cross-platform fix; connected-socket approaches don't work here since a broken stream fd can't be re-pointed at a new listener.Tests
crashed supervisor re-adopts running daemon by default— same PID survives crash+restart, stillrunning,startrefuses a duplicate,stopterminates the adopted process and completes the transitionadopted daemon death is detected and marked errored— external kill of the adopted process is caught by the poll monitorstopping an adopted daemon fires on_stop and on_exit hooks— regression for the stop-hooks race found in reviewPITCHFORK_ORPHAN_POLICY=kill; the recycled-PID bystander test is policy-independent and unchangedReview feedback from Bugbot (stop hooks, kill policy on the interval path) and Greptile (successor-state clobbering, P1) is addressed in the current revision.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Note
High Risk
Changes core supervisor lifecycle around process identity, concurrent monitors, and startup/interval reconciliation; mistakes could kill wrong processes, duplicate daemons, or corrupt state/hooks.
Overview
After an unclean supervisor exit, verified live daemon processes are reconciled instead of always being killed. New
supervisor.orphan_policydefaults toadopt: keep the process, preserve state/ports/proxy routing, and resume supervision via a 2s poll monitor (no restored stdio; unknown exit codes →erroredand retries).killpreserves the prior terminate-on-restart behavior.A
monitoredregistry with tokenizedMonitoredGuardregistration (beforeRunningstate is written on normal starts) distinguishes supervised daemons from orphans. The interval watcher runs the same fail-closed identity checks and policy for unmonitoredrunningrecords (dead/recycled →errored(-1)).Docs/schema add
orphan_policy;cleanup_orphansis described as reconciliation. Bats cover default adoption, adopted death/hooks, and pin the old kill test withPITCHFORK_ORPHAN_POLICY=kill.Reviewed by Cursor Bugbot for commit 4fa3618. Bugbot is set up for automated code reviews on this repo. Configure here.