Skip to content

fix(bin): repair process-event shutdown and tighten guard timing - #4009

Merged
kunchenguid merged 19 commits into
kunchenguid:mainfrom
mremond:fix/procevent-stop-escalation-and-retire-deadlock
Sep 9, 2026
Merged

fix(bin): repair process-event shutdown and tighten guard timing#4009
kunchenguid merged 19 commits into
kunchenguid:mainfrom
mremond:fix/procevent-stop-escalation-and-retire-deadlock

Conversation

@mremond

@mremond mremond commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Intent

ORIGINAL GOAL, in the captain's own words, from the ticket that opened this work:

The 2026-09-08 orphaned processes reveal TWO defects beyond the test leak already routed, and the second has production consequences. Established in the main home after stopping the 191 processes.

WHAT IS CERTAIN

  1. The only reaper of processes left by a worker is bin/fm-teardown.sh (around lines 1934 and 1995, with a forced path). NOTHING cleans up while a task's working copy lives. Our task copies live for days. The leak is therefore UNBOUNDED IN TIME by construction, not merely badly cleaned up. Proof: the 191 processes came from three LIVING task copies - never torn down, therefore never reaped - and the oldest dated from 2026-08-21.

WHAT IS STRONGLY SUPPORTED BUT NOT PROVEN, and this is the point to measure first
2. 96 of the 191 ignored the ordinary stop signal and required a forced kill. Yet bin/fm-procevent-lavish.sh DOES install a signal trap that cleans up and re-raises (around line 280). The most likely explanation is classic shell behaviour: a signal received while a child runs in the foreground is only handled AFTER that child returns. The child here being precisely a blocked poll, the trap cannot fire. IF THAT IS IT, the consequence goes beyond tests: the process-event contract asserts that an identified surviving group is STOPPED before being replaced. That stop uses the same ordinary signal. It could therefore fail or hang exactly when it is needed - during an ownership recovery after a crash.

TO ESTABLISH, in this order: (a) confirm or refute the deferred-signal mechanism by REPRODUCTION, not by reading; (b) if confirmed, does the process-event contract's stop path actually keep its promise, answered by measurement on a genuinely blocked child, not on a healthy one; (c) is a reaper needed somewhere OTHER than teardown - at session start, or in the supervision loop - to bound the leak in time.

NEIGHBOUR: fm-tests-leak-poll-processes handles the leak on the test side. This ticket does not replace it and does not depend on it: even if the tests stop leaking, a child that cannot be stopped remains a production problem.

That investigation was carried out and produced this branch and pull request 4009, which repaired the runner's stop escalation and a retirement deadlock.

LATER GOAL, supplied by the captain after that request was open, and the reason for THIS change:

"We have to make greptile happy so that it can be merged."

The automated reviewer on request 4009 does not merely record a finding: it states the request MUST NOT BE MERGED until the point is resolved, so the finding is blocking delivery rather than costing a badge. The captain asked for the request to become mergeable. He did NOT ask for the runner guard's two-consecutive-check debounce to be removed, and never said he was giving up that protection. Those are two different things and must not be traded for one another.

THE REVIEWER'S BLOCKING FINDING, quoted so this intent stands alone: "Watchdog deadline remains too loose. When the owner disappears immediately after a successful check, the watchdog waits for two subsequent failed checks before starting a stop that can consume another four seconds, leaving the listener alive beyond the required lease-plus-one-tick deadline. The revised regression also codifies this looser bound and permits twice its duration."

WHAT WAS DONE AND WHY, so a reviewer reading only the diff does not mistake a deliberate choice for an oversight:

  • TIGHTENED, and it is the first thing to understand about this change: the SPACING of the guard's two reads, not their number. The owner watchdog now sleeps HALF the configured check interval and still requires two consecutive failing reads, so the pair completes inside one check interval instead of costing two. Worst-case detection falls from the lease plus TWO check intervals to the lease plus ONE.

  • PRESERVED, and deliberately so: the second read. bin/fm-procevent.sh's two-consecutive-miss rule is unchanged. It is what stops a single unreadable read from killing a live runner, and it is a protection rather than padding. The reviewer wanted a single read because that was the only way it could see to reach an acceptable bound; the bound was reached another way and the protection kept.

  • The stop's own grace - two seconds for the ordinary signal, then two for the forced one, in stop_runner_pid - was deliberately LEFT ALONE. It is a ceiling paid only by a group that outlives the signal it was sent, not a delay every stop pays; a healthy runner's whole retire measures 0.40 to 0.66 seconds on this host. The reviewer's literal "lease plus one tick" is unreachable by any implementation, because signalling a process and giving it any chance at all to exit takes non-zero time. Detection now meets it; the stop then runs inside its own ceiling, and that limit is stated in the contract rather than glossed over.

  • Measurement drove the design rather than following it, and is recorded in docs/verification/process-event-sources.md: reaping an orphaned listener took 5.9 to 6.1 seconds with the tightened spacing against 7.7 to 8.1 seconds before, at a 2-second lease and 4-second check, sampling the phase between the guard's check clock and the lease clock across eight runs per variant.

  • A previously unstated term of the bound surfaced while measuring: the lease age is compared in whole seconds, so a configured lease of N is honoured until that age reads N+1. It is now part of the documented bound and of the regression's derivation instead of being absorbed into a fudge factor.

  • Both properties are now guarded by their own case, and each was proven by MUTATION rather than asserted. Putting a whole interval back between the two reads turns the bound case red. Making the guard act on one failed read turns the new debounce case red - and under that mutation the bound case passes FASTER, 9.9 seconds against 13.1. The unsafe variant is the quicker one, which is exactly why these are two separate cases: a single elapsed-time case would have registered the removal of the protection as an improvement.

  • The bound regression PINS the phase between the guard's check clock and the lease clock instead of sampling it, because with a sampled phase a guard spending two intervals passes about half the time on a lucky alignment. Its deadline is DERIVED from the documented bound, and its load slack is additive and deliberately under half a check interval so an extra whole interval cannot hide inside it. The two flat deadlines that were there before (40 and 20 seconds) and the doubling allowance on the derived one are gone; that looseness was the reviewer's third complaint.

  • The per-script duration hint in bin/fm-test-run.sh was deliberately NOT updated even though the two new cases add about 19 seconds of wall clock. docs/fm-test-portable-shards.md says those hints are replaced wholesale from CI timing artifacts of green runs, and a separate open request already does exactly that; a hand-edited estimate here would collide with it and would silently repack the shards. This suite runs in portable serial shard 3, which was green in the last CI run.

WHAT THIS DELIBERATELY DOES NOT CLAIM: satisfying the reviewer is necessary but not sufficient. On the previous head, Behavior portable serial 1 and Behavior portable serial 4 were both cancelled at the job ceiling, so the head was not green for reasons independent of this finding.

HOW THIS MUST READ, and it is part of what was asked rather than presentation: as a THIRD WAY, neither a capitulation to the reviewer nor a refusal of it. The grievance was the LOOSENESS OF THE BOUND, not the number of observations; a single read was simply the only route the reviewer could see to an acceptable bound. Another route existed, the bound was reached, and both reads were kept. A reader must be able to see both halves - what was tightened, and what was PRESERVED and why the two-read debounce protects: the guard's inputs are a lease read and a state-root identity read, either of which can fail transiently on a live healthy home, so acting on the first failure would let one isolated unreadable read kill a live service.

WRITTEN IN ADVANCE, BEFORE THIS HEAD'S INTEGRATION RUNS REPORT: on the previous head, the lanes "Behavior portable serial 1" and "Behavior portable serial 4" were both CANCELLED at the job ceiling, independently of the reviewer's finding. A new head triggers fresh runs, so those two lanes MAY complete this time. IF THEY DO, THAT IS NOT EVIDENCE THE CEILING DEFECT IS FIXED - it is one more sample of a lane that has been cut repeatedly and sometimes is not, and the shard-packing repair for it is open separately. This caveat is recorded before the run precisely so that a lucky pass cannot be reread afterwards as a resolution.

A NOTE ON THIS ATTEMPT, recorded rather than explained afterwards: a previous run of this same work failed at its test step on a pre-existing 24-contender concurrency fixture that failed IDENTICALLY on the base revision under a host load average of 14 to 18, which its own analyzer recorded as not change-specific. That failure also discarded the pipeline's own fix commit with no recoverable reference; the three repairs it had made were reconstructed and then re-proved independently by mutation rather than relayed, and they are what the second commit on this branch carries. This attempt is driven deliberately under a sustained host load of about 13, after a declared 90-minute bounded wait during which the load plateaued rather than descending, because an unbounded wait for a condition with no evidence it will arrive becomes permanent.

TWO THINGS THAT MUST APPEAR IN THE DESCRIPTION, because they live outside any one commit message and would otherwise be lost:

FIRST, A REGRESSION THIS DELIVERY INTRODUCED, NAMED AS OURS RATHER THAN QUIETLY REPAIRED. The half-interval arithmetic added here read a zero-prefixed check interval as octal: FM_PROCEVENT_OWNER_CHECK_SECONDS=010 halved to 4 instead of 5, and 08 was not a number at all, so the owner guard died before reporting ready and, because the runner fails closed when its guard cannot initialise, the listener never started. The validator accepts both values and [ compares them as decimal, so this broke a configuration that worked before this change. It was introduced by this delivery, found in review, and repaired before publication by forcing base ten before the arithmetic. It is stated plainly because a description that lists only the defects fixed in other people's code while silently swallowing one of its own is an understatement fault.

SECOND, THE PROOFS, WHICH ARE MUTATIONS RUN AGAINST THIS CODE RATHER THAN ASSERTIONS ABOUT IT. Six, each an actual run:

  • remove the decimal normalisation -> the interval case fails on 08 with "a zero-prefixed decimal interval (08) prevented the listener from starting";
  • put a full interval back between the guard's two reads -> "the guard exceeded its bound: group still running 17.1s after the last owner activity, against a documented bound of 15s";
  • the same full interval WITH runner startup forced to about 2.5 seconds, which is exactly the condition an assumed-phase test could not survive -> still red, same message;
  • that same 2.5-second startup with the correct guard, as the control -> still passes, 13.0s against the 15s bound, so the delay alone does not break the case;
  • make the phase evidence unavailable -> the case REFUSES with "could not establish the required pre-expiry guard-read phase" rather than passing, even though the group stopped quickly;
  • act on a single failed read -> the debounce case fails AND the bound case passes FASTER, 9.5s against 12.7s. The unsafe variant being the quicker one is precisely why the bound and the debounce are asserted by two separate cases and never one.

What Changed

  • Preserve ownership proof through the same stop’s TERM-to-KILL escalation so TERM-resistant children are reaped after their leader exits. Make runner exit cleanup acquire the source lock without waiting, removing retirement’s circular wait. Groups whose leaders died independently remain refused; that cleanup gap remains unresolved.
  • Halve watchdog read spacing while preserving two consecutive failures: one transient unreadable lease or state-root identity read still cannot kill a live service. Document the nominal detection bound as the lease plus whole-second rounding plus one check interval, followed by the unchanged 2s TERM/2s KILL ceiling. Repair a regression introduced by this delivery: octal arithmetic halved 010 to 4 and made 08 prevent listener startup; normalize accepted intervals to decimal before arithmetic.
  • Replace loose deadlines with derived bounds, observed guard-read phase requirements, and additive slack below half an interval; add separate debounce, decimal-interval, and shutdown regressions. The author’s six recorded mutation/control runs establish: removing decimal normalization fails listener startup on 08; restoring full-interval spacing breaches the documented 15s bound at 17.1s; that mutation still fails with approximately 2.5s startup delay; the correct guard with that delay passes at 13.0s; unavailable phase evidence fails with “could not establish the required pre-expiry guard-read phase”; and stopping after one failed read fails debounce while passing the timing case faster, at 9.5s versus 12.7s. Separate cases therefore protect both the tighter bound and the retained debounce.

Risk Assessment

✅ Low: The runtime changes are narrowly scoped and preserve ownership checks, two-read debounce, and escalation limits; no material defects or intent contradictions were found.

Testing

Targeted lifetime tests and live CLI scenarios passed after correcting two driver setup races. Baseline and mutation runs reproduced the intended failures, including phase and late-observation refusals; transcripts were retained. No full suite, lint, or other pipeline phase ran.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Signal a shell blocked on a foreground child and observe deferred trap handling ✅ pass live live-cli-initial.log: TERM trap remained deferred until the blocked foreground tail exited.
Retire a healthy source using TERM without the retirement deadlock ✅ pass live live-cli-initial.log: attached start exited 143; retirement took 0.648s.
Retire a genuinely blocked source after TERM ends its proved leader ✅ pass live live-cli-initial.log: tail survived TERM after its runner disappeared; escalation extinguished the group.
Leave a detached source without owner activity while keeping another home active ✅ pass live live-cli-initial.log: orphan reaped 5.622s after lease refresh while the independently refreshed home survived; targeted-lifetime.log: pinned bound passed at 13.6s against 15s.
Let owner activity expire while a child resists TERM and observe complete cleanup ✅ pass live live-cli.log: owner expiry triggered TERM then KILL; blocked group disappeared after 11.529s, within the deadline including additive scheduling slack.
Interrupt one owner read without killing service, then repeat the failure to trigger shutdown ✅ pass live live-cli-initial.log: actual lease and state-root interruptions each preserved service for one failed read; persistent lease absence stopped the group after two failures.
Start listeners with zero-prefixed decimal and odd intervals using exact half spacing ✅ pass live live-cli-initial.log: real watchdog children executed sleep 4, 5, 0.5, and 1.5 for configured intervals 08, 010, 1, and 3.
Reconcile an unregistered blocked source and subsequently register a replacement ✅ pass live live-cli.log: reconcile reported stopped=1 uncertain=0; the replacement started after the blocked old group was gone.
Crash a leader independently and verify retirement refuses to signal its unproved group ✅ pass live live-cli.log: retirement refused with cannot confirm runner identity, preserving both registration and surviving group.
Evidence: Consolidated observations, mutation proofs, and cleanup

Source: Consolidated observations, mutation proofs, and cleanup

Process-event live validation
Target: 77e799275d351c954a2a8f25e9ce124db0bfaa32
Tree: 70dbae1532a345d3adbdcd949e621e0be14b0b0e

Live surface: the unchanged production fm-procevent.sh public CLI, with isolated FM_HOME and claim storage, running /usr/bin/tail -f as an actual blocking source. No Lavish server or model harness is claimed. Real filesystem interruptions exercise the lease and state-root debounce without substituting dependencies.

Deferred TERM trap with a genuinely blocked foreground tail
Shell deferred its TERM trap until the genuinely blocked tail child was killed; TERM trap handled

Retire healthy source without forced-kill deadlock
Healthy tail source and group exited on TERM; attached start=143, retirement=0.648s

Retire blocked source after TERM ends its proved leader
TERM ended runner while blocked tail survived; KILL then removed full group in 5.300s

Accepted decimal and odd intervals run with exact half spacing
08 -> 4s; 010 -> 5s; 1 -> 0.5s; 3 -> 1.5s; every listener started and retired

Isolated unreadable owner inputs preserve service; repeated failure stops it
Single missing lease and single unavailable state-root read each preserved the listener; two consecutive missing lease reads stopped it

Expired detached source stops while another live home survives
Orphan stopped 5.622s after lease refresh; independently refreshed home remained alive

Unproved cold leaderless group is refused without signalling
Unrelated leader death refused retirement, preserved registration, and left the unproved group untouched; test owner then cleaned it up

Expired blocked source is reaped by its owner guard
Owner expiry triggered TERM then KILL; blocked child and group gone after 11.529s

Reconcile stops blocked unregistered source before re-registration
Reconcile stopped blocked unregistered group in 5.153s; later re-registration started one new generation after old group extinction

Targeted existing lifetime cases (fault-injection evidence, separate from live checks):
guard phase: fresh read 6.656-6.692s, expiry 8s, two full intervals could not finish before 18.656s (deadline 17s)
guard bound: lease=7s check=6s reaped 13.6s after the last owner activity, documented bound 15s
decimal interval: 08 halves to 4s and its listener started
decimal interval: 010 halves to 5s and its listener started
ordinary stop: start status=143 retirement=749ms sampled windows=5086/4907ms

Counterfactual and control runs:
RUN base-healthy
{"name": "base-healthy", "exit": 1, "expected": "RED", "matched": true, "elapsed": 18.6, "evidence": "base-healthy.log"}
not ok - the runner did not exit on TERM (start status=137, retirement=5453ms, sampled windows=5029/4984ms)
RUN no-decimal
{"name": "no-decimal", "exit": 1, "expected": "RED", "matched": true, "elapsed": 26.41, "evidence": "no-decimal.log"}
not ok - a zero-prefixed decimal interval (08) prevented the listener from starting
RUN full-interval
{"name": "full-interval", "exit": 1, "expected": "RED", "matched": true, "elapsed": 21.65, "evidence": "full-interval.log"}
not ok - the guard exceeded its bound: group still running 17.3s after the last owner activity, against a documented bound of 15s (lease term 8s + one 6s check interval + 1s stop)
RUN full-interval-delayed
{"name": "full-interval-delayed", "exit": 1, "expected": "RED", "matched": true, "elapsed": 24.74, "evidence": "full-interval-delayed.log"}
not ok - the guard exceeded its bound: group still running 17.2s after the last owner activity, against a documented bound of 15s (lease term 8s + one 6s check interval + 1s stop)
RUN correct-delayed
{"name": "correct-delayed", "exit": 0, "expected": "GREEN", "matched": true, "elapsed": 20.1, "evidence": "correct-delayed.log"}
guard phase: fresh read 6.619-6.636s, expiry 8s, two full intervals could not finish before 18.619s (deadline 17s)
guard bound: lease=7s check=6s reaped 13.8s after the last owner activity, documented bound 15s
RUN missing-phase
{"name": "missing-phase", "exit": 1, "expected": "RED", "matched": true, "elapsed": 16.82, "evidence": "missing-phase.log"}
not ok - the bound fixture could not establish the required pre-expiry guard-read phase
RUN single-read-debounce
{"name": "single-read-debounce", "exit": 1, "expected": "RED", "matched": true, "elapsed": 7.3, "evidence": "single-read-debounce.log"}
not ok - one unreadable lease read ended a runner whose home was still alive
RUN single-read-bound
{"name": "single-read-bound", "exit": 0, "expected": "GREEN", "matched": true, "elapsed": 15.48, "evidence": "single-read-bound.log"}
guard phase: fresh read 7.620-7.722s, expiry 8s, two full intervals could not finish before 19.620s (deadline 17s)
guard bound: lease=7s check=6s reaped 12.0s after the last owner activity, documented bound 15s

Driver setup corrections: the first cold-leaderless run verified refusal but its cleanup probe encountered transient EPERM; the retry exposed a missing wait for the tail child. The driver now treats EPERM conservatively as still alive and requires an observed running tail before crashing its leader. The final rerun passed and found no test-home processes. These were evidence-driver setup faults, not production failures. Original transcripts are retained.

Full repository tests, lint, formatting, static analysis, PR/CI and pipeline-control phases were not run.

Late-observation adversary (expected RED, observed exit 1):
not ok - the guard's completion was first observed 18.6s after the last owner activity, beyond its 17s deadline

Cleanup: no processes reference the isolated test directory; temporary driver homes and mutant source copies removed; worktree clean; target commit and tree unchanged.
Evidence: Initial live CLI transcript, including corrected driver setup issues

Source: Initial live CLI transcript, including corrected driver setup issues


SCENARIO: Deferred TERM trap with a genuinely blocked foreground tail
TERM sent to shell pid=84421; blocked tail pid=84938; after 0.5s both alive, trap has not run
OBSERVED: Shell deferred its TERM trap until the genuinely blocked tail child was killed; TERM trap handled

SCENARIO: Retire healthy source without forced-kill deadlock
$ FM_HOME=healthy fm-procevent.sh register lavish live-healthy -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-healthy (lavish) [exit 0]
Before retire
(88431, 88418, 88431, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-healthy')
(89242, 1, 89242, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _owner-watchdog live-healthy 88431 Wed Sep  9 23:37:25 2026     bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-healthy ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/healthy/state/procevent/.owner-guard-ready.stCaK3 16777234 54002600')
(89751, 88431, 88431, 'S', '/usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
(89837, 88431, 88431, 'S', 'perl -e \\012      use strict;\\012      use warnings;\\012      my $limit = shift;\\012      my ($written, $truncated) = (0, 0);\\012      while (1) {\\012        my $count = sysread(STDIN, my $buffer, 65536);\\012        exit 2 unless defined $count;\\012        last if $count == 0;\\012        my $take = $written < $limit ? $limit - $written : 0;\\012        $take = $count if $take > $count;\\012        if ($take > 0) {\\012          my $offset = 0;\\012          while ($offset < $take) {\\012            my $count_written = syswrite(STDOUT, $buffer, $take - $offset, $offset);\\012            exit 2 unless defined $count_written;\\012            $offset += $count_written;\\012          }\\012          $written += $take;\\012        }\\012        $truncated = 1 if $take < $count;\\012      }\\012      exit($truncated ? 3 : 0);\\012     1048576')
$ FM_HOME=healthy fm-procevent.sh retire live-healthy
retired: live-healthy [exit 0]
attached start exit=143; retire=0.648s
OBSERVED: Healthy tail source and group exited on TERM; attached start=143, retirement=0.648s

SCENARIO: Retire blocked source after TERM ends its proved leader
$ FM_HOME=blocked-retire fm-procevent.sh register lavish live-blocked -- ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/deferred-poll.sh ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/blocked-retire/poll ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-blocked (lavish) [exit 0]
Blocked foreground child before retirement
(91692, 91688, 91692, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-blocked')
(92293, 1, 92293, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _owner-watchdog live-blocked 91692 Wed Sep  9 23:37:27 2026     bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-blocked ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/blocked-retire/state/procevent/.owner-guard-ready.HwSTwM 16777234 54002685')
(92886, 91692, 91692, 'S', '/bin/bash ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/deferred-poll.sh ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/blocked-retire/poll ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
(92893, 92886, 91692, 'S', '/usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
(92972, 91692, 91692, 'S', 'perl -e \\012      use strict;\\012      use warnings;\\012      my $limit = shift;\\012      my ($written, $truncated) = (0, 0);\\012      while (1) {\\012        my $count = sysread(STDIN, my $buffer, 65536);\\012        exit 2 unless defined $count;\\012        last if $count == 0;\\012        my $take = $written < $limit ? $limit - $written : 0;\\012        $take = $count if $take > $count;\\012        if ($take > 0) {\\012          my $offset = 0;\\012          while ($offset < $take) {\\012            my $count_written = syswrite(STDOUT, $buffer, $take - $offset, $offset);\\012            exit 2 unless defined $count_written;\\012            $offset += $count_written;\\012          }\\012          $written += $take;\\012        }\\012        $truncated = 1 if $take < $count;\\012      }\\012      exit($truncated ? 3 : 0);\\012     1048576')
Runner gone, real tail still survives TERM before escalation
(92293, 1, 92293, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _owner-watchdog live-blocked 91692 Wed Sep  9 23:37:27 2026     bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-blocked ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/blocked-retire/state/procevent/.owner-guard-ready.HwSTwM 16777234 54002685')
(92886, 1, 91692, 'S', '/bin/bash ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/deferred-poll.sh ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/blocked-retire/poll ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
(92893, 92886, 91692, 'S', '/usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
retired: live-blocked [exit 0, 5.300s]
OBSERVED: TERM ended runner while blocked tail survived; KILL then removed full group in 5.300s

SCENARIO: Accepted decimal and odd intervals run with exact half spacing
$ FM_HOME=interval-08 fm-procevent.sh register lavish live-interval-08 -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-interval-08 (lavish) [exit 0]
$ FM_HOME=interval-08 fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
configured=08; real guard child: (475, 288, 288, 'S', 'sleep 4')
$ FM_HOME=interval-08 fm-procevent.sh retire live-interval-08
retired: live-interval-08 [exit 0]
$ FM_HOME=interval-010 fm-procevent.sh register lavish live-interval-010 -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-interval-010 (lavish) [exit 0]
$ FM_HOME=interval-010 fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
configured=010; real guard child: (2702, 2447, 2447, 'S', 'sleep 5')
$ FM_HOME=interval-010 fm-procevent.sh retire live-interval-010
retired: live-interval-010 [exit 0]
$ FM_HOME=interval-1 fm-procevent.sh register lavish live-interval-1 -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-interval-1 (lavish) [exit 0]
$ FM_HOME=interval-1 fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
configured=1; real guard child: (5324, 5127, 5127, 'S', 'sleep 0.5')
$ FM_HOME=interval-1 fm-procevent.sh retire live-interval-1
retired: live-interval-1 [exit 0]
$ FM_HOME=interval-3 fm-procevent.sh register lavish live-interval-3 -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-interval-3 (lavish) [exit 0]
$ FM_HOME=interval-3 fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
configured=3; real guard child: (8492, 8045, 8045, 'S', 'sleep 1.5')
$ FM_HOME=interval-3 fm-procevent.sh retire live-interval-3
retired: live-interval-3 [exit 0]
OBSERVED: 08 -> 4s; 010 -> 5s; 1 -> 0.5s; 3 -> 1.5s; every listener started and retired

SCENARIO: Isolated unreadable owner inputs preserve service; repeated failure stops it
$ FM_HOME=debounce fm-procevent.sh register lavish live-debounce -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-debounce (lavish) [exit 0]
$ FM_HOME=debounce fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
lease unavailable during guard sleep pid=11760
guard completed the failed read and started next sleep pid=14676; runner remains alive
lease restored; two more read cycles completed; group 10960 alive
state root unavailable during guard sleep pid=26636
guard completed the failed read and started next sleep pid=31958; runner remains alive
state root restored; two more read cycles completed; group 10960 alive
Persistent missing lease: group stopped after 4.213s; alive after first failed read
$ FM_HOME=debounce fm-procevent.sh retire live-debounce
retired: live-debounce [exit 0]
OBSERVED: Single missing lease and single unavailable state-root read each preserved the listener; two consecutive missing lease reads stopped it

SCENARIO: Expired detached source stops while another live home survives
$ FM_HOME=keep-live fm-procevent.sh register lavish live-keeper -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-keeper (lavish) [exit 0]
$ FM_HOME=expiry fm-procevent.sh register lavish live-expiry -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-expiry (lavish) [exit 0]
$ FM_HOME=expiry fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
Detached expiring source
(44862, 1, 44862, 'R', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-expiry')
(45225, 1, 45225, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _owner-watchdog live-expiry 44862 Wed Sep  9 23:38:04 2026     bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-expiry ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/expiry/state/procevent/.owner-guard-ready.wHz72C 16777234 54003272')
(45699, 44862, 44862, 'R', '(bash)')
(45721, 45699, 44862, 'Z', '<defunct>')
Last-owner-activity to observed extinction=5.622s; documented healthy bound=2+1+4+1=8s; deadline with additive slack=10s
$ FM_HOME=keep-live fm-procevent.sh retire live-keeper
retired: live-keeper [exit 0]
attached start exit=143; retire=0.679s
$ FM_HOME=expiry fm-procevent.sh retire live-expiry
retired: live-expiry [exit 0]
OBSERVED: Orphan stopped 5.622s after lease refresh; independently refreshed home remained alive

SCENARIO: Unproved cold leaderless group is refused without signalling
$ FM_HOME=cold-leaderless fm-procevent.sh register lavish live-cold -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-cold (lavish) [exit 0]
$ FM_HOME=cold-leaderless fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
$ FM_HOME=cold-leaderless fm-procevent.sh retire live-cold
error: cannot confirm runner identity; source remains registered: live-cold [exit 1]
Unproved surviving group untouched after retirement refusal
(52640, 1, 52640, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _owner-watchdog live-cold 52110 Wed Sep  9 23:38:11 2026     bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-cold ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/cold-leaderless/state/procevent/.owner-guard-ready.iLxkTq 16777234 54003359')
(52993, 1, 52110, 'S', '/usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
Traceback (most recent call last):
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 75, in case
    detail=fn();results.append(dict(name=name,result='pass',live=True,detail=detail));log('OBSERVED: '+str(detail))
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 178, in refusal
    os.killpg(pid,signal.SIGKILL);wait_for(lambda:not group_alive(pid))
                                  ~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 14, in wait_for
    value=fn()
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 178, in <lambda>
    os.killpg(pid,signal.SIGKILL);wait_for(lambda:not group_alive(pid))
                                                      ~~~~~~~~~~~^^^^^
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 22, in group_alive
    try:os.killpg(pid,0);return True
        ~~~~~~~~~^^^^^^^
PermissionError: [Errno 1] Operation not permitted
Remaining processes referencing the isolated test home: []
Traceback (most recent call last):
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 189, in <module>
    case('Unproved cold leaderless group is refused without signalling',refusal)
    ~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 75, in case
    detail=fn();results.append(dict(name=name,result='pass',live=True,detail=detail));log('OBSERVED: '+str(detail))
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 178, in refusal
    os.killpg(pid,signal.SIGKILL);wait_for(lambda:not group_alive(pid))
                                  ~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 14, in wait_for
    value=fn()
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 178, in <lambda>
    os.killpg(pid,signal.SIGKILL);wait_for(lambda:not group_alive(pid))
                                                      ~~~~~~~~~~~^^^^^
  File "~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py", line 22, in group_alive
    try:os.killpg(pid,0);return True
        ~~~~~~~~~^^^^^^^
PermissionError: [Errno 1] Operation not permitted
Evidence: Additional live checks and successful refusal rerun

Source: Additional live checks and successful refusal rerun


SCENARIO: Expired blocked source is reaped by its owner guard
$ FM_HOME=blocked-expiry fm-procevent.sh register lavish live-blocked-expiry -- ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/deferred-poll.sh ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/blocked-expiry/poll ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-blocked-expiry (lavish) [exit 0]
$ FM_HOME=blocked-expiry fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
Expired-owner test with genuinely blocked foreground child
(46757, 1, 46757, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-blocked-expiry')
(48539, 1, 48539, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _owner-watchdog live-blocked-expiry 46757 Wed Sep  9 23:40:43 2026     bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-blocked-expiry ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/blocked-expiry/state/procevent/.owner-guard-ready.t3iOGy 16777234 54005936')
(50279, 46757, 46757, 'S', '/bin/bash ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/deferred-poll.sh ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/blocked-expiry/poll ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
(50376, 46757, 46757, 'S', 'perl -e \\012      use strict;\\012      use warnings;\\012      my $limit = shift;\\012      my ($written, $truncated) = (0, 0);\\012      while (1) {\\012        my $count = sysread(STDIN, my $buffer, 65536);\\012        exit 2 unless defined $count;\\012        last if $count == 0;\\012        my $take = $written < $limit ? $limit - $written : 0;\\012        $take = $count if $take > $count;\\012        if ($take > 0) {\\012          my $offset = 0;\\012          while ($offset < $take) {\\012            my $count_written = syswrite(STDOUT, $buffer, $take - $offset, $offset);\\012            exit 2 unless defined $count_written;\\012            $offset += $count_written;\\012          }\\012          $written += $take;\\012        }\\012        $truncated = 1 if $take < $count;\\012      }\\012      exit($truncated ? 3 : 0);\\012     1048576')
(50436, 50279, 46757, 'S', '/usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
Guard TERM ended runner 46757, blocked tail 50436 still alive
Blocked orphan group gone 11.529s after last owner activity; bound=2+1+4+4=11s plus 2s additive slack
$ FM_HOME=blocked-expiry fm-procevent.sh retire live-blocked-expiry
retired: live-blocked-expiry [exit 0]
OBSERVED: Owner expiry triggered TERM then KILL; blocked child and group gone after 11.529s

SCENARIO: Reconcile stops blocked unregistered source before re-registration
$ FM_HOME=reconcile-stop fm-procevent.sh register lavish live-reconcile -- ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/deferred-poll.sh ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/reconcile-stop/poll ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-reconcile (lavish) [exit 0]
$ FM_HOME=reconcile-stop fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
$ FM_HOME=reconcile-stop fm-procevent.sh reconcile
reconciled: published=0 started=0 stopped=1 uncertain=0 [exit 0]
$ FM_HOME=reconcile-stop fm-procevent.sh register lavish live-reconcile -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-reconcile (lavish) [exit 0]
$ FM_HOME=reconcile-stop fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
$ FM_HOME=reconcile-stop fm-procevent.sh retire live-reconcile
retired: live-reconcile [exit 0]
OBSERVED: Reconcile stopped blocked unregistered group in 5.153s; later re-registration started one new generation after old group extinction

SCENARIO: Unproved cold leaderless group is refused without signalling
$ FM_HOME=cold-leaderless fm-procevent.sh register lavish live-cold -- /usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log
registered: live-cold (lavish) [exit 0]
$ FM_HOME=cold-leaderless fm-procevent.sh reconcile
reconciled: published=0 started=1 stopped=0 uncertain=0 [exit 0]
$ FM_HOME=cold-leaderless fm-procevent.sh retire live-cold
error: cannot confirm runner identity; source remains registered: live-cold [exit 1]
Unproved surviving group untouched after retirement refusal
(74625, 1, 74625, 'S', 'bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _owner-watchdog live-cold 74251 Wed Sep  9 23:41:05 2026     bash ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/bin/fm-procevent.sh _start live-cold ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/cold-leaderless/state/procevent/.owner-guard-ready.4O2ZP4 16777234 54006467')
(75203, 1, 74251, 'S', '/usr/bin/tail -f ~/.no-mistakes/worktrees/acf4a767348a/01M2414ECRE7T77CAXN49RSA2F/.test-phase-tmp/live-cli/follow.log')
(75363, 1, 74251, 'S', 'perl -e \\012      use strict;\\012      use warnings;\\012      my $limit = shift;\\012      my ($written, $truncated) = (0, 0);\\012      while (1) {\\012        my $count = sysread(STDIN, my $buffer, 65536);\\012        exit 2 unless defined $count;\\012        last if $count == 0;\\012        my $take = $written < $limit ? $limit - $written : 0;\\012        $take = $count if $take > $count;\\012        if ($take > 0) {\\012          my $offset = 0;\\012          while ($offset < $take) {\\012            my $count_written = syswrite(STDOUT, $buffer, $take - $offset, $offset);\\012            exit 2 unless defined $count_written;\\012            $offset += $count_written;\\012          }\\012          $written += $take;\\012        }\\012        $truncated = 1 if $take < $count;\\012      }\\012      exit($truncated ? 3 : 0);\\012     1048576')
$ FM_HOME=cold-leaderless fm-procevent.sh retire live-cold
retired: live-cold [exit 0]
OBSERVED: Unrelated leader death refused retirement, preserved registration, and left the unproved group untouched; test owner then cleaned it up
Remaining processes referencing the isolated test home: []
Evidence: Targeted lifetime regressions

Source: Targeted lifetime regressions

ok - a detached listener starts reparented, with a live descendant tree under it
ok - a listener whose owning session is gone stops itself and its whole process group
ok - reaping the listener stops the process churn under it
ok - an identical listener in a home whose session is still there is untouched
ok - retiring a source reaps its reparented listener and every descendant under it
ok - a stop the guard cannot prove is retried until the expired runner is reaped
ok - retirement escalates after TERM leaves a surviving child (absent leader)
ok - retirement escalates after TERM leaves a surviving child (zombie leader)
ok - an expired runner's guard escalates past a signal-proof child
guard phase: fresh read 6.656-6.692s, expiry 8s, two full intervals could not finish before 18.656s (deadline 17s)
guard bound: lease=7s check=6s reaped 13.6s after the last owner activity, documented bound 15s
ok - an orphaned runner is reaped within the lease plus ONE check interval
decimal interval: 08 halves to 4s and its listener started
decimal interval: 010 halves to 5s and its listener started
ok - a zero-prefixed decimal interval starts its listener and halves as decimal
ok - one unreadable read does not end a live runner
ordinary stop: start status=143 retirement=749ms sampled windows=5086/4907ms
ok - a runner exits on the ordinary stop signal instead of outliving it
ok - a group whose leader died to something else is still refused, not signalled

targeted procevent lifetime cases passed
Evidence: Baseline, mutations, and controls

Source: Baseline, mutations, and controls

RUN base-healthy
{"name": "base-healthy", "exit": 1, "expected": "RED", "matched": true, "elapsed": 18.6, "evidence": "base-healthy.log"}
not ok - the runner did not exit on TERM (start status=137, retirement=5453ms, sampled windows=5029/4984ms)
RUN no-decimal
{"name": "no-decimal", "exit": 1, "expected": "RED", "matched": true, "elapsed": 26.41, "evidence": "no-decimal.log"}
not ok - a zero-prefixed decimal interval (08) prevented the listener from starting
RUN full-interval
{"name": "full-interval", "exit": 1, "expected": "RED", "matched": true, "elapsed": 21.65, "evidence": "full-interval.log"}
not ok - the guard exceeded its bound: group still running 17.3s after the last owner activity, against a documented bound of 15s (lease term 8s + one 6s check interval + 1s stop)
RUN full-interval-delayed
{"name": "full-interval-delayed", "exit": 1, "expected": "RED", "matched": true, "elapsed": 24.74, "evidence": "full-interval-delayed.log"}
not ok - the guard exceeded its bound: group still running 17.2s after the last owner activity, against a documented bound of 15s (lease term 8s + one 6s check interval + 1s stop)
RUN correct-delayed
{"name": "correct-delayed", "exit": 0, "expected": "GREEN", "matched": true, "elapsed": 20.1, "evidence": "correct-delayed.log"}
guard phase: fresh read 6.619-6.636s, expiry 8s, two full intervals could not finish before 18.619s (deadline 17s)
guard bound: lease=7s check=6s reaped 13.8s after the last owner activity, documented bound 15s
RUN missing-phase
{"name": "missing-phase", "exit": 1, "expected": "RED", "matched": true, "elapsed": 16.82, "evidence": "missing-phase.log"}
not ok - the bound fixture could not establish the required pre-expiry guard-read phase
RUN single-read-debounce
{"name": "single-read-debounce", "exit": 1, "expected": "RED", "matched": true, "elapsed": 7.3, "evidence": "single-read-debounce.log"}
not ok - one unreadable lease read ended a runner whose home was still alive
RUN single-read-bound
{"name": "single-read-bound", "exit": 0, "expected": "GREEN", "matched": true, "elapsed": 15.48, "evidence": "single-read-bound.log"}
guard phase: fresh read 7.620-7.722s, expiry 8s, two full intervals could not finish before 19.620s (deadline 17s)
guard bound: lease=7s check=6s reaped 12.0s after the last owner activity, documented bound 15s
Evidence: Late completion observation correctly refused

Source: Late completion observation correctly refused

not ok - the guard's completion was first observed 18.6s after the last owner activity, beyond its 17s deadline

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 9 of 9 scenarios driven live against the product
Scenario Result Live Evidence
Signal a shell blocked on a foreground child and observe deferred trap handling ✅ pass live live-cli-initial.log: TERM trap remained deferred until the blocked foreground tail exited.
Retire a healthy source using TERM without the retirement deadlock ✅ pass live live-cli-initial.log: attached start exited 143; retirement took 0.648s.
Retire a genuinely blocked source after TERM ends its proved leader ✅ pass live live-cli-initial.log: tail survived TERM after its runner disappeared; escalation extinguished the group.
Leave a detached source without owner activity while keeping another home active ✅ pass live live-cli-initial.log: orphan reaped 5.622s after lease refresh while the independently refreshed home survived; targeted-lifetime.log: pinned bound passed at 13.6s against 15s.
Let owner activity expire while a child resists TERM and observe complete cleanup ✅ pass live live-cli.log: owner expiry triggered TERM then KILL; blocked group disappeared after 11.529s, within the deadline including additive scheduling slack.
Interrupt one owner read without killing service, then repeat the failure to trigger shutdown ✅ pass live live-cli-initial.log: actual lease and state-root interruptions each preserved service for one failed read; persistent lease absence stopped the group after two failures.
Start listeners with zero-prefixed decimal and odd intervals using exact half spacing ✅ pass live live-cli-initial.log: real watchdog children executed sleep 4, 5, 0.5, and 1.5 for configured intervals 08, 010, 1, and 3.
Reconcile an unregistered blocked source and subsequently register a replacement ✅ pass live live-cli.log: reconcile reported stopped=1 uncertain=0; the replacement started after the blocked old group was gone.
Crash a leader independently and verify retirement refuses to signal its unproved group ✅ pass live live-cli.log: retirement refused with cannot confirm runner identity, preserving both registration and surviving group.
  • TMPDIR=&#34;$PWD/.test-phase-tmp&#34; /bin/bash ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/targeted-lifetime.sh — selected existing lifetime cases, including zombie-leader escalation.
  • python3 ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/live-cli.py — isolated production CLI checks with real blocking tail processes.
  • ONLY_SCENARIO=&#39;Unproved cold&#39; python3 .../live-cli.py, followed by ONLY_SCENARIO=&#39;Unproved cold|Expired blocked|Reconcile stops&#39; python3 .../live-cli.py — corrected driver setup and re-drove affected checks.
  • python3 ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/mutations.py — base retirement failure, decimal normalization removal, full-interval spacing with and without delayed startup, delayed-start control, unavailable phase evidence, and single-read debounce/bound variants.
  • TMPDIR=&#34;$PWD/.test-phase-tmp&#34; PROCEVENT_CODE_ROOT=&#34;$PWD&#34; /bin/bash ~/.no-mistakes/evidence/01M2414ECRE7T77CAXN49RSA2F/late-observation.sh — correctly refused completion first observed at 18.6s against a 17s deadline.
  • Final process inventory, temporary-directory removal, and Git status/commit/tree verification confirmed complete cleanup and unchanged target 77e799275d351c954a2a8f25e9ce124db0bfaa32.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

The PR should not merge until the outstanding watchdog deadline issue is resolved.

Comment thread docs/configuration.md Outdated
Comment on lines +827 to +828
That guard accepts the lease only while the state root retains the device/inode identity recorded by the runner's claim, and initiates the verified stop after two consecutive checks cannot prove that identity and lease freshness, so one unreadable read cannot kill a live runner.
For a runner whose ownership can still be proved, the nominal cleanup bound is the lease plus up to two check intervals plus the stop's grace period; scheduling delays or failed inspection and signalling can extend it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Watchdog deadline remains too loose

When an owner disappears just after a watchdog check, the guard waits for two consecutive misses and then the stop grace period, allowing the listener to outlive the accepted lease-plus-one-tick bound. The new regression permits 60 seconds for a two-second lease and one-second check interval, so it does not enforce the required deadline.

Context Used: If there is a VISION.md file at the root of the re... (source)

@mremond

mremond commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

The red Behavior portable serial 4 check on this pull request was cancelled at the 20-minute job timeout, not failed by a test assertion.

Measured cause: the portable serial lanes are packed from a hand-maintained per-script duration table that was last refreshed 2026-09-01. It now under-predicts, so one shard carries more work than the others and reaches the job bound. The lane is also cancelled on main itself, independently of any branch.

A repair is open as #4006 — it refreshes the duration table and moves from 5 shards to 6. On its two most recent runs, 14 jobs completed with 0 cancellations.

We cannot re-run the cancelled lane ourselves: our access to this repository is pull-only, and re-running requires admin.

@mremond

mremond commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Please do not merge this branch in its current state.

The most recent automated documentation commit (d7322681) removed safety-relevant statements that earlier commits on this branch had deliberately restored:

  • .agents/skills/process-event-sources/SKILL.md — the bullet stating that a leaderless PID/PGID-reuse ambiguity preserves the claim without signalling, plus the one-identity-matched-owner-per-canonical-source guarantee across homes sharing a source store.
  • bin/fm-procevent.sh — seven lines from the header block that forms the script's --help output, including "a live owner is never displaced" and "a crashed leader or reused pid whose process group still has members cannot relax ownership cleanup".

The refusal itself remains in the code; what was removed is its statement in the operator-facing and agent-loaded surfaces. A decision on relaxing that boundary is currently open on our side, which is why we are not treating this as a wording change.

Checks are green. The green badge reflects the tests, not this regression. We are correcting it and will update here.

@mremond
mremond force-pushed the fix/procevent-stop-escalation-and-retire-deadlock branch from d732268 to f8819bf Compare September 9, 2026 08:26
@mremond mremond changed the title fix(bin): repair process-event stop escalation and cleanup deadlock fix: prevent process-event stop deadlocks and restore escalation Sep 9, 2026
Comment thread docs/configuration.md Outdated
Comment on lines +833 to +834
That guard accepts the lease only while the state root retains the device/inode identity recorded by the runner's claim, and initiates the verified stop after two consecutive checks cannot prove that identity and lease freshness, so one unreadable read cannot kill a live runner.
For a runner whose ownership can still be proved, the nominal cleanup bound is the lease plus up to two check intervals plus the stop's grace period; scheduling delays or failed inspection and signalling can extend it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Watchdog deadline remains too loose

When the owner disappears immediately after a successful check, the watchdog waits for two subsequent failed checks before starting a stop that can consume another four seconds, leaving the listener alive beyond the required lease-plus-one-tick deadline. The revised regression also codifies this looser bound and permits twice its duration.

Context Used: If there is a VISION.md file at the root of the re... (source)

@mremond

mremond commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the do-not-merge notice above: its stated reason is resolved. This is not a merge recommendation.

That notice named two removals made by an automated documentation commit on this branch. Both are corrected, verified by diff at the current head f8819bf5 against the merge base 40c50ea8, not from memory:

  • .agents/skills/process-event-sources/SKILL.md is byte-identical to base. The bullet stating that leaderless PID/PGID-reuse ambiguity preserves the claim without signalling, and the one-identity-matched-owner-per-canonical-source guarantee, are both present on base and both present at this head.
  • The header block of bin/fm-procevent.sh, which is what the script prints as its own --help, is byte-identical to base. "A live owner is never displaced" and "a crashed leader or reused pid whose process group still has members cannot relax ownership cleanup" are present.

The repository never lost either passage; only this branch did, and this branch no longer carries the change that removed them. The delivery was rescoped so it touches neither surface at all.

What this correction does not say. It removes a false reason to block; it does not clear the way, and those are different things:

  • This request is still under validation and one check is red. The automated reviewer asks for the owner-guard deadline to be brought to a lease-plus-one-tick bound. We are not making that change: reaching it literally means deleting the two-consecutive-check debounce, and that debounce is what stops a single unreadable read from killing a live runner. The measurement answering that finding is in the description.
  • Merge authority is unchanged and is not ours.
  • One question remains open on our side and is not settled by this delivery: whether a process group whose leader died to something other than the stop's own signal may ever be signalled. The code still refuses it, and this change does not relax it.

@mremond mremond changed the title fix: prevent process-event stop deadlocks and restore escalation fix: restore process-event stop escalation and avoid retirement deadlocks Sep 9, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: waiting on CI, not on the captain.

HEAD b8c36219ed8432e0d9952c2b592ce621305dcec0. Attestation MATCH. Require no-mistakes on this tip is SUCCESS. CI 34341382029 was cancelled, so the tip is not green.

Contract-class is not a merge vote yet. Stopping escalation and making a non-waiting lock acquire is a restore claim, but always-on KILL where dead code previously abandoned groups may be new-default. Do not merge while CI is not green. No captain card until the tip is otherwise ready except that class decision.

The owner guard that shipped in kunchenguid#3904 half-reaps. Against a poll child that
handles the ordinary stop signal and keeps waiting, the guard signals the group,
loses the runner leader to its own signal, then reads that success as a
leaderless group and exits without escalating. It destroys the only proof of
ownership that would have authorised the forced signal, so the survivor becomes
unreachable by retire, reconcile, sweep-home and the guard alike. A guard that
turns a leaking-but-identifiable generation into a permanently unreachable one
is worse than no guard at all.

Two defects, and they hid each other:

- The escalation re-derived ownership from the leader. `runner_group_signal`
  now takes a `proved` mode, passed only by the escalation inside the stop that
  already proved and signalled that exact generation moments earlier. A leader
  dying to our own signal is the ordinary outcome, not fresh ambiguity.
- Every stop held the per-source lock across its wait while the runner's own
  exit cleanup waited unboundedly for that same lock. That circular wait was
  broken only by the forced signal, so the forced signal silently became the
  normal path - and, by keeping the leader alive through the whole window, it
  masked the escalation defect above. The runner's exit cleanup now refuses that
  lock instead of waiting for it, which is what its existing `return 0` already
  said it did.

Fixing the lock alone would have turned every stop of a signal-proof child into
a refusal that leaves it running, so both land together and the tests pin that.

Measured on macOS with a stand-in poll child that traps TERM, INT and HUP:
the guard left it running past 70s and now clears the group within the lease
plus one check; retiring a healthy runner fell from ~2.8s with a forced group
signal every time to ~0.6s on the ordinary signal alone.

Unchanged and stated deliberately: a leader lost to anything other than the
stop's own signal still leaves a group that retire, reconcile, sweep-home and
the guard all refuse, permanently - and that source stops listening without
saying so. Whether such a group may ever be signalled is an open decision and
is not answered here.
…skill

An automatic documentation step in this branch's validation edited
.agents/skills/process-event-sources/SKILL.md, which no instruction in this
change asked it to touch. That file is not documentation about the code: it is
the agent-loaded instruction surface, what an agent reads to know what it is
permitted to do.

The step deleted this line:

  - leaderless PID/PGID-reuse ambiguity preserves the claim without signalling
    or replacement, as owned by the operating contract in
    [`docs/configuration.md`](../../../docs/configuration.md#process-to-event-sources-stateprocevent);

and folded it, with its neighbour, into a generic "registration and ownership
transitions, stop authority, and claim reclamation follow the operating
contract".

That deleted line states a PROHIBITION - that such a group is preserved WITHOUT
SIGNALLING - and it is the exact limit an open captain decision currently rests
on. Folded into a pointer, an agent reading the skill to learn what it may do
would have to chase a second document to discover it may not signal. A
prohibition that requires a second lookup is not a prohibition. The effect was
to weaken, in the instructions themselves, the boundary that keeps one home from
signalling another's process group - while the question of whether that boundary
should move at all is still open.

This is a deliberate revert, not an oversight, and it restores the file exactly
to its pre-branch state. The full statement also survives in
docs/configuration.md; that does not rescue it, because the agent handling a
process-event wake loads the skill and not the documentation.
…ation

The same automatic documentation step that edited the loaded skill also removed
this from the comment above runner_group_signal:

  A leaderless group nobody in this call ever proved remains refused too, for
  every caller. That untouched refusal is what makes a crashed leader's group
  permanent, and relaxing it is a separate open question, not something this
  path assumes.

and replaced it with a pointer to docs/configuration.md.

This one fails differently from the skill deletion, which is why it is restored
separately. There, a prohibition was moved out of the reader's path, and a
missing prohibition gets violated. Here the prohibition survives in code - the
unproved path still refuses - and what was removed is the fact that the limit is
UNDECIDED. A prohibition that has quietly lost its "this is still open" reads as
settled design, and settled design gets relied on, extended, and eventually
relaxed by someone confident they understand why it is there. That question is
open right now.

The rule this branch's four instances produce, stated once here because this is
the point of decision: an unresolved question must be marked unresolved AT THE
POINT OF DECISION, not only where the contract is documented. A reader who does
not know something is open will treat it as closed, and that default is stronger
than any pointer overcomes.

The pointer added by that step is kept alongside; this restores what it replaced
rather than reverting it.
The document step's rewrite of this record dropped the concrete figure while
keeping the surrounding measurements. What went missing was the bound itself -
lease plus two consecutive failed checks plus the stop's grace, roughly 630
seconds at the shipped 600-second lease and 15-second check - together with the
reason there are two checks rather than one: a single unreadable read must not
be enough to kill a live runner.

The mechanism survived elsewhere and the reason survived in
docs/configuration.md, so nothing was lost from the repository. The concreteness
was, and that is what this restores. A number recorded without why it is that
number is the one a later reader shortens; the reason is the whole safety
argument for the debounce, and the debounce is what stops the reaper killing a
live runner on one bad read.
…eason

An automated reviewer observed that this case allowed sixty seconds for a bound
of roughly eight, so it could not go red for the reason it names: it would have
passed a guard that took fifty-five seconds. That is correct, and it is the same
family as the defect the case exists to defend against - a check that is green
because it cannot fail, rather than because the thing it guards is working.

The deadline is now derived from the bound itself - the lease, plus the two
consecutive failed checks the guard debounces on, plus the stop's own ordinary
and forced signal windows - rather than from a flat wall-clock number, and the
shortened lease and check the fixtures run under have a single definition so a
derived deadline cannot silently diverge from the settings the guard is given.
The doubling that remains is a load allowance and is documented as one; widening
it to make a slow guard pass would convert the assertion back into decoration.

Proven by mutation rather than by argument. Against the repaired case:

  correct code                                        ok
  guard debounces on 20 misses instead of 2           not ok - "still holding
                                                      the group after 16s,
                                                      against a documented
                                                      bound of 8s"
  proved escalation removed (the original defect)     not ok - same
  code restored                                       ok

The previous sixty-second version passes every one of those mutations.

The reviewer's other claim, that the guard can survive past the announced bound
when an owner disappears immediately after a check, was measured and does not
hold against what this branch announces. Sweeping the phase deliberately at
0.0, 0.2, 0.4, 0.6 and 0.8 of a check interval gave 7.21s, 7.31s, 6.75s, 6.49s
and 6.31s, worst 7.31s, against the announced lease plus two consecutive failed
checks plus stop grace, which is up to 8s at those settings. The mechanism the
reviewer describes is real and is the announced mechanism; the bound it was
measured against is a phrasing this branch no longer carries.
This delivery is being split. It carries the two proven process fixes alone; the
instruction text travels separately, through a run that removes the
documentation step rather than refusing it at its gate.

Two surfaces are therefore returned to exactly what the base branch has, so this
delivery neither adds to them nor removes from them:

  .agents/skills/process-event-sources/SKILL.md - identical to base again. Three
  bullets an automatic documentation step had folded into a pointer, including
  that leaderless PID/PGID-reuse ambiguity preserves the claim WITHOUT
  SIGNALLING and that there is one identity-matched owner per canonical source
  across homes sharing one store.

  The header comment block of bin/fm-procevent.sh, which is what the script
  prints as its own help. Seven lines were removed from it: that a live owner is
  never displaced, that only a claim whose stale owner and independently absent
  process group prove its whole generation gone is reclaimed, that a crashed
  leader or reused pid whose process group still has members cannot relax
  ownership cleanup, and that reconcile signals only a live identity-matched
  runner group and otherwise keeps the claim without starting a replacement.
  The help output is now byte-identical to base.

Neither removal was requested by any instruction in this change, and both were
made to text that predates it. Returning them is scoping, not a third
restoration: nothing is being added to those files here.
…he runner before startup released its lock. Added a public-list synchronization barrier in tests/fm-procevent.test.sh. Forced-delay reproduction detected the deadlock before the fix; all four cases passed afterward. Targeted lint, Bash syntax, and whitespace checks passed. Greptile’s watchdog requirement conflicts with the recorded R2 decision; runtime behavior and documentation remain unchanged. Full CI rerun belongs to the outer executor
…hey fail

On the failure path only, these cases now print what they actually saw: the
identity recorded at claim time, the identity readable at that moment, the size
of the signals file, the leader's state and wchan, every live member of the
runner's process group with its own state and wchan, the elapsed time since the
stop began, and what retire said. None of it runs when a case passes.

WHY THIS IS KEPT, stated accurately rather than by its original reason. It was
written to make an unexplained CI failure verifiable. That failure is now
explained - it was a fixture deadlock, diagnosed and repaired in the preceding
commit - so that justification has expired and is not the reason given here.
The reason it stays is smaller and independent of that failure: it is already
written, it is small, it sits in the file whose assertion this change reworked,
and an assertion that could not say why it failed cost most of a morning to
diagnose from the outside. The next failure will not be this one.

WHAT A PASSING RUN WOULD NOT MEAN: a pass is a sample of behaviour already
observed many times, not proof that anything is fixed. Only a failure carrying
the evidence above establishes a cause.
…sh: removed premature child completion and waited for runner exit before retiring the restart fixture. Controlled Linux reproductions demonstrated failure before and success after. The full Linux process-event suite, six focused macOS checks, targeted ShellCheck, Bash syntax, and whitespace checks passed. Runtime behavior, guard debounce, and documentation remain unchanged. CI rerun belongs to the outer executor
A THIRD WAY, not a capitulation to the reviewer and not a refusal of it.

The automated reviewer's grievance was the LOOSENESS OF THE BOUND, not the
number of observations the guard makes before it acts. It asked for a single
read because that was the only route it could see to an acceptable bound. There
was another route, and this change takes it: the bound is reached and both reads
are kept.

TIGHTENED - the SPACING of the guard's two reads, not their number. The owner
watchdog now sleeps half the configured check interval and still requires two
consecutive failing reads, so the pair completes inside one check interval
instead of costing two. Worst-case detection falls from the lease term plus TWO
check intervals to the lease term plus ONE. At the shipped 600s lease and 15s
interval the stated bound falls from ~635s to ~620s.

PRESERVED - the second read. bin/fm-procevent.sh's two-consecutive-miss rule is
untouched. WHY IT PROTECTS: the guard's inputs are a lease read and a state-root
identity read, and either can fail transiently on a live, healthy home. Acting
on the first failure would let one isolated unreadable read kill a live service.
Requiring a second, independent read is what makes that impossible, and it is a
protection rather than padding. Nothing was traded away to reach the bound.

Both properties are now guarded by their own case, and each was proven by
MUTATION rather than asserted:

  - putting a full interval back between the two reads fails the bound case:
    "still running 17.0s after the last owner activity, against a documented
    bound of 15s";
  - acting on one failed read fails the new debounce case: "one unreadable lease
    read ended a runner whose home was still alive" - while the bound case then
    passes FASTER, 9.9s against 13.1s. The unsafe variant being the quicker one
    is exactly why these are two cases: one elapsed-time case would have
    registered the removal of the protection as an improvement.

MEASURED, sampling the phase between the guard's check clock and the lease clock
across eight runs per variant, on macOS (Darwin 25.5.0). Reaping an orphaned
listener whose home stopped refreshing its lease:

  lease 2s / interval 1s:  4.41-5.29s before,  3.48-4.65s after
  lease 2s / interval 4s:  7.69-8.12s before,  5.94-6.13s after

The 4s configuration is the informative one: the gap is about one check
interval, which is precisely the term that was removed.

A previously unstated term of the bound surfaced while measuring: the lease age
is compared in whole seconds, so a configured lease of N is honoured until that
age reads N+1. It is now part of the documented bound and of the regression's
derivation instead of being absorbed into a fudge factor.

The bound regression derives its deadline from the documented bound instead of a
flat number, and PINS the phase between the guard's check clock and the lease
clock rather than sampling it, because with a sampled phase a guard spending two
intervals passes about half the time on a lucky alignment. Its load slack is
additive and stays under half a check interval, so an extra whole interval
cannot hide inside it. The two flat deadlines that were there before (40s and
20s) and the doubling allowance on the derived one are gone; that looseness was
the reviewer's third complaint.

The stop's own grace is untouched: 2s for the ordinary signal, then 2s for the
forced one. It is a ceiling paid only by a group that outlives the signal it was
sent, not a delay every stop pays - a healthy runner's whole retire measures
0.40-0.66s on this host. The reviewer's literal "lease plus one tick" is
unreachable by any implementation, since signalling a process and giving it any
chance to exit takes non-zero time; detection now meets it and the stop runs
inside its own ceiling, and the contract says so rather than glossing it.

NECESSARY BUT NOT SUFFICIENT, and written BEFORE this head's integration runs
start rather than after they report. On the previous head, "Behavior portable
serial 1" and "Behavior portable serial 4" were both CANCELLED at the job
ceiling, independently of this finding. A new head triggers fresh runs, so those
two lanes MAY complete this time. IF THEY DO, THAT IS NOT EVIDENCE THE CEILING
DEFECT IS FIXED. It is one more sample of a lane that has been cut repeatedly
and sometimes is not; the shard-packing repair for it is open separately. Do not
reread a lucky pass here as a resolution.

Relatedly, and deliberately: the per-script duration hint in bin/fm-test-run.sh
was NOT updated even though the two new cases add ~19s of wall clock.
docs/fm-test-portable-shards.md says those hints are replaced wholesale from CI
timing artifacts of green runs, and that repair is the open request doing it; a
hand-edited estimate here would collide with it and silently repack the shards.
This suite runs in portable serial shard 3, which was green in the last run.

Verification: tests/fm-procevent.test.sh green, plus
tests/fm-captain-hold-lifecycle.test.sh, the test-coverage guard, and
bin/fm-lint.sh. The unrelated "reconcile stops a runner whose registration was
removed" case flaked in 4 of 7 local full runs; an isolated 20-trial
reproduction measured it at 13/20 unclean before this change and 11/20 after, so
it is issue 4080 and is not aggravated here.
…e timing phase

REPAIRED BEFORE PUBLICATION, AND IT WAS OURS. The half-interval arithmetic added
by the previous commit read a zero-prefixed interval as octal: 010 halved to 4
instead of 5, and 08 was not a number at all, so the owner guard died before
reporting ready and the runner failed closed and never listened. The validator
accepts those values and `[` compares them as decimal, so this broke a
configuration that worked before. Introduced by this delivery, found in review,
repaired here. Forcing base ten before the arithmetic is the whole runtime fix.

Proven by driving it rather than by reading the source: a new case starts a real
listener at 08 and at 010 and observes the guard's actual sleep argument - 4s and
5s. Removing the normalisation turns that case red with "a zero-prefixed decimal
interval (08) prevented the listener from starting".

THE TIMING PHASE IS NOW OBSERVED AND ENFORCED, NOT ASSUMED. The bound case
pinned its phase by CONSTRUCTION, from an assumed startup time, and enforced
nothing. Review was right that this is not enough: once startup reaches about two
seconds the expiry lands in a different part of the interval and the case
silently stops rejecting a two-interval guard while still reporting success. A
bound that cannot fail for the reason it names is the defect this whole delivery
exists to correct, so it must not ship inside the fix for it.

Now the lease is synchronised to the guard's own FIRST observed lease read,
every later real read is recorded, and the case REFUSES unless one recorded read
proves the required phase: it read the synchronised reference, it was still
fresh, and it began late enough that two further full intervals could not finish
before the deadline. An unestablished precondition refuses; it does not proceed
on trust. The derived deadline, the two-read debounce and the additive slack are
unchanged, and the slack invariant is now asserted rather than left to a comment.

Review also found the deadline was only ever checked while the group was still
alive, so a sampler descheduled past it would see the group gone and certify
success. The observed completion time is now checked too.

PROVEN BY MUTATION, each one run against this code:

  - remove the decimal normalisation -> the interval case fails on 08;
  - a full interval between the two reads -> "the guard exceeded its bound:
    group still running 17.1s ... against a documented bound of 15s";
  - a full interval WITH startup forced to ~2.5s, which is exactly the condition
    the old construction pin could not survive -> still red, same message;
  - the same ~2.5s startup with the correct guard -> still passes, 13.0s against
    the 15s bound, so the delay alone does not break the case;
  - phase evidence made unavailable -> "could not establish the required
    pre-expiry guard-read phase", a refusal rather than a pass, even though the
    group stopped quickly;
  - act on one failed read -> the debounce case fails and the bound case passes
    FASTER, 9.5s against 12.7s, which is why these remain separate cases.

Verification: full tests/fm-procevent.test.sh green, and bin/fm-lint.sh clean.
@mremond
mremond force-pushed the fix/procevent-stop-escalation-and-retire-deadlock branch from b8c3621 to f9d9408 Compare September 9, 2026 21:48
@mremond mremond changed the title fix: restore process-event stop escalation and avoid retirement deadlocks fix(bin): repair process-event shutdown and tighten guard timing Sep 9, 2026
@kunchenguid
kunchenguid merged commit 00334ef into kunchenguid:main Sep 9, 2026
15 checks passed
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: this is merged. Thank you @mremond — really appreciate you taking the time on this.

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.

2 participants