Commit Graph
572 Commits
Author SHA1 Message Date
Garry Tan 418357332f Revert "fix(embed): stamp gateway-resolved model in content_chunks.model, not compiled default (#2846)"
This reverts commit 5aa4795c04.
2026-07-23 12:02:36 -07:00
Garry Tan 45f85df8f4 Revert "fix(webhook): extract links for incremental push syncs (#2850)"
This reverts commit 11659743a2.
2026-07-23 12:02:36 -07:00
Garry Tan 9a70945152 Revert "fix(autopilot): give full-cycle dispatch a 30-minute timeout floor (#2852)"
This reverts commit b98fae9b61.
2026-07-23 12:02:36 -07:00
Garry Tan aea6df3da7 Revert "fix(onboard): stop repeating the same auto-remediation within a run (#2854)"
This reverts commit 054badbe60.
2026-07-23 12:02:36 -07:00
Garry Tan 35edd0e2d5 Revert "fix(scripts): capture check/shard rc before watchdog teardown in no-timeout fallback (#2864)"
This reverts commit e9a4fee97f.
2026-07-23 12:02:36 -07:00
Garry Tan 6388be2088 Revert "fix(list_pages): surface truncation instead of silently capping enumeration (#2865)"
This reverts commit 323610ecd7.
2026-07-23 12:02:36 -07:00
323610ecd7 fix(list_pages): surface truncation instead of silently capping enumeration (#2865)
list_pages clamps limit to max 100 (default 50) — deliberate server
protection, pinned in test/search-limit.test.ts. But the clamp was
SILENT: a caller whose limit was defaulted or clamped got a
full-looking array with no signal that rows were dropped, and with the
default updated_desc sort the dropped rows are always the OLDEST —
precisely what exhaustive consumers (audits, scans, backfills) exist
to find. Observed in the field: a source with 212 pages enumerated as
80 visible rows, hiding 26 pages from a compliance scan for days.

Fix, with no response-shape change (MCP consumers still get an array)
and no engine surface change (handler probes limit+1):

- handler probes one row past the effective limit; when the caller's
  limit was NOT honored (unset -> default, or clamped to cap) and rows
  were dropped, it warns on stderr for local (CLI) callers — same
  operator-facing channel as the put_page unknown-type hint, but
  without the isTTY gate: scripted callers are exactly the consumers
  that cannot detect truncation any other way, and stderr keeps stdout
  parseable. An explicit honored limit stays silent (ordinary
  pagination), as does a clamped-but-complete result. Remote (MCP)
  ctx never writes to stderr.
- LIST_PAGES_DESCRIPTION documents the cap and the exhaustive-listing
  recipe (sort=updated_asc + updated_after cursor) — the description
  is the signal channel MCP clients actually read.
- regression suite: default-limit truncation warns, honored limit
  silent, clamped-but-complete silent, remote silent, and the
  documented cursor recipe enumerates a corpus to completion.

Co-authored-by: YMYD <53603073+OJ-OnJourney@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:38:20 -07:00
e9a4fee97f fix(scripts): capture check/shard rc before watchdog teardown in no-timeout fallback (#2864)
On machines with neither gtimeout nor timeout on PATH, run-verify-parallel.sh
and run-unit-parallel.sh fall back to a bg-pid + sleep-watchdog cap. Both
read $? only after tearing the watchdog down (kill + wait on cap_pid), so the
sentinel .exit files recorded the killed watchdog's status — 143 — instead of
the check/shard's own exit code. Every run reported total failure (verify:
pass=0 fail=31; unit: rc=143 per shard) while every per-check/shard log
showed success.

Capture rc immediately after `wait $pid` in both scripts, and reap the
watchdog's sleep child (pkill -P, children-first — the same orphan quirk the
heartbeat cleanup documents) so the fallback stops leaking one sleep per
check/shard.

Regression tests force the fallback branch hermetically on any host via a
curated PATH with no timeout binaries: the verify dispatcher runs from a
tempdir copy with a stubbed `bun`, pinning exit 0 + all-zero sentinels when
checks pass and the check's own rc (not 143) when one fails; the unit wrapper
runs real two-shard fixture passes, pinning rc=0 sentinels and a real
failure's rc=1.

Co-authored-by: YMYD <53603073+OJ-OnJourney@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:38:15 -07:00
Sanchal RanjanandGitHub 054badbe60 fix(onboard): stop repeating the same auto-remediation within a run (#2854)
When the recommendation list is refreshed between remediation steps, a
remediation that doesn't clear its own health signal is reintroduced
under its stable id and attempted again, indefinitely on long runs.
Track attempted recommendation ids for the run and skip re-attempts.

Includes a behavioral regression test: a persistently-stuck signal is
attempted once, the loop terminates, and other remediations still run.
2026-07-23 11:38:10 -07:00
Sanchal RanjanandGitHub b98fae9b61 fix(autopilot): give full-cycle dispatch a 30-minute timeout floor (#2852)
Dispatch timeout was derived as interval*2 with a 5-minute floor, tuned
for light per-interval work. A full autopilot cycle routinely needs more
than 10 minutes at common intervals, so healthy full cycles were killed
mid-run. Full-cycle dispatch now gets a 30-minute floor; lighter
dispatches keep the interval-derived budget.

Adds a regression test for the full-cycle floor.
2026-07-23 11:38:05 -07:00
SongandGitHub 11659743a2 fix(webhook): extract links for incremental push syncs (#2850)
* test(webhook): pin sync extraction contract (#2849)

* test(webhook): target the submitted sync payload (#2849)

* fix(webhook): run extraction in sync job (#2849)

* fix(sync): align push trigger extraction (#2849)
2026-07-23 11:38:00 -07:00
5aa4795c04 fix(embed): stamp gateway-resolved model in content_chunks.model, not compiled default (#2846)
upsertChunks fell back to the compile-time DEFAULT_EMBEDDING_MODEL
('zeroentropyai:zembed-1') when a ChunkInput carried no explicit `model`.
The embed pipeline (src/commands/embed.ts) builds ChunkInputs without a
`model` field, so rows whose vectors were produced by the config-resolved
model (e.g. openai:text-embedding-3-large) were mislabeled with the
hardcoded default — corrupting the provenance that signature-drift
staleness and dimension-migration logic depend on.

Both engines now resolve the gateway's runtime embedding model once per
upsert and use it as the fallback, mirroring the existing resolve-then-
default pattern used for schema sizing. Regression test added (pglite);
verified via negative control that it fails against the old fallback.

This is a write-path change (upsertChunks), not a search-path change, so
retrieval eval replay is not applicable.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:37:06 -07:00
e36251c023 fix(sources): stop source config re-wrapping into a growing JSON string scalar (#2829) (#2837)
`sources.config` is a jsonb OBJECT column, but a read→write cycle that
JSON.stringify'd an already-stringified value re-wrapped it into a JSON string
scalar ("{}", "\"{}\"", ...) that grew one layer per write. parseSourceConfig
only unwrapped one layer, so the corruption never healed and federation/ACL
reads saw a string instead of the settings object.

- Add normalizeSourceConfig: a bounded (10-iteration) loop that JSON.parses
  while the value is a string and returns {} (with a console.warn) when the
  result is not a plain object. All six `UPDATE sources SET config` writers run
  their config through it before stringify, converging the stored value back to
  a jsonb object on the next write.
- parseSourceConfig now does the same bounded unwrap and warns once when more
  than one layer was found (one layer is the normal PGLite path).
- Add a `source_config_shape` doctor check that flags any sources row where
  jsonb_typeof(config) <> 'object', with the repair path.
- Unit-test the helper (object passthrough, 1-layer, 5-layer nested, garbage
  and over-bound inputs) and the doctor check (mock engine).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:37:02 -07:00
Ziyang GuoandGitHub 7bbd087cb7 fix(pages): restore soft-deleted rows on putPage (#2779) 2026-07-23 11:25:00 -07:00
TurgutKuralandGitHub e0d2cbf353 fix(doctor): distinguish entity timeline coverage from whole-brain density (#2761)
Issue #2298: 'gbrain doctor' surfaced two distinct timeline
metrics under the same user-facing 'timeline' label:

1. Entity timeline coverage (graph_coverage metric)
   - numerator: eligible entity pages WITH a timeline entry
   - denominator: eligible entity pages
   - 0-1 fraction, surfaced by graph_coverage check
2. Whole-brain timeline density (brain-score 0-15 component)
   - numerator: all pages WITH a timeline entry
   - denominator: all pages
   - 0-15 scale, surfaced by brain_score breakdown

These have DIFFERENT numerators/denominators. The old single
'timeline X%' label let a reader mistake the entity-scoped
percentage for whole-brain density.

Presentation/contract clarity only — scoring formula,
health weights, takes, source routing, extraction UNCHANGED.

- doctor.ts graph_coverage: 'timeline X%' -> 'entity timeline coverage X%'
- doctor.ts brain_score: 'timeline X/15' -> 'timeline density (all pages) X/15'
- cli.ts get_health: 'Timeline coverage (entity pages)' ->
  'Timeline density (all pages): X/15 (whole-brain brain-score component)'

Test: test/doctor-timeline-metric-labels-2298.test.ts uses a
synthetic in-memory PGLite fixture (NO private EriadorMu data):
4 total pages, 2 eligible entity pages, 1 entity page with a
timeline entry, 1 total page with a timeline entry.
Expected: entity coverage 1/2 = 50%; whole-brain density
1/4 -> round(25% * 15) = 4/15. Asserts the two metrics
render with distinct scoped labels and the brain-score component
is explicitly whole-brain (no 'entity' in its label). 5/5 pass.

Addresses #2298
2026-07-23 11:24:55 -07:00
7a1f61a31a fix: clear verified sync head sentinels (#2734)
Co-authored-by: Matthew Thompson <matthew@symmetric-consulting.com>
2026-07-23 11:24:50 -07:00
9b8b829ca5 fix(extract): --stale sweep runs the real resolver — basename resolution reaches stale pages (#2576) (#2717)
extractStaleFromDB still used the pre-#972 `includeFrontmatter ? resolver :
nullResolver` ternary. The synthetic resolver has no resolveBasenameMatches,
so the gate in extractPageLinks skipped the issue-#972 bare-wikilink pass
regardless of link_resolution.global_basename — the sweep stamped every page
as extracted while silently dropping its [[bare-name]] links. Same brain,
same pages: `extract --stale` created 0 links where `extract links --source
db` created 218.

- Always pass the real batch resolver; gate passes via extractPageLinks opts
  ({ skipFrontmatter: !includeFrontmatter, globalBasename }), mirroring
  extractLinksFromDB — including the codex-[P1] sourceId scoping.
- Bump LINK_EXTRACTOR_VERSION_TS (documented protocol) so pages stamped by
  the broken sweep re-flag stale and re-extract under the fixed logic.
- Regression tests: bare wikilink resolves on --stale with the flag ON;
  still drops with the flag OFF (back-compat). The #1768 fixture now derives
  its updated_at from LINK_EXTRACTOR_VERSION_TS instead of a hardcoded date,
  so future version bumps can't silently flip its version arm.

Fixes bug 1 + bug 3 of #2576. Bug 2 (DIR_PATTERN gaps) is a separate
whitelist design call, intentionally not addressed here.

Co-authored-by: YMYD <53603073+OJ-OnJourney@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:24:45 -07:00
c27b2e4b0f fix(put): refuse to overwrite a non-empty page with empty content (#2708)
An empty --content (most commonly a non-interactive caller that meant
file input — put has no --file flag — so the missing --content fell
back to reading empty stdin) silently blanked existing pages. put_page
now rejects an empty/whitespace-only body over an existing non-empty
page with invalid_params, pointing at `gbrain capture --file PATH
--slug SLUG` for file input; allow_empty: true (CLI: --allow-empty)
opts into an intentional blank. The guard read is scoped to the exact
(source_id, slug) row the write targets; new-slug creates and
soft-deleted-page overwrites stay allowed.

Co-authored-by: Matthew Thompson <matthew@symmetric-consulting.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:24:40 -07:00
Ziyang GuoandGitHub 9bcfa67748 fix(schema): count dead prefixes by slug (#2697) 2026-07-23 11:24:35 -07:00
16eb8cd06c fix(doctor): flag embed backfills without a worker (#2696)
Co-authored-by: gbrain-contrib <gbrain-contrib@example.com>
2026-07-23 11:24:30 -07:00
Garry Tan 92a3202198 Revert "fix(trajectory): stop negative metrics from inverting regression signals (#2621)"
This reverts commit 5dcf3e7b2f.
2026-07-23 11:24:25 -07:00
Garry Tan 8b7e30afcd Revert "perf(contextual-retrieval): bound per-chunk synopsis concurrency (#2628)"
This reverts commit 3454dca0b4.
2026-07-23 11:24:25 -07:00
Garry Tan 68e4cebd1a Revert "fix(health): count 'entity' pages in graph health metrics (#2639)"
This reverts commit 8fc93c8fac.
2026-07-23 11:24:25 -07:00
Garry Tan 8bbb19102c Revert "fix(test): isolate GBRAIN_HOME in hybrid-reranker integration test (#1527) (#2640)"
This reverts commit fe6850b067.
2026-07-23 11:24:25 -07:00
Garry Tan 9ae4e04d22 Revert "feat(ai): dashscope-rerank recipe (DashScope serves PLURAL /reranks under compatible-api) (#2644)"
This reverts commit 220af4b2d0.
2026-07-23 11:24:25 -07:00
Garry Tan fe2f2f6b2a Revert "fix: clarify PGLite data-dir lock contention (#2658)"
This reverts commit 0556dbdc2c.
2026-07-23 11:24:25 -07:00
Garry Tan fc169d9770 Revert "fix(import): normalize mixed-case slugs (#2695)"
This reverts commit 50406fc212.
2026-07-23 11:24:25 -07:00
Ziyang GuoandGitHub 50406fc212 fix(import): normalize mixed-case slugs (#2695) 2026-07-23 11:03:09 -07:00
zayandGitHub 0556dbdc2c fix: clarify PGLite data-dir lock contention (#2658) 2026-07-23 11:03:03 -07:00
220af4b2d0 feat(ai): dashscope-rerank recipe (DashScope serves PLURAL /reranks under compatible-api) (#2644)
DashScope's OpenAI-compatible rerank endpoint lives at
{base}/compatible-api/v1/reranks — PLURAL leaf, different base path from
the embedding surface (compatible-mode). Reusing llama-server-reranker
against DashScope forces users to hand-patch the recipe's '/rerank' leaf
in node_modules, which every upgrade silently reverts (and llama.cpp
genuinely serves singular /rerank, so changing that recipe would break
real llama.cpp users).

New dedicated recipe rides the v0.40.6.1 recipe-pluggable reranker path:
- id dashscope-rerank, base_url_default compatible-api/v1 (intl), ZE wire
- path '/reranks', default_timeout_ms 30s, 5MB payload ceiling
- models: only qwen3-rerank (live-verified 200; gte-rerank-v2 is rejected
  by the compat surface with 'Unsupported model for OpenAI compatibility
  mode', so it is deliberately not listed)
- separate recipe (not a reranker touchpoint on dashscope) because
  provider_base_urls is keyed by recipe id and the two capabilities need
  different prefixes — same topology as llama-server vs
  llama-server-reranker

Tests: recipe shape smoke mirroring recipe-llama-server-reranker.test.ts
(path/timeout/payload pins, /v1/v1 concat guard, auth resolve, sibling
recipe isolation). bun test test/ai/: 322 pass / 0 fail.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:02:53 -07:00
fe6850b067 fix(test): isolate GBRAIN_HOME in hybrid-reranker integration test (#1527) (#2640)
The four `hybridSearch — reranker enabled (reorder)` cases stub the gateway
at 1536 dims (DIMS). Since v0.36.3.0 hybridSearch resolves the embedding
column via loadConfig(), whose precedence is
cfg.embedding_dimensions > gateway dims > default. On any machine whose
~/.gbrain/config.json sets embedding_dimensions to something other than 1536
(e.g. text-embedding-3-small at 1280), the real config outranks the stub: the
1536-d stub vector fails the gateway dim check, the error is swallowed, search
falls back to keyword-only, and the reranker never runs (rerankerFn gets 0
docs, rerank_score undefined). Green in CI only because a fresh runner has no
config file — deterministic red on a contributor's machine.

Fix (test-only): isolate GBRAIN_HOME to an empty tmpdir in beforeAll so
loadConfig() returns null and the stub's dims win, then restore it and clean
up in afterAll. Same idiom as emptyHome() in
test/ai/gateway-probe-chat-model.test.ts.

Verified with a planted ~/.gbrain/config.json at 1280 dims: 2 pass / 4 fail
before, 6 pass / 0 fail after; still green with no config file. typecheck clean.

Fixes #1527

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 11:01:51 -07:00
Tyler RobinsonandGitHub 8fc93c8fac fix(health): count 'entity' pages in graph health metrics (#2639)
getHealth's entity_pages CTE and the top-linked-pages query only match the
legacy 'person' and 'company' types, so brains using the gbrain-base-v2
pack's 'entity' type report 0% entity link/timeline coverage in `gbrain
health` even when doctor's graph_coverage shows real coverage. Add 'entity'
to both queries in both engines (PGLite + Postgres, in lockstep per the
engine-parity rule) and extend the getHealth graph-metrics test with an
entity-typed page.

Validation: bun test test/pglite-engine.test.ts --test-name-pattern 'getHealth graph metrics' (5 pass).
2026-07-23 11:01:46 -07:00
spiky02plateauandGitHub 3454dca0b4 perf(contextual-retrieval): bound per-chunk synopsis concurrency (#2628)
Replace the strictly sequential per-chunk synopsis loop with a bounded
sliding worker pool (existing runSlidingPool helper). Results land in
chunk order via index-addressed writes; code chunks still bypass the
wrapper; embedding remains one page-level batch after all synopses.

New knob GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY, default 4, clamped to
[1,16]; 1 reproduces the prior sequential behavior exactly. Each chunk
task still acquires/releases the global synopsis rate-lease, which
remains the cross-worker governor; the lease id now travels from
acquire to release instead of shared mutable state, and lease waits
are abort-responsive.

At 20-45s per synopsis call, a 120-chunk transcript page previously
needed 60-90+ min wall time and routinely outlived job timeouts.
2026-07-23 11:01:39 -07:00
morlutoandGitHub 5dcf3e7b2f fix(trajectory): stop negative metrics from inverting regression signals (#2621) 2026-07-23 11:01:35 -07:00
Garry Tan dbca701008 Revert "fix(import): fall back to body H1 for title when frontmatter lacks title: instead of slug-derived junk (#2446) (#2495)"
This reverts commit 033fd24fe8.
2026-07-23 11:01:29 -07:00
Garry Tan 4b6cf32c9f Revert "fix(cycle): extract_facts guard requires live backing page, not just non-NULL entity_slug (#2497)"
This reverts commit 53c9086945.
2026-07-23 11:01:29 -07:00
Garry Tan 0c66715f90 Revert "fix(propose_takes): memoize empty extractions so zero-claim pages don't re-spend every cycle (#2514)"
This reverts commit 1233051a20.
2026-07-23 11:01:29 -07:00
Garry Tan 55af5fc091 Revert "fix: handle <think> reasoning tags in parseExtractorOutput (#2559)"
This reverts commit 2724c3b6c9.
2026-07-23 11:01:29 -07:00
Garry Tan 10b5746053 Revert "fix(storage): Supabase signed URLs — prepend /storage/v1 (#2565)"
This reverts commit 5a295bc293.
2026-07-23 11:01:29 -07:00
Garry Tan 5bee08c3c4 Revert "fix: honor explicit list_pages limit for local callers, warn on remote clamp, thread offset (#2591)"
This reverts commit 70ffe4a2a2.
2026-07-23 11:01:29 -07:00
Garry Tan f02919c041 Revert "fix(minions): default timeout for contextual reindex (#2611)"
This reverts commit fc1f88cdcb.
2026-07-23 11:01:29 -07:00
Garry Tan 66fa5fba22 Revert "fix(migrations): let force-retry escape completed ledger entries (#2616)"
This reverts commit e79b8d5780.
2026-07-23 11:01:29 -07:00
spiky02plateauandGitHub e79b8d5780 fix(migrations): let force-retry escape completed ledger entries (#2616)
statusForVersion short-circuited on any 'complete' entry before checking
the trailing 'retry' marker, so --force-retry appended an inert row and a
version marked complete with zero work done could never be re-run without
hand-editing completed.jsonl. Check retry-latest first: an explicit
--force-retry now yields 'pending' even past an earlier 'complete', while
a stray 'partial' after 'complete' still cannot regress the version.
2026-07-23 09:17:01 -07:00
spiky02plateauandGitHub fc1f88cdcb fix(minions): default timeout for contextual reindex (#2611) 2026-07-23 09:16:55 -07:00
70ffe4a2a2 fix: honor explicit list_pages limit for local callers, warn on remote clamp, thread offset (#2591)
gbrain list --limit 100000 silently returned 100 rows (default 50) with
no warning, and --offset was accepted but dropped at the op layer even
though PageFilters has supported it all along.

- Local CLI callers (ctx.remote === false, the same trust boundary that
  already bypasses scope enforcement) get an explicit limit above 100
  honored — full enumeration is a legitimate local operation.
- Remote MCP/OAuth callers keep the 100-row DoS cap, now loud: one
  logger.warn (stderr, stdout stays script-clean) with both numbers,
  parity with the three search-path clamp warnings.
- offset is declared as a param (so the CLI coerces it to number) and
  threaded to engine.listPages for real pagination.


Claude-Session: https://claude.ai/code/session_01Vswwe1y5fQbJWfbaSK3enT

Co-authored-by: deacon-botdoctor <291411030+deacon-botdoctor@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:16:50 -07:00
5a295bc293 fix(storage): Supabase signed URLs — prepend /storage/v1 (#2565)
SupabaseStorage.getSignedUrl built the download URL as `${projectUrl}${signedURL}`,
but Supabase's sign API returns `signedURL` relative to the Storage API root
(/object/sign/<bucket>/<path>?token=...), so the generated link dropped /storage/v1
and returned 404. Now prepends `${projectUrl}/storage/v1`, tolerating an
already-absolute URL or a value that already carries the prefix. `gbrain files
signed-url` links resolve again.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:16:45 -07:00
2724c3b6c9 fix: handle <think> reasoning tags in parseExtractorOutput (#2559)
Reasoning models (MiniMax-M3, DeepSeek-R1, etc.) return <think>...</think>
tags in the content field before the actual JSON output. This caused
parseExtractorOutput to fail in two ways:

1. The fence regex /^\`\`\`(json)?...$/ requires the fence at text start;
   <think> preceding it prevents matching, so the raw text (with trailing
   fences) hits JSON.parse and throws.

2. When think tags contain [ or { characters, indexOf finds them inside
   the reasoning block instead of the actual JSON array.

Changes:
- Strip <think>...</think> tags before any parsing (covers all reasoning models)
- Add JSON.parse fallback: truncate at last ] or } to handle trailing
  noise (leftover markdown fences after stripping)

Tests: 28/28 pass (3 new cases for think tags + trailing noise).

Co-authored-by: qaz8545355 <junjun@openclaw.local>
2026-07-23 09:16:39 -07:00
1233051a20 fix(propose_takes): memoize empty extractions so zero-claim pages don't re-spend every cycle (#2514)
The idempotency row is only written inside `for (const p of proposals)`, so a
page that extracts ZERO gradeable claims never records an idempotency tuple
and is re-sent to the LLM on every cycle forever. The docstring's "unchanged
page never re-spends tokens" contract only holds for pages that produce >=1
claim; a page that legitimately has no gradeable claims (or any machine-
generated page) is a perpetual cache miss and re-spends tokens indefinitely.

Fix: when `proposals.length === 0`, write one tombstone row keyed by the same
(source_id, page_slug, content_hash, prompt_version) tuple, with
status='rejected' so it never surfaces in a pending-review query (the pending
index filters status='pending'). Content changes (new content_hash) or a
PROPOSE_TAKES_PROMPT_VERSION bump still miss the tombstone and re-extract. The
extractor-throw path `continue`s before the tombstone, so failed pages are
retried rather than cached.

Guard against a subtle regression: `parseExtractorOutput` returns [] for BOTH
a genuine empty extraction AND malformed/prose/truncated model output, so
naively tombstoning every [] would permanently suppress a page that has claims
but hit a transient parse failure. `defaultExtractor` now throws when the
output is empty-but-not-a-clean-`[]` (new `isWellFormedEmptyExtraction`
predicate), routing transient failures into the existing retry path; only a
cleanly-parsed empty array is memoized.

Adds a `tombstones_written` counter for observability.

Tests: tombstone written on genuine empty extraction; two-cycle idempotency
(no repeat LLM call on an unchanged zero-claim page); extractor error writes
no tombstone; isWellFormedEmptyExtraction discriminates clean-[] from
malformed/prose/non-empty output. propose-takes suite: 36 pass / 0 fail.

Co-authored-by: ivandebot <187176982+ivandebot@users.noreply.github.com>
2026-07-23 09:16:34 -07:00
53c9086945 fix(cycle): extract_facts guard requires live backing page, not just non-NULL entity_slug (#2497)
The empty-fence guard counted every `row_num IS NULL AND entity_slug IS NOT NULL`
row as a pending v0_32_2 backfill, but the inline facts writer keeps producing
rows of exactly that shape post-migration: when a resolved slug has no fenceable
page (slugify-floor / stub-guard-blocked unprefixed slugs like `wingman`,
`people-jane-doe`), backstop.ts falls through to a DB-only insert with row_num
NULL. Those rows are structurally unfenceable — no page to fence onto, and the
ledger-complete migration won't re-run — so they jammed the phase forever
(~16/day observed) and the warning advised a no-op `apply-migrations --yes`.

Discriminator: a row is a genuine backfill candidate only if its entity_slug
resolves to a LIVE page in the same source (EXISTS in `pages` with deleted_at
NULL) — mirroring the migration's Phase B, which only fences slugs that map to
a writable page. Genuine pre-v0.32.2 rows (their entity page exists) still gate;
inline-writer unfenceable rows no longer do. Warning text updated to name the
"entity page present, not yet fenced" condition.

Regression tests pin both sides: unfenceable rows (no page / soft-deleted page)
do NOT gate and the phase converges; a legacy row WITH a backing page still
gates. Fails pre-fix, passes post-fix.

(#2484)

Co-authored-by: Javier Aldape <javieraldape@Javiers-Laptop.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:16:28 -07:00
033fd24fe8 fix(import): fall back to body H1 for title when frontmatter lacks title: instead of slug-derived junk (#2446) (#2495)
Co-authored-by: Javier Aldape <javieraldape@Javiers-Laptop.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 09:16:22 -07:00