fix(platform): panic-recovering supervisor for every background goroutine (#92) - #95
Merged
Merged
Conversation
…tine (#92) Yesterday's scheduler-died incident (#85) was one instance of a systemic bug: every long-running goroutine in the platform lacks panic recovery and exposes no liveness signal. In a multi-tenant SaaS deployment, a single tenant's bad data panicking any subsystem takes down the subsystem for every tenant, silently, with all standard health probes still green. That is a scale-of-one sev-1. This PR: 1. Introduces `platform/internal/supervised/` with two primitives: a. RunWithRecover(ctx, name, fn) — runs fn in a recover wrapper. On panic logs the stack + exponential-backoff restart (1s → 2s → 4s → … → 30s cap). On clean return (fn decided to stop) returns. On ctx.Done cancels cleanly. b. Heartbeat(name) + LastTick(name) + Snapshot() + IsHealthy(names, staleThreshold) — shared in-memory liveness registry. Every subsystem calls Heartbeat(name) at the end of each tick so operators can distinguish "goroutine alive and healthy" from "alive but stuck inside a single tick". 2. Wraps every `go X.Start(ctx)` in main.go: - broadcaster.Subscribe (Redis pub/sub relay → WebSocket) - registry.StartLivenessMonitor - registry.StartHealthSweep - scheduler.Start (the one that died yesterday) - channelMgr.Start (Telegram / Slack) 3. Adds `supervised.Heartbeat("scheduler")` inside the scheduler tick loop as the first end-to-end demonstration. Follow-up PRs will add heartbeats to the other four subsystems. 4. Adds `GET /admin/liveness` endpoint returning per-subsystem last_tick_at + seconds_ago. Operators can poll this and alert on any subsystem whose seconds_ago exceeds 2x its cron/tick interval. 5. Unit tests for RunWithRecover (clean return no restart; panic restarts with backoff; ctx cancel stops restart loop) and for the liveness registry. Net new code: ~160 lines + ~100 lines of tests. Refactor of main.go: ~10 line changes. No behavior change on happy path; only lifts what happens on a panic. Closes #92. Supersedes the local recover added to scheduler.go in #90 (kept conceptually, but now via the shared helper).
…ts work-in-progress The first scheduler heartbeat (#95) only fired AFTER each tick completed. A tick that runs fireSchedule for 110+ seconds (long agent prompts) would make /admin/liveness report scheduler as stale even though it was actively working. Observed today: scheduler firing UIUX audit, last_tick_at lagged by 95s+ and incrementing. Three places now call Heartbeat: 1. Top of tick() — proves we're past the ticker.C wait 2. Inside each fire goroutine, before fireSchedule — ANY active fire keeps the heartbeat fresh 3. Inside each fire goroutine, after fireSchedule — captures the moment the per-fire work completes (The post-tick Heartbeat in Start() is still there as the "all idle" case.) Net result: /admin/liveness reports stale only if the scheduler genuinely isn't doing anything for >2× pollInterval, which is the actual signal we want.
…ervised heartbeat)
This was referenced Apr 15, 2026
Closed
HongmingWang-Rabbit
added a commit
that referenced
this pull request
Apr 15, 2026
…-stale during long fires (#140) The #95 scheduler heartbeat scheme relied on: 1. Top of tick() (once per poll interval) 2. Per-fire goroutine entry + exit That leaves a gap: tick() ends with wg.Wait(), so if a single fire takes longer than pollInterval (UIUX audits routinely take 60-120s; max fireTimeout is 5min), the next tick doesn't run and no top-of-tick heartbeat fires. Per-fire heartbeats only bracket the fire — between entry and the HTTP response returning, nothing heartbeats either. Observed today: /admin/liveness reports seconds_ago=251 while docker logs show the scheduler actively firing 'Hourly ecosystem watch'. Scheduler is fine; liveness is lying. Adds an independent 10s heartbeat pulse goroutine inside Start(), decoupled from tick completion. The existing heartbeats at tick top + per-fire are kept as redundant signals but this pulse is the one that guarantees liveness freshness regardless of what tick is doing. Ships the exact fix proposed in #140 body. Closes #140.
3 tasks
HongmingWang-Rabbit
pushed a commit
that referenced
this pull request
Apr 15, 2026
…ht sweep Captures ~27 PRs merged across both repos this session: security hardening cluster (#94/#99/#106/#110/#119/#162/#155/#167/#185/#200/#203/ #209/#233), data-integrity fixes (#212/#224/#236), CI runner migration (#186), platform/scheduler reliability (#95/#149/#207/#206), workspace runtime features (#205/#208/#198/#216/#225/#235/#231), code-review follow-ups (#228/#232). Updated counts: 816 Go (+70), 1180 Python (+40), 453 vitest (unchanged — UI/a11y patches), 97 jest (unchanged). CLAUDE.md additions: - Idle Loop section (#205) under Architectural Patterns - Admin auth middleware variants section linking docs/runbooks/admin-auth.md - Migration runner section explaining the .down.sql filter (#212) - Per-route auth notes in the API table (PATCH field-whitelist, CanvasOrBearer on PUT /canvas/viewport, AdminAuth on bundles/events/templates-import/ approvals-pending/admin-liveness) - Database section updated with workspace_auth_tokens auto-revoke (#110), scheduler.error_detail surfacing (#206), workspace_schedules.last_status 'skipped' state (#207) PLAN.md additions: - New Recently launched (overnight sweep) section with full PR/issue index - Phase status updated (B–G now complete, H partial) - Live infrastructure deltas (migration fix, token rotation, legal pages) - Outstanding items consolidated Edit-history file expanded from the tick-9 stub to a full session record covering malware cleanup, CI runner migration, security cluster, data integrity, infra/feature/code-review batches, and outstanding user actions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
molecule-ai Bot
pushed a commit
that referenced
this pull request
Apr 21, 2026
…ts work-in-progress The first scheduler heartbeat (#95) only fired AFTER each tick completed. A tick that runs fireSchedule for 110+ seconds (long agent prompts) would make /admin/liveness report scheduler as stale even though it was actively working. Observed today: scheduler firing UIUX audit, last_tick_at lagged by 95s+ and incrementing. Three places now call Heartbeat: 1. Top of tick() — proves we're past the ticker.C wait 2. Inside each fire goroutine, before fireSchedule — ANY active fire keeps the heartbeat fresh 3. Inside each fire goroutine, after fireSchedule — captures the moment the per-fire work completes (The post-tick Heartbeat in Start() is still there as the "all idle" case.) Net result: /admin/liveness reports stale only if the scheduler genuinely isn't doing anything for >2× pollInterval, which is the actual signal we want.
molecule-ai Bot
pushed a commit
that referenced
this pull request
Apr 21, 2026
fix(platform): panic-recovering supervisor for every background goroutine (#92)
molecule-ai Bot
pushed a commit
that referenced
this pull request
Apr 21, 2026
…-stale during long fires (#140) The #95 scheduler heartbeat scheme relied on: 1. Top of tick() (once per poll interval) 2. Per-fire goroutine entry + exit That leaves a gap: tick() ends with wg.Wait(), so if a single fire takes longer than pollInterval (UIUX audits routinely take 60-120s; max fireTimeout is 5min), the next tick doesn't run and no top-of-tick heartbeat fires. Per-fire heartbeats only bracket the fire — between entry and the HTTP response returning, nothing heartbeats either. Observed today: /admin/liveness reports seconds_ago=251 while docker logs show the scheduler actively firing 'Hourly ecosystem watch'. Scheduler is fine; liveness is lying. Adds an independent 10s heartbeat pulse goroutine inside Start(), decoupled from tick completion. The existing heartbeats at tick top + per-fire are kept as redundant signals but this pulse is the one that guarantees liveness freshness regardless of what tick is doing. Ships the exact fix proposed in #140 body. Closes #140.
molecule-ai Bot
pushed a commit
that referenced
this pull request
Apr 21, 2026
…ht sweep Captures ~27 PRs merged across both repos this session: security hardening cluster (#94/#99/#106/#110/#119/#162/#155/#167/#185/#200/#203/ #209/#233), data-integrity fixes (#212/#224/#236), CI runner migration (#186), platform/scheduler reliability (#95/#149/#207/#206), workspace runtime features (#205/#208/#198/#216/#225/#235/#231), code-review follow-ups (#228/#232). Updated counts: 816 Go (+70), 1180 Python (+40), 453 vitest (unchanged — UI/a11y patches), 97 jest (unchanged). CLAUDE.md additions: - Idle Loop section (#205) under Architectural Patterns - Admin auth middleware variants section linking docs/runbooks/admin-auth.md - Migration runner section explaining the .down.sql filter (#212) - Per-route auth notes in the API table (PATCH field-whitelist, CanvasOrBearer on PUT /canvas/viewport, AdminAuth on bundles/events/templates-import/ approvals-pending/admin-liveness) - Database section updated with workspace_auth_tokens auto-revoke (#110), scheduler.error_detail surfacing (#206), workspace_schedules.last_status 'skipped' state (#207) PLAN.md additions: - New Recently launched (overnight sweep) section with full PR/issue index - Phase status updated (B–G now complete, H partial) - Live infrastructure deltas (migration fix, token rotation, legal pages) - Outstanding items consolidated Edit-history file expanded from the tick-9 stub to a full session record covering malware cleanup, CI runner migration, security cluster, data integrity, infra/feature/code-review batches, and outstanding user actions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.
Summary
Addresses the systemic bug exposed by #85 (scheduler died silently for 12+ hours): every long-running goroutine in the platform lacked panic recovery and a liveness signal. In SaaS this is a scale-of-one sev-1 — one tenant's bad data panics a subsystem → every tenant loses it → every health probe stays green.
Introduces
platform/internal/supervised/withRunWithRecover+ a liveness registry, wraps everygo X.Start(ctx)inmain.go, addsGET /admin/livenessfor operator visibility.Changes
New package
platform/internal/supervised/(142 LoC + 120 LoC tests):RunWithRecover(ctx, name, fn)— recover wrapper with exponential backoff (1s → 2s → 4s → … → 30s cap). Panic → restart. Clean return → stop.ctx.Done→ stop.Heartbeat(name)/LastTick(name)/Snapshot()/IsHealthy(expected, staleThreshold)— in-memory liveness registry.platform/cmd/server/main.go— wraps every background goroutine:go broadcaster.Subscribe(ctx)go supervised.RunWithRecover(ctx, "broadcaster", broadcaster.Subscribe)go registry.StartLivenessMonitor(ctx, onOffline)go registry.StartHealthSweep(ctx, prov, 15s, onOffline)go cronSched.Start(ctx)go supervised.RunWithRecover(ctx, "scheduler", cronSched.Start)go channelMgr.Start(ctx)platform/internal/scheduler/scheduler.go— first subsystem to callsupervised.Heartbeat("scheduler")after each tick. Demonstrates the end-to-end pattern; follow-up PRs will add heartbeats to the other four.platform/internal/router/router.go— newGET /admin/livenessendpoint returning{ subsystems: { "<name>": { last_tick_at, seconds_ago } } }.Tests
platform/internal/supervised/supervised_test.go— four cases:ctx.Donestops the restart loopLocal:
ok github.com/Molecule-AI/molecule-monorepo/platform/internal/supervised 4.115s. CI will re-validate.Why this gates SaaS launch
Per #92: in multi-tenant production, every subsystem outage is simultaneous for every tenant, silent, and invisible to standard health probes (HTTP, container, DB all stay green). A single bad row, malformed cron expression, or oversized A2A payload from ONE tenant kills the subsystem for EVERY tenant. Platform restart doesn't help if the bad input is persistent. That is exactly the failure mode we hit on 2026-04-14.
With this PR: a panic is logged + the subsystem restarts in 1-30s;
/admin/livenesssurfaces stuck-but-not-crashed subsystems too (e.g. a tick that deadlocks); operators can alert onseconds_ago > 2×interval.Related
Follow-ups (separate PRs)
supervised.Heartbeatcalls inside each of the other 4 subsystems (liveness-monitor, health-sweep, broadcaster, channel-manager) so/admin/livenessis fully populated./healthonsupervised.IsHealthy([...], 2×interval)so external orchestrators (k8s, fly.io) see 503 when a subsystem is stuck.supervised_last_tick_seconds_ago{subsystem="..."}for scrape-based alerting.🤖 Generated with Claude Code