Summary
runExtractFacts in src/core/cycle/extract-facts.ts treats opts.slugs = [] the same as opts.slugs being omitted because the guard checks falsiness on the array, not its presence. A sync no-op therefore turns into a full-brain facts walk via engine.getAllSlugs(). On multi-thousand-page brains this exceeds the autopilot-cycle timeout (~600s) and the job dead-letters with timeout exceeded.
Repro
- Brain with several thousand pages (reproduced on a 6,492-page Supabase brain; smaller brains should reproduce as long as the full walk exceeds the autopilot-cycle timeout).
- Run a sync with no changes — autopilot subsequently dispatches an
autopilot-cycle job whose sync phase ends with slugs: [] (incremental no-op).
- The cycle reaches
[cycle.extract_facts] start and runs for the full timeout window before dead-lettering.
$ gbrain jobs list --status active
ID Name Status ...
1037 autopilot-cycle active ... ← stays active until ~600s timeout
$ tail -f ~/.gbrain/autopilot.err
[cycle.extract_facts] start
... (no further output until timeout) ...
Autopilot stopping (cycle-failure-cap).
Root cause
src/core/cycle/extract-facts.ts (current 3933eb6/v0.35.1.0):
let slugs: string[];
if (opts.slugs && opts.slugs.length > 0) {
slugs = opts.slugs;
} else {
// Full walk: every page in the brain.
const allSlugs = await engine.getAllSlugs();
slugs = Array.from(allSlugs);
}
opts.slugs && opts.slugs.length > 0 is falsy for both `undefined` (intended full-walk) AND [] (incremental no-op from sync). Callers passing the empty array unintentionally trigger the full walk.
Suggested fix
Make presence — not length — distinguish the modes:
let slugs: string[];
if (opts.slugs !== undefined) {
// Empty array is a real incremental no-op from sync. Do not fall back
// to a full-brain walk or autopilot no-change cycles time out.
slugs = opts.slugs;
} else {
const allSlugs = await engine.getAllSlugs();
slugs = Array.from(allSlugs);
}
Regression test
// test/extract-facts-phase.test.ts
test('runExtractFacts with empty slugs array scans 0 pages (incremental no-op)', async () => {
const result = await runExtractFacts(engine, { slugs: [] });
expect(result.pagesScanned).toBe(0);
});
Verification post-fix
On the same 6,492-page brain, post-patch runExtractFacts({ slugs: [] }) completes in ~0.4s; autopilot job that previously dead-lettered at ~600s now completes inside one cycle.
Workaround
Carry a local patch with the presence check above. Has been running on my install since 2026-05-13 (v0.33.1.0) with no regressions across the v0.34.x and v0.35.x wave.
Environment
- gbrain v0.35.1.0 (
3933eb6)
- Bun 1.3.13
- Supabase Postgres (ap-south-1 session-mode pooler, 5432)
- macOS Sequoia 15.6
Summary
runExtractFactsinsrc/core/cycle/extract-facts.tstreatsopts.slugs = []the same asopts.slugsbeing omitted because the guard checks falsiness on the array, not its presence. A sync no-op therefore turns into a full-brain facts walk viaengine.getAllSlugs(). On multi-thousand-page brains this exceeds the autopilot-cycle timeout (~600s) and the job dead-letters withtimeout exceeded.Repro
autopilot-cyclejob whosesyncphase ends withslugs: [](incremental no-op).[cycle.extract_facts] startand runs for the full timeout window before dead-lettering.Root cause
src/core/cycle/extract-facts.ts(current3933eb6/v0.35.1.0):opts.slugs && opts.slugs.length > 0is falsy for both `undefined` (intended full-walk) AND[](incremental no-op from sync). Callers passing the empty array unintentionally trigger the full walk.Suggested fix
Make presence — not length — distinguish the modes:
Regression test
Verification post-fix
On the same 6,492-page brain, post-patch
runExtractFacts({ slugs: [] })completes in ~0.4s; autopilot job that previously dead-lettered at ~600s now completes inside one cycle.Workaround
Carry a local patch with the presence check above. Has been running on my install since 2026-05-13 (v0.33.1.0) with no regressions across the v0.34.x and v0.35.x wave.
Environment
3933eb6)