mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
* fix(import): coerce non-string frontmatter title/slug/type (#1939) YAML `title: 2024-06-01` parses to a Date and `title: 1458` to a number; the old `(frontmatter.X as string)` cast was a compile-time lie, so downstream `.toLowerCase()` threw and (via the importer failure gate) could wedge sync indefinitely. parseMarkdown now coerces via coerceFrontmatterString (Date -> UTC ISO date, deterministic), and the pure assessContentSanity self-protects against a non-string title. * feat(sync): bounded auto-skip failure ledger; poison file can't wedge indexing (#1939) New src/core/sync-failure-ledger.ts owns the failure store + a crash-safe, multi-source, concurrent bounded auto-skip valve. A file that fails N consecutive syncs (GBRAIN_SYNC_AUTOSKIP_AFTER, default 3) auto-skips so it can't freeze all indexing forever, while fresh failures still fail-closed and a `<head>` history-rewrite sentinel hard-blocks even with --skip-failed. - (source_id, path) keying — failures never merge across sources - success clears a path so attempts are truly consecutive - advance-before-ack ordering (a crash can't mark a file skipped while wedged) - shared applySyncFailureGate used by BOTH the incremental and full-sync gates - legacy-row normalization + duplicate collapse on load - cross-process lock + atomic temp-rename, age-based stale-lock break sync.ts re-exports the ledger for existing callers; import.ts records source-scoped and defers the bookmark to the gate under managedBookmark. * fix(doctor): sync_failures severity via one shared decision on both surfaces (#1939) Local buildChecks and remote doctorReportRemote now both route through decideSyncFailureSeverity, so a stuck bookmark escalates WARN -> FAIL consistently (oldest-open age > fail cadence, or large unresolved count), auto-skipped pages stay visible (WARN, not hidden), and the acknowledged/acknowledged_at field-split that caused drift is gone. The remote surface stays subprocess-free (file read + Date.parse only). * chore(test): add trailing newline to e5-lease-cap-ab baseline fixture * fix(sync): address adversarial review findings on the failure ledger (#1939) - #1: a parse-failed file that is later deleted/renamed-away no longer leaves a permanent open ledger row. Removed paths (filtered.deleted, renamed-from, and the "gone from disk" forward-delete skip branch) are treated as resolved so the ledger self-heals instead of aging doctor to a stuck FAIL. - #3: decideSyncFailureSeverity escalates to FAIL on OPEN (blocking) failures only — auto_skipped rows already advanced the bookmark, so they stay WARN-visible regardless of count, matching the state-machine contract. * chore: bump version and changelog (v0.42.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document sync-failure ledger + auto-skip valve for v0.42.30.0 KEY_FILES.md: new src/core/sync-failure-ledger.ts entry (bounded auto-skip state machine, decideGateAction/decideSyncFailureSeverity/applySyncFailureGate, GBRAIN_SYNC_AUTOSKIP_AFTER); update sync.ts (failure store moved to ledger, re-exported), doctor.ts (sync_failures severity via shared rule on both surfaces), markdown.ts (coerceFrontmatterString), import.ts (managedBookmark). live-sync.md: poison-file auto-skip tricky-spot. Regenerated llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-bump to v0.42.31.0 (queue collision on 0.42.30.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-bump to v0.42.32.0 (queue collision) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
765 lines
28 KiB
TypeScript
765 lines
28 KiB
TypeScript
// ─────────────────────────────────────────────────────────────────
|
|
// Sync failure ledger — issue #1939 (was "Bug 9" in src/core/sync.ts)
|
|
// ─────────────────────────────────────────────────────────────────
|
|
//
|
|
// When a sync run catches a per-file parse error (YAML with unquoted
|
|
// colons, malformed frontmatter, a non-string title, etc.), we record it
|
|
// here instead of just logging and moving on. Goals:
|
|
// 1. Gate the sync.last_commit bookmark advance in BOTH sync gates
|
|
// (incremental + full/runImport) through one shared policy.
|
|
// 2. Give a visible, per-(source,path) record of what failed, with the
|
|
// commit hash to re-attempt after fixing the source file.
|
|
// 3. `gbrain sync --skip-failed` acknowledges a known-bad set.
|
|
// 4. BOUNDED AUTO-SKIP: a file that fails N consecutive syncs is
|
|
// auto-skipped so a single poison file can't wedge ALL indexing
|
|
// forever — without ever silently dropping a FRESH failure, and
|
|
// never auto-skipping an infra sentinel like `<head>`.
|
|
//
|
|
// This module is a LEAF (imports only fs/path/crypto/config) so it can be
|
|
// re-exported from sync.ts without a circular dependency. The state lives
|
|
// in `~/.gbrain/sync-failures.jsonl`, one JSON object per line.
|
|
//
|
|
// State machine (per (source_id, path)):
|
|
//
|
|
// recordFailures (file fails a sync run)
|
|
// │ attempts++ each consecutive run
|
|
// ▼
|
|
// ┌──────┐ clearFailures (file imports OK) ┌─────────┐
|
|
// │ open │ ───────────────────────────────────────│ removed │
|
|
// └──────┘ └─────────┘
|
|
// │ │
|
|
// --skip-│ │ attempts >= threshold AND no fresh failures
|
|
// failed │ │ (after bookmark advance)
|
|
// ▼ ▼
|
|
// ┌────────────┐ ┌──────────────┐
|
|
// │acknowledged│ │ auto_skipped │ (still UNRESOLVED → doctor WARN
|
|
// │ (resolved) │ │ visible │ until the file imports cleanly)
|
|
// └────────────┘ └──────────────┘
|
|
//
|
|
// `acknowledged` = ok (human resolved). `open` + `auto_skipped` = unresolved
|
|
// (doctor surfaces them). `<head>` and any `<…>` sentinel is recorded but
|
|
// NEVER auto-skipped/acknowledged-to-advance — a history rewrite must hard-block.
|
|
|
|
import {
|
|
existsSync as _existsSync,
|
|
readFileSync as _readFileSync,
|
|
writeFileSync as _writeFileSync,
|
|
mkdirSync as _mkdirSync,
|
|
renameSync as _renameSync,
|
|
openSync as _openSync,
|
|
closeSync as _closeSync,
|
|
unlinkSync as _unlinkSync,
|
|
statSync as _statSync,
|
|
} from 'fs';
|
|
import { join as _joinPath } from 'path';
|
|
import { gbrainPath as _gbrainPath } from './config.ts';
|
|
import { createHash as _createHash } from 'crypto';
|
|
|
|
export const DEFAULT_SOURCE_ID = 'default';
|
|
/** Reserved sentinel paths (e.g. `<head>`) start with this; never file paths. */
|
|
export const SENTINEL_PREFIX = '<';
|
|
export const DEFAULT_AUTOSKIP_AFTER = 3;
|
|
const LOCK_STALE_MS = 30_000;
|
|
const LOCK_SPIN_MS = 50;
|
|
const LOCK_TIMEOUT_MS = 5_000;
|
|
|
|
export type SyncFailureState = 'open' | 'acknowledged' | 'auto_skipped';
|
|
|
|
export interface SyncFailure {
|
|
/** Owning source (#1939 Codex #2 — failures must not merge across sources). */
|
|
source_id: string;
|
|
path: string;
|
|
error: string;
|
|
/** Structured error code extracted from the error message. */
|
|
code: string;
|
|
/** Most recent commit this path failed on. */
|
|
commit: string;
|
|
line?: number;
|
|
/** ISO — start of the current unresolved streak. */
|
|
first_seen: string;
|
|
/** ISO — last update. */
|
|
ts: string;
|
|
/** Consecutive failed sync runs for (source_id, path). */
|
|
attempts: number;
|
|
state: SyncFailureState;
|
|
resolved_at?: string;
|
|
// Legacy MIRROR fields, still WRITTEN for one release so any pre-#1939
|
|
// reader of `acknowledged_at` keeps working. Derived from `state`.
|
|
acknowledged?: boolean;
|
|
acknowledged_at?: string | null;
|
|
}
|
|
|
|
export interface AcknowledgeResult {
|
|
count: number;
|
|
summary: Array<{ code: string; count: number }>;
|
|
}
|
|
|
|
/** A real importable file (not an infra sentinel like `<head>`). */
|
|
export function isSkippablePath(path: string): boolean {
|
|
return !path.startsWith(SENTINEL_PREFIX);
|
|
}
|
|
|
|
/**
|
|
* Resolve the auto-skip threshold from `GBRAIN_SYNC_AUTOSKIP_AFTER`
|
|
* (default 3). `0` disables the valve entirely (pure fail-closed).
|
|
*/
|
|
export function resolveAutoSkipThreshold(): number {
|
|
const raw = process.env.GBRAIN_SYNC_AUTOSKIP_AFTER;
|
|
if (raw === undefined || raw === '') return DEFAULT_AUTOSKIP_AFTER;
|
|
const n = Number(raw);
|
|
if (!Number.isFinite(n) || n < 0) return DEFAULT_AUTOSKIP_AFTER;
|
|
return Math.floor(n);
|
|
}
|
|
|
|
/**
|
|
* Best-effort extraction of a structured error code from a sync failure
|
|
* message. Order matters: DB-layer errors are checked BEFORE YAML-layer
|
|
* ones so Postgres `duplicate key` isn't mislabeled as a YAML duplicate-key.
|
|
*/
|
|
export function classifyErrorCode(errorMsg: string): string {
|
|
// SLUG_MISMATCH: thrown by importFromFile() at src/core/import-file.ts.
|
|
if (/slug.*does not match|SLUG_MISMATCH/i.test(errorMsg)) return 'SLUG_MISMATCH';
|
|
|
|
// DB-layer errors come BEFORE the YAML duplicate-key check.
|
|
if (/duplicate key value violates unique constraint|DB_DUPLICATE_KEY/i.test(errorMsg)) {
|
|
return 'DB_DUPLICATE_KEY';
|
|
}
|
|
if (/canceling statement due to statement timeout|STATEMENT_TIMEOUT/i.test(errorMsg)) {
|
|
return 'STATEMENT_TIMEOUT';
|
|
}
|
|
|
|
// YAML / frontmatter patterns.
|
|
if (/YAML parse failed|YAML_PARSE/i.test(errorMsg)) return 'YAML_PARSE';
|
|
if (/YAMLException|duplicated mapping key|YAML_DUPLICATE_KEY/i.test(errorMsg)) {
|
|
return 'YAML_DUPLICATE_KEY';
|
|
}
|
|
if (/File is empty or whitespace-only|Frontmatter must start with ---|MISSING_OPEN/i.test(errorMsg)) {
|
|
return 'MISSING_OPEN';
|
|
}
|
|
if (/No closing --- delimiter|Heading at line .* found inside frontmatter|MISSING_CLOSE/i.test(errorMsg)) {
|
|
return 'MISSING_CLOSE';
|
|
}
|
|
if (/Frontmatter block is empty|EMPTY_FRONTMATTER/i.test(errorMsg)) return 'EMPTY_FRONTMATTER';
|
|
if (/Content contains null bytes|NULL_BYTES|null byte/i.test(errorMsg)) return 'NULL_BYTES';
|
|
if (/Nested double quotes|NESTED_QUOTES/i.test(errorMsg)) return 'NESTED_QUOTES';
|
|
|
|
// Generic fallbacks.
|
|
if (/invalid UTF-?8|INVALID_UTF8/i.test(errorMsg)) return 'INVALID_UTF8';
|
|
if (/file too large|content too large|FILE_TOO_LARGE/i.test(errorMsg)) return 'FILE_TOO_LARGE';
|
|
if (/skipping symlink|symlink|SYMLINK_NOT_ALLOWED/i.test(errorMsg)) return 'SYMLINK_NOT_ALLOWED';
|
|
|
|
// takes-v2 fence + holder grammar failures.
|
|
if (/TAKES_TABLE_MALFORMED|TAKES_ROW_NUM_COLLISION|TAKES_FENCE_UNBALANCED/i.test(errorMsg)) {
|
|
return 'TAKES_TABLE_MALFORMED';
|
|
}
|
|
if (/TAKES_HOLDER_INVALID/i.test(errorMsg)) return 'TAKES_HOLDER_INVALID';
|
|
|
|
// Embedding error classification.
|
|
if (/embedding requires [A-Z][A-Z0-9_]+_API_KEY|EMBEDDING_NO_CREDS/i.test(errorMsg)) {
|
|
return 'EMBEDDING_NO_CREDS';
|
|
}
|
|
if (/Anthropic has no embedding model|EMBEDDING_NO_TOUCHPOINT/i.test(errorMsg)) {
|
|
return 'EMBEDDING_NO_TOUCHPOINT';
|
|
}
|
|
if (/\brate.?limit|\b429\b|too many requests|rate_limited|RateLimit/i.test(errorMsg)) {
|
|
return 'EMBEDDING_RATE_LIMIT';
|
|
}
|
|
if (/insufficient_quota|quota exceeded|exceeded.*quota|credit balance is too low|billing|EMBEDDING_QUOTA/i.test(errorMsg)) {
|
|
return 'EMBEDDING_QUOTA';
|
|
}
|
|
if (/maximum context length|max_tokens|context length|input too long|input length exceeds|tokens? exceed|too many tokens|EMBEDDING_OVERSIZE/i.test(errorMsg)) {
|
|
return 'EMBEDDING_OVERSIZE';
|
|
}
|
|
|
|
// content-sanity reject disposition.
|
|
if (/PAGE_JUNK_PATTERN/i.test(errorMsg)) return 'PAGE_JUNK_PATTERN';
|
|
|
|
return 'UNKNOWN';
|
|
}
|
|
|
|
/** Group failures by error code and return a sorted summary. */
|
|
export function summarizeFailuresByCode(
|
|
failures: Array<{ error: string; code?: string }>,
|
|
): Array<{ code: string; count: number }> {
|
|
const counts: Record<string, number> = {};
|
|
for (const f of failures) {
|
|
const code = f.code ?? classifyErrorCode(f.error);
|
|
counts[code] = (counts[code] ?? 0) + 1;
|
|
}
|
|
return Object.entries(counts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([code, count]) => ({ code, count }));
|
|
}
|
|
|
|
/**
|
|
* Format a code-grouped summary as a human-readable multi-line string.
|
|
* Accepts either raw failures (summarized internally) or an already-
|
|
* summarized `{code, count}[]`. Empty input → empty string.
|
|
*/
|
|
export function formatCodeBreakdown(
|
|
input: Array<{ error: string; code?: string }> | Array<{ code: string; count: number }>,
|
|
): string {
|
|
const summary =
|
|
input.length > 0 && typeof (input[0] as { count?: unknown }).count === 'number'
|
|
? (input as Array<{ code: string; count: number }>)
|
|
: summarizeFailuresByCode(input as Array<{ error: string; code?: string }>);
|
|
return summary.map(s => ` ${s.code}: ${s.count}`).join('\n');
|
|
}
|
|
|
|
function _failuresDir(): string {
|
|
return _gbrainPath();
|
|
}
|
|
|
|
export function syncFailuresPath(): string {
|
|
return _joinPath(_failuresDir(), 'sync-failures.jsonl');
|
|
}
|
|
|
|
function _ledgerKey(f: { source_id: string; path: string }): string {
|
|
// NUL separator can't appear in a source id or path.
|
|
return `${f.source_id} |