Files
gbrain/test/fence-write.test.ts
T
0c6fcab555 v0.35.4.0 fix(doctor,entities): supervisor crash classification + bare-name resolver + 58x perf + stub guard observability (#1085)
* fix(doctor,entities): supervisor crash classification + bare-name resolver + stub guard

- doctor.ts/jobs.ts: classify worker exits with code !== 0 as real crashes
  vs code === 0 clean restarts (separate counter); fixes false-positive
  WARN on healthy supervisors
- entities/resolve.ts: prefix-expansion step between fuzzy match and
  slugify fallback catches bare first names that score too low on pg_trgm;
  picks highest-connection candidate as tiebreaker
- facts/fence-write.ts: stub-creation guard refuses to spawn unprefixed
  entity pages at brain root
- facts/backstop.ts: routes stubGuardBlocked facts to engine.insertFact
  so the fact still persists even when no markdown file is created
- docs/issues/doctor-auto-heal-and-scoring.md: spec for follow-up doctor
  health-score improvements
- .gitignore: guard reports/network-intelligence/ (private brain exports)

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

* chore(privacy): scrub real names from entity-resolve test fixtures and JSDoc

Replace YC partner names with placeholders per CLAUDE.md privacy rule:
alice-example, bob-example, charlie-example, dave-example. Stripe and
Stripe Atlas retained (allowed household brands; exercises the two-word
company-prefix case).

Test semantics preserved:
- Alice / Dave: single-match cases
- Bob / Charlie: multi-match tiebreaker cases (winner has more chunks)

All 13 entity-resolve cases pass with the scrubbed fixtures.

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

* refactor(supervisor): extract classifyWorkerExit() helper (DRY)

Three call sites were inline-classifying worker exits: supervisor's
restart policy (child-worker-supervisor.ts:291), doctor's supervisor
check (doctor.ts:1016), and jobs supervisor status (jobs.ts:806). Same
rule, three copies — drift risk if one is updated without the others.

Extract to src/core/minions/exit-classification.ts as a pure function.
Signature consumes audit-JSON shape ({ code: number | null }) so doctor
and jobs (which read serialized events from JSONL) and supervisor (which
reads Node's exit callback) call the same function. Helper's classification
rule: code === 0 → clean_exit, everything else (non-zero, null, undefined,
missing) → crash. Default-to-crash prevents corrupted rows from silently
demoting into the clean-restart bucket.

5 hermetic unit tests (test/exit-classification.test.ts) pin all edge cases.

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

* feat(facts): audit + sunset comment for stub-guard fires

Wire telemetry into the v0.34.5 stub-guard at fence-write.ts:190. Every
guard fire now appends a JSONL line to
~/.gbrain/audit/stub-guard-YYYY-Www.jsonl with {ts, slug, source_id,
fact_count}. Operator visibility for the sunset criterion: when the new
audit log reads <5 hits/week for 3 consecutive weeks on production
brains, the prefix-expansion in resolveEntitySlug is sufficient and the
guard can be removed in v0.36.

Reader (readRecentStubGuardEvents) deliberately diverges from
supervisor-audit.ts:readSupervisorEvents — it reads BOTH the current AND
previous ISO-week file before filtering by ts. supervisor-audit's reader
only reads the current week, which loses 24h-window correctness across
Monday 00:00 UTC (a Sunday 23:55 event lives in last week's file). The
2-file read costs nothing and makes the window actually 24h.

9 hermetic unit tests pin filename math, the writer's
swallows-errors contract, the cross-week-boundary read, sort order,
missing-file behavior, and malformed-row tolerance. The cross-week test
is the regression guard: if a future refactor copies the supervisor's
single-file pattern, that test fails.

Follow-up TODO (not in this PR): fix readSupervisorEvents to use the
same 2-file pattern. The new stub-guard reader becomes the canonical
template to copy back.

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

* feat(doctor): stub_guard_24h check surfaces resolver gaps

Adds a new doctor check that reads ~/.gbrain/audit/stub-guard-YYYY-Www.jsonl
(via the dual-week-aware reader from T8) and surfaces the 24h fire count.
WARN at >10 fires — at that rate the prefix-expansion in resolveEntitySlug
is probably missing a case (typo prefix, alias, non-Latin script) and
operators should grep the audit log for the offending slugs. Below the
threshold but non-zero shows as OK with a count, so operators can watch
the v0.36 sunset criterion (<5/week for 3 weeks → guard can be removed).
Zero hits emits no check, keeping the doctor output clean on healthy
brains.

5 source-grep regression tests pin the contract: check name, WARN
threshold, fix hint mentions the audit log + the resolver function name,
reader is the dual-week-aware variant (NOT the supervisor-audit single-
week pattern), and zero-hits stays silent.

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

* test(facts): pin stub-guard contract at writeFactsToFence + backstop layers

- fence-write.test.ts: 3 new cases for the v0.34.5 stub guard. Bare slugs
  return {inserted: 0, stubGuardBlocked: true, ids: []} and create no
  file/.tmp at brain root. Prefixed slugs bypass the guard (regression
  guard against accidentally inverting the slug.includes('/') check).
  Empty facts array short-circuits before the guard fires.
- facts-backstop.test.ts: 1 new case for the end-to-end routing. A
  bare-name LLM extraction resolves through to a bare slug, hits the
  guard, and lands in the facts table via engine.insertFact (DB-only).
  No phantom .md file; entity_slug stores the bare slug;
  source_markdown_slug is null. This is the routing contract Codex
  flagged as a "split-brain" data shape — the test pins the by-design
  behavior so a future refactor can't silently drop these facts.

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

* test(supervisor): pin classifyWorkerExit consumer wire-up + regressions

12 new cases on top of the 5 helper unit tests:
- doctor.ts / jobs.ts / child-worker-supervisor.ts each import the helper
- All three call classifyWorkerExit at least once
- doctor.ts and jobs.ts no longer carry the pre-T7 inline filter
- supervisor uses the helper result to choose the clean_exit branch
- audit-event shape round-trip: code=0 → clean_exit, code=1 → crash,
  code=null+SIGKILL → crash (catches future shape changes)

The regression guards (3) and the wire-up checks (6) close the gap that
motivated T7 in the first place: if a future change accidentally re-inlines
the filter or shifts the audit event shape, the test fails before
production sees the silent divergence.

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

* perf(entities): correlated subqueries scoped to slug-LIKE candidates

Replace the derived-table JOIN shape in tryPrefixExpansion with
correlated subqueries. The pre-fix SQL did

  LEFT JOIN (SELECT to_page_id, COUNT(*) FROM links GROUP BY to_page_id) li ON ...

which forced the planner to aggregate the entire links + content_chunks
tables on every prefix-expansion call — O(N) per call where N is total
links/chunks in the brain. On a 100K-link / 50K-chunk brain that's slow
enough to bottleneck fact-extraction.

New shape uses correlated subqueries:

  (SELECT COUNT(*) FROM links WHERE to_page_id = p.id)
    + (SELECT COUNT(*) FROM links WHERE from_page_id = p.id)
    + (SELECT COUNT(*) FROM content_chunks WHERE page_id = p.id)

The slug LIKE filter is already selective (typical brain has 0-5 pages
per prefix), so the three subqueries run N≈3 times per matched row
against the existing indexes on links.to_page_id, links.from_page_id,
and content_chunks.page_id. Behavior preserved: 13/13 entity-resolve
tests pass (single-match + multi-match tiebreaker + edge cases).

Codex's outside-voice review caught the dead-end design that an earlier
draft of this plan proposed (a CTE with `LIMIT 50` candidate cap — would
have excluded correct high-connection candidates if their slug sorted
late). Correlated subqueries without a candidate cap are the cleaner
shape that lets the LIKE filter do the bounding work.

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

* test(entities): perf regression guard for prefix-expansion (58x speedup)

Hermetic PGLite benchmark with 5K pages + 50K links + 25K chunks. Runs
the pre-T12 derived-table shape and the new correlated-subquery shape
side-by-side against the same fixture, asserts NEW >= 5x faster than OLD.
Baseline-ratio, not absolute wall-clock — different machines / Bun
versions / CI load can shift absolute timings by 10x without indicating
a real regression, but the SHAPE difference between "aggregate the full
tables" and "correlated subquery per candidate" is what we care about.

Measured: old_median=18.16ms, new_median=0.31ms, speedup=58.22x.
The 5x assertion has plenty of headroom.

The OLD SQL is embedded verbatim as the regression baseline. If a future
refactor re-introduces full-table aggregation (LEFT JOIN against
SELECT...GROUP BY over the whole links or content_chunks table), the
test fails. PGLite-only — Postgres planner can shape derived-table
JOINs differently enough that the 5x ratio could be noise on a 5K-page
fixture. The structural correctness of the rewrite is the same on both;
this is purely a planner-shape regression guard.

.slow.test.ts suffix keeps it out of the fast loop (run via
`bun run test:slow`).

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

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

Wave content:
- Privacy scrub: PII rebuilt out of branch history; real names → placeholders
- Bug fix: doctor + jobs no longer count clean worker exits as crashes
- Bug fix: entity resolver prefix-expansion catches bare first names
- DRY refactor: classifyWorkerExit() helper (one rule, 3 call sites)
- Observability: stub_guard_24h doctor check + ISO-week audit log
- Perf: 58x speedup on tryPrefixExpansion query shape

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

* fix: rebump v0.35.2.0 → v0.35.4.0 + scrub TODOS.md privacy violation

VERSION/package.json/CHANGELOG header rebumped to v0.35.4.0 per user
request (queue allocation). TODOS.md rephrased to not literally name
the banned private-agent string — that was the CI failure root cause
on the v0.35.2.0 push. CHANGELOG.md is on check-privacy.sh's allow-list
(meta-documentation exception); TODOS.md is not.

CI re-runs against this commit.

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-17 08:51:33 -07:00

304 lines
11 KiB
TypeScript

/**
* v0.32.2 — fence-write module tests.
*
* Exercises the markdown-first write path: page lock + stub-create +
* atomic .tmp + parse-validate + engine.insertFacts batch + the
* legacy fallback for missing local_path. Real PGLite + a real
* filesystem under a per-test tempdir.
*
* The page-lock contention test (multi-process integration via
* Bun.spawn) lives in test/e2e/facts-lock-contention.test.ts (commit
* 10's invariant E2E capstone, since spawning child processes is an
* E2E concern). These unit/integration cases cover the in-process
* happy + recovery paths.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { writeFactsToFence, lookupSourceLocalPath } from '../src/core/facts/fence-write.ts';
import type { FenceInputFact } from '../src/core/facts/fence-write.ts';
let engine: PGLiteEngine;
let brainDir: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
// Fresh tempdir per test so the fence-write FS state is hermetic.
brainDir = mkdtempSync(join(tmpdir(), 'fence-write-test-'));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query('DELETE FROM facts');
// Default source pointed at the fresh brainDir.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(
`UPDATE sources SET local_path = $1 WHERE id = 'default'`,
[brainDir],
);
});
const baseInput = (overrides: Partial<FenceInputFact> = {}): FenceInputFact => ({
fact: 'Founded Acme in 2017',
kind: 'fact',
notability: 'high',
source: 'mcp:put_page',
visibility: 'world',
confidence: 1.0,
validFrom: new Date(Date.UTC(2017, 0, 1)),
embedding: null,
sessionId: null,
...overrides,
});
describe('writeFactsToFence — happy path', () => {
test('stub-creates entity page when none exists, writes fence, stamps DB', async () => {
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'people/alice' },
[baseInput()],
);
expect(result.inserted).toBe(1);
expect(result.ids).toHaveLength(1);
expect(result.legacyFallback).toBeUndefined();
expect(result.fenceWriteFailed).toBeUndefined();
// Page was stub-created with min frontmatter.
const filePath = join(brainDir, 'people/alice.md');
expect(existsSync(filePath)).toBe(true);
const body = readFileSync(filePath, 'utf-8');
expect(body).toContain('type: person');
expect(body).toContain('slug: people/alice');
expect(body).toContain('## Facts');
expect(body).toContain('Founded Acme in 2017');
// DB row has v51 columns populated.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dbRows = await (engine as any).db.query(
'SELECT row_num, source_markdown_slug, fact FROM facts WHERE id = $1',
[result.ids[0]],
);
expect(dbRows.rows[0]).toMatchObject({
row_num: 1,
source_markdown_slug: 'people/alice',
fact: 'Founded Acme in 2017',
});
});
test('appends to existing entity page without overwriting body', async () => {
// Pre-create the entity page with custom content.
const filePath = join(brainDir, 'people/bob.md');
mkdirSync(join(brainDir, 'people'), { recursive: true });
writeFileSync(
filePath,
'---\ntype: person\ntitle: Bob\nslug: people/bob\n---\n\n# Bob\n\nMet at YC W22.\n',
'utf-8',
);
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'people/bob' },
[baseInput({ fact: 'Founded Widgets Inc.' })],
);
expect(result.inserted).toBe(1);
const body = readFileSync(filePath, 'utf-8');
expect(body).toContain('Met at YC W22.'); // preserved
expect(body).toContain('# Bob'); // preserved
expect(body).toContain('## Facts'); // added
expect(body).toContain('Founded Widgets Inc.');
});
test('multi-fact batch appends consecutive row_nums', async () => {
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'people/carol' },
[
baseInput({ fact: 'Claim 1' }),
baseInput({ fact: 'Claim 2' }),
baseInput({ fact: 'Claim 3' }),
],
);
expect(result.inserted).toBe(3);
expect(result.ids).toHaveLength(3);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query(
`SELECT row_num, fact FROM facts WHERE source_markdown_slug = 'people/carol' ORDER BY row_num`,
);
expect(rows.rows.map((r: { row_num: number; fact: string }) => r.row_num)).toEqual([1, 2, 3]);
expect(rows.rows.map((r: { fact: string }) => r.fact)).toEqual(['Claim 1', 'Claim 2', 'Claim 3']);
});
test('appending to a page that already has a facts fence continues row_num sequence', async () => {
// First write seeds the fence with rows 1 and 2.
await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'people/dan' },
[baseInput({ fact: 'First' }), baseInput({ fact: 'Second' })],
);
// Second write should pick up at row_num=3.
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'people/dan' },
[baseInput({ fact: 'Third' })],
);
expect(result.inserted).toBe(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query(
`SELECT row_num, fact FROM facts WHERE source_markdown_slug = 'people/dan' ORDER BY row_num`,
);
expect(rows.rows[2]).toMatchObject({ row_num: 3, fact: 'Third' });
});
test('stub-creates nested directories (companies/x → mkdir companies)', async () => {
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'companies/acme' },
[baseInput({ fact: 'Founded 2017' })],
);
expect(result.inserted).toBe(1);
expect(existsSync(join(brainDir, 'companies/acme.md'))).toBe(true);
const body = readFileSync(join(brainDir, 'companies/acme.md'), 'utf-8');
expect(body).toContain('type: company'); // type inferred from slug prefix
});
});
describe('writeFactsToFence — legacy fallback', () => {
test('null localPath returns legacyFallback:true with no inserts', async () => {
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: null, slug: 'people/whoever' },
[baseInput()],
);
expect(result).toEqual({ inserted: 0, ids: [], legacyFallback: true });
// No DB inserts happened either.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = await (engine as any).db.query('SELECT COUNT(*) AS n FROM facts');
expect(Number(rows.rows[0].n)).toBe(0);
});
test('empty facts array returns inserted:0 without touching FS', async () => {
const slug = 'people/should-not-exist';
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug },
[],
);
expect(result).toEqual({ inserted: 0, ids: [] });
// The page file should NOT have been stub-created since there was
// nothing to write.
expect(existsSync(join(brainDir, `${slug}.md`))).toBe(false);
});
});
describe('writeFactsToFence — atomic recovery', () => {
test('after a successful write, no .tmp file is left behind', async () => {
await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'people/erin' },
[baseInput()],
);
const tmpPath = join(brainDir, 'people/erin.md.tmp');
expect(existsSync(tmpPath)).toBe(false);
});
});
describe('writeFactsToFence — stub guard (v0.34.5)', () => {
test('refuses to stub-create an unprefixed entity page (bare slug)', async () => {
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'alice' },
[baseInput()],
);
// Result shape: no facts inserted, guard flag set, no ids.
expect(result.inserted).toBe(0);
expect(result.ids).toHaveLength(0);
expect(result.stubGuardBlocked).toBe(true);
// No phantom file at brain root.
expect(existsSync(join(brainDir, 'alice.md'))).toBe(false);
// No phantom .tmp either.
expect(existsSync(join(brainDir, 'alice.md.tmp'))).toBe(false);
});
test('prefixed slugs (people/, companies/, etc.) bypass the guard', async () => {
// Sanity: re-prove the happy path right next to the guard test so a
// future refactor that breaks the guard's slug.includes('/') check
// can't silently pass by only running the guard case.
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'people/zelda' },
[baseInput({ fact: 'Founded Hyrule Labs in 2024' })],
);
expect(result.inserted).toBe(1);
expect(result.stubGuardBlocked).toBeUndefined();
expect(existsSync(join(brainDir, 'people/zelda.md'))).toBe(true);
});
test('empty facts array is a no-op (does NOT trigger the guard)', async () => {
// Empty input short-circuits BEFORE the guard runs — confirming the
// guard only fires when there's actual work the caller wants to do.
const result = await writeFactsToFence(
engine,
{ sourceId: 'default', localPath: brainDir, slug: 'alice' },
[],
);
expect(result.inserted).toBe(0);
expect(result.stubGuardBlocked).toBeUndefined();
expect(result.legacyFallback).toBeUndefined();
expect(existsSync(join(brainDir, 'alice.md'))).toBe(false);
});
});
describe('lookupSourceLocalPath', () => {
test('returns the configured local_path for an existing source', async () => {
const got = await lookupSourceLocalPath(engine, 'default');
expect(got).toBe(brainDir);
});
test('returns null for unknown source_id', async () => {
const got = await lookupSourceLocalPath(engine, 'nonexistent');
expect(got).toBeNull();
});
test('returns null when local_path is NULL on the source row', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (engine as any).db.query(`UPDATE sources SET local_path = NULL WHERE id = 'default'`);
const got = await lookupSourceLocalPath(engine, 'default');
expect(got).toBeNull();
});
});
// Cleanup any leftover tempdirs after the whole suite.
afterAll(() => {
// No-op: each test cleaned up via the beforeEach; this is a safety net.
try {
if (brainDir) rmSync(brainDir, { recursive: true, force: true });
} catch {
/* best-effort */
}
});