fix(postgres-engine): build-then-swap reconnect() so a failed rebuild can't brick the engine (#1593) (#1906)

The instance-pool reconnect() did disconnect() (nulling _sql) BEFORE connect(),
so a connect() failure during a transient Postgres blip left _sql null for the
rest of the process — every subsequent non-retry-wrapped call then fell through
to the never-connected module singleton and threw 'No database connection',
crashing the autopilot worker into a respawn loop. Build-then-swap: snapshot the
live pool, build a fresh one, end the old only once the new validates, restore
on failure. Keeps upstream's reap-detection + pool-recovery audit. Confirmed by
jalagrange on closed PR #1593; this is the Layer-1 fix he left to us.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ayers
2026-07-17 11:32:23 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cfc120fcb3
commit 9eac872136
2 changed files with 32 additions and 4 deletions
+25 -3
View File
@@ -5374,20 +5374,42 @@ export class PostgresEngine implements BrainEngine {
logPoolRecovery(isReap ? 'reap_detected' : 'reconnect_other', ctx?.error);
} catch { /* audit is best-effort */ }
// Instance pool: BUILD-THEN-SWAP. Snapshot the live pool, build a fresh one,
// and only tear the old one down once the new one is proven live. The naive
// disconnect()-then-connect() ordering nulls `_sql` BEFORE the rebuild, so a
// connect() failure during a transient blip leaves `_sql === null` for the
// rest of the process. A dead `_sql` falls through to the module-singleton
// accessor — which the autopilot process never connected — so every
// subsequent non-retry-wrapped call (getConfig, per-phase reads) throws
// "No database connection: connect() has not been called" and crashes the
// worker into a respawn loop (#1593 root-cause). Holding the old pool until
// the new one validates keeps the engine usable; postgres.js pools self-heal
// on the next query once Postgres is back, and batchRetry's backoff retries.
const oldSql = this._sql;
const oldManager = this.connectionManager;
try {
// Instance pool: tear down old pool (best-effort — it may already be dead).
try { await this.disconnect(); } catch { /* swallow */ }
this._sql = null; // force connect() to build a fresh pool, not reuse
// connect() validates the new pool via `SELECT 1` before returning.
await this.connect(this._savedConfig);
// New pool is live — discard the old one best-effort.
if (oldSql) { try { await oldSql.end({ timeout: 5 }); } catch { /* swallow */ } }
try {
const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts');
logPoolRecovery('reconnect_succeeded');
} catch { /* best-effort */ }
} catch (err) {
// Rebuild failed: tear down the half-built pool (if any) and restore the
// prior live pool + manager so the engine stays usable.
if (this._sql && this._sql !== oldSql) {
try { await this._sql.end({ timeout: 5 }); } catch { /* swallow */ }
}
this._sql = oldSql;
this.connectionManager = oldManager;
try {
const { logPoolRecovery } = await import('./audit/pool-recovery-audit.ts');
logPoolRecovery('reconnect_failed', err);
} catch { /* best-effort */ }
throw err;
throw err; // let batchRetry's backoff handle the retry
} finally {
this._reconnecting = false;
}
+7 -1
View File
@@ -311,7 +311,13 @@ describe('Eng-review D3 — executeRaw has no per-call retry wrapper', () => {
// can classify the triggering error for the pool-recovery audit. Match the
// prefix so both `reconnect()` and `reconnect(ctx?)` satisfy the contract.
expect(src).toContain('async reconnect(');
expect(src).toContain('await this.disconnect()');
// #1593 build-then-swap: reconnect() no longer disconnect()-then-connect()s
// on the instance-pool path (that nulled _sql, so a connect() failure during
// a transient blip left the engine permanently dead → worker respawn loop).
// It now snapshots the live pool, builds a fresh one, and ends the OLD pool
// only once the new one validates — restoring it on failure. Assert the
// old-pool teardown, which is the recovery contract this test guards.
expect(src).toContain('await oldSql.end(');
});
it('Supervisor still has the 3-strikes-then-reconnect path', () => {