Skip to content

Fix pipeline stall after a stage's child exits (#22) - #23

Merged
ulmentflam merged 2 commits into
mainfrom
fix/issue-22-pipeline-stall
Jul 28, 2026
Merged

Fix pipeline stall after a stage's child exits (#22)#23
ulmentflam merged 2 commits into
mainfrom
fix/issue-22-pipeline-stall

Conversation

@ulmentflam

@ulmentflam ulmentflam commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Fixes #22.

Diagnosis

The monitor's exit edge was:

if not status.running and self.state.last_exit_code != status.exit_code:

last_exit_code persists across Monitor instances. A pipeline builds a fresh Monitor per stage over one shared state.json, so stage N's clean exit leaves last_exit_code: 0 on disk, and when stage N+1's child also exits 0 the comparison is 0 != 0 — false. The edge never fires, _handle_exit never runs, the stage never completes, and the loop spins forever over a dead child while the heartbeat keeps advancing.

That accounts for every symptom in the report: the log ending at Starting process with nothing after, last_exit_code: 0, zero children, a live heartbeat 18h past started_at, and the first stage advancing normally (fresh state → 0 != None → edge fires). The same trap caught any second autosentry run in a directory whose previous run exited 0, pipeline or not.

Reproduced, then fixed

Pre-fix, on a real three-stage pipeline of sh -c 'echo …; sleep 1':

children of pid: 0
{'started_at': '17:11:39', 'last_heartbeat': '17:16:00', 'last_exit_code': 0, 'child_running': False, 'stage': 'stage_three'}
[17:11:39] [INFO] autosentry starting — supervisor=local cmd=sh -c echo three; sleep 1
[17:11:39] [ACTION] Starting process: sh -c echo three; sleep 1
   <nothing, indefinitely>

It also confirms the reporter's note that SIGTERM marks the current stage complete and skips ahead — timeout 60 fired, stage_two was marked complete, and stage_three wedged the same way. Post-fix the same pipeline runs all three stages to completion and exits 0. The smoke script is kept at ~/Workspace/scripts/autosentry-issue-22-smoke.sh.

What changed

The stall (suggestion 1). The exit edge now keys on the child's started_at — every supervisor stamps a fresh one in start() — so it fires exactly once per child regardless of what a previous stage or run left behind. This also retires the last_exit_code = None hack in _restart_policy_fallback, so that field can go back to meaning the last exit code actually observed.

restart_always + clean exit — the same wedge by a different road. The default exit_code detector is non-zero-only, so a clean exit fired no detection, no healer ran, and the restart_policy fallback (which hangs off the detection path) never got a chance. restart_always now does the relaunch itself. It's audited in restart_history but doesn't spend the unverified-restart budget: that counter is the kill-switch for a healer that can't land a fix, and a service that exits 0 and comes back hasn't failed at anything.

Detectability (suggestion 2). A heartbeat only proves a thread is scheduling. state.json now carries child_running, child_started_at, and child_dead_since next to last_heartbeat, plus stage / stage_index / stage_count so the current stage is answerable from state alone (previously the only record was a reset_history entry for the advance into the stage, which reads identically whether the stage is running or wedged). autosentry probe exposes them and reduces the pair to one field:

$ autosentry probe | jq '.monitor | {pid_alive, stale, wedged, child_dead_seconds, stage}'
{"pid_alive": true, "stale": false, "wedged": true, "child_dead_seconds": 46821, "stage": "log124M_fineweb_fp8"}

pid_alive && !stale is exactly what a naive check calls healthy, so that's the case worth naming. probe exits 1 on it, so it drops into a cron liveness hook unchanged. Probe schema_version → 2. autosentry status and the TUI show child liveness beside the heartbeat.

Stage-level backstop (suggestion 3). monitor.dead_child_grace_seconds (default 900, 0 disables): past that long with no live child, the monitor logs, notifies, and exits nonzero. It's a backstop, not a normal path — every legitimate route out of a dead child resolves in seconds, and slow healer work blocks the loop rather than ticking through it. Any future variant of this failure becomes a loud pipeline failure instead of silent idle hours.

On the template comment: the shipped autosentry.yaml.tmpl does configure a stall detector and has no "hang coverage comes from an external log-mtime watchdog" note — that looks like local config text rather than something autosentry ships. The template now documents dead_child_grace_seconds so the in-box coverage is explicit either way.

Tests

17 new regression tests in tests/test_issue_22_pipeline_stall.py covering the edge with pre-seeded last_exit_code: 0, per-child edge semantics across a restart, the full run() loop returning instead of spinning, restart_always relaunch and budget accounting, the watchdog (trips, resets, disables), state round-tripping, stage context in and out of the pipeline, and probe reporting wedged vs healthy. Full suite: 348 passed, lint + format + pyrefly clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added pipeline stage details and child-process liveness to status displays and persisted state.
    • Enhanced autosentry probe with wedged-monitor detection, stage information, and schema version 2.
    • Added configurable dead-child grace period (900 seconds by default; set to 0 to disable).
    • Added documentation and quick-start guidance for monitoring supervisor health.
  • Bug Fixes

    • Prevented pipeline stalls after clean child exits.
    • Ensured restart_always reliably relaunches after clean exits.
    • Added watchdog handling for prolonged dead-child conditions, including notification and nonzero exit.

The monitor's exit edge was `state.last_exit_code != status.exit_code`,
compared against a field that persists across Monitor instances. A
pipeline builds a fresh Monitor per stage over one shared state.json, so
stage N's clean exit left `last_exit_code: 0` on disk and stage N+1's
clean exit compared equal: the edge never fired, `_handle_exit` never
ran, the stage never completed, and the supervisor looped forever over a
dead child while the heartbeat kept advancing. Every liveness check read
healthy throughout. The same trap caught any second `autosentry run` in a
directory whose previous run exited 0.

The edge now keys on the child's `started_at`, which every supervisor
stamps fresh in `start()`, so it fires once per child regardless of what
a previous stage or run left behind. Reproduced pre-fix against a real
three-stage pipeline (dead child, live heartbeat, log ending at "Starting
process"); all three stages complete after.

Two follow-ons in the same failure class:

- `lifecycle: restart_always` now relaunches after a clean exit. The
  default exit_code detector is non-zero-only, so a clean exit fired no
  detection, no healer ran, and the restart_policy fallback (which hangs
  off the detection path) never got a chance - the same silent wedge by a
  different road. The relaunch is audited but doesn't spend the
  unverified-restart budget; a service that exits 0 and comes back hasn't
  failed at anything.
- `monitor.dead_child_grace_seconds` (default 900) stops a supervisor
  that has had no child for that long, converting any future variant from
  silent idle hours into a loud nonzero exit.

A heartbeat only proves a thread is scheduling, so state.json now records
`child_running` / `child_started_at` / `child_dead_since` beside it, plus
`stage` / `stage_index` / `stage_count`. `autosentry probe` reduces the
pair to `monitor.wedged` - pid alive, heartbeat fresh, no child - and
exits 1 on it, so it drops into a cron liveness hook unchanged. Probe
schema_version is now 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The supervisor now detects child exits by child identity, relaunches clean exits under restart_always, tracks child and pipeline-stage liveness, terminates prolonged dead-child states, and exposes wedge diagnostics through state, status, TUI, and autosentry probe.

Changes

Supervisor liveness and recovery

Layer / File(s) Summary
Persisted liveness and configuration contracts
src/autosentry/state.py, src/autosentry/config.py, src/autosentry/templates/autosentry.yaml.tmpl
State now stores child and stage metadata, restart accounting distinguishes unverified restarts, and monitor.dead_child_grace_seconds defaults to 900 seconds with 0 disabling the watchdog.
Child exit detection and watchdog lifecycle
src/autosentry/monitor.py, tests/test_issue_22_pipeline_stall.py, tests/test_issue_5_lifecycle_and_state_save.py, tests/test_restart_budget.py
Exit handling keys transitions by child started_at, persists liveness, detects prolonged dead children, and explicitly relaunches clean exits under restart_always.
Stage context propagation and cleanup
src/autosentry/pipeline.py, tests/test_pipeline.py, tests/test_issue_22_pipeline_stall.py
Pipeline stages receive name and position metadata, while stage markers are cleared after failure or successful completion.
Health reporting and documentation
src/autosentry/cli/commands/probe.py, src/autosentry/cli/commands/status.py, src/autosentry/tui.py, README.md, CHANGELOG.md, tests/test_issue_22_pipeline_stall.py
Probe schema version 2 reports child, stage, and wedged fields; status and TUI display liveness; documentation describes probe-based wedge detection and the new grace setting.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Supervisor
  participant Monitor
  participant StateStore
  participant Probe
  Supervisor->>Monitor: report child status
  Monitor->>StateStore: save child and stage liveness
  Monitor->>Monitor: detect exit edge or watchdog timeout
  Monitor->>Supervisor: restart child or stop monitor
  Probe->>StateStore: load persisted state
  Probe-->>Probe: derive wedged status
Loading

Possibly related PRs

  • ulmentflam/autosentry#10: Both changes use the supervised child’s started_at identity in monitor restart-related logic.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: fixing the pipeline stall when a stage child exits.
Linked Issues check ✅ Passed The changes address issue #22 by fixing exit detection, exposing child/stage liveness, and adding a dead-child grace timeout.
Out of Scope Changes check ✅ Passed The diff stays focused on the pipeline-stall fix, related monitoring/state updates, docs, and regression tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-22-pipeline-stall

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/autosentry/monitor.py (1)

549-566: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exhausted budget from unrelated restarts blocks restart_always's clean-exit relaunch.

_restart_after_clean_exit is only reached if the pre-existing budget_exhausted check passes. state.restarts can already be at/past max_restarts from earlier rule/healer-driven restarts on the action-apply path (_fire_detection, which increments via record_restart(unverified=True) without an immediate exhaustion check), yet the monitor doesn't necessarily stop at that point. When the child later exits cleanly, this check fires first and gives up instead of relaunching — even though clean-exit relaunches are documented to never spend this budget. This defeats restart_always's "keep restarting" contract for a scenario the current tests don't exercise (they only run relaunches with state.restarts staying at 0).

🔧 Proposed fix: check the always-relaunch path before the budget gate
-        if budget_exhausted(self.state.restarts, self.state.max_restarts):
-            log().error(f"max restarts ({self.state.max_restarts}) reached — giving up")
-            self._notify(
-                "exit",
-                "max restarts reached",
-                f"giving up after {self.state.restarts} restart(s)",
-            )
-            self._record_vault_exhaustion(detector=None)
-            return False
-        if lifecycle == "restart_always" and exit_code == 0:
+        if lifecycle == "restart_always" and exit_code == 0:
+            # Clean-exit relaunches never spend the unverified budget, so
+            # they must not be gated by it either — otherwise an earlier,
+            # unrelated run of failed restarts can permanently wedge a
+            # lifecycle that promises to always come back.
             return self._restart_after_clean_exit(exit_code)
+        if budget_exhausted(self.state.restarts, self.state.max_restarts):
+            log().error(f"max restarts ({self.state.max_restarts}) reached — giving up")
+            self._notify(
+                "exit",
+                "max restarts reached",
+                f"giving up after {self.state.restarts} restart(s)",
+            )
+            self._record_vault_exhaustion(detector=None)
+            return False
🤖 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/autosentry/monitor.py` around lines 549 - 566, Move the `restart_always`
clean-exit branch in the monitor’s lifecycle handling before the
`budget_exhausted` guard, so `_restart_after_clean_exit(exit_code)` is invoked
for `exit_code == 0` without consuming or being blocked by the restart budget.
Preserve the existing budget exhaustion notification and return behavior for all
other lifecycle and exit-code paths.
🤖 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 `@README.md`:
- Around line 277-279: Update the README cron example around autosentry probe so
it restarts the supervisor only when monitor liveness is unhealthy, not for
every nonzero probe exit. Use a liveness-only probe mode if available; otherwise
parse the probe’s monitor liveness fields and preserve pending-incident exit
statuses without restarting.

In `@src/autosentry/cli/commands/probe.py`:
- Around line 174-184: Update the wedge threshold logic near the wedged
calculation to preserve an explicit cfg.monitor.dead_child_grace_seconds value
of 0 as disabled rather than replacing it with stall_threshold. Ensure the probe
cannot report wedged from this backstop when the configured grace period is
zero, while retaining the configured positive threshold and existing fallback
behavior for an unset value.
- Around line 20-26: Update the probe output example containing last_exit_code,
child_running, wedged, stage, stage_index, and stage_count so it remains valid
JSON by removing the inline # annotations; preserve the field values and move
explanations outside the JSON sample or explicitly label the sample as JSONC.

---

Outside diff comments:
In `@src/autosentry/monitor.py`:
- Around line 549-566: Move the `restart_always` clean-exit branch in the
monitor’s lifecycle handling before the `budget_exhausted` guard, so
`_restart_after_clean_exit(exit_code)` is invoked for `exit_code == 0` without
consuming or being blocked by the restart budget. Preserve the existing budget
exhaustion notification and return behavior for all other lifecycle and
exit-code paths.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 03318895-626f-42f3-a937-433378c212d1

📥 Commits

Reviewing files that changed from the base of the PR and between b864b4d and cf4181b.

📒 Files selected for processing (14)
  • CHANGELOG.md
  • README.md
  • src/autosentry/cli/commands/probe.py
  • src/autosentry/cli/commands/status.py
  • src/autosentry/config.py
  • src/autosentry/monitor.py
  • src/autosentry/pipeline.py
  • src/autosentry/state.py
  • src/autosentry/templates/autosentry.yaml.tmpl
  • src/autosentry/tui.py
  • tests/test_issue_22_pipeline_stall.py
  • tests/test_issue_5_lifecycle_and_state_save.py
  • tests/test_pipeline.py
  • tests/test_restart_budget.py

Comment thread README.md
Comment on lines +277 to +279
```bash
*/5 * * * * cd /path/to/run && autosentry probe -q >/dev/null || systemctl restart autosentry
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not restart on every nonzero probe exit.

autosentry probe exits 1 for pending incidents as well as DOWN/STALE/WEDGED monitors. This cron entry will restart a healthy supervisor whenever a normal incident is waiting, potentially interrupting recovery or causing restart loops. Parse the monitor liveness fields or add a liveness-only probe mode before restarting.

🤖 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 `@README.md` around lines 277 - 279, Update the README cron example around
autosentry probe so it restarts the supervisor only when monitor liveness is
unhealthy, not for every nonzero probe exit. Use a liveness-only probe mode if
available; otherwise parse the probe’s monitor liveness fields and preserve
pending-incident exit statuses without restarting.

Comment on lines +20 to +26
"last_exit_code": null,
"child_running": true, # is there actually a child under it?
"child_dead_seconds": null, # how long there hasn't been one
"wedged": false, # heartbeating fine, supervising nothing
"stage": "pretrain", # pipeline position, null off-pipeline
"stage_index": 1,
"stage_count": 3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the probe example valid JSON.

The # annotations make this block invalid JSON even though it is presented as probe JSON output. Move the explanations outside the sample or label it as JSONC.

🤖 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/autosentry/cli/commands/probe.py` around lines 20 - 26, Update the probe
output example containing last_exit_code, child_running, wedged, stage,
stage_index, and stage_count so it remains valid JSON by removing the inline #
annotations; preserve the field values and move explanations outside the JSON
sample or explicitly label the sample as JSONC.

Comment on lines +174 to +184
# The monitor's own backstop threshold when set; otherwise fall back to
# the stall threshold so the probe still reports something useful for
# users who disabled the backstop.
wedge_threshold = float(cfg.monitor.dead_child_grace_seconds or stall_threshold)
wedged = (
pid_alive
and not stale
and not child_running
and child_dead_seconds is not None
and child_dead_seconds > wedge_threshold
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Honor the documented dead_child_grace_seconds: 0 disablement.

cfg.monitor.dead_child_grace_seconds or stall_threshold converts an explicit 0 into the stall timeout, so autosentry probe can still report wedged and exit nonzero. This contradicts the configuration reference, which says 0 disables the backstop; either preserve that semantics here or document the probe as intentionally independent.

🤖 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/autosentry/cli/commands/probe.py` around lines 174 - 184, Update the
wedge threshold logic near the wedged calculation to preserve an explicit
cfg.monitor.dead_child_grace_seconds value of 0 as disabled rather than
replacing it with stall_threshold. Ensure the probe cannot report wedged from
this backstop when the configured grace period is zero, while retaining the
configured positive threshold and existing fallback behavior for an unset value.

@ulmentflam
ulmentflam merged commit d40765e into main Jul 28, 2026
12 checks passed
@ulmentflam
ulmentflam deleted the fix/issue-22-pipeline-stall branch July 28, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pipeline stalls indefinitely after a stage's child exits — supervisor stays alive and heartbeating with zero children

1 participant