mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix(minions): honest attempt accounting + cooperative abort-honoring + per-handler timeouts (#1737) - Wall-clock and stall dead-letter paths now increment attempts_made (terminal, no retry — wall-clock fires at 2x cumulative timeout; retrying non-idempotent embed/subagent work would duplicate side effects). Surface stalled_counter in jobs get so 'started 3 / stalled 2 / attempts 0' reads true instead of looking like broken accounting. - Thread AbortSignal through embed-backfill/autopilot-cycle -> runPhaseEmbed -> runEmbedCore -> embedAll(Stale)/embedPage, checking it on BOTH --stale and --all paths and between embed batches. A timed-out embed phase now bails within a batch, so the cycle finally releases gbrain_cycle_locks instead of running the full 10-15 min after the job was killed (the daily cycle-wedge). New shared src/core/abort-check.ts (isAborted/throwIfAborted/anySignal). - Per-handler default wall-clock budget (handler-timeouts.ts) stamped at submit for long handlers without an explicit timeout_ms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(minions): queue-scoped DB supervisor singleton + canonical pidfile + doctor max-rss check (#1849) - Acquire a queue-scoped DB lock (tryAcquireDbLock, keyed on the raw DB identity + queue) on supervisor.start(): a second supervisor on the same (db, queue) fails fast with exit 2 regardless of $HOME/--pid-file. Refresh on a dedicated timer; on refresh failure past the threshold, fail SAFE (exit non-zero) before the TTL could lapse and let a second supervisor take over. Release on shutdown. - Canonical default pidfile keyed on brain id (currentBrainId, config-only, no DB connect) so two brains under one HOME no longer share supervisor.pid. - doctor: new supervisor_singleton check surfaces the effective --max-rss (from the started audit event) and warns when the lock holder's (host,pid) differs from the local pidfile — comparing host+pid, not bare pid. Registered in doctor-categories. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent-voice): topic-aware persona context via server-resolved topicId (#1851) Summon Mars/Venus into a specific conversation topic so they boot already knowing the recent thread. A per-topic call link carries only topicId (+ optional display topicName); the server resolves the recent-conversation context from the brain (topics/<topicId>.md) — topic CONTENT is never accepted over the wire (that would be prompt injection + a leak into URLs/referrers/logs). topicId is a strict slug with a path-traversal guard. New '# Topic Context' prompt slot injected after the persona body so identity-first ordering still wins; persona identity unchanged. No topicId -> generic behavior. Contract doc + persona skill docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: file #1737 slot-reservation fair-scheduling follow-up TODO (F7) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.42.29.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync KEY_FILES.md for v0.42.29.0 minions + abort wave (#1737, #1849) Fold the #1737/#1849 behavior into the existing per-file entries and add the two new core files, keeping the doc at current-state truth: - queue.ts: honest attempt accounting on wall-clock + stall dead-letter paths; defaultTimeoutMsFor stamping at submit. - supervisor.ts: queue-scoped DB singleton lock (supervisorLockId, classifySupervisorSingleton, LOCK_LOST, refresh-fail-safe, brain-id pidfile, max_rss_mb audit). - worker-registry.ts: currentDbIdentity(). - New entries: src/core/abort-check.ts, src/core/minions/handler-timeouts.ts. - New doctor.ts extension: supervisor_singleton check. - cycle.ts / embed.ts extensions: AbortSignal threading note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
6.2 KiB
TypeScript
159 lines
6.2 KiB
TypeScript
/**
|
|
* #1849: queue-scoped DB supervisor singleton.
|
|
*
|
|
* The pidfile guard is mutually exclusive only per pidfile PATH; the DB lock
|
|
* makes the (database, queue) pair the mutex domain so two supervisors with
|
|
* different $HOME / --pid-file can't both run on one queue. These tests pin:
|
|
* - the lock id keys on DB identity + queue (T2)
|
|
* - a second acquire of the same (db, queue) lock is refused (the singleton)
|
|
* - different queues don't collide
|
|
* - refresh-failure past the threshold fails SAFE (exits non-zero) (F1A)
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, spyOn } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { tryAcquireDbLock } from '../src/core/db-lock.ts';
|
|
import { MinionSupervisor, ExitCodes, supervisorLockId, classifySupervisorSingleton } from '../src/core/minions/supervisor.ts';
|
|
import type { DbLockHandle } from '../src/core/db-lock.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw(`DELETE FROM gbrain_cycle_locks WHERE id LIKE 'gbrain-supervisor:%'`);
|
|
});
|
|
|
|
describe('#1849 supervisorLockId', () => {
|
|
test('keys on DB identity AND queue', () => {
|
|
expect(supervisorLockId('default', 'postgres://x')).toBe('gbrain-supervisor:postgres://x:default');
|
|
expect(supervisorLockId('shell', 'postgres://x')).toBe('gbrain-supervisor:postgres://x:shell');
|
|
// Different DB identity → different lock even for the same queue.
|
|
expect(supervisorLockId('default', 'postgres://a'))
|
|
.not.toBe(supervisorLockId('default', 'postgres://b'));
|
|
});
|
|
});
|
|
|
|
describe('#1849 classifySupervisorSingleton (doctor)', () => {
|
|
test('no live lock → no_lock', () => {
|
|
expect(classifySupervisorSingleton({
|
|
lockLive: false, lockHolderHost: 'h', lockHolderPid: 1, localHost: 'h', localPid: 1,
|
|
})).toBe('no_lock');
|
|
});
|
|
|
|
test('live lock held by the local (host,pid) → single', () => {
|
|
expect(classifySupervisorSingleton({
|
|
lockLive: true, lockHolderHost: 'box', lockHolderPid: 42, localHost: 'box', localPid: 42,
|
|
})).toBe('single');
|
|
});
|
|
|
|
test('live lock held by a DIFFERENT pid → mismatch (rogue second supervisor)', () => {
|
|
expect(classifySupervisorSingleton({
|
|
lockLive: true, lockHolderHost: 'box', lockHolderPid: 99, localHost: 'box', localPid: 42,
|
|
})).toBe('mismatch');
|
|
});
|
|
|
|
test('same pid but DIFFERENT host → mismatch (bare pid is meaningless cross-host)', () => {
|
|
expect(classifySupervisorSingleton({
|
|
lockLive: true, lockHolderHost: 'other', lockHolderPid: 42, localHost: 'box', localPid: 42,
|
|
})).toBe('mismatch');
|
|
});
|
|
|
|
test('live lock but no local pidfile → mismatch', () => {
|
|
expect(classifySupervisorSingleton({
|
|
lockLive: true, lockHolderHost: 'box', lockHolderPid: 42, localHost: 'box', localPid: null,
|
|
})).toBe('mismatch');
|
|
});
|
|
});
|
|
|
|
describe('#1849 DB lock is the real singleton', () => {
|
|
test('second acquire of the same (db, queue) lock is refused', async () => {
|
|
const id = supervisorLockId('default', 'pglite:test');
|
|
const first = await tryAcquireDbLock(engine, id, 5);
|
|
expect(first).not.toBeNull();
|
|
// A second supervisor (different pidfile, same db+queue) gets null → exit 2.
|
|
const second = await tryAcquireDbLock(engine, id, 5);
|
|
expect(second).toBeNull();
|
|
// After release, a fresh supervisor can take over.
|
|
await first!.release();
|
|
const third = await tryAcquireDbLock(engine, id, 5);
|
|
expect(third).not.toBeNull();
|
|
await third!.release();
|
|
});
|
|
|
|
test('different queues on the same DB do not collide', async () => {
|
|
const a = await tryAcquireDbLock(engine, supervisorLockId('default', 'pglite:test'), 5);
|
|
const b = await tryAcquireDbLock(engine, supervisorLockId('shell', 'pglite:test'), 5);
|
|
expect(a).not.toBeNull();
|
|
expect(b).not.toBeNull();
|
|
await a!.release();
|
|
await b!.release();
|
|
});
|
|
});
|
|
|
|
describe('#1849 refresh-failure fails safe (F1A)', () => {
|
|
test('exits LOCK_LOST after the failure threshold; tolerates a single blip', async () => {
|
|
const sup = new MinionSupervisor(engine, { cliPath: '/bin/sh', healthInterval: 0, json: true });
|
|
const exitSpy = spyOn(process, 'exit').mockImplementation(((_code?: number) => {
|
|
throw new Error(`exit:${_code}`); // stop execution like the real exit would
|
|
}) as never);
|
|
|
|
let refreshCalls = 0;
|
|
const failingLock: DbLockHandle = {
|
|
id: 'x',
|
|
refresh: async () => { refreshCalls++; throw new Error('pooler down'); },
|
|
release: async () => {},
|
|
};
|
|
sup._setDbLockForTests(failingLock);
|
|
|
|
try {
|
|
// First two failures: tolerated (counter climbs, no exit).
|
|
await sup._refreshDbLockForTests();
|
|
await sup._refreshDbLockForTests();
|
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
// Third failure crosses the threshold → shutdown → process.exit(LOCK_LOST).
|
|
try { await sup._refreshDbLockForTests(); } catch { /* exit stub throws */ }
|
|
expect(exitSpy).toHaveBeenCalledWith(ExitCodes.LOCK_LOST);
|
|
expect(refreshCalls).toBe(3);
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
test('a successful refresh resets the failure counter', async () => {
|
|
const sup = new MinionSupervisor(engine, { cliPath: '/bin/sh', healthInterval: 0, json: true });
|
|
const exitSpy = spyOn(process, 'exit').mockImplementation(((_code?: number) => {
|
|
throw new Error(`exit:${_code}`);
|
|
}) as never);
|
|
|
|
let mode: 'fail' | 'ok' = 'fail';
|
|
const flakyLock: DbLockHandle = {
|
|
id: 'x',
|
|
refresh: async () => { if (mode === 'fail') throw new Error('blip'); },
|
|
release: async () => {},
|
|
};
|
|
sup._setDbLockForTests(flakyLock);
|
|
|
|
try {
|
|
await sup._refreshDbLockForTests(); // fail 1
|
|
await sup._refreshDbLockForTests(); // fail 2
|
|
mode = 'ok';
|
|
await sup._refreshDbLockForTests(); // success → reset
|
|
mode = 'fail';
|
|
await sup._refreshDbLockForTests(); // fail 1 again
|
|
await sup._refreshDbLockForTests(); // fail 2
|
|
// Counter was reset, so we are NOT past threshold yet.
|
|
expect(exitSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|