v0.41.13.0 fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436) (#1456)

* fix(sync): infiniteGameExp + foxhoundinc 5-bug wave (#1422, #1433, #1434, #1309, #1436)

Five real production bugs from infiniteGameExp (PostgreSQL onboarding) and
foxhoundinc (dream-cycle reproduction), each silent-failure shape where gbrain
told the user the operation succeeded when it didn't.

* #1422 — `gbrain dream` swallowed connectEngine errors. Bind the caught
  error and surface `[dream] WARNING: could not connect to DB (...)` on
  stderr before falling through to filesystem-only phases. runDream(null)
  no-DB fallback preserved.

* #1433 — `gbrain sync` deleted previously-indexed log.md / schema.md /
  index.md / README.md pages on every re-sync. Refactor isSyncable
  through private classifySync helper; expose unsyncableReason (companion
  returning the same tagged reason) and SYNC_SKIP_FILES named export.
  Cleanup loop guards on reason === 'metafile' before deleting.

* #1434 — `gbrain sync` without --source on single-vault brains routed
  to source_id='default' (zero pages) and silently failed. Add resolver
  tier 5.5 'sole_non_default' AFTER brain_default (explicit user intent
  wins). Wire runSync + runImport to call resolveSourceWithTier
  unconditionally so the tier actually fires. Stderr nudge on tier hit;
  suppress with GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE=1.

* #1309 — overlapping ingest roots created duplicate pages. New
  BrainEngine.findDuplicatePage?(sourceId, {hash, frontmatterId}) with
  identity-based posture: SKIP when frontmatter.id matches (true
  external duplicate), WARN-ALWAYS on content_hash collision with
  different/missing fm.id, FAIL CLOSED on lookup error. Migration v95
  adds partial index pages_dedup_idx (Postgres CONCURRENTLY, PGLite
  plain CREATE).

* #1436 — MCP fuzzy get_page returned slug candidates from sources
  outside caller's scope. resolveSlugs signature extended with
  {sourceId?, sourceIds?} matching the sourceScopeOpts helper output;
  operations.ts threads it through. Both engines preserve unscoped
  back-compat for internal CLI callers.

Plus a stable tiebreaker on searchVector ORDER BY (score DESC, page_id
ASC, chunk_id ASC) in both engines. Caught while wiring the index above
— basis-vector eval fixtures with tied scores depend on planner row
order, which any new index on pages could flip. Pins eval-replay-gate
ranking determinism against future index changes.

Per codex review of the original plan: caught 6 load-bearing gaps that
the engineering review missed (runSync bypass, #1436 misclassified as
fixed, dedup fail-open, content-hash-alone too aggressive, soft-delete
filter missing, tier-ordering contradiction). All folded in pre-merge.

Tests: 65 new wave cases across 7 new files + 1 extended; all green.

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-25 19:53:59 -07:00
committed by GitHub
parent 25fbae3e18
commit f56cc619ba
25 changed files with 1735 additions and 42 deletions

View File

@@ -809,6 +809,29 @@ export class PostgresEngine implements BrainEngine {
return rowToPage(rows[0]);
}
/**
* v0.41.13 (#1309) — identity-based dedup pre-check.
* See `BrainEngine.findDuplicatePage` for the contract.
*/
async findDuplicatePage(
sourceId: string,
opts: { hash: string; frontmatterId?: string | null },
): Promise<{ slug: string; id: number } | null> {
const sql = this.sql;
const fmId = opts.frontmatterId ?? null;
const rows = await sql`
SELECT id, slug FROM pages
WHERE source_id = ${sourceId}
AND deleted_at IS NULL
AND (content_hash = ${opts.hash} OR (frontmatter->>'id' = ${fmId} AND ${fmId}::text IS NOT NULL))
ORDER BY id
LIMIT 1
`;
if (rows.length === 0) return null;
const r = rows[0] as { id: number | string; slug: string };
return { slug: r.slug, id: Number(r.id) };
}
async putPage(slug: string, page: PageInput, opts?: { sourceId?: string }): Promise<Page> {
slug = validateSlug(slug);
const sql = this.sql;
@@ -1275,18 +1298,31 @@ export class PostgresEngine implements BrainEngine {
}));
}
async resolveSlugs(partial: string): Promise<string[]> {
async resolveSlugs(partial: string, opts?: { sourceId?: string; sourceIds?: string[] }): Promise<string[]> {
const sql = this.sql;
// v0.41.13 #1436: source scope via postgres.js tagged-template
// fragments. When neither opt is set the resolver stays unscoped
// for back-compat with internal callers. The `deleted_at IS NULL`
// filter excludes soft-deleted rows (v0.26.5) from fuzzy candidates
// — they're not legitimate match targets for a remote `get_page`.
const sources = opts?.sourceIds ?? null;
const scalar = opts?.sourceId ?? null;
const scopeFragment = sources
? sql` AND source_id = ANY(${sources}::text[])`
: scalar
? sql` AND source_id = ${scalar}`
: sql``;
// Try exact match first
const exact = await sql`SELECT slug FROM pages WHERE slug = ${partial}`;
const exact = await sql`SELECT slug FROM pages WHERE slug = ${partial} AND deleted_at IS NULL${scopeFragment}`;
if (exact.length > 0) return [exact[0].slug];
// Fuzzy match via pg_trgm
const fuzzy = await sql`
SELECT slug, similarity(title, ${partial}) AS sim
FROM pages
WHERE title % ${partial} OR slug ILIKE ${'%' + partial + '%'}
WHERE deleted_at IS NULL AND (title % ${partial} OR slug ILIKE ${'%' + partial + '%'})${scopeFragment}
ORDER BY sim DESC
LIMIT 5
`;
@@ -1722,7 +1758,9 @@ export class PostgresEngine implements BrainEngine {
raw_score * ${sourceFactorCaseOnSlug} AS score,
false AS stale
FROM hnsw_candidates
ORDER BY score DESC
-- v0.41.13: stable tiebreaker for tied scores. See pglite-engine for
-- rationale (basis-vector test fixtures, planner-dependent ordering).
ORDER BY score DESC, page_id ASC, chunk_id ASC
LIMIT ${limitParam}
OFFSET ${offsetParam}
`;