v0.35.8.0 feat(cycle): phantom-page redirect inside extract_facts (#1138)

* feat(cycle): phantom-page redirect inside extract_facts (v0.35.8.0)

Drains the existing pile of unprefixed entity pages (alice.md, acme.md)
that pre-PR-#1010 routing left behind. Folds the cleanup into the existing
extract_facts cycle phase via two new lossless engine primitives so the
v0.32.2 reconciliation contract owns drift handling instead of a parallel
implementation duplicating it.

Layers:
- engine: refreshPageBody + migrateFactsToCanonical on Postgres + PGLite
- resolver: resolvePhantomCanonical + findPrefixCandidates (codex #1/#11)
- orchestrator: src/core/cycle/phantom-redirect.ts + phantom-audit JSONL
- cycle: sourceId/brainDir threaded; 3 new totals counters
- tests: 38 unit + 6 parity + 4 E2E (48 total) pinning all 12 codex findings

* fix(test): pin clock in sync_freshness boundary tests (CI flake)

CI test (1) failed: `sync_freshness check > exact 72h boundary → warn`.
The test set `last_sync_at = Date.now() - 72h`, then checkSyncFreshness
called Date.now() again to compute ageMs. Between the two reads the
clock advanced (0.43ms in this CI run, microseconds locally) which
pushed ageMs above the strict 72h fail threshold and flipped the
status from warn to fail.

Same shape latent in the 24h boundary test — fixed both.

Fix:
- checkSyncFreshness gains an optional `opts.nowMs` test-only seam.
  Production callers omit it and get live wall-clock semantics.
- Both boundary tests now capture nowMs once and thread it through
  both `last_sync_at` and the check, eliminating drift between reads.

Verified deterministic: 10 consecutive runs of the 72h boundary test
pass on this machine (was occasionally failing before).
This commit is contained in:
Garry Tan
2026-05-18 06:22:12 -07:00
committed by GitHub
parent 1dadd9ed71
commit 61b79e7c99
18 changed files with 2456 additions and 13 deletions

View File

@@ -774,6 +774,55 @@ export class PostgresEngine implements BrainEngine {
return { slugs, count: slugs.length };
}
async refreshPageBody(
slug: string,
sourceId: string,
compiledTruth: string,
timeline: string,
contentHash: string,
): Promise<void> {
const sql = this.sql;
// Narrow UPDATE — leaves frontmatter, type, chunks, links, embeddings,
// tags, takes untouched. Skips soft-deleted rows so a redirect retry
// can't accidentally reanimate the body of a deleted canonical.
await sql`
UPDATE pages
SET compiled_truth = ${compiledTruth},
timeline = ${timeline},
content_hash = ${contentHash},
updated_at = now()
WHERE source_id = ${sourceId}
AND slug = ${slug}
AND deleted_at IS NULL
`;
}
async migrateFactsToCanonical(
phantomSlug: string,
canonicalSlug: string,
sourceId: string,
): Promise<{ migrated: number }> {
const sql = this.sql;
// UPDATE preserves every other column (embedding, valid_*, kind,
// status, notability, confidence, source_session, ...). Idempotent
// by virtue of the WHERE clause matching nothing on re-run.
//
// We scope to `expired_at IS NULL` so the migration touches only
// active facts. Forgotten / superseded rows that already carry an
// expiry stay where they are — soft-deleting the phantom page is
// sufficient to make them invisible without rewriting their slug
// (and rewriting would break the audit trail in listSupersessions).
const result = await sql`
UPDATE facts
SET entity_slug = ${canonicalSlug},
source_markdown_slug = ${canonicalSlug}
WHERE source_id = ${sourceId}
AND source_markdown_slug = ${phantomSlug}
AND expired_at IS NULL
`;
return { migrated: result.count ?? 0 };
}
async listPages(filters?: PageFilters): Promise<Page[]> {
const sql = this.sql;
const limit = filters?.limit || 100;