Files
gbrain/test/embed-helper-migration.test.ts
T
613da94093 v0.42.29.0 fix(minions): long-job abort-honoring + attempt accounting + supervisor singleton; topic-aware voice (#1737, #1849, #1851) (#1943)
* 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>
2026-06-07 08:12:50 -07:00

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/);
});
});