Skip to content

fix(platform): panic-recovering supervisor for every background goroutine (#92) - #95

Merged
HongmingWang-Rabbit merged 3 commits into
mainfrom
fix/supervised-goroutines
Apr 15, 2026
Merged

fix(platform): panic-recovering supervisor for every background goroutine (#92)#95
HongmingWang-Rabbit merged 3 commits into
mainfrom
fix/supervised-goroutines

Conversation

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor

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/ with RunWithRecover + a liveness registry, wraps every go X.Start(ctx) in main.go, adds GET /admin/liveness for 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:

Before After
go broadcaster.Subscribe(ctx) go supervised.RunWithRecover(ctx, "broadcaster", broadcaster.Subscribe)
go registry.StartLivenessMonitor(ctx, onOffline) wrapped
go registry.StartHealthSweep(ctx, prov, 15s, onOffline) wrapped
go cronSched.Start(ctx) go supervised.RunWithRecover(ctx, "scheduler", cronSched.Start)
go channelMgr.Start(ctx) wrapped

platform/internal/scheduler/scheduler.go — first subsystem to call supervised.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 — new GET /admin/liveness endpoint returning { subsystems: { "<name>": { last_tick_at, seconds_ago } } }.

Tests

platform/internal/supervised/supervised_test.go — four cases:

  • Clean return does not restart
  • Panic restarts with backoff (deliberately panic 3 times, verify recovery)
  • ctx.Done stops the restart loop
  • Liveness registry: heartbeat → read back, IsHealthy with fresh/stale thresholds, Snapshot is a copy

Local: 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/liveness surfaces stuck-but-not-crashed subsystems too (e.g. a tick that deadlocks); operators can alert on seconds_ago > 2×interval.

Related

Follow-ups (separate PRs)

  1. Add supervised.Heartbeat calls inside each of the other 4 subsystems (liveness-monitor, health-sweep, broadcaster, channel-manager) so /admin/liveness is fully populated.
  2. Gate /health on supervised.IsHealthy([...], 2×interval) so external orchestrators (k8s, fly.io) see 503 when a subsystem is stuck.
  3. Prometheus metric supervised_last_tick_seconds_ago{subsystem="..."} for scrape-based alerting.

🤖 Generated with Claude Code

HongmingWang-Rabbit and others added 3 commits April 14, 2026 20:34
…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.
@HongmingWang-Rabbit
HongmingWang-Rabbit merged commit 7859d43 into main Apr 15, 2026
7 checks passed
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.
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>
@HongmingWang-Rabbit
HongmingWang-Rabbit deleted the fix/supervised-goroutines branch April 16, 2026 12:31
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>
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.

1 participant