mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +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>
111 lines
5.4 KiB
TypeScript
111 lines
5.4 KiB
TypeScript
/**
|
|
* Structural regression test for the embed.ts → worker-pool migration
|
|
* (v0.41.15.0, T3, REGRESSION per IRON RULE).
|
|
*
|
|
* What this test asserts:
|
|
* 1. embed.ts imports `runSlidingPool` from `../core/worker-pool.ts`.
|
|
* 2. Both pre-migration sliding-pool sites are GONE:
|
|
* - the `let nextIdx = 0; async function worker() {}` shape
|
|
* - the `Promise.all(Array.from({ length: numWorkers }, () => worker()))`
|
|
* shape (which paired with the above).
|
|
* 3. Both migration sites call `runSlidingPool({ items: ..., workers: CONCURRENCY, ... })`.
|
|
* 4. The legacy `CONCURRENCY = parseInt(process.env.GBRAIN_EMBED_CONCURRENCY...)`
|
|
* default is preserved (codex finding #13 — embed's pre-existing default
|
|
* pre-dates autoConcurrency; the migration must NOT route it through
|
|
* resolveWorkersWithClamp). This is the load-bearing back-compat for
|
|
* every existing brain that relies on the 20-worker embed sweep.
|
|
*
|
|
* Why structural rather than end-to-end:
|
|
* Per codex finding #16/#17, embed.ts byte-equality via stubbed transport
|
|
* was overclaimed in the original plan — it can't prove provider retry
|
|
* behavior, SDK usage accounting, or progress event ordering preservation.
|
|
* The pre-migration sliding pool didn't have any of those guarantees either;
|
|
* it just had a small inline `while (nextIdx < pages.length)` loop. The
|
|
* honest contract is "the same pages still get embedded by the same workers
|
|
* reading from the same queue, just through a helper now." That's a
|
|
* structural property — easiest to pin by source grep.
|
|
*
|
|
* The helper's contracts (atomic claim, abort propagation, failures[]
|
|
* shape, BudgetExhausted bypass) are exhaustively tested in
|
|
* test/worker-pool.test.ts; embed inherits those by import.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { readFileSync } from 'fs';
|
|
import { resolve } from 'path';
|
|
|
|
const REPO_ROOT = resolve(import.meta.dir, '..');
|
|
const EMBED_PATH = resolve(REPO_ROOT, 'src/commands/embed.ts');
|
|
const EMBED_SOURCE = readFileSync(EMBED_PATH, 'utf-8');
|
|
|
|
describe('embed.ts → worker-pool migration (T3)', () => {
|
|
test('imports runSlidingPool from worker-pool helper', () => {
|
|
expect(EMBED_SOURCE).toMatch(
|
|
/import\s*\{\s*runSlidingPool\s*\}\s*from\s*['"]\.\.\/core\/worker-pool\.ts['"]/,
|
|
);
|
|
});
|
|
|
|
test('calls runSlidingPool at least twice (embedAll + embedAllStale paths)', () => {
|
|
const matches = EMBED_SOURCE.match(/runSlidingPool\(/g) ?? [];
|
|
expect(matches.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('pre-migration `let nextIdx = 0; async function worker()` shape is gone', () => {
|
|
// The exact shape the migration replaced. Either token alone could
|
|
// legitimately appear in a comment or string literal; both together
|
|
// on adjacent lines indicates a regression to the inline pool.
|
|
const inlinePool =
|
|
/let\s+nextIdx\s*=\s*0\s*;\s*\n\s*async\s+function\s+worker\s*\(/;
|
|
expect(EMBED_SOURCE).not.toMatch(inlinePool);
|
|
});
|
|
|
|
test('pre-migration `Promise.all(Array.from({ length: numWorkers }, () => worker()))` is gone', () => {
|
|
// Migration-specific shape from the original inline pool. The
|
|
// generic `Promise.all(...)` pattern is still allowed elsewhere
|
|
// (gateway, etc.); only the worker-pool-fanout shape is banned.
|
|
const fanout =
|
|
/Promise\.all\(Array\.from\(\{\s*length:\s*numWorkers\s*\}/;
|
|
expect(EMBED_SOURCE).not.toMatch(fanout);
|
|
});
|
|
|
|
test('preserves GBRAIN_EMBED_CONCURRENCY default of 20 (codex #13)', () => {
|
|
// The pre-migration default must survive: env override or 20.
|
|
// Routing through resolveWorkersWithClamp would change this behavior
|
|
// (autoConcurrency returns 1 for small file counts even on Postgres),
|
|
// breaking every existing brain that relies on the 20-worker default.
|
|
expect(EMBED_SOURCE).toMatch(
|
|
/parseInt\(process\.env\.GBRAIN_EMBED_CONCURRENCY\s*\|\|\s*['"]20['"]/,
|
|
);
|
|
});
|
|
|
|
test('runSlidingPool call sites pass `workers: CONCURRENCY`', () => {
|
|
// The migrated calls must thread the pre-existing CONCURRENCY value
|
|
// through, not invent a new default. Catches the regression where
|
|
// a future contributor swaps `workers: CONCURRENCY` for a literal.
|
|
// Allow optional commas/whitespace — match both call sites' shape.
|
|
const callSites = EMBED_SOURCE.match(
|
|
/runSlidingPool\(\s*\{[\s\S]*?workers:\s*CONCURRENCY/g,
|
|
);
|
|
expect(callSites?.length ?? 0).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('embedAllStale path still threads the cancellation signal into pool', () => {
|
|
// The pre-migration code checked `!budgetSignal.aborted` in the worker
|
|
// loop. The migration moves that check into the helper via the `signal`
|
|
// option. #1737 then composed the wall-clock budget with the caller's
|
|
// abort into `effectiveSignal` (anySignal(budgetSignal, externalSignal)) so
|
|
// a killed job stops the pool too. If a future refactor drops the signal,
|
|
// both wall-clock budget AND cooperative abort cancellation regress.
|
|
expect(EMBED_SOURCE).toMatch(
|
|
/runSlidingPool\(\s*\{[\s\S]*?signal:\s*effectiveSignal[\s\S]*?\}\)/,
|
|
);
|
|
});
|
|
|
|
test('failureLabel projector uses page.slug (memory bound on large brains)', () => {
|
|
// Per codex #10 + D7, failures[] must not store full Page objects.
|
|
// We can't test the runtime behavior without a full mock engine, but
|
|
// we CAN assert the call sites pass the projector explicitly.
|
|
expect(EMBED_SOURCE).toMatch(/failureLabel:\s*\(page\)\s*=>\s*page\.slug/);
|
|
});
|
|
});
|