Skip to content

fix(runtime): bound the shutdown drain, serialize boot migrations, and make capture failures visible (#9485, #9486, #9487) - #9512

Merged
JSONbored merged 3 commits into
mainfrom
fix/runtime-shutdown-migration-observability
Jul 28, 2026
Merged

fix(runtime): bound the shutdown drain, serialize boot migrations, and make capture failures visible (#9485, #9486, #9487)#9512
JSONbored merged 3 commits into
mainfrom
fix/runtime-shutdown-migration-observability

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Three self-host runtime defects: a redeploy that stalls reviews for half an hour, a concurrent boot that can corrupt a migration backfill, and a browserless outage nothing can see.

Closes #9485
Closes #9486
Partially addresses #9487 (code-side half; see Scope)

#9485 — an unbounded drain turned every redeploy into a 20–30 minute stall

stop() waited while (active > 0) with no deadline. The redeploy-companion recreates the container, so SIGTERM landed on an in-flight multi-minute AI review, the wait blocked until the orchestrator's grace period expired, and the process was SIGKILLed.

The damage came next: the killed job's lease had been heartbeated right up to the kill, so reclaimExpiredProcessingJobs — which only recovers rows older than QUEUE_PROCESSING_TIMEOUT_MS (30 minutes by default) — did not pick it up for another 20–30 minutes. Against a 1–5 minute latency target with automated redeploys, that is the single largest source of "a review silently stalled for half an hour".

The wait is now bounded (QUEUE_SHUTDOWN_DRAIN_DEADLINE_MS, default 120s — comfortably inside this deployment's 300s stop_grace_period). On expiry the process re-pends its own in-flight rows before returning, which is safe precisely because activeJobIds is process-local: these are rows we claimed and are about to abandon. A SIGKILL race becomes an immediate retry. Both backends.

#9486 — concurrent boots could re-run a DML backfill

Boot migrations took no cross-instance lock. With two instances booting together, A applies a migration atomically while B's identical transaction fails already exists — which runSelfHostMigrations treats as drift and retries statement-by-statement. CREATE TABLE is tolerated, but a table-rebuild INSERT … SELECT or an UPDATE backfill re-executes against the already-migrated schema. B then dies on the ledger INSERT's unique violation — a message matching neither duplicate column nor already exists — and crash-loops a cycle.

#9027 made a single migration atomic against a crash; this makes the whole run atomic against a concurrent boot, via a Postgres session advisory lock.

Why a registry rather than a new statement: a session advisory lock is held by the connection that took it, so it cannot be acquired through prepare()/batch() — those hand back a pooled client per call. createPgAdapter now registers its pool in a WeakMap so a caller needing a dedicated connection can find it, leaving the D1Database surface exactly the shape every other backend implements. SQLite falls straight through (single-process by construction).

#9487 (code half) — a browserless outage was invisible

Visual capture failures were logged but never counted. console.log is invisible to Prometheus, so an outage silently degraded every screenshot to a dash cell with nothing to alert on — and because the screenshot-table gate treats absent evidence as a close signal, that outage could close legitimate visual PRs before anyone noticed. Now loopover_visual_capture_total{result}.

Scope

#9487 also covers production alerting config that does not live in this repo: the LoopoverBackupMissing alert firing for 9 days on the retired sqlite target, the p95 SLO alert computed from ~3 requests/5min with no route label, the secret_leak gate's 44% false-positive rate on metagraphed, and Redis's allkeys-lru policy sharing a keyspace with correctness keys. Those are alertmanager/Prometheus rules and private per-repo config on edge-nl-01, so the issue stays open for them.

Validation

  • npx tsc --noEmit -p tsconfig.json — clean
  • 482 passed across selfhost-sqlite-queue, selfhost-pg-queue, selfhost-migrate, selfhost-metrics, visual-shot
  • Patch coverage against this diff: 0 uncovered changed lines

Regressions:

  • stop() returns rather than hanging on a job that outlives the deadline, and the abandoned row comes back as pending — on both backends.
  • A render failure increments {result: "error"}, so a browserless outage is alertable.

Invariants:

  • A job finishing inside the deadline drains normally and is not re-pended (no spurious retries).
  • The advisory lock is taken before the work and released after — an unlock-before-work ordering would serialize nothing.
  • The lock and the client are released even when the migration throws; otherwise every later boot would block on a stranded lock forever.
  • A non-Postgres backend runs straight through, and the lock key is one stable integer so separate instances genuinely serialize.

Three best-effort/defensive arms are annotated with their reasons rather than given contrived tests — each degrades to the pre-fix behaviour, which is what the change improves on rather than depends on.

…d make capture failures visible (#9485, #9486, #9487)

stop() waited on in-flight work with NO deadline, so a redeploy blocked on a multi-minute AI
review until the orchestrator SIGKILLed the process -- and because the killed job's lease had
been heartbeated right up to the kill, reclaimExpiredProcessingJobs (which only recovers rows
older than 30 minutes) left it stalled for another 20-30. Against a 1-5 minute latency target
with automated redeploys, that was the largest single source of a silently stalled review. The
wait is now bounded, and on expiry this process re-pends its OWN in-flight rows -- safe because
activeJobIds is process-local -- turning a SIGKILL race into an immediate retry. Both backends.

Boot migrations took no cross-instance lock. Two instances booting together meant one applied a
migration atomically while the other's identical transaction failed "already exists", which
runSelfHostMigrations treats as DRIFT and retries statement-by-statement -- re-executing any
table-rebuild INSERT ... SELECT or UPDATE backfill against the already-migrated schema, then
dying on the ledger INSERT's unique violation (a message matching neither tolerated shape) and
crash-looping. #9027 made a single migration atomic against a crash; this makes the whole run
atomic against a concurrent boot, via a Postgres session advisory lock. A session lock is held
by its connection, so it cannot be taken through the pooled adapter -- hence the adapter->pool
registry rather than a new statement.

Visual capture failures were logged but never counted, so a browserless outage was invisible in
Prometheus while it degraded every screenshot to a dash cell -- and since the screenshot gate
treats absent evidence as a close signal, that outage could close legitimate visual PRs before
anyone noticed.
…on lock, and the capture metric

Adds the pg-queue drain-deadline regression and its completing-drain invariant, the pool
registry, and a render-failure metric test proving a browserless outage is now alertable.
Three best-effort/defensive arms are annotated with their reasons rather than given contrived
tests: the advisory unlock's catch (a failed unlock means a broken session, and releasing the
client drops the lock anyway), and the abandoned-set empty arm plus its catch (both degrade to
the pre-#9485 lease-expiry path this block exists to improve on).
@loopover-orb

loopover-orb Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Important

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏳ LoopOver is waiting…

LoopOver has seen this pull request and is waiting on CI checks to finish before reviewing it. This comment will update once the review runs.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed · 🟨 Waiting

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 28, 2026
@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 28, 2026
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 72 bytes (0.0%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
loopover-ui 7.64MB 72 bytes (0.0%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: loopover-ui

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/add-scalar-classes-DFGb2zvF.js (New) 2.16MB 2.16MB 100.0% 🚀
assets/tanstack-vendor-DcvWwWnK.js (New) 862.21kB 862.21kB 100.0% 🚀
assets/docs.fumadocs-spike-api-reference-r2qmppYN.js (New) 443.45kB 443.45kB 100.0% 🚀
assets/AgentScalarChatInterface.vue-D9tqyh8t.js (New) 201.7kB 201.7kB 100.0% 🚀
assets/modal-h7WjmIT9.js (New) 184.5kB 184.5kB 100.0% 🚀
assets/client-ClPJbz4H.js (New) 151.47kB 151.47kB 100.0% 🚀
assets/self-hosting-configuration-COab1cgJ.js (New) 101.96kB 101.96kB 100.0% 🚀
assets/maintainer-panel-Da2qpqZD.js (New) 78.99kB 78.99kB 100.0% 🚀
assets/routes-BVqu1brA.js (New) 35.96kB 35.96kB 100.0% 🚀
assets/owner-panel-CCQXsPZf.js (New) 27.92kB 27.92kB 100.0% 🚀
assets/app-DLjiXil4.js (New) 25.78kB 25.78kB 100.0% 🚀
assets/ui-vendor-mUV_tMLi.js (New) 24.57kB 24.57kB 100.0% 🚀
assets/miner-panel-DPtK1fDb.js (New) 20.24kB 20.24kB 100.0% 🚀
assets/app.runs-A1qmO5MP.js (New) 20.22kB 20.22kB 100.0% 🚀
assets/api._op-BqzAe9Km.js (New) 17.57kB 17.57kB 100.0% 🚀
assets/self-hosting-docs-audit-DJLrluUs.js (New) 16.6kB 16.6kB 100.0% 🚀
assets/docs._slug-DrP_xh_e.js (New) 15.52kB 15.52kB 100.0% 🚀
assets/playground-panel-BmwOrUu3.js (New) 14.42kB 14.42kB 100.0% 🚀
assets/fairness-Dw_Z7_o_.js (New) 10.73kB 10.73kB 100.0% 🚀
assets/app.audit-B4yR7KQ6.js (New) 10.08kB 10.08kB 100.0% 🚀
assets/app.config-generator-CIvQuzWq.js (New) 10.06kB 10.06kB 100.0% 🚀
assets/maintainers-BhOIug_z.js (New) 8.06kB 8.06kB 100.0% 🚀
assets/miners-By-tCmyG.js (New) 7.91kB 7.91kB 100.0% 🚀
assets/agents-D-kZQIOw.js (New) 7.74kB 7.74kB 100.0% 🚀
assets/commands-panel-DYVwc_2_.js (New) 6.65kB 6.65kB 100.0% 🚀
assets/maintainer-workflow-IkeI55ns.js (New) 6.52kB 6.52kB 100.0% 🚀
assets/digest-panel-D6sHQrtm.js (New) 6.15kB 6.15kB 100.0% 🚀
assets/repos._owner._repo.quality-DH6d5vwQ.js (New) 6.14kB 6.14kB 100.0% 🚀
assets/docs-nav-D5-voSa2.js (New) 6.01kB 6.01kB 100.0% 🚀
assets/docs.index-_EvywpD2.js (New) 5.95kB 5.95kB 100.0% 🚀
assets/api.index-DX05pKdI.js (New) 4.7kB 4.7kB 100.0% 🚀
assets/docs-D-O7Dbux.js (New) 2.7kB 2.7kB 100.0% 🚀
assets/api-CoD9pdEc.js (New) 2.69kB 2.69kB 100.0% 🚀
assets/docs-page-Dv06YfD3.js (New) 2.1kB 2.1kB 100.0% 🚀
assets/table-B2zq2Uc6.js (New) 1.75kB 1.75kB 100.0% 🚀
assets/app.workbench-eTikqiTA.js (New) 1.58kB 1.58kB 100.0% 🚀
assets/tabs-C37L0SmV.js (New) 1.39kB 1.39kB 100.0% 🚀
assets/app.repos-BuvuIZKv.js (New) 1.07kB 1.07kB 100.0% 🚀
assets/input-Di_dc2sg.js (New) 796 bytes 796 bytes 100.0% 🚀
assets/file-cog-DxJm0yH7.js (New) 758 bytes 758 bytes 100.0% 🚀
assets/app.maintainer-C2zS_ZCh.js (New) 502 bytes 502 bytes 100.0% 🚀
assets/app.owner-CFHpqZiy.js (New) 474 bytes 474 bytes 100.0% 🚀
assets/app.commands-CtS0k7_0.js (New) 455 bytes 455 bytes 100.0% 🚀
assets/app.playground-DSoymPFf.js (New) 442 bytes 442 bytes 100.0% 🚀
assets/index-CUMaHCrP.js (New) 438 bytes 438 bytes 100.0% 🚀
assets/app.digest-DETPMbFW.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/eye-off-icGq_6zg.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/app.miner-YOIkwoyD.js (New) 422 bytes 422 bytes 100.0% 🚀
assets/key-round-Dh2apGP9.js (New) 355 bytes 355 bytes 100.0% 🚀
assets/bot-4v5ppCC2.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/trash-2-D4SDSOgm.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/save-9tbqCrCZ.js (New) 327 bytes 327 bytes 100.0% 🚀
assets/git-pull-request-arrow-B5WVZOiW.js (New) 321 bytes 321 bytes 100.0% 🚀
assets/list-checks-Dj0w3gEF.js (New) 279 bytes 279 bytes 100.0% 🚀
assets/compass-D7TBJHvN.js (New) 251 bytes 251 bytes 100.0% 🚀
assets/history-BcyUuY_8.js (New) 237 bytes 237 bytes 100.0% 🚀
assets/message-square-8KIvQ7qY.js (New) 233 bytes 233 bytes 100.0% 🚀
assets/lock-BFXmCx2R.js (New) 206 bytes 206 bytes 100.0% 🚀
assets/rotate-cw-oqpXF-_d.js (New) 201 bytes 201 bytes 100.0% 🚀
assets/play-BEb2Gw0B.js (New) 190 bytes 190 bytes 100.0% 🚀
assets/circle-check-TibR41W7.js (New) 178 bytes 178 bytes 100.0% 🚀
assets/search-254dOzd1.js (New) 174 bytes 174 bytes 100.0% 🚀
assets/add-scalar-classes-DUQb7lQc.js (Deleted) -2.16MB 0 bytes -100.0% 🗑️
assets/tanstack-vendor-sUd4-iZs.js (Deleted) -862.21kB 0 bytes -100.0% 🗑️
assets/docs.fumadocs-spike-api-reference-C8dKd-fh.js (Deleted) -443.45kB 0 bytes -100.0% 🗑️
assets/AgentScalarChatInterface.vue-66cSI5DN.js (Deleted) -201.7kB 0 bytes -100.0% 🗑️
assets/modal-DiWKRGp8.js (Deleted) -184.5kB 0 bytes -100.0% 🗑️
assets/client-B15ImyKI.js (Deleted) -151.47kB 0 bytes -100.0% 🗑️
assets/self-hosting-configuration-BLECzfnN.js (Deleted) -101.89kB 0 bytes -100.0% 🗑️
assets/maintainer-panel-L5CmhE3s.js (Deleted) -78.99kB 0 bytes -100.0% 🗑️
assets/routes-DpL6IZHW.js (Deleted) -35.96kB 0 bytes -100.0% 🗑️
assets/owner-panel-BcPJ2s-l.js (Deleted) -27.92kB 0 bytes -100.0% 🗑️
assets/app-ssLhsjEQ.js (Deleted) -25.78kB 0 bytes -100.0% 🗑️
assets/ui-vendor-B6uo62Xa.js (Deleted) -24.57kB 0 bytes -100.0% 🗑️
assets/miner-panel-CHkigePk.js (Deleted) -20.24kB 0 bytes -100.0% 🗑️
assets/app.runs-DgDqQIiV.js (Deleted) -20.22kB 0 bytes -100.0% 🗑️
assets/api._op-Dhyx4jJi.js (Deleted) -17.57kB 0 bytes -100.0% 🗑️
assets/self-hosting-docs-audit-DJJYNi6h.js (Deleted) -16.6kB 0 bytes -100.0% 🗑️
assets/docs._slug-DK7uHo3Z.js (Deleted) -15.52kB 0 bytes -100.0% 🗑️
assets/playground-panel-CNGuSTYF.js (Deleted) -14.42kB 0 bytes -100.0% 🗑️
assets/fairness-BP-jqELb.js (Deleted) -10.73kB 0 bytes -100.0% 🗑️
assets/app.audit-Cj3Wa49F.js (Deleted) -10.08kB 0 bytes -100.0% 🗑️
assets/app.config-generator-BIHynSxb.js (Deleted) -10.06kB 0 bytes -100.0% 🗑️
assets/maintainers-CyuheKjv.js (Deleted) -8.06kB 0 bytes -100.0% 🗑️
assets/miners-g3S0DziV.js (Deleted) -7.91kB 0 bytes -100.0% 🗑️
assets/agents-DZAEolQ-.js (Deleted) -7.74kB 0 bytes -100.0% 🗑️
assets/commands-panel-Dc8E7qzz.js (Deleted) -6.65kB 0 bytes -100.0% 🗑️
assets/maintainer-workflow-Bp1botcD.js (Deleted) -6.52kB 0 bytes -100.0% 🗑️
assets/digest-panel-iJ70-HcL.js (Deleted) -6.15kB 0 bytes -100.0% 🗑️
assets/repos._owner._repo.quality-BNZ02dQd.js (Deleted) -6.14kB 0 bytes -100.0% 🗑️
assets/docs-nav-Cy97kWvg.js (Deleted) -6.01kB 0 bytes -100.0% 🗑️
assets/docs.index-zbb0LSvV.js (Deleted) -5.95kB 0 bytes -100.0% 🗑️
assets/api.index-DCPwDChq.js (Deleted) -4.7kB 0 bytes -100.0% 🗑️
assets/docs-D1foIej0.js (Deleted) -2.7kB 0 bytes -100.0% 🗑️
assets/api-9U9yD_ue.js (Deleted) -2.69kB 0 bytes -100.0% 🗑️
assets/docs-page-D4sOmPJr.js (Deleted) -2.1kB 0 bytes -100.0% 🗑️
assets/table-CbwZT2g3.js (Deleted) -1.75kB 0 bytes -100.0% 🗑️
assets/app.workbench-R1l9c0VC.js (Deleted) -1.58kB 0 bytes -100.0% 🗑️
assets/tabs-VkzrsDbD.js (Deleted) -1.39kB 0 bytes -100.0% 🗑️
assets/app.repos-D-rF86CY.js (Deleted) -1.07kB 0 bytes -100.0% 🗑️
assets/input-_Y89XwQE.js (Deleted) -796 bytes 0 bytes -100.0% 🗑️
assets/file-cog-D9Rm7Faq.js (Deleted) -758 bytes 0 bytes -100.0% 🗑️
assets/app.maintainer-CBojz_cq.js (Deleted) -502 bytes 0 bytes -100.0% 🗑️
assets/app.owner-n0s_W2UQ.js (Deleted) -474 bytes 0 bytes -100.0% 🗑️
assets/app.commands-BHZULm0o.js (Deleted) -455 bytes 0 bytes -100.0% 🗑️
assets/app.playground-DnV52G5B.js (Deleted) -442 bytes 0 bytes -100.0% 🗑️
assets/index-BmiKkSg0.js (Deleted) -438 bytes 0 bytes -100.0% 🗑️
assets/app.digest-De2IGhch.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/eye-off-DM9IXLT5.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/app.miner-JCRnvBKg.js (Deleted) -422 bytes 0 bytes -100.0% 🗑️
assets/key-round-zjwqfLjI.js (Deleted) -355 bytes 0 bytes -100.0% 🗑️
assets/bot-Da7Oy3zg.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/trash-2-DPt7kVCf.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/save-CTGBB9vf.js (Deleted) -327 bytes 0 bytes -100.0% 🗑️
assets/git-pull-request-arrow-D6xt49Y9.js (Deleted) -321 bytes 0 bytes -100.0% 🗑️
assets/list-checks-Cf9j1V8x.js (Deleted) -279 bytes 0 bytes -100.0% 🗑️
assets/compass-NrHQka4m.js (Deleted) -251 bytes 0 bytes -100.0% 🗑️
assets/history-al7sk1Zn.js (Deleted) -237 bytes 0 bytes -100.0% 🗑️
assets/message-square-D6f-PYjd.js (Deleted) -233 bytes 0 bytes -100.0% 🗑️
assets/lock-Bg3aGHXa.js (Deleted) -206 bytes 0 bytes -100.0% 🗑️
assets/rotate-cw-D8URfKp_.js (Deleted) -201 bytes 0 bytes -100.0% 🗑️
assets/play-BMh-2Bzy.js (Deleted) -190 bytes 0 bytes -100.0% 🗑️
assets/circle-check-BrZCp9vq.js (Deleted) -178 bytes 0 bytes -100.0% 🗑️
assets/search-BqH2vsyU.js (Deleted) -174 bytes 0 bytes -100.0% 🗑️

@JSONbored
JSONbored merged commit d6d0b5d into main Jul 28, 2026
7 checks passed
@JSONbored
JSONbored deleted the fix/runtime-shutdown-migration-observability branch July 28, 2026 05:17
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.63%. Comparing base (d1c770c) to head (e55717e).
⚠️ Report is 5 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9512      +/-   ##
==========================================
+ Coverage   89.55%   93.63%   +4.07%     
==========================================
  Files         843      741     -102     
  Lines      110135    60489   -49646     
  Branches    26207    21310    -4897     
==========================================
- Hits        98635    56640   -41995     
+ Misses      10238     2898    -7340     
+ Partials     1262      951     -311     
Flag Coverage Δ
backend 93.63% <100.00%> (-1.64%) ⬇️
control-plane ?
engine ?
rees ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/review/visual/shot.ts 95.73% <100.00%> (+0.68%) ⬆️
src/selfhost/metrics.ts 100.00% <ø> (ø)
src/selfhost/queue-common.ts 98.65% <100.00%> (+<0.01%) ⬆️
src/selfhost/sqlite-queue.ts 99.63% <100.00%> (+<0.01%) ⬆️

... and 275 files with indirect coverage changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. manual-review Gittensor contributor context

Projects

None yet

1 participant