Commit Graph
2 Commits
Author SHA1 Message Date
5c49225e4b v0.42.45.0 feat(sync): delta-aware cost estimator — stop wedging the daily cron (#2139) (#2224)
* feat(core): shared computeSyncDelta + spend-posture module (#2139)

sync-delta.ts: ONE implementation of "what changed since last_commit",
consumed by both the sync executor and the inline cost estimator so the
gate's dollar figure can't drift from what the sync imports.

spend-posture.ts: spend.posture config + parseUsdLimit/formatUsdLimit
off-switch parsing (off/unlimited/none → Infinity; undefined at the budget
boundary so ledger rows never serialize null).

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

* feat(sync): delta-aware cost estimator + non-TTY auto-defer + per-source failure acks (#2139)

The inline-embed cost gate was a ~400x phantom: it priced the entire tree
whenever the working tree was dirty (always, on an active brain), then blocked
the daily cron with exit 2. Now:

- performSyncInner + the estimator both route through computeSyncDelta, so the
  estimate mirrors execution (fetch-first delta; dirty-but-caught-up tree → $0).
- shouldBlockSync is posture-aware; non-TTY above floor AUTO-DEFERS embeds to
  capped backfill jobs (exit 0) instead of wedging — single shared
  runInlineCostGate on both --all and single-source paths.
- --full prices delta + stale backlog (full sync sweeps it inline).
- off/unlimited on the cost knobs; tokenmax bypasses the backfill cap (still
  ledgered) but never the cooldown.
- --skip-failed/--retry-failed scoped per source; the D15 parallel refusal is
  lifted (the #1939 ledger is per-source + lock-serialized).

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

* feat(config): register spend-control keys + validate spend.posture (#2139)

Adds spend.posture + the five previously --force-only spend knobs to
KNOWN_CONFIG_KEYS so `config set` accepts them directly (removes the
archaeology the issue complained about), and rejects invalid spend.posture
values at set time with a paste-ready hint.

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

* feat(reindex,enrich,onboard): spend.posture across the remaining cost gates (#2139)

reindex-code: tokenmax makes the cost gate informational; --max-cost accepts
off/unlimited. enrich + onboard --auto: tokenmax lifts the refuse-without-cap
guardrail and runs UNCAPPED (spend still ledgered by BudgetTracker). Explicit
--max-usd always wins over posture.

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

* test: cost-gate, delta estimator, spend-posture, off-switch coverage (#2139)

New sync-delta + sync-cost-estimate unit suites; rewritten cost-gate serial
tests (auto-defer instead of exit 2, posture, off-switch, format split,
single-source); parseUsdLimit/posture-aware shouldBlockSync; backfill cap-off
+ tokenmax-bypass + cooldown-still-refuses; config known-key acceptance.

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

* docs(spend-controls): single spend-control surface + ref-map + follow-up TODOs (#2139)

New docs/operations/spend-controls.md (every gate, key, default, off switch,
posture interaction); CLAUDE.md reference-map row; two P3 follow-up TODOs
(measured chunk-count gating, per-source defer granularity). llms bundles
regenerated.

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

* fix(spend): SSRF-harden estimator fetch + complete off/uncapped across reindex/enrich/onboard (#2139)

Ship-stage codex pre-landing review caught four P1s in the secondary cost gates:

- The delta estimator's fetch-first ran `git fetch` through the plain git()
  helper, bypassing the GIT_SSRF_FLAGS + GIT_TERMINAL_PROMPT=0 hardening that
  real sync uses. Added `fetchRemote()` to git-remote.ts (same flags as
  pullRepo) and route the estimator through it — a cost preview / dry-run can
  no longer hit a remote through a less-protected path.
- `reindex --max-cost off`, `enrich --max-usd off`, `onboard --auto --max-usd
  off` were parsed but didn't actually proceed/uncap. Now: explicit off (and
  spend.posture=tokenmax) proceed past the confirmation/missing-cap refusal AND
  run uncapped. enrich threads an Infinity sentinel mapped to "no BudgetTracker
  ceiling" (never raw Infinity → no null in audit rows); reindex/onboard use
  their native undefined=uncapped path. Spend still ledgered.

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

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

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

* docs(KEY_FILES): update sync/embedding/git-remote/reindex entries to post-#2139 truth

document-release pass: the cost-gate entries described the pre-#2139 behavior
(full-tree-ceiling estimator, --skip-failed-rejects-under-parallel, exit-2
confirmation gate). Updated to current truth — delta-aware estimator via the
shared computeSyncDelta, per-source failure acks under parallel, non-TTY
auto-defer (no exit 2), posture-aware shouldBlockSync. Added entries for the
two new core modules (sync-delta.ts, spend-posture.ts) + fetchRemote on
git-remote.ts + reindex --max-cost off.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:08:47 -07:00
146a8f1eed v0.41.31.0 feat(embed): delta-aware sync --all cost gate + real stale-embedding semantics (#1632)
* fix(cost): embedding cost preview uses configured model rate, not hardcoded OpenAI

The sync --all cost gate computed spend from a hardcoded
EMBEDDING_COST_PER_1K_TOKENS = 0.00013 (OpenAI text-embedding-3-large)
and labeled the preview with the back-compat EMBEDDING_MODEL constant,
regardless of the actually-configured embedding model. A brain running a
cheaper model (e.g. zeroentropyai:zembed-1 @ $0.05/Mtok) saw a preview
that named the wrong provider and over-stated spend ~2.6x ($337 vs $130
on a 2.6B-token corpus).

estimateEmbeddingCostUsd now resolves the live model via the gateway and
prices it through embedding-pricing.ts (the existing per-provider:model
table), falling back to the OpenAI rate only when the gateway is
unconfigured (unit-test context) or the model is unknown. sync.ts surfaces
the real model name in the preview message and JSON.

Regression test pins model-aware pricing: openai 3-large vs zembed-1 must
produce materially different previews; collapsing both to the OpenAI number
fails the assertion.

* fix(cost): sync --all gate is informational when embed is deferred; delta-aware inline gate

Under federated_v2 (default), sync --all DEFERS embedding to per-source
embed-backfill jobs that already cap spend at $25/source/24h. The v0.20
cost gate predated that cap and fired ConfirmationRequired + exit 2 on
EVERY non-TTY sync --all without --yes, regardless of cost — blocking
nightly crons over already-synced corpora and forcing permanent --yes.

The gate is now mode-aware:
  - Deferred embed (v2 default): print an FYI deferred notice (cap-aware,
    "not charged by this sync") + the stale-chunk backlog estimate, and
    NEVER exit 2. The backfill cap is the real money gate.
  - Inline embed (v2 off, or --serial without --no-embed): keep the
    blocking gate, but estimate the actual delta — full-tree ceiling for
    changed sources (unchanged sources contribute 0 via the same git +
    chunker_version "do work?" gate doctor/sync use) + stale backlog — and
    block only when it exceeds the new configurable floor
    sync.cost_gate_min_usd (default $0.50).

New pure helpers in embedding.ts (willEmbedSynchronously, shouldBlockSync)
keep the decision logic hermetically testable. New engine method
sumStaleChunkChars (both engines) prices the embedding backlog via
estimateCostFromChars. estimateSyncAllCost's per-source walk extracted to
estimateSourceTreeTokens (reused by the inline estimator).

Regressions pinned: R-1 deferred non-TTY never exit 2 (headline), R-2
inline above-floor still exit 2 (protection), plus the willEmbedSynchronously
/ shouldBlockSync matrix and sumStaleChunkChars engine + scope + embed_skip
coverage.

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

* feat(embed): real stale semantics — re-embed on model/dims swap (migration v108)

Pre-v0.41.30 "stale" meant only `embedding IS NULL`, so swapping the
embedding model or dimensions left the whole corpus silently embedded under
the OLD model — `embed --stale` ignored it and search quality quietly
degraded.

New `pages.embedding_signature` (TEXT, migration v108) stamps the embedding
provenance (`<provider:model>:<dims>`) whenever a page's chunks are embedded.
A later model/dims swap makes the stored signature differ from the current
one, which the embed paths now detect and re-embed.

GRANDFATHER (critical): the stale predicate is
  `embedding IS NULL OR (embedding_signature IS NOT NULL AND <> $current)`
so a NULL signature is NEVER stale. After the migration every existing page
has NULL → none flagged → the next `embed --stale` does NOT re-embed the
whole corpus. Signatures are stamped going forward only.

Surface:
  - countStaleChunks / sumStaleChunkChars gain an optional `signature` opt
    that widens staleness (read-only; used by the dry-run preview + the
    sync cost preview, which is now signature-aware).
  - invalidateStaleSignatureEmbeddings(signature, sourceId?) NULLs the
    embeddings of signature-mismatched pages so the EXISTING NULL-embedding
    cursor (listStaleChunks, untouched) re-embeds them — keeps the keyset
    pagination logic intact.
  - setPageEmbeddingSignature stamps after a page's chunks land.
  - Both embed loops wired: `gbrain embed --stale`/`--all` (embed.ts) and the
    embed-backfill minion (embed-stale.ts) invalidate-then-stamp.

Migration v108 + bootstrap probe (both engines) + REQUIRED_BOOTSTRAP_COVERAGE
entry. Pinned by test/embedding-signature-stale.test.ts (R-4 grandfather,
mismatch detection, matching no-op, scoped invalidate, stamp) + the
bootstrap-coverage gate.

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

* feat(sync): surface embed-backfill job state in sources status + deferred notice

Under federated_v2, `sync --all` exits 0 and embedding lags behind in
embed-backfill jobs (subject to cooldown + the per-source 24h cap). Pre-fix
an operator had no signal those jobs were queued or lagging — the sync looked
"done" while embeddings trickled in later.

`gbrain sources status` now shows a BACKFILL column per source
(active(N)/queued(N)/idle) plus the last completion timestamp, read from
minion_jobs. The deferred-sync notice appends "N backfill job(s) queued" so a
cron operator sees work is enqueued, not lost. Both reads are best-effort —
a brain that never ran a worker (no minion_jobs table) reports idle/0 instead
of crashing the dashboard.

SyncStatusReportSource gains backfill_queued / backfill_active /
backfill_last_completed_at (additive; JSON envelope schema_version unchanged).
Pinned by a new case in test/e2e/sync-status-pglite.test.ts.

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

* test: add currentEmbeddingSignature to embedding.ts mocks + sync stale EXPECTED_PHASES

Commit 3 made embed.ts import currentEmbeddingSignature from embedding.ts.
Four tests mock.module the whole embedding.ts and omitted the new export, so
embed.ts (imported transitively) failed at load with "Export named
'currentEmbeddingSignature' not found". Add the export to each mock:
embed.serial.test.ts, e2e/cycle.test.ts, e2e/dream.test.ts,
e2e/dream-cycle-phase-order-pglite.test.ts.

Also sync the stale EXPECTED_PHASES in dream-cycle-phase-order-pglite.test.ts
to match cycle.ts ALL_PHASES — extract_atoms, synthesize_concepts, and
conversation_facts_backfill drifted in after the test was last touched
(v0.41.0.0) and were never added, so both phase-order assertions were failing
on the branch before this wave (confirmed against 0906ab0a). The dry-run cycle
emits all 20 phases, so mirroring the constant makes both assertions pass.

Pre-existing, unrelated: cycle.test.ts / dream.test.ts have 5 runCycle
failures via direct `bun test` (the conversation_facts_backfill phase uses the
module-singleton getConnection) — present identically at 0906ab0a, not touched
by this wave.

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

* test: pin R-3 chunker-drift regression + embed-signature stamp-call wiring

Ship-workflow coverage audit flagged two gaps:
- R-3 (mandatory regression) had no dedicated test: the inline unchanged-source
  short-circuit requires git-unchanged AND chunker_version match, but nothing
  pinned the chunker half. Add a case where git is unchanged (HEAD==last_commit,
  clean) but chunker_version is stale → estimate still fires (exit 2), plus a
  control where chunker matches → short-circuits to $0 (no block).
- The embed loops' setPageEmbeddingSignature call-site was only kept green by the
  mock, never asserted. Add a test that runs `embed --all` and asserts the stamp
  fires once per page with the current signature.

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

* fix(embed): stamp embedding_signature on the inline import + per-slug paths (F1); inline cost gate counts new-content only (F2)

Adversarial review caught that the stale-detection feature was inert for
non-federated/inline brains: the embed-write paths that DON'T go through
embed.ts/embed-stale.ts never stamped pages.embedding_signature.

F1 — stamp at the remaining write sites:
  - embedPage (gbrain embed <slug> + sync's post-import runEmbedCore({slugs}))
  - importFromContent markdown branch (inline import/sync embed + gbrain import)
  - importCodeFile (only when EVERY chunk was freshly embedded this call —
    reuse-by-hash carries old-model vectors, so a mixed page stays unstamped
    rather than falsely marked current)
Without this, inline-synced pages kept NULL signatures → grandfathered → never
re-embedded on a model/dims swap. Now all embed-write paths stamp.

F2 — coupled regression the F1 fix would otherwise introduce: the inline cost
gate added the stale backlog (NULL + signature drift) into the BLOCKING cost,
but `gbrain sync` inline only embeds new/changed content — the backlog is
`gbrain embed --stale`'s job. Once F1 gives inline brains real signatures, a
model swap would inflate the inline gate and block the next cron for cost the
sync never incurs. Inline blocking cost is now new-content only; the stale
backlog is shown informationally ("pending gbrain embed --stale"). Deferred
path keeps the signature-aware backlog FYI (the backfill does clear it).

Pinned by test/import-signature-stamp.serial.test.ts (inline stamp + --no-embed
NULL) and the existing R-2/R-3 inline-gate tests (still exit 2 on new-content).

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

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

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

* fix(sources): wire BACKFILL into the real `sources status` path (P0a); guard partial-page signature stamping (P0b)

Codex adversarial review caught two issues in the v0.41.30 wave:

P0a — `gbrain sources status` routes through computeAllSourceMetrics
(source-health.ts), not the buildSyncStatusReport helper where the BACKFILL
column was added, so the CLI never showed it. Add per-source embed-backfill
active/queued counts to computeAllSourceMetrics (one extra FILTER on the
existing minion_jobs query) and render a BACKFILL column in `sources status`.
The deferred-sync notice's queued-job count (live sync path) already worked.

P0b — embedPage / embedAllStale / embed-stale stamped embedding_signature
unconditionally after embedding only the STALE subset of a page's chunks. A
partially-embedded page (some chunks preserved from a prior embed under
unknown/old provenance) would be falsely marked current, hiding the old
vectors from future stale detection. Now stamp only when EVERY chunk of the
page was (re)embedded this pass (toEmbed === chunks / stale === existing).
importFromContent embeds the full chunk set so it stays unconditional;
importCodeFile already had the equivalent guard. `gbrain embed --all` fully
re-embeds and stamps mixed pages.

Accepted as documented limitations (not fixed): the inline cost gate can
over-estimate a >100-file `--serial` sync that performSync will defer
(non-default mode, conservative-high bias), and model-swap invalidation NULLs
drifted vectors before re-embed (a deliberate, rare operation).

Pinned by a new backfill-counts case in test/source-health.test.ts.

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

* docs: update project documentation for v0.41.30.0

Refresh CLAUDE.md Key Files + Commands for the embedding cost-model + stale-semantics wave: model-aware cost helpers in embedding.ts (currentEmbeddingPricePerMTok / currentEmbeddingSignature / willEmbedSynchronously / shouldBlockSync), the embedding-signature stale-detection engine quartet (sumStaleChunkChars / setPageEmbeddingSignature / invalidateStaleSignatureEmbeddings + widened countStaleChunks), migration v108, signature stamping across embed.ts / import-file.ts, the mode-aware sync --all cost gate + sync.cost_gate_min_usd config key, and the sources status BACKFILL column. Add a same-dimension-swap auto-reembed note to docs/embedding-migrations.md. Regenerate llms-full.txt.

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

* chore: re-version 0.41.30.0 → 0.41.31.0 (queue slot)

Mechanical version-string sweep across VERSION, package.json, CHANGELOG,
CLAUDE.md, docs, source/test comments, and regenerated llms bundles. No logic
change.

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

* fix(test): pin embedding dim in cosine-rescore-column test (CI shard-5 contamination)

CI shard 5 failed deterministically with `expected 1280 dimensions, not 1536`.
Root cause: cosine-rescore-column.test.ts hardcodes 1536-dim `embedding`
vectors and asserts length 1536, but its beforeAll ran `initSchema()` with no
gateway config. initSchema sizes the `embedding` column from
getEmbeddingDimensions(), whose default is 1280 (zeroentropyai:zembed-1). The
test only passed by inheriting a leaked 1536 gateway config from an earlier
test (or, locally, from ~/.gbrain). When the v0.41.31 merge shifted the
weight-aware shard bin-packing, the file order changed so the 1280 default won
in CI → vector(1280) column → 1536 insert rejected. (Passed locally because
the dev machine's ~/.gbrain resolves 1536.)

Fix: configureGateway({ openai:text-embedding-3-large, 1536 }) in beforeAll
BEFORE connect/initSchema so the column is deterministically vector(1536)
regardless of ambient/leaked state, and resetGateway() in afterAll for
hygiene. Proven: under a forced-1280 gateway preload the old test reproduces
the exact CI error and the fixed test passes (4/4).

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

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:30:56 -07:00