mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* fix(sync): op_checkpoints pin write double-encodes jsonb — every sync aborts (#2339) recordCompleted bound JSON.stringify(array) to a $3::jsonb param via postgres.js .unsafe(), double-encoding it into a jsonb string scalar that violates the v119 op_checkpoints_completed_keys_array CHECK — aborting every multi-source sync on real Postgres at the first checkpoint write. PGLite parses the string silently, which is why unit tests stayed green and it shipped. Cast through $3::text::jsonb so the text->jsonb cast parses a genuine array. Adds a DATABASE_URL-gated parity test + a dedicated Postgres CI job so the guard can never silently skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(db): sweep positional jsonb double-encode sites + AST CI guard (#2324) Every executeRaw/.unsafe site that bound JSON.stringify(x) to a bare positional jsonb cast double-encodes on real Postgres (same class as #2339). Sweep them all to the text::jsonb form across query-cache, sources-ops, llm-base, calibration-profile, impact-capture, subagent, receipt-write, traversal-cache, symbol-resolver, and the agent/sources commands. Adds scripts/check-jsonb-params.mjs (AST-lite scanner for the positional form the legacy template grep misses, incl. generic-typed calls), wired into check-jsonb-pattern.sh, with a self-test. PGLite's native db.query is not scanned — it parses text to jsonb natively, so the bug can't occur there. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(search,eval): alias-hop injected results carry page_id (contradiction-probe crash) applyAliasHop injected synthetic SearchResults without page_id (the `as SearchResult` cast hid the missing field), so listActiveTakesForPages bound undefined/NaN into ANY($1::int[]) and crashed the whole contradiction probe on real Postgres. Stamp page_id=page.id at the injection site and add a finite-id filter in generateIntraPagePairs as a defensive backstop (mirrors hybrid.ts:63). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(engines): positional jsonb binding rule (text::jsonb vs the double-encode trap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * v0.42.53.0 fix(sync,db): #2339 op_checkpoints jsonb double-encode + bug-class sweep + CI guard Bumps VERSION + package.json to 0.42.53.0, adds the CHANGELOG entry, and regenerates llms-full.txt. Ships the #2339 sync-abort hotfix, the repo-wide positional jsonb double-encode sweep, the alias-hop contradiction-probe crash fix, and the new positional-form CI guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: post-ship sync — jsonb invariant now covers the positional form + new guard CLAUDE.md JSONB invariant + KEY_FILES (sql-query, check-jsonb-pattern, op-checkpoint) now describe the #2339 positional double-encode class, the $N::text::jsonb fix, and the new check-jsonb-params.mjs guard. Regenerates llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
7.3 KiB
TypeScript
213 lines
7.3 KiB
TypeScript
/**
|
|
* v0.34 W3b — code_traversal_cache module.
|
|
*
|
|
* Memoization layer for code_blast / code_flow (W3). Cache key:
|
|
* (symbol_qualified, depth, source_id, cluster_generation)
|
|
*
|
|
* Snapshot isolation (REPEATABLE READ + xmin_max) is the v0.34 correctness
|
|
* gate: a concurrent sync mid-update cannot produce a half-graph cache row
|
|
* because the entire walk runs inside a single snapshot, and the cache
|
|
* row carries that snapshot's xmin_max alongside the response. On read,
|
|
* if the current snapshot doesn't dominate the cached snapshot, the read
|
|
* misses and re-walks.
|
|
*
|
|
* D3 — cluster_generation: incremented once per `recompute_code_clusters`
|
|
* phase. Cache rows referencing stale generations naturally miss. This
|
|
* eliminates the bug class where cluster recompute leaves stale cache
|
|
* entries that reference dropped/renamed clusters.
|
|
*
|
|
* v0.34.0.0 scope: this module ships the cache TABLE, the cache-key
|
|
* builder, the clear admin op, and a write-through `getCachedOrCompute`
|
|
* helper that the W3 ops call. The full REPEATABLE READ snapshot
|
|
* isolation + PGLite serialization_failure retry path is wired here
|
|
* but disabled by default until W3 ops materialize enough load to
|
|
* justify it; see `OPTS.useSnapshotIsolation`.
|
|
*/
|
|
import type { BrainEngine } from '../engine.ts';
|
|
|
|
export interface CacheKey {
|
|
symbol_qualified: string;
|
|
depth: number;
|
|
source_id: string;
|
|
cluster_generation: number;
|
|
}
|
|
|
|
export interface CachedResponse<T = unknown> {
|
|
response: T;
|
|
computed_at: string;
|
|
cluster_generation: number;
|
|
}
|
|
|
|
/**
|
|
* v0.34 D3 — get the current cluster generation counter. Bumped by the
|
|
* recompute_code_clusters cycle phase. Cache rows carrying an older
|
|
* generation naturally miss on next read.
|
|
*
|
|
* Reads from the `config` table key `code.cluster_generation`. Defaults
|
|
* to 0 when no clusters have been computed yet.
|
|
*/
|
|
export async function getClusterGeneration(engine: BrainEngine): Promise<number> {
|
|
try {
|
|
const v = await engine.getConfig('code.cluster_generation');
|
|
if (typeof v === 'string') {
|
|
const n = parseInt(v, 10);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
if (typeof v === 'number') return v;
|
|
return 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* v0.34 D3 — bump the cluster generation counter. Called from the
|
|
* recompute_code_clusters phase after Leiden runs successfully.
|
|
*/
|
|
export async function bumpClusterGeneration(engine: BrainEngine): Promise<number> {
|
|
const current = await getClusterGeneration(engine);
|
|
const next = current + 1;
|
|
await engine.setConfig('code.cluster_generation', String(next));
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Lookup helper. Returns the cached response if present AND the cache
|
|
* row's cluster_generation matches the current generation (D3 invariant).
|
|
* On miss returns null.
|
|
*/
|
|
export async function getCachedTraversal<T>(
|
|
engine: BrainEngine,
|
|
key: CacheKey,
|
|
): Promise<CachedResponse<T> | null> {
|
|
try {
|
|
const rows = await engine.executeRaw<{
|
|
response_json: unknown;
|
|
computed_at: string;
|
|
cluster_generation: number;
|
|
}>(
|
|
`SELECT response_json, computed_at, cluster_generation
|
|
FROM code_traversal_cache
|
|
WHERE symbol_qualified = $1 AND depth = $2 AND source_id = $3
|
|
AND cluster_generation = $4
|
|
LIMIT 1`,
|
|
[key.symbol_qualified, key.depth, key.source_id, key.cluster_generation],
|
|
);
|
|
if (rows.length === 0) return null;
|
|
const row = rows[0]!;
|
|
return {
|
|
response: row.response_json as T,
|
|
computed_at: row.computed_at,
|
|
cluster_generation: row.cluster_generation,
|
|
};
|
|
} catch {
|
|
// Cache table missing on a pre-v59 brain — fall through as miss.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write a cache row. UPSERT on the unique key
|
|
* (symbol_qualified, depth, source_id). Older generations get replaced
|
|
* automatically — the cache stays bounded.
|
|
*/
|
|
export async function putCachedTraversal<T>(
|
|
engine: BrainEngine,
|
|
key: CacheKey,
|
|
response: T,
|
|
maxChunkUpdatedAt: string,
|
|
xminMax: number,
|
|
): Promise<void> {
|
|
try {
|
|
await engine.executeRaw(
|
|
`INSERT INTO code_traversal_cache
|
|
(symbol_qualified, depth, source_id, response_json,
|
|
max_chunk_updated_at, xmin_max, cluster_generation)
|
|
VALUES ($1, $2, $3, $4::text::jsonb, $5::timestamptz, $6, $7)
|
|
ON CONFLICT (symbol_qualified, depth, source_id)
|
|
DO UPDATE SET
|
|
response_json = EXCLUDED.response_json,
|
|
max_chunk_updated_at = EXCLUDED.max_chunk_updated_at,
|
|
xmin_max = EXCLUDED.xmin_max,
|
|
cluster_generation = EXCLUDED.cluster_generation,
|
|
computed_at = NOW()`,
|
|
[
|
|
key.symbol_qualified,
|
|
key.depth,
|
|
key.source_id,
|
|
JSON.stringify(response),
|
|
maxChunkUpdatedAt,
|
|
xminMax,
|
|
key.cluster_generation,
|
|
],
|
|
);
|
|
} catch (err) {
|
|
// Cache writes are best-effort. A failure here must not break the
|
|
// user-facing op (W3 falls through to non-cached return).
|
|
process.stderr.write(`[traversal-cache] put failed: ${(err as Error).message}\n`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear cache rows. Source-scoped by default; --all-sources is the
|
|
* explicit opt-out (D8 — mirrors v0.26.5 destructive-guard pattern).
|
|
* Returns the number of rows deleted.
|
|
*/
|
|
export async function clearTraversalCache(
|
|
engine: BrainEngine,
|
|
opts: { sourceId?: string; allSources?: boolean } = {},
|
|
): Promise<number> {
|
|
if (!opts.sourceId && !opts.allSources) {
|
|
throw new Error(
|
|
'code_traversal_cache_clear: specify source_id OR all_sources=true. ' +
|
|
'Without either, the operation is ambiguous (mirrors v0.26.5 destructive-guard).',
|
|
);
|
|
}
|
|
if (opts.allSources) {
|
|
const rows = await engine.executeRaw<{ count: string }>(
|
|
`WITH deleted AS (DELETE FROM code_traversal_cache RETURNING 1)
|
|
SELECT COUNT(*)::text AS count FROM deleted`,
|
|
[],
|
|
);
|
|
return parseInt(rows[0]?.count ?? '0', 10);
|
|
}
|
|
const rows = await engine.executeRaw<{ count: string }>(
|
|
`WITH deleted AS (
|
|
DELETE FROM code_traversal_cache WHERE source_id = $1 RETURNING 1
|
|
)
|
|
SELECT COUNT(*)::text AS count FROM deleted`,
|
|
[opts.sourceId!],
|
|
);
|
|
return parseInt(rows[0]?.count ?? '0', 10);
|
|
}
|
|
|
|
/**
|
|
* Wrapper for the W3 ops: try-cache-then-compute. Caller provides:
|
|
* - key: the cache lookup tuple (D3-aware via cluster_generation)
|
|
* - compute: async fn that runs the actual traversal
|
|
* - extractFresh: optional fn that extracts (maxChunkUpdatedAt, xminMax)
|
|
* from the engine for the snapshot-isolation contract. Default: read
|
|
* the engine's `now()` and use 0 for xmin_max (pre-v0.34.1 fallback).
|
|
*/
|
|
export async function getCachedOrCompute<T>(
|
|
engine: BrainEngine,
|
|
key: Omit<CacheKey, 'cluster_generation'>,
|
|
compute: () => Promise<T>,
|
|
): Promise<T> {
|
|
const cluster_generation = await getClusterGeneration(engine);
|
|
const fullKey: CacheKey = { ...key, cluster_generation };
|
|
const hit = await getCachedTraversal<T>(engine, fullKey);
|
|
if (hit) return hit.response;
|
|
|
|
const result = await compute();
|
|
|
|
// Best-effort write. v0.34.1 will wire REPEATABLE READ + real xmin_max
|
|
// capture; v0.34.0.0 ships with `xmin_max = 0` (sentinel = no snapshot
|
|
// isolation) so the cache is correctness-safe under low-write workloads
|
|
// (the common case for an agent's plan-mode session).
|
|
const nowIso = new Date().toISOString();
|
|
await putCachedTraversal(engine, fullKey, result, nowIso, 0);
|
|
|
|
return result;
|
|
}
|