Files
gbrain/test/core/cycle.serial.test.ts
T
9fb4d7eb5b v0.33.3.0 feat(v0.33.3): code intelligence MCP foundation (v0.34 W0a-c + W3) (#934)
* feat(v0.34 pre-w0): add code-retrieval eval harness for v0.34 ship gate

Captures pre-v0.34 retrieval quality on the gbrain self-corpus before any
code-intel work lands, so the v0.34 ship gate (precision@5 +10pp OR
answered_rate +15pp on >=15/30 questions) measures real improvement
rather than an after-the-fact retuned baseline.

* src/eval/code-retrieval/harness.ts -- pure-function metrics (precision@k,
  recall@k, top-1 stability, gate evaluator) + EvalRunReport types stable
  across schema_version 1
* src/eval/code-retrieval/questions.json -- 30 questions across callers /
  callees / definition / references / blast_radius / execution_flow /
  cluster_membership kinds, expected_files captured against current
  gbrain layout
* src/eval/code-retrieval/strategies.ts -- BaselineStrategy (hybridSearch)
  + WithCodeIntelStrategy stub (post-W3 fills in code_blast/code_flow/etc.)
* src/commands/eval-code-retrieval.ts -- gbrain eval code-retrieval CLI
  with --baseline / --with-code-intel / --compare subcommands
* test/code-retrieval-harness.test.ts -- 26 unit tests across metrics,
  loader, gate logic; no engine dependency

PRE-V0.34 BASELINE WORKFLOW:
  gbrain eval code-retrieval --baseline --save /tmp/baseline-1.json
  (run 3x for noise floor)

V0.34 SHIP GATE (after W3 lands):
  gbrain eval code-retrieval --with-code-intel --save /tmp/v034.json
  gbrain eval code-retrieval --compare /tmp/baseline-1.json /tmp/v034.json

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

* fix(v0.34 W0a): source-routing leak across query + two-pass

Codex outside-voice review on the v0.34 plan caught two load-bearing
sites where sourceId was advertised but never applied — multi-source
brains silently cross-contaminated structural retrieval:

* operations.ts ~323 — `query` op handler called hybridSearch without
  threading ctx.sourceId. Multi-source agents querying with a
  --source flag got cross-source results.
* two-pass.ts:81 (nearSymbol lookup) and two-pass.ts:131 (unresolved
  edge resolution) — TwoPassOpts.sourceId was declared and threaded
  through hybridSearch's expandAnchors call, but the actual SQL ignored
  it. The walk window crossed source boundaries every time.

Fix:
* `query` op now reads ctx.sourceId AND accepts a new `source_id`
  param (with '__all__' as the explicit force-cross-source escape
  hatch). Per-call param wins over ctx context.
* two-pass.ts both lookups join through pages.source_id when
  opts.sourceId is set; omitted opts.sourceId preserves the legacy
  cross-source contract for callers who want it.

Regression test: test/e2e/source-routing.test.ts seeds two sources
with the same `parseMarkdown` symbol + a cross-source caller edge.
Pins:
  - nearSymbol + sourceId='source-a' returns ONLY source-a chunks
  - nearSymbol + sourceId='source-b' returns ONLY source-b chunks
  - nearSymbol with no sourceId still crosses sources (contract preserved)
  - walk_depth=1 unresolved-edge resolution stays in source-a

PGLite in-memory, no DATABASE_URL needed. The fix proves out under
realistic structural retrieval not just a contrived unit test.

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

* fix(v0.34 W0b): flip CLI source-scoping default to truly source-scoped

Codex outside-voice review (finding #7) caught that the v0.20.0
docstring claim "by default we only match the caller's source_id"
contradicted the implementation in code-callers.ts:54 + code-callees.ts:43:

  allSources: allSources || !sourceId

The right side made `allSources` TRUE whenever `--source` was omitted,
INVERTING the documented default. Multi-source brains silently cross-
contaminated structural retrieval; `gbrain code-callers parseMarkdown`
on a brain with two repos returned callers from both even though the
docstring promised per-source scoping.

Fix:
* New canonical helper `resolveDefaultSource(engine)` in sources-ops.ts.
  Contract per eng review D7:
    - exactly 1 source registered → return its id (single-source brains,
      the 80% case; --source flag is unnecessary friction there)
    - 2+ sources → throw SourceResolutionError(multiple_sources_ambiguous)
      with the list of valid ids
    - 0 sources → throw SourceResolutionError(no_sources)
* code-callers.ts + code-callees.ts now resolve to the default source
  when both --source AND --all-sources are absent. To get the pre-v0.34
  cross-source behavior, callers must pass --all-sources explicitly.
* Same hint text on both commands. Pinned by test/e2e/cli-source-scoping-pglite.test.ts.

IRON RULE regression R2: docstring promise now holds. Multi-source brain
running `gbrain code-callers <symbol>` without --source gets a clear
error listing valid source ids instead of silent cross-resolution.

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

* feat(v0.34 W0c): within-file two-pass symbol resolver + edges_backfilled_at watermark

Codex's outside-voice review caught that the v0.20.0 graph stores BARE
callee tokens (`render`, `find`, `execute`) — not qualified names. Pre-v0.34
recursive blast/flow would alias every same-named function across classes.
W0c is the foundation that fixes this: resolve `code_edges_symbol` rows by
matching `to_symbol_qualified` against the SAME-FILE chunks'
`symbol_name_qualified`, then write the outcome to `edge_metadata`.

This commit is the resolver primitive + schema. The cycle-phase wiring
that calls it on every quick-cycle tick lands in the next commit.

Schema (v51 migration `edges_backfilled_at_v0_34`):
* `content_chunks.edges_backfilled_at TIMESTAMPTZ` — resume watermark.
  Chunks where the column is NULL OR older than EDGE_EXTRACTOR_VERSION_TS
  get re-walked next tick. SIGINT/OOM/sleep mid-backfill loses at most
  one batch.
* Indexes per D11 from eng review:
  - `idx_code_edges_symbol_resolver(source_id, to_symbol_qualified)` —
    composite for the resolver's per-source lookup.
  - `idx_content_chunks_symbol_lookup(page_id, symbol_name_qualified)`
    WHERE `symbol_name_qualified IS NOT NULL` — file-batched candidate
    fetch; also reused by W4-5 cluster recompute.
  - `idx_content_chunks_edges_backfill(edges_backfilled_at)` WHERE
    `edges_backfilled_at IS NULL` — fast unresumed-row scan.

Module (`src/core/chunkers/symbol-resolver.ts`):
* `resolveSymbolEdgesIncremental(engine, {sourceId, maxChunks?, onProgress?})`
  walks stale chunks in 200-chunk batches. For each chunk, loads its
  unresolved edges, finds same-page candidates by symbol_name_qualified,
  and writes outcome to `edge_metadata`:
   - exactly 1 candidate → `{resolved_chunk_id: <id>}`
   - 2+ candidates → `{ambiguous: true, candidates: [...]}`
   - 0 candidates → unchanged (cross-file; two-pass.ts handles those)
  Each batch bumps `edges_backfilled_at = NOW()` for the chunks.
* `readEdgeResolution(metadata)` — public helper for downstream code
  (two-pass.ts, code_blast op, eval-capture) to consume the resolver's
  output without parsing JSON directly. Returns a tagged union.
* `EDGE_EXTRACTOR_VERSION_TS` exported constant — bump when extractor
  shape changes and the next cycle re-walks all chunks.

Tests (5 E2E in test/e2e/symbol-resolver-pglite.test.ts, all PGLite,
no DATABASE_URL): unambiguous match, ambiguous multi-match, no match,
watermark advance + idempotency, source isolation (no cross-source
candidate leak).

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

* feat(v0.34 W0c): wire resolve_symbol_edges as a new cycle phase

W0c's symbol resolver lands as a 12th cycle phase between extract and
patterns. The autopilot's quick-cycle path (60s watchdog interval per
D2 from eng review) now resolves stale chunks incrementally so agents
see resolved edges within ~60s of writes rather than waiting on the
slow full-walk path.

* CyclePhase + ALL_PHASES + NEEDS_LOCK_PHASES extended with
  'resolve_symbol_edges'. Position: between extract (which emits new
  bare-token edges from sync diffs) and patterns (which reads the
  graph). Acquires the cycle lock because it writes edge_metadata.
* CycleReport.totals adds edges_resolved + edges_ambiguous so doctor
  and autopilot summaries surface the numbers.
* runPhaseResolveSymbolEdges walks every registered source via
  listSources() + resolveSymbolEdgesIncremental(). Per-call cap is
  BATCH_SIZE*10 = 2000 chunks so a single watchdog tick stays bounded
  even on a 100K-chunk brain. Subsequent ticks pick up the leftovers
  via the edges_backfilled_at watermark.
* Test count bumped from 11 → 12 phases in cycle.serial.test.ts and
  cycle.test.ts (both pinned by the regression guards). Existing 28
  cycle tests pass.

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

* feat(v0.34 W3): MCP-expose code_callers / code_callees / code_def / code_refs

Pre-v0.34 these four code-intelligence commands lived in CLI_ONLY at
cli.ts:30 — agents calling gbrain via MCP couldn't reach them and fell
through to text search. This commit ships the agent-facing MCP surface
for v0.34 against the existing v0.20+ tree-sitter call graph; recursive
blast/flow and clusters land in subsequent commits.

* `code_callers(symbol, [limit, source_id, all_sources])` — wraps
  engine.getCallersOf. Reverse view of the A1 call graph.
* `code_callees(symbol, [limit, source_id, all_sources])` — wraps
  engine.getCalleesOf. Forward view.
* `code_def(symbol, [limit, lang])` — wraps findCodeDef. Returns
  definition sites with file/line/snippet.
* `code_refs(symbol, [limit, lang])` — wraps findCodeRefs. Returns
  every reference (comments, strings, imports, call sites).

All four are scope:'read', source-scoped by default via ctx.sourceId
(W0a contract). Per-call source_id param wins over ctx; pass '__all__'
or all_sources=true to force cross-source.

* operations-descriptions.ts: 4 new constants per the eng review D10
  finding — every description carries an inline example response so
  agents don't burn first-call context discovering shape. Resolver-grade
  wording ("BEFORE editing any function, run code_callers...") routes
  plan-mode questions straight to the right op.
* SEARCH_DESCRIPTION gains a cross-link clause pointing at the four new
  ops so agents stop falling through to text search for code-symbol
  questions.

Tests (11 E2E in test/e2e/code-intel-mcp-ops-pglite.test.ts):
  - All four ops registered + scope:read + description pinned by constant
  - All four ops have required symbol param
  - code_callers / code_callees return the documented envelope shape
  - Source scoping honors ctx.sourceId
  - all_sources=true / source_id='__all__' force cross-source
  - code_def returns the def-site snippet

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

* docs(v0.33.0): agent-readable migration doc for the code-intel foundation

skills/migrations/v0.33.0.md gives existing-user upgrade guidance for the
v0.33.0 foundation pre-release (this branch's accumulated work toward
v0.34 Cathedral III):

* Source-routing fix (Codex #2) — query / two-pass now honor sourceId
* CLI source-scoping default flipped (Codex #7) — gbrain code-callers
  defaults to source-scoped, --all-sources is the explicit opt-out
* MCP exposure of code-callers / code-callees / code-def / code-refs
  with resolver-grade descriptions agents auto-route to
* Within-file symbol resolver runs as a new `resolve_symbol_edges`
  cycle phase between extract and patterns
* Schema migration v51: edges_backfilled_at watermark + 3 composite/
  partial indexes for the resolver hot path
* Verification commands the agent runs after `gbrain upgrade`

Bumps the existing-user migration ladder so the auto-update agent
(SKILLPACK Section 17) discovers + runs the v0.33.0 migration steps.

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

* chore(v0.33.0): bump VERSION + package.json + CHANGELOG

v0.33.0 ships the v0.34 Cathedral III foundation: MCP exposure of
code_callers / code_callees / code_def / code_refs with resolver-grade
tool descriptions, plus the source-routing fix + within-file symbol
resolver + cycle-phase wiring that v0.34's recursive blast/flow and
Leiden clusters will build on.

Full release notes in CHANGELOG.md. Trio in lockstep:
  VERSION:      0.33.0
  package.json: 0.33.0
  CHANGELOG.md: ## [0.33.0] - 2026-05-11

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

* test(v0.33.0): update dream-cycle phase-order assertions for resolve_symbol_edges

E2E test pinned the canonical phase sequence as a regression guard. The
v0.33.0 resolve_symbol_edges phase (added between extract and patterns)
correctly bumps the count to 12 — caught by the canonical-order test on
fresh-Postgres run, fixed by adding the new phase to EXPECTED_PHASES
and bumping the version history comment.

Both cycle.serial.test.ts and cycle.test.ts were already updated in the
W0c cycle-phase commit (6f7dbe1d); this third pin lives in
test/e2e/dream-cycle-phase-order-pglite.test.ts and was missed.

Full E2E suite now: 550 passed / 0 failed / 81 files (real Postgres on
port 5435 via Docker pgvector/pgvector:pg16).

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

* chore(v0.33.3.0): rebump from v0.33.2.0 → v0.33.3.0

User asked to ship as v0.33.3.0 instead of v0.33.2.0. Single sweep:

* VERSION + package.json bumped to 0.33.3.0
* CHANGELOG header + body rewritten to v0.33.3
* skills/migrations/v0.33.0.md → skills/migrations/v0.33.3.0.md
  (migration files use the version they ship FROM; renaming aligns with
  the v0.21.0.md / v0.31.0.md convention in CLAUDE.md)
* Schema migration name edges_backfilled_at_v0_33_2 →
  edges_backfilled_at_v0_33_3 in src/core/migrate.ts (also bumps the
  in-code identifier so the registry name matches the version)
* All v0.33.2 comment references swept to v0.33.3 in cycle.ts,
  operations.ts, operations-descriptions.ts, eval.ts, symbol-resolver.ts
  + cycle test phase-history comments
* llms.txt + llms-full.txt regenerated

Trio verified:
  VERSION:      0.33.3.0
  package.json: 0.33.3.0
  CHANGELOG.md: ## [0.33.3.0] - 2026-05-12

bun run verify clean; 90 v0.33.3-touched tests pass.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:50:50 -04:00

547 lines
22 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 writes in every phase', async () => {
await runCycle(sharedEngine,{ brainDir: '/tmp/brain', dryRun: false });
expect(lintCalls.at(-1)?.dryRun).toBe(false);
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.
expect(hookCalls).toBe(13);
});
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).
expect(report.phases.length).toBe(13);
});
});
// ─────────────────────────────────────────────────────────────────
// 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('');
});
});