Files
gbrain/test/core/cycle.serial.test.ts
T
84fed4194a v0.41.11.0 feat: conversation retrieval upgrade — production-bar replacement for PR #1406 (#1446)
* v0.41.11.0 feat: conversation retrieval upgrade — production-bar replacement for PR #1406

Long chat threads stop swallowing your search results. The recall miss
class on long iMessage/Slack imports (60K+ msg history; a chunk that
reads only "Locker 93 code 9494" has no topical anchor because "cabin"
was established 50K messages earlier) gets fixed by walking
conversation/meeting/slack/email pages, splitting into time-windowed
segments (30-min gap or 30-msg cap), prepending a topical/temporal
header, and running through the existing extractFactsFromTurn() so
the resulting anchor-rich facts surface in gbrain search.

This is the production-bar replacement for PR #1406 (which closes
LAST per Codex T6d, AFTER this PR is green). The bug fix survives 1:1;
the wrapping closes 14 load-bearing issues the original PR deferred
or shipped silent bugs around. The wave went through CEO scope review,
3 rounds of spec review, 2 rounds of Codex outside voice grounding
the plan against actual code, and 2 passes of eng review.

Version-slot note: originally planned as v0.41.2.0; master shipped its
own v0.41.2.0 (lens packs) plus v0.41.3-6.0 between plan-time and
ship-time. Re-bumped to v0.41.11.0 (next free slot; v0.41.7-10 claimed
by other open PRs).

Key files (new):
- src/commands/extract-conversation-facts.ts — CLI command with
  --types, --max-cost-usd, --background, --override-disabled,
  --slug, --dry-run, --limit, --since, --force, --sleep,
  --segment-limit, --source-id. Strict per-source core; two-phase
  page enumeration (paginated listPages with 10×25MB cap = 250MB
  worst case); 25MB body cap; page-global row_num accumulator
  (Codex C1 unique-index collision fix); page-level TERMINAL audit
  row after all segments commit (Codex C7 durable extraction marker);
  optional opts.budgetTracker (Codex C5 — nested withBudgetTracker
  REPLACES, so caller-managed scope passes tracker through); reads
  compiled_truth + timeline (F1 — PR silently dropped timeline half);
  honors facts.extraction_enabled kill-switch with --override-disabled
  escape (F2); --types reads cycle config as single source of truth
  (Eng-v2 A2); fingerprint on sourceId only (Eng-v2 A3 — widening
  types doesn't invalidate completion); string-encoded op-checkpoint
  entries "sourceId|slug|endIso" for resume; segment caps tuned
  6500/30 (Eng-v2 T5) to stay under extract.ts MAX_TURN_TEXT_CHARS=8000.
- src/core/cycle/conversation-facts-backfill.ts — cycle phase wrapper
  (default OFF). Iterates listSources() directly; creates ONE
  brain-wide BudgetTracker per tick + wraps the loop in
  withBudgetTracker + passes tracker through opts.budgetTracker so
  core doesn't nest-replace. Two-layer cost AND walltime protection:
  per-source caps ($1, 20min) AND brain-wide caps ($5, 30min).
- test/extract-conversation-facts.test.ts — 27 unit cases (parse,
  segment, render, checkpoint encoding, fingerprint, terminal audit
  row, row_num accumulator, F2 kill-switch, --override-disabled).
- skills/migrations/v0.41.11.0.md — agent-facing migration guide.

Key files (modified):
- src/commands/jobs.ts — register extract-conversation-facts Minion
  handler. NOT in PROTECTED_JOB_NAMES; BudgetExhausted catch + persist
  + mark completed with result.budget_exhausted (NOT a failure).
- src/commands/doctor.ts — computeConversationFactsBacklogCheck (3-state:
  SKIPPED when feature disabled per Eng-v2 C9, OK at backlog=0, WARN
  at >10 with paste-ready remediation step via makeRemediationStep).
  Doctor query is source-scoped (Codex C2 cross-source safety) and
  matches the TERMINAL audit row (Codex C7), not any-fact-for-slug.
- src/commands/sources.ts — runAudit extended with
  facts_backfill_estimate field for cost preview.
- src/cli.ts — CLI_ONLY + CLI_ONLY_SELF_HELP + THIN_CLIENT_REFUSED_COMMANDS
  + dispatch case for extract-conversation-facts.
- src/core/cycle.ts — new CyclePhase 'conversation_facts_backfill';
  PHASE_SCOPE='source' (taxonomy only per cycle.ts:131 — wrapper does
  own multi-source iteration); wired into ALL_PHASES + NEEDS_LOCK_PHASES;
  dispatch block runs between consolidate and embed.
- src/core/migrate.ts — migration v94 adds partial index
  idx_facts_extract_conversation_session ON facts(source_id, source_session)
  WHERE source LIKE 'cli:extract-conversation-facts%' so doctor query
  stays fast on million-fact brains. v14 precedent: transaction:false +
  invalid-index pre-drop on Postgres, plain CREATE INDEX on PGLite.
- src/core/schema-pack/base/gbrain-base.yaml — promote conversation
  (temporal, extractable) and atom (annotation, NOT extractable —
  atoms ARE the extracted form) into base. Flip concept.extractable:
  true semantically (cosmetic on backstop path per Codex T3; the
  original grandfather migration was solving a phantom, dropped).
  Filing rules added for both new types.
- src/core/schema-pack/base/gbrain-recommended.yaml — remove duplicate
  conversation (now inherits via extends: gbrain-base).
- src/core/types.ts — ALL_PAGE_TYPES extended with conversation, atom.
- test/extractable-pack.test.ts — updated parity gate (24 page types
  vs PR's 22; concept + conversation now extractable, atom not).
- test/schema-cli.test.ts — page-count expectation 22→24.
- VERSION + package.json bumped to 0.41.11.0.
- CHANGELOG.md release-summary in the required ELI10-first voice +
  itemized changes section.
- CLAUDE.md Key Files entry for the new modules + architecture notes.
- llms.txt + llms-full.txt regenerated.

Plan + decisions persisted at:
  ~/.claude/plans/system-instruction-you-are-working-linear-unicorn.md
CEO plan at:
  ~/.gstack/projects/garrytan-gbrain/ceo-plans/2026-05-25-conversation-retrieval-upgrade.md

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

* fix: align v0.41.11.0 phase ordering + bump hardcoded counts after master merge

Three CI failures from the master merge in this branch:

1. test/phase-scope-coverage.test.ts pinned `ALL_PHASES.length === 19`
   and `Object.keys(PHASE_SCOPE).length === 19`. After merging master's
   v0.41 lens-packs (extract_atoms + synthesize_concepts) + my new
   conversation_facts_backfill phase, the total is 20.

2. test/core/cycle.serial.test.ts had two hardcoded `19` assertions
   (`hookCalls` and `report.phases.length`) tracking the same count.
   Both bumped to 20.

3. cycle.serial's `'default: all 6 phases run in order'` test asserts
   `report.phases.map(p => p.phase) === ALL_PHASES`. My initial commit
   put `conversation_facts_backfill` in ALL_PHASES between consolidate
   and propose_takes, but the runCycle dispatch block runs it AFTER
   the calibration trio (propose_takes / grade_takes / calibration_profile)
   and BEFORE embed. List and dispatch order didn't match, so the
   equality assertion failed.

   Resolution: moved 'conversation_facts_backfill' in ALL_PHASES to
   AFTER 'calibration_profile' so list-order matches dispatch-order.
   The dispatch block placement was correct (and remains correct);
   the list-position comment originally said "AFTER consolidate" but
   the dispatch runs it after the WHOLE consolidate→calibration_profile
   block, not just after consolidate. Comment now reflects reality.

Verified: 61/61 pass across the 3 affected test files (2.9s wallclock).

The CI logs also showed a "(unnamed) [3058.07ms]" failure in shard 1;
unable to reproduce locally (test/scripts/run-unit-parallel.test.ts
passes 6/6 in 1s). Suspected CI-load flakiness under bun's parallel
scheduler. If it persists on the next CI run, will dig in.

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

* fix(ci): orphan sleep cleanup in run-unit-parallel.sh heartbeat

Two CI runs in a row reported `(fail) (unnamed) [~2400ms]` in shard 1
on this PR. Investigation:

- CI's end-of-job cleanup logged: "Terminate orphan process: pid (3344)
  (sleep)" × 6 sleep processes.
- The 6 matches exactly the 6 `runWrapper()` calls in
  test/scripts/run-unit-parallel.test.ts (1 orphan sleep per invocation).
- Each `runWrapper()` spawns scripts/run-unit-parallel.sh, which spawns
  a heartbeat function that runs `while true; do sleep 10; ...; done`
  in the background.
- The wrapper's EXIT trap was `kill "$HB_PID" 2>/dev/null` — kills the
  heartbeat shell, but its currently-running `sleep 10` child gets
  reparented to init/launchd because SIGTERM to a bash shell sleeping
  inside `sleep` doesn't propagate to the sleep child before wait
  returns. Known bash quirk on Linux.
- bun's test runner treats the orphan sleeps as a `(unnamed)` failure
  attributed to the test file that spawned the wrapper.

Fix: pkill children FIRST, then kill heartbeat. If we kill heartbeat
first, its child sleep orphans and pkill -P can no longer find it
(ppid changes to 1). Reorder applied to both the trap AND the normal
shutdown path.

Verified locally: before fix, 6 orphan sleeps after the test ran;
after fix, 0 orphan sleeps. Test still passes 6/6 in ~1s.

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

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:21:59 -07:00

560 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for src/core/cycle.ts — runCycle primitive.
*
* Tests use mock.module to replace each phase's library function with
* deterministic stubs. Zero fixtures, zero DB, zero network. Covers
* the dryRun × phases × lock_held × engine-null matrix.
*
* The lock primitives are tested against an in-memory PGLite engine
* so they exercise real SQL paths.
*/
import { describe, test, expect, mock, beforeEach, beforeAll, afterAll, afterEach } from 'bun:test';
import { existsSync, unlinkSync } from 'fs';
// ─── Mocks ──────────────────────────────────────────────────────────
// Track what each phase was called with so tests can assert.
let lintCalls: Array<{ target: string; fix: boolean; dryRun: boolean | undefined }> = [];
let backlinksCalls: Array<{ action: string; dir: string; dryRun: boolean | undefined }> = [];
let syncCalls: Array<{ dryRun: boolean | undefined; noPull: boolean | undefined; noExtract: boolean | undefined; sourceId: string | undefined }> = [];
let extractCalls: Array<{ mode: string; dir: string; slugs: string[] | undefined }> = [];
let embedCalls: Array<{ stale: boolean | undefined; dryRun: boolean | undefined }> = [];
let orphansCalls: number = 0;
// Mock lint
mock.module('../../src/commands/lint.ts', () => ({
runLintCore: async (opts: any) => {
lintCalls.push({ target: opts.target, fix: opts.fix, dryRun: opts.dryRun });
return { total_issues: 2, total_fixed: opts.dryRun ? 0 : 2, pages_scanned: 5 };
},
}));
// Mock backlinks
mock.module('../../src/commands/backlinks.ts', () => ({
runBacklinksCore: async (opts: any) => {
backlinksCalls.push({ action: opts.action, dir: opts.dir, dryRun: opts.dryRun });
return { action: opts.action, gaps_found: 3, fixed: opts.dryRun ? 0 : 3, pages_affected: 2, dryRun: !!opts.dryRun };
},
// keep other exports present so import doesn't error
extractEntityRefs: () => [],
extractPageTitle: () => '',
hasBacklink: () => false,
buildBacklinkEntry: () => '',
findBacklinkGaps: () => [],
fixBacklinkGaps: () => 0,
runBacklinks: async () => {},
}));
// Mock sync
mock.module('../../src/commands/sync.ts', () => ({
performSync: async (_engine: any, opts: any) => {
syncCalls.push({ dryRun: opts.dryRun, noPull: opts.noPull, noExtract: opts.noExtract, sourceId: opts.sourceId });
return {
status: opts.dryRun ? 'dry_run' : 'synced',
fromCommit: 'abcd',
toCommit: 'efgh',
added: opts.dryRun ? 0 : 4,
modified: opts.dryRun ? 0 : 2,
deleted: 0,
renamed: 0,
chunksCreated: opts.dryRun ? 0 : 10,
embedded: 0,
pagesAffected: opts.dryRun ? [] : ['a', 'b'],
};
},
runSync: async () => {},
buildSyncManifest: () => ({ added: [], modified: [], deleted: [], renamed: [] }),
isSyncable: () => true,
pathToSlug: (s: string) => s,
}));
// Mock extract
mock.module('../../src/commands/extract.ts', () => ({
runExtractCore: async (_engine: any, opts: any) => {
extractCalls.push({ mode: opts.mode, dir: opts.dir, slugs: opts.slugs });
return { links_created: 7, timeline_entries_created: 3, pages_processed: opts.slugs?.length ?? 5 };
},
walkMarkdownFiles: () => [],
extractMarkdownLinks: () => [],
resolveSlug: () => null,
}));
// Mock embed
mock.module('../../src/commands/embed.ts', () => ({
runEmbedCore: async (_engine: any, opts: any) => {
embedCalls.push({ stale: opts.stale, dryRun: opts.dryRun });
return {
embedded: opts.dryRun ? 0 : 8,
skipped: 2,
would_embed: opts.dryRun ? 8 : 0,
total_chunks: 10,
pages_processed: 3,
dryRun: !!opts.dryRun,
};
},
runEmbed: async () => {},
}));
// Mock orphans
mock.module('../../src/commands/orphans.ts', () => ({
findOrphans: async () => {
orphansCalls++;
return {
orphans: [],
total_orphans: 1,
total_linkable: 20,
total_pages: 20,
excluded: 0,
};
},
queryOrphanPages: async () => [],
shouldExclude: () => false,
deriveDomain: () => 'root',
formatOrphansText: () => '',
}));
// Import after mocks.
const { runCycle, ALL_PHASES } = await import('../../src/core/cycle.ts');
const { PGLiteEngine } = await import('../../src/core/pglite-engine.ts');
// Shared PGLite engine per describe block. Each block does its own
// beforeAll/afterAll (below). `truncateCycleLocks` clears the cycle
// lock row between tests so state doesn't leak across assertions.
async function truncateCycleLocks(engine: InstanceType<typeof PGLiteEngine>) {
await (sharedEngine as any).db.query('DELETE FROM gbrain_cycle_locks');
}
// One shared PGLite engine for the whole file. Creating a fresh engine
// per describe (15 migrations each) was causing the parallel test suite
// to hit beforeAll timeouts. truncateCycleLocks between tests keeps
// state clean.
let sharedEngine: InstanceType<typeof PGLiteEngine>;
beforeAll(async () => {
sharedEngine = new PGLiteEngine();
await sharedEngine.connect({});
await sharedEngine.initSchema();
}, 60_000); // OAuth v25 + full migration chain needs breathing room
afterAll(async () => {
if (sharedEngine) await sharedEngine.disconnect();
}, 60_000);
beforeEach(() => {
lintCalls = [];
backlinksCalls = [];
syncCalls = [];
extractCalls = [];
embedCalls = [];
orphansCalls = 0;
});
// ─── dryRun propagation (regression guards) ────────────────────────
describe('runCycle — dryRun propagates to every phase', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('dryRun:true reaches lint, backlinks, sync, embed', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: true });
expect(lintCalls.at(-1)?.dryRun).toBe(true);
expect(backlinksCalls.at(-1)?.dryRun).toBe(true);
expect(syncCalls.at(-1)?.dryRun).toBe(true);
expect(embedCalls.at(-1)?.dryRun).toBe(true);
});
test('dryRun:false does not let maintenance append generated backlinks to tracked pages', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: false });
expect(lintCalls.at(-1)?.dryRun).toBe(false);
// Maintenance should audit backlink gaps but not run the legacy fixer that
// appends "Referenced in" timeline entries into entity pages. The graph
// extractor/auto-link path is the canonical link store; filesystem backlink
// fixes are still available through `gbrain check-backlinks fix` when a
// human explicitly asks for them.
expect(backlinksCalls.at(-1)?.action).toBe('check');
expect(backlinksCalls.at(-1)?.dryRun).toBe(false);
expect(syncCalls.at(-1)?.dryRun).toBe(false);
expect(embedCalls.at(-1)?.dryRun).toBe(false);
});
test('dryRun skips extract phase (no dry-run support)', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: true });
expect(extractCalls.length).toBe(0);
const extractPhase = report.phases.find(p => p.phase === 'extract');
expect(extractPhase?.status).toBe('skipped');
expect(extractPhase?.details.reason).toBe('no_dry_run_support');
});
});
// ─── Phase selection ──────────────────────────────────────────────
describe('runCycle — phase selection', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('default: all 6 phases run in order', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report.phases.map(p => p.phase)).toEqual(ALL_PHASES);
});
test('--phase lint only runs lint', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['lint'] });
expect(report.phases.map(p => p.phase)).toEqual(['lint']);
expect(lintCalls.length).toBe(1);
expect(backlinksCalls.length).toBe(0);
expect(syncCalls.length).toBe(0);
});
test('--phase orphans only runs orphans', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['orphans'] });
expect(orphansCalls).toBe(1);
expect(syncCalls.length).toBe(0);
});
});
// ─── Lock-skip for non-DB-write phase selections ──────────────────
describe('runCycle — cycle lock acquire/release semantics', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('phases: [orphans] (read-only) skips the lock entirely', async () => {
// We can tell the lock wasn't acquired because the lock table is
// never written to. Seeding a stale holder and verifying it survives
// the run would also work, but a simpler assertion: no rows ever
// existed for a read-only-only selection.
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['orphans'] });
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
expect(rows[0].n).toBe(0);
});
test('phases including lint DOES acquire + release (table empty after run)', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['lint'] });
// Lock is released in finally, so no rows survive the run.
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
expect(rows[0].n).toBe(0);
});
test('phases including sync DOES acquire + release the lock', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', phases: ['sync'] });
const { rows } = await (sharedEngine as any).db.query('SELECT COUNT(*)::int AS n FROM gbrain_cycle_locks');
expect(rows[0].n).toBe(0);
});
});
// ─── Lock held by another live holder ──────────────────────────────
describe('runCycle — cycle_already_running skip', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('returns status=skipped when lock is held by live pid in the future', async () => {
// Seed a lock row that looks live (far-future TTL, different PID).
await (sharedEngine as any).db.query(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES ('gbrain-cycle', 99999, 'other-host', NOW(), NOW() + INTERVAL '1 hour')`
);
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report.status).toBe('skipped');
expect(report.reason).toBe('cycle_already_running');
expect(report.phases.length).toBe(0);
// None of the phase runners were called.
expect(lintCalls.length).toBe(0);
expect(syncCalls.length).toBe(0);
});
test('TTL-expired lock is auto-claimed (crashed holder)', async () => {
// Seed a lock row that looks stale (TTL already past).
await (sharedEngine as any).db.query(
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at)
VALUES ('gbrain-cycle', 99999, 'crashed-host', NOW() - INTERVAL '2 hours', NOW() - INTERVAL '1 hour')`
);
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report.status).not.toBe('skipped');
expect(syncCalls.length).toBe(1); // cycle ran
});
});
// ─── Engine null path ─────────────────────────────────────────────
describe('runCycle — engine = null (filesystem-only mode)', () => {
const lockFile = require('path').join(require('os').homedir(), '.gbrain', 'cycle.lock');
afterEach(() => {
if (existsSync(lockFile)) { try { unlinkSync(lockFile); } catch { /* */ } }
});
test('filesystem phases still run when engine is null', async () => {
const report = await runCycle(null, { brainDir: '/tmp/brain' });
// Lint and backlinks ran.
expect(lintCalls.length).toBe(1);
expect(backlinksCalls.length).toBe(1);
// DB phases skipped with reason:no_database.
const syncPhase = report.phases.find(p => p.phase === 'sync');
expect(syncPhase?.status).toBe('skipped');
expect(syncPhase?.details.reason).toBe('no_database');
const embedPhase = report.phases.find(p => p.phase === 'embed');
expect(embedPhase?.status).toBe('skipped');
// syncCalls + embedCalls are empty because DB-required phases skipped.
expect(syncCalls.length).toBe(0);
expect(embedCalls.length).toBe(0);
});
test('file lock blocks concurrent engine=null cycles', async () => {
// Seed a lock file pointing at PID 1 (init/launchd — always alive on
// unix, and never equals our test PID). Fresh mtime means "live holder".
// With engine=null + the default phases selection, lint + backlinks
// trigger NEEDS_LOCK_PHASES → acquireFileLock sees the live holder and
// returns null → runCycle returns skipped/cycle_already_running.
const { writeFileSync, mkdirSync } = require('fs');
const path = require('path');
mkdirSync(path.dirname(lockFile), { recursive: true });
writeFileSync(lockFile, `1\n${new Date().toISOString()}\n`);
const report = await runCycle(null, { brainDir: '/tmp/brain' });
expect(report.status).toBe('skipped');
expect(report.reason).toBe('cycle_already_running');
// None of the filesystem phases ran because the lock blocked entry.
expect(lintCalls.length).toBe(0);
expect(backlinksCalls.length).toBe(0);
});
});
// ─── Status derivation ─────────────────────────────────────────────
describe('runCycle — status derivation', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('ok when work was done (non-dry-run)', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(['ok', 'partial']).toContain(report.status);
// Non-dry-run fixtures produce work (fixes:2, added:4 etc.), so:
expect(report.status).toBe('ok');
expect(report.totals.lint_fixes).toBe(2);
expect(report.totals.backlinks_added).toBe(3);
expect(report.totals.pages_synced).toBe(6); // added + modified from sync mock
expect(report.totals.pages_embedded).toBe(8);
expect(report.totals.orphans_found).toBe(1);
});
test('schema_version is stable at "1"', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report.schema_version).toBe('1');
});
test('CycleReport shape includes all required top-level fields', async () => {
const report = await runCycle(sharedEngine,{ brainDir: '/tmp/brain' });
expect(report).toHaveProperty('schema_version');
expect(report).toHaveProperty('timestamp');
expect(report).toHaveProperty('duration_ms');
expect(report).toHaveProperty('status');
expect(report).toHaveProperty('brain_dir');
expect(report).toHaveProperty('phases');
expect(report).toHaveProperty('totals');
});
});
// ─── yieldBetweenPhases hook ─────────────────────────────────────
describe('runCycle — yieldBetweenPhases hook', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
});
test('hook is called between every phase', async () => {
let hookCalls = 0;
await runCycle(sharedEngine,{
brainDir: '/tmp/brain',
yieldBetweenPhases: async () => {
hookCalls++;
},
});
// v0.26.5: 9 phases (added `purge`).
// v0.29: 10 phases (added `recompute_emotional_weight`).
// v0.31: 11 phases (added `consolidate` between recompute and embed).
// v0.32.2: 12 phases (added `extract_facts` between extract and patterns).
// v0.33.3: 13 phases (added `resolve_symbol_edges` between extract_facts and patterns) → 13 yield calls.
// v0.36.1.0: 16 phases (added `propose_takes`, `grade_takes`, `calibration_profile` between consolidate and embed).
// v0.39.0.0: 17 phases (added `schema-suggest` between orphans and purge — T12 schema cathedral).
// v0.41.2.0: 19 phases (added `extract_atoms` after extract_facts + `synthesize_concepts` after patterns).
// v0.41.11.0: 20 phases (added `conversation_facts_backfill` between consolidate and propose_takes).
expect(hookCalls).toBe(20);
});
test('hook exceptions do not abort the cycle', async () => {
const report = await runCycle(sharedEngine,{
brainDir: '/tmp/brain',
yieldBetweenPhases: async () => {
throw new Error('synthetic hook error');
},
});
// v0.33.3: 13 phases (v0.32.2's 12 + resolve_symbol_edges).
// v0.36.1.0: 16 phases (Hindsight calibration wave adds propose_takes, grade_takes, calibration_profile).
// v0.39.0.0: 17 phases (T12 schema-suggest phase between orphans and purge).
// v0.41.11.0: 20 phases (+extract_atoms, +synthesize_concepts, +conversation_facts_backfill).
expect(report.phases.length).toBe(20);
});
});
// ─────────────────────────────────────────────────────────────────
// Wave regression guards (#417 + Codex F2)
// ─────────────────────────────────────────────────────────────────
describe('runCycle — incremental extract slug propagation (#417)', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
syncCalls = [];
extractCalls = [];
});
test('cycle threads sync.pagesAffected into extract phase as the slugs argument', async () => {
// performSync mock returns pagesAffected = ['a', 'b']. The extract phase
// must receive those exact slugs, not undefined (which would trigger a full walk).
await runCycle(sharedEngine, { brainDir: '/tmp/brain' });
// Sync ran once
expect(syncCalls.length).toBe(1);
// Extract ran once with the slugs from sync (not undefined)
expect(extractCalls.length).toBe(1);
expect(extractCalls[0].slugs).toEqual(['a', 'b']);
});
test('extract phase falls back to full walk when sync was skipped (slugs undefined)', async () => {
// Run only the extract phase — sync didn't run, so syncPagesAffected
// is undefined and extract should walk the full directory (slugs:undefined).
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['extract'] });
expect(syncCalls.length).toBe(0);
expect(extractCalls.length).toBe(1);
expect(extractCalls[0].slugs).toBeUndefined();
});
});
describe('runCycle — Codex F2: noExtract is gated on whether extract phase runs', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
syncCalls = [];
extractCalls = [];
});
test('full cycle (sync + extract): noExtract=true so sync skips inline extraction (extract phase handles it)', async () => {
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['sync', 'extract'] });
expect(syncCalls.length).toBe(1);
expect(syncCalls[0].noExtract).toBe(true); // dedupe enabled
expect(extractCalls.length).toBe(1); // extract phase ran
});
test('phases:[sync] only: noExtract=false so sync runs inline extraction (no silent extract drop)', async () => {
await runCycle(sharedEngine, { brainDir: '/tmp/brain', phases: ['sync'] });
expect(syncCalls.length).toBe(1);
// Critical: noExtract must be false here. If it were true, the user just lost
// their extraction without any indication. This is the F2 regression guard.
expect(syncCalls[0].noExtract).toBe(false);
expect(extractCalls.length).toBe(0); // extract phase did NOT run
});
});
// ─── sourceId resolution (regression #475) ─────────────────────────
//
// Production OpenClaw deployment hit a 30+ min hang on every autopilot
// cycle because runPhaseSync was calling performSync without sourceId,
// so sync read the global config.sync.last_commit key (which had drifted
// out of git history after a force-push GC'd the commit). The per-source
// sources.last_commit anchor was valid the entire time. PR #475 added
// resolveSourceForDir() so the cycle reads the per-source anchor instead.
//
// These tests pin the resolver -> performSync(opts.sourceId) plumbing.
describe('runCycle — sourceId resolution (regression #475)', () => {
beforeEach(async () => {
await truncateCycleLocks(sharedEngine);
await (sharedEngine as any).db.query('DELETE FROM sources');
});
test('seeded sources row → performSync receives matching sourceId', async () => {
await (sharedEngine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
['default', 'default', '/tmp/brain-475-a'],
);
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-a' });
expect(syncCalls.at(-1)?.sourceId).toBe('default');
});
test('no matching sources row → performSync receives sourceId=undefined', async () => {
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-b' });
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
});
test('different brainDir than registered source → undefined (no cross-match)', async () => {
await (sharedEngine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES ($1, $2, $3)`,
['other', 'other', '/some/other/brain'],
);
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-c' });
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
});
test('sources table missing (very old brain) → catch returns undefined, sync still runs', async () => {
// CRITICAL: do NOT DROP TABLE on the shared engine. initSchema() only
// re-runs PENDING migrations; once schema_version is at latest, the
// v20 migration that creates `sources` will not re-execute. Use a
// fresh one-shot engine so the shared engine isn't degraded for
// every later test in this file.
const fresh = new PGLiteEngine();
await fresh.connect({});
await fresh.initSchema();
await (fresh as any).db.query('DROP TABLE IF EXISTS sources CASCADE');
try {
await runCycle(fresh, { brainDir: '/tmp/brain-475-d' });
expect(syncCalls.at(-1)?.sourceId).toBeUndefined();
} finally {
await fresh.disconnect();
}
});
test('multiple rows with same local_path → resolver returns one matching id (non-deterministic)', async () => {
// Schema has no UNIQUE on local_path; SQL has no ORDER BY. Either id
// is acceptable; the contract is "any matching id, never null when
// matches exist." This test pins behavior so the follow-up
// UNIQUE-constraint TODO has a regression target.
await (sharedEngine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES
('first', 'first', '/tmp/brain-475-e'),
('second', 'second', '/tmp/brain-475-e')`,
);
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-e' });
const sourceId = syncCalls.at(-1)?.sourceId;
expect(sourceId).toBeDefined();
expect(['first', 'second']).toContain(sourceId as string);
});
test('empty-string id row → resolver propagates as "" (defensive)', async () => {
// Schema has id as PRIMARY KEY (NOT NULL), so NULL id can't happen.
// Empty string CAN be inserted, and the resolver's `rows[0]?.id`
// would treat any falsy id as "no source" via the optional chain.
// This test pins the current behavior (we DO pass '' through to
// performSync) so a future refactor doesn't silently regress it.
await (sharedEngine as any).db.query(
`INSERT INTO sources (id, name, local_path) VALUES ('', 'empty', '/tmp/brain-475-f')`,
);
await runCycle(sharedEngine, { brainDir: '/tmp/brain-475-f' });
expect(syncCalls.at(-1)?.sourceId).toBe('');
});
});