Files
gbrain/scripts/check-worker-lock-renewal-shape.sh
T
Garry TanGitHub@garrytan-agents <noreply@github.com>Claude Opus 4.7
6ae94301a6 v0.41.26.1 fix: lock-renewal cathedral — closes ~39 worker crashes/day (supersedes #1567) (#1572)
* v0.41.26.1 fix: lock-renewal cathedral — closes ~39 worker crashes/day (supersedes #1567)

Production worker daemons against Supabase / PgBouncer were crashing
~39 times/day with `unhandledRejection at renewLock`. PR #1567
proposed the right try/catch shape; this wave incorporates it and
closes the entire bug class (4 inside-review + 8 outside-voice
findings absorbed via 9 locked design decisions).

What's fixed:

- `setInterval(async () => await renewLock(...))` replaced with a
  sync wrapper around the new pure `runLockRenewalTick` function.
  No more unhandled rejections escaping the timer callback.
- Second crash vector closed: `.catch()` on the stored
  `executeJob(...).finally(...)` promise so failJob/completeJob
  throws during the same outage can't propagate to
  `process.on('unhandledRejection')`.
- Per-call `Promise.race` timeout (default `lockDuration/3`) bounds
  hung renewLock calls so the re-entrancy guard can't wedge
  indefinitely.
- Time-based abort (NOT count-based) so the worker releases its
  lock BEFORE another worker can reclaim. With the prior 3-strike
  count + 30s lockDuration, a 15s window let other workers race.
- Infrastructure aborts (`lock-renewal-failed`, `lock-lost`) don't
  burn job attempts — `executeJob`'s catch consults the exported
  `INFRASTRUCTURE_ABORT_REASONS` set and skips `failJob` so the
  stall detector reclaims cleanly.
- Universal grace-eviction: 30s force-evict safety net now fires
  for ANY abort reason, not just `job.timeout_ms`.

What's added:

- `src/core/minions/lock-renewal-tick.ts` (NEW): pure extracted
  state-machine function + env-knob resolver. Three operator-tunable
  knobs via env (max-failures-for-audit, call-timeout-ms,
  safety-margin-ms) with stderr-warn-once on bad input + default
  fallback.
- `src/core/audit/lock-renewal-audit.ts` (NEW): sibling of
  `batch-retry-audit.ts`. Four outcomes: failure /
  success_after_failure / gave_up / executeJob_rejected. JSONL at
  `~/.gbrain/audit/lock-renewal-YYYY-Www.jsonl`.
- `src/core/audit/redact-connection-info.ts` (NEW): shared privacy
  helper. Strips Postgres URLs, host=, user=, password=, IPv4 from
  error messages before they hit audit JSONL. Wired into BOTH the
  new lock-renewal audit AND the existing batch-retry audit
  (privacy backfill — same risk class).
- `scripts/check-worker-lock-renewal-shape.sh` (NEW): CI guard
  wired into `bun run verify`. Asserts the v0.41.22.1 bug pattern
  (`lockTimer = setInterval(async ...)`) stays absent AND the pure
  function call site survives refactors. Bug-pattern-specific so it
  doesn't fight legitimate refactors (codex C12).

Tests: 64 new cases across 5 new test files. 182 existing minion +
worker tests still pass. All hermetic — no PGLite, no real network,
no `mock.module`.

Plan + 9 decisions + codex outside-voice review at
~/.claude/plans/system-instruction-you-are-working-humming-nygaard.md

Closes #1567 (incorporates the contributor's try/catch shape; closes
the bug class structurally).

Co-Authored-By: @garrytan-agents <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fill A-H gaps for v0.41.26.1 lock-renewal cathedral

The original v0.41.26.1 wave shipped 64 hermetic unit tests on the
pure tick function, audit primitives, and redactor. Post-ship audit
flagged 8 wiring gaps the pure tests can't see — A (launchJob
wiring), B (executeJob skip-failJob), C (.catch on stored promise),
D (INFRASTRUCTURE_ABORT_REASONS export), E (universal grace-evict),
F (executeJob_rejected end-to-end), G (re-entrancy guard at worker
layer), H (gold-standard E2E regression).

Now closed:

- **test/worker-lock-renewal-e2e.serial.test.ts** (1 test, gap H):
  the headline gold-standard regression. Real PGLite + real
  MinionWorker + executeRaw wrap that injects renewLock failures on
  demand. Pins that the worker process DOES NOT crash via
  unhandledRejection under sustained renewLock throws, the handler
  observes abort.signal.aborted = true with reason
  'lock-renewal-failed', and the audit JSONL contains both `failure`
  and `gave_up` events. The exact v0.41.22.1 production bug class.
  Quarantined to its own file because bun:test serial + PGLite has an
  unresolved interaction with multiple MinionWorker-driven tests in
  the same file (second test's queue.add hangs indefinitely).

- **test/worker-lock-renewal-shape.test.ts** (18 tests, gaps A-G):
  source-shape behavioral pins. Greps worker.ts function bodies for
  the patterns the locked decisions promised: launchJob calls
  runLockRenewalTick + resolveLockRenewalKnobs + uses
  lockRenewalAudit; tickInFlight declared and gated correctly; stored
  executeJob promise has .catch with logExecuteJobRejected + console
  stderr; abort.signal.addEventListener fires for any abort (not just
  timeout_ms); INFRASTRUCTURE_ABORT_REASONS used inside executeJob's
  catch with return-early shape. Bug-pattern-specific so a refactor
  that genuinely improves the shape passes; a refactor that
  accidentally strips a guarantee fails loud.

All 83 lock-renewal wave tests pass in 5.2s. 205 existing minion +
worker tests still green. No production code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: typecheck errors in worker-lock-renewal-e2e.serial.test.ts

CI verify failed on the new E2E gap-fill test (commit a8b282d4) due
to two TS errors that bun's runtime accepts but tsc rejects:

1. line 92: `originalExecuteRaw(sql, ...args)` with `args: unknown[]`
   — TS can't prove args has <=2 elements, so the call site looks
   like 1+1+N args against a function that accepts 1-3. Fixed by
   destructuring the wrap params explicitly: `(sql, params?, opts?)`
   matching the executeRaw signature, then calling
   `originalExecuteRaw(sql, params, opts)` with named args.

2. line 145: `expect(abortReason).toBe('lock-renewal-failed')` where
   `abortReason: string | null = null`. TS narrows the variable to
   `null` because the closure assignment in worker.register isn't
   observable to the inferrer. bun:test's `.toBe` overload then
   picks the null variant and rejects the string literal. Fixed by
   `as unknown as string` cast — the preceding `handlerAbortObserved`
   assertion guarantees we entered the branch where abortReason was
   assigned. Documented inline.

Local verify (29 checks) now green; E2E test still passes in 5.0s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: @garrytan-agents <noreply@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:20:58 -07:00

94 lines
4.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# v0.41.22.2 — CI guard against the v0.41.22.1 lock-renewal crash class.
#
# The bug pattern: `setInterval(async () => { await something() })` lets
# any throw inside the async callback propagate to Node's process-level
# `unhandledRejection` handler, which kills the worker with exit 1.
# Production lost ~39 worker processes/day to this exact shape when
# PgBouncer rotated connections during a renewLock call.
#
# This guard enforces two invariants on `src/core/minions/worker.ts`:
#
# 1. The BUG pattern is absent: no `setInterval(async ...)` literal.
# A future refactor that inlines `setInterval(async () => { await
# renewLock(...) })` again would re-introduce the v0.41.22.1
# crash class via the exact original surface.
#
# 2. The GOOD pattern is present: launchJob calls `runLockRenewalTick`.
# Without this call-site, the timer logic could be re-inlined via
# a different shape AND bypass the first invariant. The
# `runLockRenewalTick` extraction is also the only test seam that
# gives the state machine behavioral coverage.
#
# Intentionally bug-pattern-specific, not implementation-specific: a
# future refactor to `setTimeout`-recursion or `AbortController`-based
# scheduling passes as long as the bug pattern stays absent (codex C12
# from the v0.41.22.2 outside-voice review).
#
# Usage: scripts/check-worker-lock-renewal-shape.sh
# Exit: 0 when shape is good, 1 when violations found.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
# Allow tests to override the target file for fixture-based meta-tests.
TARGET="${GBRAIN_LOCK_RENEWAL_SHAPE_TARGET:-src/core/minions/worker.ts}"
if [ ! -f "$TARGET" ]; then
echo "ERROR: shape guard target file not found: $TARGET"
exit 1
fi
# Invariant 1: the LOCK-RENEWAL site must not use the bug shape.
#
# The bug-class regex is `setInterval(...async...)`, but it appears
# legitimately elsewhere in worker.ts (the stall-detector loop at
# line ~269 uses it with try/catch — codex C13 covers re-entrancy
# guard for that path separately). To keep this guard from fighting
# unrelated decisions, we narrow scope to the renewal timer
# specifically by requiring the assignment shape `lockTimer = setInterval(`.
#
# A future refactor that renames `lockTimer` would slip past this
# guard; that's an accepted tradeoff (the variable name has been
# stable since v0.10 and is the load-bearing test seam for
# `launchJob`'s `inFlight` accounting).
#
# Uses POSIX ERE + [[:space:]] for BSD-grep portability (macOS shipping
# grep doesn't support -P / \s).
if grep -Eq 'lockTimer[[:space:]]*=[[:space:]]*setInterval\([[:space:]]*async' "$TARGET"; then
echo "ERROR: $TARGET contains the v0.41.22.1 bug pattern (\`setInterval(async ...)\`)."
echo
echo " Async timer callbacks let unhandledRejection escape to the"
echo " process-level handler and crash the worker daemon."
echo
echo " Fix: wrap the timer callback synchronously around an IIFE that"
echo " routes through src/core/minions/lock-renewal-tick.ts:"
echo
echo " setInterval(() => {"
echo " if (tickInFlight) return;"
echo " tickInFlight = true;"
echo " void runLockRenewalTick(deps, state)"
echo " .then(handleResult)"
echo " .catch(handlePostError)"
echo " .finally(() => { tickInFlight = false; });"
echo " }, lockDurationMs / 2);"
exit 1
fi
# Invariant 2: good pattern present. launchJob must call
# `runLockRenewalTick` or the test seam is gone.
if ! grep -q 'runLockRenewalTick' "$TARGET"; then
echo "ERROR: $TARGET does not call \`runLockRenewalTick\`."
echo
echo " Lock-renewal logic must route through"
echo " src/core/minions/lock-renewal-tick.ts so the state-machine"
echo " behavior stays unit-testable (no PGLite needed, no"
echo " setInterval / process plumbing in tests). Re-introduce the"
echo " call site at launchJob's renewal timer."
exit 1
fi
echo "lock-renewal shape OK ($TARGET)"