Files
gbrain/test/handlers-embed-backfill.test.ts
T
23e0541d9b fix(cycle): budget the patterns subagent from remaining job time — one phase's fixed 35-min worst case defeats any interval-derived cycle budget (#2781) (#2959)
* fix(cycle): budget the patterns subagent from remaining job time, not a fixed constant (#2781)

The autopilot-cycle job gets an interval-derived timeout stamped at submit,
but the patterns phase submits its subagent with a fixed 30-min job timeout
and waits up to 35 min — one phase's worst case exceeds ANY interval-derived
budget <= 35 min, so the parent job dead-letters mid-patterns and the tail
phases (consolidate -> schema-suggest) starve for days (#2781, the deeper
half left open by the #2852 dispatch-floor fix).

- MinionJobContext.deadlineAtMs: absolute deadline from the claim-time
  timeout_at stamp (the DB ground truth handleTimeouts() sweeps against;
  re-stamped on every claim so retries get a fresh budget). Null when the
  job has no per-job timeout.
- worker: the per-job abort timer now derives its delay from timeout_at
  when present, so the in-process timer, the DB sweeper, and the
  handler-visible deadline agree on ONE absolute instant.
- autopilot-cycle + autopilot-global-maintenance handlers thread
  deadlineAtMs into runCycle; CycleOpts carries it to the patterns phase.
- patterns: clampSubagentBudgets() derives BOTH the child job timeout and
  the wait timeout from the same child deadline (parent deadline minus a
  60s stop-margin reserve — enough for the wait poll + force-evict grace
  + cleanup, deliberately NOT a promise that tail phases complete). Under
  a 2-min minimum the phase skips honestly (insufficient_cycle_budget)
  instead of submitting a guaranteed-kill LLM call; the next cycle
  retries with a fresh budget.
- Direct callers (gbrain dream) pass no deadline and keep the configured
  timeouts unchanged.

Follow-up (separate PR): synthesize has the same shape plus sequential
per-child waits that accumulate N x subagent_wait_timeout_ms past any
parent budget; it needs per-wait remaining-time recomputation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaNDjecPPvhC7LRqkxkhGF

* fix(cycle): address review — cancel timed-out patterns child; thread deadline through phase-wrapper handlers

- P1: the child's timeout_ms clock starts at ITS claim, so a queued child
  could outlive the parent deadline the wait was clamped to. On wait
  timeout, cancelJob strips it (waiting -> cancelled; active -> lock
  stripped, worker abort fires next renew tick).
- P2: makePhaseHandler (standalone patterns/synthesize/... minion jobs)
  now threads job.deadlineAtMs into runCycle like the autopilot handlers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RaNDjecPPvhC7LRqkxkhGF

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:56:00 -07:00

145 lines
5.2 KiB
TypeScript

/**
* Tests for src/core/minions/handlers/embed-backfill.ts (v0.40 D2, D6).
*
* Validates the handler-side contract:
* - Happy path: embeds, returns 'success' with chunk + spend counts
* - D2 lock: second concurrent handler call returns 'already_in_progress'
* - D15.1 finally: lock ALWAYS releases (try/finally even on abort)
*
* Hermetic — uses injected embedFn via the underlying embedStaleForSource
* test seam? No — the handler doesn't expose embedFn passthrough. Instead
* we exercise the handler against a brain with zero stale chunks so no
* actual embed call lands. That gives us a deterministic test of the lock
* + budget + status branches without needing a fake gateway.
*
* The kill-resume contract is covered by test/embed-stale.test.ts at the
* helper layer; the handler just routes through.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { makeEmbedBackfillHandler } from '../src/core/minions/handlers/embed-backfill.ts';
import { tryAcquireDbLock } from '../src/core/db-lock.ts';
import type { MinionJobContext } from '../src/core/minions/types.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 30000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
// Clean minion_jobs + lock rows. Preserve config (schema version + flags).
await engine.executeRaw('DELETE FROM minion_jobs');
await engine.executeRaw(`DELETE FROM gbrain_cycle_locks WHERE id LIKE 'gbrain-embed-backfill:%'`);
});
/** Build a minimal MinionJobContext for testing. */
function fakeJob(data: Record<string, unknown>): MinionJobContext {
const controller = new AbortController();
return {
id: 1,
name: 'embed-backfill',
data,
attempts_made: 0,
signal: controller.signal,
deadlineAtMs: null,
shutdownSignal: controller.signal,
updateProgress: async () => {},
updateTokens: async () => {},
log: async () => {},
isActive: async () => true,
readInbox: async () => [],
};
}
describe('embed-backfill handler — happy path', () => {
test('zero stale chunks → success with embedded=0', async () => {
const handler = makeEmbedBackfillHandler(engine);
const result = await handler(fakeJob({ sourceId: 'default' }));
expect(result).toMatchObject({
status: 'success',
sourceId: 'default',
embedded: 0,
chunksProcessed: 0,
pagesProcessed: 0,
});
});
test('throws when sourceId missing', async () => {
const handler = makeEmbedBackfillHandler(engine);
await expect(handler(fakeJob({}))).rejects.toThrow(/sourceId is required/);
});
test('throws when sourceId is empty string', async () => {
const handler = makeEmbedBackfillHandler(engine);
await expect(handler(fakeJob({ sourceId: '' }))).rejects.toThrow(/sourceId is required/);
});
});
describe('embed-backfill handler — D2 lock contract', () => {
test('IRON-RULE: second call returns already_in_progress when lock is held', async () => {
// Hold the per-source lock externally
const lock = await tryAcquireDbLock(engine, 'gbrain-embed-backfill:default', 60);
expect(lock).not.toBeNull();
try {
const handler = makeEmbedBackfillHandler(engine);
const result = await handler(fakeJob({ sourceId: 'default' }));
expect(result).toMatchObject({
status: 'already_in_progress',
sourceId: 'default',
embedded: 0,
spentUsd: 0,
});
} finally {
await lock?.release();
}
});
test('different sources do not contend on each other locks', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name, config) VALUES ('other-src', 'other-src', '{"federated":true}') ON CONFLICT (id) DO NOTHING`,
);
const lockA = await tryAcquireDbLock(engine, 'gbrain-embed-backfill:default', 60);
expect(lockA).not.toBeNull();
try {
// 'other-src' should still succeed
const handler = makeEmbedBackfillHandler(engine);
const result = await handler(fakeJob({ sourceId: 'other-src' }));
expect(result.status).toBe('success');
} finally {
await lockA?.release();
}
});
test('IRON-RULE: lock is released after handler completes (try/finally)', async () => {
const handler = makeEmbedBackfillHandler(engine);
await handler(fakeJob({ sourceId: 'default' }));
// After handler returns, the lock row should NOT block a fresh acquire.
const lock = await tryAcquireDbLock(engine, 'gbrain-embed-backfill:default', 60);
expect(lock).not.toBeNull();
await lock?.release();
});
test('IRON-RULE: lock released on throw (sourceId-missing path)', async () => {
const handler = makeEmbedBackfillHandler(engine);
try {
await handler(fakeJob({})); // throws before lock is acquired
} catch {
// expected
}
// Lock was never acquired (throw happened in parseParams pre-lock),
// so the row should be cleanly absent. Verify a fresh acquire works.
const lock = await tryAcquireDbLock(engine, 'gbrain-embed-backfill:default', 60);
expect(lock).not.toBeNull();
await lock?.release();
});
});