Files
gbrain/test/handlers.test.ts
T
5d9dc4393e v0.22.10 fix: autopilot-cycle handler forwards job.data.phases to runCycle (#521)
* fix: autopilot-cycle handler forwards job.data.phases to runCycle

The autopilot-cycle handler always ran ALL_PHASES regardless of job data.
This caused production stalls when the embed phase had a large backlog
(17K+ stale chunks) that exceeded the 30-minute job timeout. Every 5-min
cycle would start, hit the embed wall, stall, and get force-killed —
creating an infinite stall loop that kept the queue perpetually unhealthy.

The fix validates job.data.phases against ALL_PHASES (preventing injection)
and forwards the selected phases to runCycle(). Callers can now submit
fast cycles (lint+backlinks+sync+extract) on a 5-min cron and run embed
separately with a longer timeout during off-peak hours.

If phases is omitted, not an array, or filters to empty, behavior is
unchanged (all phases run).

Tests: 4 new cases covering phase restriction, invalid name filtering,
empty array fallback, and non-array type safety.

* test: widen autopilot-cycle handler-block window for phases-passthrough

The regression guard sliced the first 500 chars after `worker.register('autopilot-cycle'`
and asserted `signal: job.signal` was present. The phase-validation block added in
787ec7de pushed the signal arg past that boundary, so CI test shard 3 failed even
though the handler still propagates the signal correctly. Bump the window to 2000.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.22.10)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: sync release notes for v0.22.10

Note autopilot-cycle phases passthrough fix on the src/commands/jobs.ts
key-files annotation so future readers know the handler honors
job.data.phases (validated against ALL_PHASES) as of v0.22.10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: regenerate llms-full.txt for v0.22.10 CLAUDE.md update

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:13:05 -07:00

225 lines
9.4 KiB
TypeScript

/**
* Tests for registerBuiltinHandlers in src/commands/jobs.ts.
*
* Covers:
* - Every expected handler name is registered.
* - autopilot-cycle handler returns { partial, status, report } (v0.17
* runCycle-backed shape) when any step fails — does NOT throw itself
* (critical invariant: an intermittent phase failure must not cause
* the Minion to retry and block every future cycle).
*/
import { describe, test, expect, beforeAll, afterAll, mock } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MinionWorker } from '../src/core/minions/worker.ts';
import { registerBuiltinHandlers } from '../src/commands/jobs.ts';
let engine: PGLiteEngine;
let worker: MinionWorker;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
worker = new MinionWorker(engine, { queue: 'test' });
await registerBuiltinHandlers(worker, engine);
}, 30_000);
afterAll(async () => {
await engine.disconnect();
});
describe('registerBuiltinHandlers', () => {
test('registers all built-in handler names', () => {
const names = worker.registeredNames;
// Existing handlers from pre-v0.11.1
expect(names).toContain('sync');
expect(names).toContain('embed');
expect(names).toContain('lint');
expect(names).toContain('import');
// New in v0.11.1 (Tier 1 + autopilot-cycle)
expect(names).toContain('extract');
expect(names).toContain('backlinks');
expect(names).toContain('autopilot-cycle');
});
test('total handler count includes all 7 names', () => {
expect(worker.registeredNames.length).toBeGreaterThanOrEqual(7);
});
});
describe('autopilot-cycle handler — partial failure does NOT throw', () => {
test('phase failure returns partial:true + structured report, no throw', async () => {
// Call the handler directly with a job pointing at a nonexistent repo.
// Filesystem-dependent phases (lint, backlinks, sync) all fail because
// the dir / .git repo isn't there. DB-dependent phases (extract,
// embed, orphans) run fine against the in-memory test engine.
//
// CRITICAL INVARIANT: the handler must return successfully even when
// phases fail. Throwing would cause the Minion to retry, blocking
// every future cycle on an intermittent bug. v0.17 moves this
// guarantee into runCycle itself (per-phase try/catch in cycle.ts).
const handler = (worker as any).handlers.get('autopilot-cycle');
expect(handler).toBeDefined();
const result = await handler({
data: { repoPath: '/definitely-does-not-exist-for-autopilot-test' },
signal: { aborted: false } as any,
job: { id: 1, name: 'autopilot-cycle' } as any,
});
expect(result).toBeDefined();
expect((result as any).partial).toBe(true);
// v0.17 shape: { partial, status, report }. The report's phases array
// replaces the old failed_steps list.
expect(['partial', 'failed']).toContain((result as any).status);
const report = (result as any).report;
expect(report).toBeDefined();
expect(report.schema_version).toBe('1');
expect(Array.isArray(report.phases)).toBe(true);
// The filesystem-dependent phases should have failed on a missing dir.
const failedPhases = report.phases
.filter((p: any) => p.status === 'fail')
.map((p: any) => p.phase);
expect(failedPhases).toContain('lint');
expect(failedPhases).toContain('backlinks');
expect(failedPhases).toContain('sync');
});
test('all phases succeed → result has structured report (smoke)', async () => {
// Smoke: invoke against a real (if empty) git repo. If every phase
// completes (or gracefully skips), the handler returns a result
// object with the full runCycle report. Some phases may still warn
// (empty repo has nothing to lint/sync) — the invariant is that the
// handler never throws.
const fs = await import('fs');
const { execSync } = await import('child_process');
const { tmpdir } = await import('os');
const { join } = await import('path');
const dir = fs.mkdtempSync(join(tmpdir(), 'gbrain-autopilot-cycle-'));
try {
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email test@example.com', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name Test', { cwd: dir, stdio: 'pipe' });
execSync('git commit --allow-empty -m init', { cwd: dir, stdio: 'pipe' });
const handler = (worker as any).handlers.get('autopilot-cycle');
const result = await handler({
data: { repoPath: dir },
signal: { aborted: false } as any,
job: { id: 2, name: 'autopilot-cycle' } as any,
});
// The handler MUST return a result object, never throw, regardless
// of individual phase outcomes.
expect(result).toBeDefined();
expect(typeof (result as any).partial).toBe('boolean');
expect('report' in (result as any)).toBe(true);
expect((result as any).report.schema_version).toBe('1');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 30_000);
});
describe('autopilot-cycle handler — phase passthrough', () => {
test('job.data.phases restricts which phases run', async () => {
const fs = await import('fs');
const { execSync } = await import('child_process');
const { tmpdir } = await import('os');
const { join } = await import('path');
const dir = fs.mkdtempSync(join(tmpdir(), 'gbrain-phase-pass-'));
try {
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email test@example.com', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name Test', { cwd: dir, stdio: 'pipe' });
execSync('git commit --allow-empty -m init', { cwd: dir, stdio: 'pipe' });
const handler = (worker as any).handlers.get('autopilot-cycle');
// Request only lint and sync — embed should NOT appear
const result = await handler({
data: { repoPath: dir, phases: ['lint', 'sync'] },
signal: { aborted: false } as any,
job: { id: 10, name: 'autopilot-cycle' } as any,
});
expect(result).toBeDefined();
const report = (result as any).report;
expect(report).toBeDefined();
const phaseNames = report.phases.map((p: any) => p.phase);
expect(phaseNames).toContain('lint');
expect(phaseNames).toContain('sync');
// Phases NOT requested must be absent
expect(phaseNames).not.toContain('embed');
expect(phaseNames).not.toContain('extract');
expect(phaseNames).not.toContain('backlinks');
expect(phaseNames).not.toContain('orphans');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 30_000);
test('invalid phase names in job.data.phases are filtered out', async () => {
const fs = await import('fs');
const { execSync } = await import('child_process');
const { tmpdir } = await import('os');
const { join } = await import('path');
const dir = fs.mkdtempSync(join(tmpdir(), 'gbrain-phase-invalid-'));
try {
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email test@example.com', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name Test', { cwd: dir, stdio: 'pipe' });
execSync('git commit --allow-empty -m init', { cwd: dir, stdio: 'pipe' });
const handler = (worker as any).handlers.get('autopilot-cycle');
// Mix valid and bogus names — only 'lint' should survive filtering
const result = await handler({
data: { repoPath: dir, phases: ['lint', 'BOGUS', 'rm -rf /'] },
signal: { aborted: false } as any,
job: { id: 11, name: 'autopilot-cycle' } as any,
});
const report = (result as any).report;
const phaseNames = report.phases.map((p: any) => p.phase);
expect(phaseNames).toContain('lint');
expect(phaseNames).not.toContain('BOGUS');
expect(phaseNames.length).toBe(1);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 30_000);
test('empty phases array falls back to all phases (same as no phases)', async () => {
const handler = (worker as any).handlers.get('autopilot-cycle');
// Empty array should fall through to ALL_PHASES (same as omitting phases)
const result = await handler({
data: { repoPath: '/definitely-does-not-exist-for-phase-test', phases: [] },
signal: { aborted: false } as any,
job: { id: 12, name: 'autopilot-cycle' } as any,
});
const report = (result as any).report;
// With all phases, filesystem phases fail on missing dir
const phaseNames = report.phases.map((p: any) => p.phase);
expect(phaseNames).toContain('lint');
expect(phaseNames).toContain('backlinks');
expect(phaseNames).toContain('sync');
}, 30_000);
test('non-array phases value is ignored (falls back to all)', async () => {
const handler = (worker as any).handlers.get('autopilot-cycle');
// String instead of array — should be ignored
const result = await handler({
data: { repoPath: '/definitely-does-not-exist-for-phase-test', phases: 'lint' },
signal: { aborted: false } as any,
job: { id: 13, name: 'autopilot-cycle' } as any,
});
const report = (result as any).report;
const phaseNames = report.phases.map((p: any) => p.phase);
// Should have all phases since the string was ignored
expect(phaseNames).toContain('lint');
expect(phaseNames).toContain('sync');
expect(phaseNames).toContain('embed');
}, 30_000);
});