v0.41.25.0 perf(sync): batched deletes + global page-generation clock (supersedes #1538)#1566
Merged
Conversation
…0.41.21.0 T1) Two new REQUIRED methods on the BrainEngine interface, implemented on both Postgres and PGLite engines. Closes the per-file N+1 query pattern that PR #1538 batched on Postgres only. deletePages(slugs: string[], opts: { sourceId: string }): Promise<string[]> — Single SQL round-trip: DELETE FROM pages WHERE slug = ANY($1::text[]) AND source_id = $2 RETURNING slug — Returns slugs ACTUALLY DELETED (D6, codex CDX-8) so callers can filter pagesAffected to exclude phantom slugs (paths in the deletion list but with no DB row). — Single-batch primitive: caller chunks input to DELETE_BATCH_SIZE. Throws if input exceeds the cap. — sourceId is REQUIRED at the type level (D5, codex CDX-10). Asymmetric with single-row deletePage which keeps the optional 'default' fallback for back-compat. v0.42+ TODO to tighten. resolveSlugsByPaths(paths, opts): Promise<Map<path, slug>> — Batch path → slug lookup. Single SQL round-trip: SELECT slug, source_path FROM pages WHERE source_path = ANY($1::text[]) AND source_id = $2 — Missing paths absent from the Map (caller falls back to path-derived slug, same contract as resolveSlugByPathOrSourcePath). — Empty input short-circuits to empty Map (no SQL). src/core/engine-constants.ts (NEW) — Single source of truth for DELETE_BATCH_SIZE = 500. — Both engines import; no engine-from-engine coupling. — Lives outside engine.ts (the interface module) to avoid circular imports. Also updates the deletePage JSDoc (CDX-11): drops the misleading "hard delete is admin-only" framing. `gbrain sync` hard-deletes on every run that sees a deleted file; not admin-only. Co-Authored-By: garrytan-agents <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-file delete loop (sync.ts:1241-1257) and per-file
rename slug-resolve (sync.ts:1263-1295) with interleaved per-batch
flows using engine.resolveSlugsByPaths + engine.deletePages. Also
refactors resolveSlugByPathOrSourcePath (sync.ts:267) to delegate to
the new batch helper when sourceId is set — one owner of the SQL +
fallback semantics (D8).
ROUND-TRIP COUNTS (73K-delete commit):
pre-fix: 73,000 SELECTs + 73,000 DELETEs = 146,000 (~5 hours)
post-fix: 146 SELECTs + 146 DELETEs = 292 (~2 minutes)
Headline win: a single commit deleting 73K files no longer jams the
sync pipeline for hours, no longer cascades staleness across every
other source on the brain.
Shape (T2 delete loop, per the plan's ASCII diagram):
filtered.deleted (73K paths)
│
▼
slice into batches of DELETE_BATCH_SIZE (500)
│
▼ for each batch:
abort-check ──► partial('timeout')
│
▼
engine.resolveSlugsByPaths(batch, {sourceId}) ◀── 1 SQL round-trip
│
▼
slugs = batch.map(path => map.get(path)
?? resolveSlugForPath(path)) ◀── pure-JS fallback
│ for frontmatter-
▼ fallback slugs
try {
deleted = engine.deletePages(slugs, opts) ◀── 1 SQL round-trip
pagesAffected.push(...deleted) ◀── D6 confirmed only
} catch {
// D7 decompose: per-slug deletePage,
// unrecoverable failures → failedFiles
}
Per-batch try-catch (D7) decomposes batch DELETE failures to per-slug
deletePage so a transient blip on batch 73 doesn't lose 500 deletes —
it self-heals to one-at-a-time for that batch only. Unrecoverable
per-slug failures land in failedFiles (matching the existing
import-loop pattern at sync.ts:~1350). failedFiles declaration
hoisted above the delete loop so both delete decompose and import
loops feed the same sync-bookmark gate.
T4 rename loop: pre-resolves all `from` slugs in batches via
resolveSlugsByPaths BEFORE iterating. Per-file updateSlug + importFile
calls stay (those are inherently per-file). The try/catch around
updateSlug for slug-doesn't-exist preserves verbatim.
T3 DRY refactor: resolveSlugByPathOrSourcePath delegates to
resolveSlugsByPaths via a single-element array when sourceId is set.
When sourceId is undefined (legacy unscoped callers), falls back to
the original executeRaw shape — the batch engine surface requires
sourceId per D5 (multi-source-bug-class defense).
Atomicity coarsening (D3): each batch is one transaction. A mid-batch
abort or connection failure rolls back up to DELETE_BATCH_SIZE - 1
successful deletes from the in-flight batch. Sync is idempotent so
the next run picks them up via git diff regenerating the deletion
list. Documented at the call site + in the deletePages JSDoc.
Co-Authored-By: garrytan-agents <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(v0.41.21.0 T5)
Migration v104: page_generation_clock_and_statement_trigger.
The pre-v0.41.21.0 query-cache Layer 1 bookmark read MAX(generation) FROM
pages to detect "writes happened since cache-store". Two bugs in that
contract — independent of any sync work, surfaced by codex
outside-voice on the /plan-eng-review pass:
1. The row-level bump_page_generation_trg (migration v91) sets
NEW.generation = OLD.generation + 1 on UPDATE. Updating a NON-MAX
page didn't advance MAX(generation). Cache silently served stale
for any UPDATE-to-non-max page. (CDX-2)
2. The trigger is BEFORE INSERT OR UPDATE — DELETE doesn't fire it
at all. Even an AFTER DELETE wouldn't move MAX (surviving rows
are untouched). (CDX-1)
Fix: single-row page_generation_clock counter, bumped per-statement
(FOR EACH STATEMENT — per-row would turn a 73K-row batch DELETE into
73K UPDATEs on the same counter, recreating the bottleneck this PR
fixes elsewhere — codex CDX-4). Layer 1 reads the clock value
directly (T6, separate commit). Per-row pages.generation stays for
Layer 2 (per-page snapshot via jsonb_each + LEFT JOIN pages) which
doesn't care about MAX, only per-page advancement.
Seeded with COALESCE(MAX(pages.generation), 0) so existing query_cache
rows stored under the old MAX semantics aren't all instantly
invalidated on upgrade. Their max_generation_at_store stamp compares
cleanly against the seeded clock; future writes bump the clock and
the bookmark fires correctly.
CREATE TABLE page_generation_clock (
id INTEGER PRIMARY KEY CHECK (id = 1),
value BIGINT NOT NULL DEFAULT 0
);
CREATE TRIGGER bump_page_generation_clock_trg
AFTER INSERT OR UPDATE OR DELETE ON pages
FOR EACH STATEMENT
EXECUTE FUNCTION bump_page_generation_clock_fn();
Mirror in src/core/pglite-schema.ts so fresh PGLite installs get the
table + trigger via SCHEMA_SQL replay. The forward-reference bootstrap
probe doesn't need an entry: page_generation_clock is created directly
by SCHEMA_SQL (no separate index or FK references it), so the
schema-bootstrap-coverage gate is satisfied as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (v0.41.21.0 T6)
Closes the silent stale-cache bug class that's been live in master
since the bookmark feature shipped. Pre-fix, gbrain search would
silently serve stale cached results in three independent scenarios:
1. UPDATE to a non-max-generation page (CDX-2) — the row-level
trigger advanced per-page generation but didn't move
MAX(generation), so the bookmark passed.
2. DELETE of any page (CDX-1) — the trigger didn't fire at all,
and even an AFTER DELETE wouldn't move MAX.
3. Empty-result cache row + subsequent matching INSERT (CDX-6 /
D20) — page_generations = '{}'::jsonb was "vacuously valid" via
Layer 2, surviving any clock bump.
Fix:
buildPageGenerationsSnapshot (store path)
— Replaces the SELECT MAX(generation) FROM pages reads at
cache-write time with SELECT value FROM page_generation_clock
WHERE id = 1.
— Empty pageIds path: only need the clock value (D20 contract).
— Combined non-empty path: per-page generation (Layer 2 substrate)
+ clock value, both folded in one round trip via UNION ALL.
CACHE_GATE_WHERE_CLAUSE (lookup path)
— Layer 1 reads page_generation_clock.value (single-row O(1)
lookup, faster than the pre-fix MAX(generation) backward index
scan).
— Layer 2 stricter: requires page_generations <> '{}'::jsonb AND
the per-page check (not OR with the vacuously-valid `= '{}'`
shortcut). Empty snapshots can no longer survive a Layer 1 miss.
validateCacheRowAgainstPages (pure validator)
— Layer 2 returns false for empty snapshots when Layer 1 fails.
— Documented contract change.
Backward compat: pre-v0.40.3.0 cache rows have
max_generation_at_store = 0 AND page_generations = '{}'::jsonb. On a
populated brain, Layer 1 fails (clock > 0). Layer 2 is now stricter
so legacy rows invalidate once on first post-upgrade lookup, then the
cache fills back correctly. Acceptable one-time miss spike;
post-upgrade cache is structurally sound.
The clock seed (COALESCE(MAX(pages.generation), 0)) from migration
v104 keeps NON-empty legacy rows passing Layer 1 until the next
write — they don't all invalidate at once.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…T7+T8+T9)
Tests for every behavior the v0.41.21.0 wave introduces or changes.
New test files:
test/sync-delete-batch.test.ts (PGLite hermetic)
— engine.deletePages: empty input short-circuit, returns confirmed
slugs (D6), multi-source isolation, cascade integrity (chunks +
links cleared via FK), rejects oversized input.
— engine.resolveSlugsByPaths: empty input, present + missing rows,
D10 exotic-filename substrate (🌟.md / ทดสอบ.md / عربي.md),
source isolation.
— D13 pagesAffected filter: 100 deletable + 10 ghost paths →
deletePages returns 100 (regression-pin: pre-fix would return
all 110 via D6's pre-RETURNING shape).
test/sync-delete-batch.slow.test.ts (.slow suffix keeps it out of
the fast loop)
— 10K-page batched delete completes in <5s on PGLite. Measured
277ms on dev hardware (18x under the gate); pins the headline
perf promise.
test/sync-rename-batch.test.ts (PGLite hermetic)
— 500-rename batch slug-resolve in 1 round-trip (exactly at
DELETE_BATCH_SIZE boundary).
— Frontmatter-fallback rename: exotic source_paths resolve via
the batch SELECT.
— Mixed present + missing: partial Map (missing → caller falls
back to path-derived).
test/page-generation-counter.test.ts (PGLite hermetic)
— Statement-level trigger fires once per INSERT statement (raw
SQL — NOT putPage, which uses ON CONFLICT DO UPDATE and bumps
by 2 in PG semantics).
— Statement-level trigger fires once per UPDATE statement.
— Headline contract: batch DELETE bumps clock by 1, NOT by row
count (25-row batch → +1).
— CDX-1 regression: DELETE of non-max page bumps clock.
— CDX-2 regression: UPDATE of non-max page bumps clock (raw SQL).
— D14 end-to-end: clock advances after batch DELETE → cache rows
stamped at the prior clock value are now stale by Layer 1.
— CDX-6/D20: empty-result cache + INSERT matching page → clock
advances (Layer 1 fires).
— Documents the PG quirk: putPage's INSERT...ON CONFLICT DO UPDATE
bumps clock by 2 (both INSERT and UPDATE triggers fire).
Test-helper update:
test/helpers/reset-pglite.ts
— Added page_generation_clock to PRESERVE_TABLES so the seeded
single-row counter survives resetPgliteState between tests
(same treatment as schema_version). Production never truncates.
Existing test contract inversions (CDX-6 / D20 fix):
test/query-cache-gate.test.ts
— Pre-v0.41.21.0 "vacuously valid for legacy empty snapshot"
assertion inverted: empty snapshot now invalidates when Layer 1
fires. Add positive CDX-6 regression test (empty-result + INSERT
matching page).
— SQL shape regression: page_generation_clock in Layer 1 (negative
regression guard: MAX(generation) FROM pages MUST be gone).
— Empty-snapshot reject guard:
`qc.page_generations <> '{}'::jsonb` present; the old
`qc.page_generations = '{}'::jsonb OR` shortcut MUST be gone.
test/e2e/cache-gate-pglite.test.ts
— Pre-v0.41.21.0 "legacy row serves vacuously" test inverted:
legacy rows now invalidate on first clock advance post-upgrade.
— CDX-1 regression: DELETE bumps clock → cached query for
surviving pages invalidates.
— CDX-2 regression: UPDATE-to-non-max-page bumps clock → cache
invalidates.
— CDX-11 comment fix: drop misleading "hard delete is admin-only"
framing; gbrain sync hard-deletes on every run.
Engine parity extension:
test/e2e/engine-parity.test.ts
— deletePages parity: same input set, both engines return same
string[] of confirmed-deleted slugs (D6).
— resolveSlugsByPaths parity: same Map on both engines.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ation clock (T10) VERSION bump (0.41.18.0 → 0.41.21.0; master is at 0.41.20.0 so next free slot per the queue allocator). CHANGELOG entry with the ELI10 lead per CLAUDE.md voice rules. CLAUDE.md annotations on engine.ts, postgres-engine.ts, pglite-engine.ts, sync.ts, and query-cache-gate.ts plus a new entry for engine-constants.ts. llms-full.txt regenerated to match CLAUDE.md (per CLAUDE.md mandatory rule). Co-Authored-By: garrytan-agents <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…persede # Conflicts: # CHANGELOG.md # CLAUDE.md # VERSION # llms-full.txt # src/core/migrate.ts
…persede # Conflicts: # CHANGELOG.md # VERSION # package.json # src/core/migrate.ts
Bun's default test reporter doesn't print per-test markers — only a
single shard-end summary block when you pass it a file list. The
existing heartbeat tried to count `^[[:space:]]+✓` lines as a live
pass-count proxy, but bun never emits them in the multi-file mode this
runner uses, so every mid-run heartbeat showed `0p 0f` for the entire
12-20 minute wallclock. Users (and agents polling the runner) couldn't
distinguish "still bootstrapping" from "wedged" from "almost done."
Fix: parse three complementary real-time signals instead.
1. Total files this shard was assigned — parsed from the
`[unit-shard N/M] running X files` banner that run-unit-shard.sh
echoes before invoking bun test. Available from second 1.
2. PGLite initSchema() count — proxy for "test files started so far."
Each PGLite-using test file's beforeAll triggers one initSchema(),
which logs `Schema version 1 → 106 (101 migration(s) pending)`.
Undercounts because not every test file opens a PGLite engine
(covers ~30-60% of files in practice), but it's the only real-time
progress signal bun's default reporter leaves in the log. The
output uses a `~` prefix to convey "approximate count."
3. Log size in KB — strictly monotonic liveness signal that works
even when the PGLite count is still 0 (early-shard startup before
the first initSchema fires).
4. Per-shard elapsed time — formatted as MmSSs.
New mid-run heartbeat line:
[heartbeat] [s1: ~62/190f 476KB 12m31s] [s2: ~63/190f 513KB 12m31s] ...
When a shard finishes, the heartbeat upgrades to its final summary
including pass/fail counts from bun's end-of-shard summary block:
[heartbeat] [s1: done ✓ 2807p 0f] [s2: done ✓ 2784p 0f] ...
Portability: BSD awk on macOS doesn't support `match($0, /re/, arr)`
with the array sink — that's a gawk extension. The total-files parser
uses sed instead so the runner stays portable to the default Mac toolchain.
Helpers are pure functions and unit-testable in isolation: pass a log
file path, get the parsed number. No mocking. No bun runtime required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per user request — skip v0.41.23.0 / v0.41.24.0 slots to land at v0.41.25.0. Master is at v0.41.22.1, no version-trio collision. Touches VERSION, package.json, CHANGELOG header, CLAUDE.md annotations, src/core/engine-constants.ts header, src/core/migrate.ts migration v106 comment, regenerated llms-full.txt + llms.txt. Migration version (v106) and CDX1-6 trigger semantics unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-existing bug in master's SCHEMA_SQL ordering, surfaced by CI on this PR but lived silently on master since v0.41.18.0. migration_impact_log declares `job_id BIGINT REFERENCES minion_jobs(id)`, but its CREATE TABLE was at line 658 while minion_jobs's CREATE TABLE was at line 778. On any fresh-install initSchema() the FK target didn't exist yet: psql:/tmp/schema.sql:672: ERROR: relation "minion_jobs" does not exist postgres-js's `unsafe()` aborts the multi-statement batch on the first error response, so every CREATE TABLE after migration_impact_log (including minion_jobs itself) never ran. Every subsequent CLI subprocess that opened a connection then crashed with `relation "minion_jobs" does not exist` on its first query. Why master CI sometimes passed: the per-shard advisory lock + the test setup's `engine.initSchema()` second pass (which runs the migrations array) would eventually create minion_jobs via the v5 `minion_jobs_table` migration. From there migration_impact_log would land via migration v103 with its FK resolving correctly. But CLI subprocesses spawned by mechanical.test.ts's Parallel Import block open their OWN connections and run a fresh `engine.connect() → initSchema()` — that path runs SCHEMA_SQL FIRST and aborted at the same forward-reference error before the migrations array could repair. Fix: relocate the migration_impact_log CREATE TABLE + its two indexes to AFTER the minion_jobs CREATE TABLE block (lines ~865), keeping the rest of the schema layout intact. PGLite schema (pglite-schema.ts) already had the correct ordering — only Postgres SCHEMA_SQL needed the move. Verified: fresh-DB local repro that previously failed 31/34 tests with `relation minion_jobs does not exist` now passes 78/78 in test/e2e/mechanical.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…persede # Conflicts: # CHANGELOG.md # VERSION # package.json # src/core/migrate.ts
…persede # Conflicts: # CHANGELOG.md # VERSION # package.json
mgunnin
added a commit
to mgunnin/gbrain
that referenced
this pull request
May 28, 2026
* upstream/master: v0.41.26.1 fix: lock-renewal cathedral — closes ~39 worker crashes/day (supersedes garrytan#1567) (garrytan#1572) v0.41.26.0 fix: dream --source + ingest junk titles + emoji-crash (supersedes garrytan#1559, garrytan#1561) (garrytan#1571) v0.41.25.0 perf(sync): batched deletes + global page-generation clock (supersedes garrytan#1538) (garrytan#1566) v0.41.24.0 fix(conversation-parser): threshold gates + bold-paren-time pattern — 20,167 Circleback messages unblocked (closes garrytan#1533) (garrytan#1543) v0.41.23.0 feat: extract operator surfaces + pack-driven extractables (garrytan#1541) v0.41.22.1 feat: brainstorm/lsd judge fixes (closes garrytan#1540 end-to-end) (garrytan#1562) v0.41.22.0 feat: type-unification cathedral — 94 types → 15 canonical (closes garrytan#1479) (garrytan#1542) v0.41.21.0 feat(ops): 5 daily-driver pains fixed in one wave (garrytan#1545) v0.41.20.0 feat: gbrain status + doctor --scope=brain (fix wave 2: items garrytan#6 + garrytan#7) (garrytan#1544) feat: v0.41.19.0 Supavisor Retry Cathedral (garrytan#1537) v0.41.18.0: gbrain onboard — the activation surface gbrain didn't have before (garrytan#1521) v0.41.17.0 feat: --workers N on every bulk command + facts dim doctor parity (garrytan#1519) v0.41.16.0 feat: conversation parser cathedral + progressive-batch primitive (closes garrytan#1461) (garrytan#1510) v0.41.15.0 feat(sync): --timeout + --max-age + partial status (closes garrytan#1472 RFC) (garrytan#1506)
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Big-delete syncs no longer choke your brain — and your search results were silently going stale in a different way that's now fixed.
A single commit that deletes a lot of files (atom-backfill reorganization, prefix sweep, big import cleanup) used to jam
gbrain syncfor ~5 hours. While it sat, every cron run timed out, every other source's sync got blocked, andgbrain doctorslid toward zero. 73K-delete commit now completes in ~2 minutes instead.While digging into the delete path, codex outside-voice round on the plan found a second unrelated bug: the query-cache Layer 1 bookmark read
MAX(generation) FROM pages, which was structurally broken — UPDATEs to a non-max page didn't advance MAX, DELETEs didn't fire the trigger at all, and empty-result cache rows were "vacuously valid" via Layer 2.gbrain searchwas silently serving stale results any time you updated an older page, deleted anything, or had an empty-result query that later matched a new page. That's also fixed via a new statement-levelpage_generation_clock+ Layer 1 read-side rewrite.Supersedes PR #1538 (already closed) — credit
@garrytan-agentsfor the SQL batching idea + headline 73K-delete number that motivated the plan.Changes by area
Sync delete loop (perf — the headline win):
BrainEngine.deletePages(slugs[], {sourceId})returning confirmed-deleted slugs (single-batch primitive; caller chunks toDELETE_BATCH_SIZE)BrainEngine.resolveSlugsByPaths(paths[], {sourceId})returningMap<path, slug>(single SQL round-trip)src/core/engine-constants.ts(NEW) ownsDELETE_BATCH_SIZE = 500failedFilespattern)fromslugs in batchesresolveSlugByPathOrSourcePath(single-call helper) delegates toresolveSlugsByPathswhensourceIdis set — one owner of SQL + fallback semanticsQuery-cache contract (correctness — codex outside-voice catch):
page_generation_clocksingle-row table + statement-levelAFTER INSERT OR UPDATE OR DELETEtrigger onpages(one clock bump per SQL statement regardless of row cardinality)COALESCE(MAX(pages.generation), 0)so existing cache rows aren't all instantly invalidated on upgradequery-cache-gate.tsLayer 1 readspage_generation_clock.value(was:MAX(generation) FROM pages)page_generations = {}snapshots no longer "vacuously valid" — they must satisfy Layer 1's clock bookmarkTest runner DX (bonus):
scripts/run-unit-parallel.shheartbeat shows real progress instead of[s1: 0p 0f ...]. Bun's default reporter doesn't print per-test markers, so the existing^[[:space:]]+✓regex matched nothing — every heartbeat showed 0p 0f for the entire 17-20 min wallclock. New output:[s1: ~62/190f 487KB 12m31s]— files-in-progress proxy via PGLite initSchema count + total from runner banner + log size + per-shard elapsed.Test Coverage
test/sync-delete-batch.test.ts(NEW)test/sync-delete-batch.slow.test.ts(NEW)test/sync-rename-batch.test.ts(NEW)test/page-generation-counter.test.ts(NEW)test/e2e/engine-parity.test.ts(EXTEND)test/query-cache-gate.test.ts(UPDATE)test/e2e/cache-gate-pglite.test.ts(EXTEND)Local results pre-merge: 11,437 pass / 0 fail unit suite; 277ms on the 10K-delete PGLite perf gate (18x under the 5s gate);
bun run verifyclean (28 checks).Bisectable commits
a28d64efengine surfacefb47db45sync.ts batched delete + rename + DRY refactord51c872cglobal clock schema + migration v10601d4bad2query-cache read-side519d0ec9tests + helper + contract inversions + CDX-11 comment fix1ee9d1b1v0.41.21.0 release boilerplate (CHANGELOG, VERSION, CLAUDE.md, llms-full.txt)7db671aa1st master mergeee2c13432nd master merge (renumbered v0.41.22.0 → v0.41.23.0, migration v105 → v106 after master's slug_aliases + brainstorm fixes landed)79b1845aDX: test-runner heartbeat shows real progressPlan + decisions
Plan + 20 design decisions (D1-D20) + codex outside-voice round at
~/.claude/plans/system-instruction-you-are-working-ethereal-narwhal.md.The codex outside-voice round caught that the originally-proposed counter-as-sidecar Phase 4 design was broken (
max(MAX(pages.generation), counter.value) = MAX(pages.generation)after a non-max delete). The collision forced redesign as a global clock used by ALL writes, which incidentally closed the pre-existing CDX-2 UPDATE-to-non-max stale-cache bug class.Test plan
gbrain sync --source <id>wallclock (before: minutes; after: seconds)🤖 Generated with Claude Code
Co-Authored-By: garrytan-agents noreply@anthropic.com