fix(pi): restore watcher successors independently of hung wake delivery - #274
Merged
Merged
Conversation
…ing handled A hung branch settlement was holding the restore lock, so a later worker completion could leave monitoring stopped. Split restore from serialized delivery so later actionable closes still start a successor. Co-authored-by: Cursor <cursoragent@cursor.com>
…monitoring failures
…rupting hung delivery
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Investigate the supervisor problems the captain has observed: workers finish or request validation but sit idle until the captain asks, and primary monitoring repeatedly stops after reporting a successful restart. The captain changed the supervisor model but does not know whether the model is responsible. Establish the actual cause or causes and recommend the smallest verified correction; do not assume model failure or treat another restart as a fix. The initial request authorized diagnosis only.
Captain's subsequent authorization: "yes lets fix the supervisor too". Implement the recommended smallest correction: monitoring must restart independently of slow or hung supervisor processing, so a second and subsequent worker notification cannot leave monitoring stopped. Preserve durable notifications and safe ownership across session replacement, and verify multiple consecutive cycles with deliberately hung processing. The diagnosis reproduced this mechanism using actual Pi extensions: monitoring restore was held while the supervisor was still processing a previous worker notification, so a later completion could leave monitoring stopped. The later model change was not its initiating cause. No live conversation discard or unrelated model-policy change is authorized.
What Changed
.pi/extensions/fm-primary-pi-watch.ts: the generation now tracksdelivering(serialized pending-wake pump in flight) separately fromrestoring, which holds the single in-flight restore promise. A later actionable close arriving while an earlier wake is still being delivered now restores a successor arm on a side path instead of returning early, so a second or subsequent worker notification can no longer leave monitoring stopped. Concurrent callers await the shared restore's real result, side-path typed failures and thrown persistence errors are surfaced immediately rather than waiting for the hung delivery to settle, and arm close/error handling plus away-standby re-arming now check both flags.tests/fm-pi-hung-delivery-herdr-e2e.test.sh, a live isolated-Herdr-lab guard that hangs branch settlement and requires further worker completions to keep a live successor with a fresh watcher beacon, and registered it inbin/fm-test-run.sh. Extendedtests/fm-pi-watch-extension.test.shwith held-failure, held-exception, slow-success, and slow-failure variants covering consecutive restores during hung delivery, session replacement, and wakes annotated with a shared restore failure.docs/watcher-continuity.md, and recorded the new live guard's command, scope limits, and 2026-09-11 Pi 0.85.1 run output indocs/verification/supervision.md.Risk Assessment
✅ Low: The restore/delivery split and the two fix rounds are narrowly scoped and I traced the shared-promise dedup, duplicate-restore, deferred-close, away-mode, and shutdown paths without finding a reachable wrong result; the only substantiated issue is a non-portable
statcall in a new opt-in test, and the intent's required behaviors are all present and covered by five portable modes plus the e2e guard.Testing
I derived the scenarios from the captain's authorization (monitoring must restart independently of slow or hung supervisor processing, across multiple consecutive cycles, preserving durable notifications and replacement-session ownership). Three of them were driven against a real Pi process running in an isolated non-default Herdr lab: the live run showed three consecutive worker completions during a hung branch settlement each restoring a live successor watcher (successor=started for every cycle, zero successor=none) with a fresh watcher beacon after an unattended interval, and reverting only the extension to the base commit makes that same live run fail at the second completion, which is the reported symptom. The four remaining scenarios - restore-retry exhaustion, restore-time persistence exception, and the two shared in-flight restore outcomes, plus the model-policy/conversation-discard absence check - were only exercised in the in-process harness or by diff inspection, never against the live product, so they are reported as untested with the missing capability named. There is no UI surface in this change, so live evidence is CLI transcripts and persisted watcher state rather than screenshots. I made one test-legibility edit: the five parameterized modes printed identical pass lines, so the mode name is now in the label.
FM_PI_HUNG_DELIVERY_HERDR_E2E=1 tests/fm-pi-hung-delivery-herdr-e2e.test.sh- real Pi in an isolated named Herdr lab; live-pi-watch-cycle-exits.log records successor=started for all three cycles and….last-watcher-beatage asserted within the unattended window, captured in live-pi-beacon.txtnot ok - second actionable close did not restore a successor while settlement hung(live-pi-hung-delivery-e2e-BASE-prefix.txt)node --unhandled-rejections=strict, not against the live product. A live result needs the e2e harness to corrupt the watcher s…Evidence: Live Pi + Herdr lab run (fixed code) - three restored successors during hung settlement
Evidence: Persisted watcher cycle-exit state from the live lab run
arm_pid=184908 watcher_pid=184938 reason=actionable-signal successor=started:186861 arm_pid=186844 watcher_pid=186861 reason=actionable-signal successor=started:190813 arm_pid=190788 watcher_pid=190813 reason=actionable-signal successor=started:193480Evidence: Live Pi + Herdr lab run against BASE commit - fails at the second completion
not ok - second actionable close did not restore a successor while settlement hungEvidence: Watcher beacon freshness after the unattended interval in the live lab
Evidence: Five hung-settlement modes against the real extension module (non-live harness)
ok - ... still hung (consecutive) ok - ... still hung (held-failure) ok - ... still hung (held-exception) ok - ... still hung (slow-success) ok - ... still hung (slow-failure)Evidence: Same portable scenario against BASE commit - monitoring left with zero live arms
Error: timeout waiting for second successor during hung settlement: live=0 rows=2Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
.pi/extensions/fm-primary-pi-watch.ts:815- The side-path restore is invoked asvoid restoreContinuity(owner, newest.predecessorArmPid)and its{ failure }result is discarded.restoreAfterActionableClosenever surfaces or schedules anything itself: on total failure it just returns the typed message (retries exhausted, or the earlybreakwhen the lock is read-only / no live session). Concrete sequence: wake A is delivered to the branch and the settlement promise never resolves (the exact hung case this fix targets); worker 2's arm closes actionably; the side path restores; that restore fails after retryLimit=5 backoff attempts (or because the lock moved).owner.childis null,owner.retryTimeris null,deferredCloseis null, and nosurfaceFailureruns - monitoring is stopped and silent, and the pump'sfinallythat would have appended the failure to the wake never runs because the delivery is hung. Before this change the same restoration failure always reached main as text appended to the wake. Smallest remedy: await the side-path result andsurfaceFailure(owner, failure)(or schedule the bounded retry) when it is non-empty..pi/extensions/fm-primary-pi-watch.ts:797-restoreContinuityreturns{ failure: "" }- indistinguishable from a verified successful restore, and with norecovery- whenever another restore is merely in flight. The serialized pump calls it at line 864 and consumes that value as truth. Concrete sequence: wake A's branch settlement hangs; worker 2's close starts a side restore whose successor is slow to report readiness (armReadyTimeoutMs plus up to 5 backoff retries, several seconds); the settlement then resolves, the pump loops to pending B, callsrestoreContinuity, seesrestoring === true, and gets{ failure: "" }. Two wrong results follow without any error: (a)restoration.recoveryis undefined, sodeliverActionableWakeskipsconfirmHandlingDeliveryWithRetry, andfm-watch-arm.sh --handling-deliverednever transitions the.watcher-downrecovery marker from downtime to handling for that generation; (b) if the concurrent restore ultimately fails, wake B is delivered withrepairFailed=falseand no failure annotation, so main is told monitoring is fine while it is stopped. Smallest remedy: store the in-flight restore promise on the generation and have a concurrent caller await and return that promise's real result instead of a synthetic empty success..pi/extensions/fm-primary-pi-watch.ts:36- Acknowledging the deliberate, documented boundary rather than requesting a change: while a settlement stays hung, (a) the queued later wakes are never delivered to anyone - the portable test assertssession.prompts.length === 0for cycles 2 and 3 - and (b) a non-actionable failure close of a restored successor only recordsowner.deferredClose, which is drained in the pump'sfinallyand therefore never while the delivery is hung. Both match the header comment's stated contract and the authorized scope (monitoring restart only), so no action here.🔧 Fix applied.
2 issues (1 warning, 1 info) still open:
.pi/extensions/fm-primary-pi-watch.ts:815- The new side path awaitsrestoreContinuityOUTSIDE the pump's try/catch, so a thrown error becomes an unhandled promise rejection instead of a typed monitoring failure. Every caller ofprocessPendingActionablesuses barevoid(lines 742, 791, 931, 1173, 1228) with no.catch, so under Node's default--unhandled-rejections=throwthe whole Pi host process dies. Concrete path: wake A's branch settlement hangs; worker 2's arm closes actionably; the side path callsrestoreContinuity->restoreAfterActionableClose->startArm->markLoaded()(:258-261), whosemkdirSync(state)/writeFileSync(marker)throw on EACCES/ENOSPC/read-only state dir - the same persistence-failure class the file already models elsewhere (seetest_pi_replacement_persistence_failure_stops_arm_child). On the pump path that throw is caught at :904-907 and surfaced aswatcher: FAILED - Pi extension could not deliver an actionable wake; on the side path nothing catches it. Note the shared promise makes this leak even when the pump also awaits it: the pump's catch handles its own awaiter, the side path's still rejects. Smallest remedy: wrap lines 813-818 in try/catch andsurfaceFailurethe detail, mirroring the pump's existing catch..pi/extensions/fm-primary-pi-watch.ts:816- Acknowledging a deliberate consequence of the two combined fixes rather than requesting a change: when the pump and the side path await the SAME failing restore, main receives the typed restoration failure twice - once standalone fromsurfaceFailureat :816 and once appended to the later wake at :868/:871. The newslow-failuretest mode asserts both messages, so this is the fixer's intended behavior and it satisfies the user's instruction to surface the failure without waiting on the hung settlement. Impact is bounded: main's remediation isfm_watch_arm_pi, whose second call returnswatcher: unchanged. No action.🔧 Fix applied.
1 info still open:
tests/fm-pi-hung-delivery-herdr-e2e.test.sh:102-beacon_age_scalls GNU-onlystat -c %Y "$beat"with nostat -f %mfallback. On macOS/BSD that command fails silently,mtimeis empty,$((now - mtime))is a bash arithmetic syntax error, and[ "" -le 13 ]errors, so the guard reports "watcher beacon went stale during the unattended interval (age=s)" while the beacon is actually fresh. Every other mtime reader in this repo uses the portable pair (tests/lib.sh:353, tests/fm-secondmate-reconcile.test.sh:950, tests/fm-omp-primary-live-e2e.test.sh:265, bin/fm-wake-lib.sh:95-97). Remedy:stat -c %Y "$beat" 2>/dev/null || stat -f %m "$beat" 2>/dev/null.✅ **Test** - passed
✅ No issues found.
FM_PI_HUNG_DELIVERY_HERDR_E2E=1 tests/fm-pi-hung-delivery-herdr-e2e.test.sh- real Pi in an isolated named Herdr lab; live-pi-watch-cycle-exits.log records successor=started for all three cycles and….last-watcher-beatage asserted within the unattended window, captured in live-pi-beacon.txtnot ok - second actionable close did not restore a successor while settlement hung(live-pi-hung-delivery-e2e-BASE-prefix.txt)node --unhandled-rejections=strict, not against the live product. A live result needs the e2e harness to corrupt the watcher s…FM_PI_HUNG_DELIVERY_HERDR_E2E=1 tests/fm-pi-hung-delivery-herdr-e2e.test.sh(real Pi 0.85.1 in an isolated named Herdr lab)Same live e2e with.pi/extensions/fm-primary-pi-watch.tsreverted to base 668b61f1 - reproduces the failuretests/fm-pi-watch-extension.test.sh(full file, 100+ cases including the 5 new hung-settlement modes)test_pi_hung_settlement_later_cycles_restore_successorin modesconsecutive,held-failure,held-exception,slow-success,slow-failureSame portable modeconsecutiveagainst the base extension - reproduceslive=0 rows=2Captured.watch-cycle-exits.logand.last-watcher-beatfrom the live lab run as product-state evidencegit diff 668b61f1..690534f6 -- .pi/extensions/fm-primary-pi-watch.tsfiltered for model/thinking/history-discard lines - none✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.