Repository navigation
fix(supervisor): stop guard false kills under host starvation (30s→120s, configurable) - #1537
Conversation
…0s, configurable) The supervisor and calendar guards SIGKILLed live parents on 2026-10-28 after 31-36 s heartbeat stalls under host load. Raise the shared default heartbeat timeout to 120 s and expose BRAINLAYER_SUPERVISOR_GUARD_TIMEOUT_SECONDS (env file or environment, finite, 10-900); an invalid value raises. Pipe EOF from a dead parent still reaps at once. The run_guard loop is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 30 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: dc17a0a0-c11e-4e42-8524-ac8b59bed38d) |
…-file value Round-1 review (Codex): the shared env-file parser skips a `$(...)` or backtick assignment as absent, so BRAINLAYER_SUPERVISOR_GUARD_TIMEOUT_SECONDS=$(bad) silently resolved to 120 s or the process value. Detect the present but unsupported assignment for this knob and raise; config.py is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: d4d80a52-10b3-4612-8d27-89391b1e3608) |
BrainLayer ratchetEvery Value below was measured by this run. A row this machine cannot measure says Hosted success gives no verdict on mapped bytes, search p50/p95, idle CPU, or fallback replay debt.
🟢 GREEN measured, within budget · 🔴 RED measured, out of budget — a finding to clear before merge · ⚪ n/a not measurable on this machine, never guessed. No RED rows. Measured on Linux/x86_64 · measured |
A prior full calendar's guard can now outlive guard_timeout by up to MAX_STALL_GRACE_S of host-stall re-arms (this PR). The full-calendar admission deadline counted guard_timeout only, so the next invocation could give up with calendar-admission-timeout while the prior guard was still legitimately alive. Add MAX_STALL_GRACE_S to the bound. Found by the #1537 x #1536 combined-timing re-check (R-1537-codex.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
BrainLayer's own guards sent every SIGKILL (2026-10-28, M4 Max, 1.5.78).
supervisor/guard-events.jsonlandsupervisor/calendar/guard-events.jsonlrecordguard-timeout, reasonheartbeat-timeout:last_heartbeat_age_s31.2run_guardcallsos.kill(parent, SIGKILL). The SIGTERM that stopped backup-daily is the same guard'sreap_groups(SIGTERM, then SIGKILL, to the registered pgids).spawn type = background (5)with low-priority I/O. Under load 13–16 and about 10 GB of swap they went more than 30 s without a heartbeat, longer than the oldguard_timeout=30allows.os.readreturnb"", the loop breaks, and the groups are reaped. The timeout only matters for a parent that is alive but silent, so 120 s does not delay cleanup after a crash.Change
supervisor_guard.configured_guard_timeout()is the one resolver. Default 120 s. Override withBRAINLAYER_SUPERVISOR_GUARD_TIMEOUT_SECONDS, from the BrainLayer env file (which wins, as forBRAINLAYER_BACKUP_TIMEOUT_SECONDS) or from the environment. The value must be finite and 10 ≤ v ≤ 900. Anything else raisesValueErrornaming the variable; there is no silent fallback. That includes an env-file assignment using$(...)or backticks. The shared parser skips such a line as absent, so the resolver detects it for this knob and raises;config.pyis unchanged for every other knob (round-1 fix).Supervisor(guard_timeout=None)andrun_calendar(guard_timeout=None)resolve through it. An explicit argument still wins, so the existing sub-second test fixtures are unchanged.run_guardis untouched. The resolver sits at the top of the module, away from the region the parallel host-stall PR (W2,fix/guard-host-stall) edits.docs/operations/supervisor-jobs.mdupdated.operation_timeout120 s) and the overrun-kill path are unchanged.Deadline audit (every value derived from
guard_timeout)service_supervisor.pyloop sleepmin(0.1, guard_timeout/4)maintenance_calendar.py_execute_steppollmin(0.1, guard_timeout/4)maintenance_calendar.pypulse(..., min(0.1, guard_timeout/4))heartbeat_admit_calendarfull-invocation admission deadlinemax(step.timeout, 7500) + 60 + guard_timeout + 2*shutdown + 30launch_guard(startup_timeout=30)readinessrun_guardquiesced (Q) deadlineshutdown_timeout + 2reap_groups(timeout=shutdown_timeout)Nothing else reads
guard_timeout(grep -rn guard_timeout src/brainlayer).wal_checkpoint's_guard_timeout_secondsis a separate checkpoint-lock knob and is not affected.Tests (written first, seen red)
tests/test_supervisor_guard.pytest_guard_timeout_defaults_to_120_secondstest_guard_timeout_env_override_and_env_file_overridetest_invalid_guard_timeout_raises_never_falls_back[environ|env-file × 0, -5, nan, inf, -inf, 9.99, 900.5, 901, abc, ""](20 cases)test_default_guard_survives_60s_silent_parent_and_kills_past_120s: in-processrun_guardon an injected clock and an honest scriptedselect, with no real sleep. A 60 s silence is survived; the SIGKILL lands exactly 120 s after the last heartbeat.test_eof_from_dead_parent_reaps_at_once_regardless_of_timeout: EOF at t=5 s reaps immediately, with no kill.test_unsupported_env_file_substitution_raises_never_falls_back[$(bad),bad, "$(echo 60)", 6echo 0× with/without process override 240](8 cases; red on 4b0f36b, green on the fix) (round 1)test_substitution_on_other_knobs_keeps_the_shared_parser_contract(round 1)tests/test_service_supervisor.py::test_supervisor_guard_timeout_defaults_to_120_and_reads_overridetests/test_maintenance_calendar.py::test_calendar_guard_timeout_defaults_to_120_and_reads_overrideLocal results:
tests/test_supervisor_guard.py(51) passed; the scoped pre-push passed.test_supervisor_guard,test_service_supervisor,test_maintenance_calendar{,_completion,_leases}andtest_supervisor_jobs: 193 passed in 158 s.ruff checkandruff format --checkclean.BRAINLAYER_PREPUSH_SCOPE=changed-onlyand passed.Not run: the full suite, Swift tests, and live-DB tests (host under memory pressure, per the brief). There is no live deploy or probe; this PR is not deployed.
Loop record
Loop-record: implementer=claude/claude-opus-5-5/medium reviewer=codex/sol rounds=1/0 defects=1 role=worker
4b0f36b9): CHANGES REQUESTED, one P2. Defect: an env-file value using$(...)or backticks was skipped by the shared parser and silently fell back to 120 s or the process value. Fixed in5bb07b2d, with 8 regression cases plus an other-knob contract test.fix/guard-host-stall) rebases onto it afterwards; the reviewer notes a textual conflict at the module constants, and both blocks should be kept.— brainlayerClaude (worker) · claude-code/claude-opus-5-5
🤖 Generated with Claude Code
Note
Medium Risk
Changes supervisor/calendar liveness enforcement defaults and startup validation for a critical timeout knob; stuck-but-alive parents now survive longer before SIGKILL, and bad config fails hard instead of falling back.
Overview
Fixes false guard-timeout SIGKILLs when background launchd parents stall 31–36s under load by raising the shared heartbeat budget from 30s to 120s for the daemon and maintenance calendar.
Adds
supervisor_guard.configured_guard_timeout()as the single resolver: default 120s, override viaBRAINLAYER_SUPERVISOR_GUARD_TIMEOUT_SECONDS(BrainLayer env file wins over process env), validated finite 10–900; invalid or substitution-heavy env lines raise at startup instead of silently falling back.Supervisorandrun_calendarnow defaultguard_timeout=Noneand resolve through this helper; explicit constructor/argument values still override (tests keep sub-second fixtures).Docs in
supervisor-jobs.mddescribe the new knob and note that overdue executor work still suppresses heartbeats, so a wedged worker is killed one guard timeout after the last heartbeat (longer than before).run_guardbehavior is unchanged; pipe EOF still reaps dead parents immediately.Tests cover defaults, overrides, bounds, env-file substitution errors, and scripted timing (60s silence survives; kill at 120s after last heartbeat; EOF reaps at once).
Reviewed by Cursor Bugbot for commit 5bb07b2. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Raise supervisor guard timeout default from 30s to 120s and make it configurable
configured_guard_timeoutin supervisor_guard.py: default 120s, override viaBRAINLAYER_SUPERVISOR_GUARD_TIMEOUT_SECONDS(env file takes precedence over process env), valid range 10–900s inclusive. Invalid, non-finite, or out-of-range values raiseValueErrorinstead of falling back to the default.Supervisor.__init__in service_supervisor.py andrun_calendarin maintenance_calendar.py now use the configured value when no explicitguard_timeoutis passed; explicit arguments still win.ValueError.Macroscope summarized 5bb07b2.