From 3a2033e8e2497b1a19a09eb9cf803866e500d1d7 Mon Sep 17 00:00:00 2001 From: lost9999 <56498264+lost9999@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:31:55 +0800 Subject: [PATCH] v0.42.52.0 fix(sync): bump last_sync_at heartbeat on 0-changes sync (#2335) D4 invariant ("never advance last_commit on partial", sync.ts comment) preserved. last_sync_at is a monitoring signal read by doctor sync_freshness (warn 24h / fail 72h), separate from the import-converged bookmark. Without this heartbeat write, a cron-driven */15 sync over a quiet vault pins last_sync_at to the last real commit, so doctor falsely flags the source as stale for as long as the vault is quiet. Reproduction (5 lines, no gbrain install required): 1. Setup a fresh obsidian source + commit a single .md file 2. gbrain sync --source obsidian # first_sync, last_sync_at = NOW 3. (do nothing) gbrain sync --source obsidian # up_to_date 4. SELECT last_sync_at FROM sources WHERE id = 'obsidian'; 5. Observed: still pinned to step 2. Expected: bumped to step 3. Fix: in the up_to_date early-return (sync.ts line ~1786), execute a single `UPDATE sources SET last_sync_at = now() WHERE id = $1` before returning. The D4-protected writeSyncAnchor path is untouched. Test: test/sync.test.ts adds a describe block that runs two consecutive syncs against a quiet vault and asserts last_sync_at advances while last_commit is unchanged. PGLite + executeRaw pattern matches the existing #1970 test scaffold. Workaround: hourly psql touch in WSL crontab documented at https://github.com/garrytan/gbrain/issues/[link-to-issue] Co-authored-by: lost9999 --- src/commands/sync.ts | 12 +++++++ test/sync.test.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index eb5b98f58..9331a9a2f 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1786,6 +1786,18 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise 0); if (lastCommit === headCommit && !versionMismatch && !versionNeverSet && !hasDetachedWorkingTreeChanges) { + // v0.42.52.0 (PR #22xx): bump last_sync_at as a heartbeat on every successful + // 0-changes sync. D4 invariant ("never advance last_commit on partial") is + // preserved: last_sync_at is a monitoring signal (doctor sync_freshness + // reads it), separate from the import-converged bookmark. Without this, + // a cron-driven `*/15 sync` over a quiet vault leaves last_sync_at pinned + // to the last real commit, so doctor falsely flags the source as stale. + if (opts.sourceId) { + await engine.executeRaw( + `UPDATE sources SET last_sync_at = now() WHERE id = $1`, + [opts.sourceId], + ); + } return { status: 'up_to_date', fromCommit: lastCommit, diff --git a/test/sync.test.ts b/test/sync.test.ts index acac5e348..ae7901585 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -911,3 +911,88 @@ describe('#1970: unreachable last_commit bookmark recovery', () => { expect(settled.status).toBe('up_to_date'); }); }); + +describe('v0.42.52.0: 0-changes sync bumps last_sync_at heartbeat (D4 invariant preserved)', () => { + let engine: PGLiteEngine; + const repos: string[] = []; + + beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + }); + + afterAll(async () => { + await engine.disconnect(); + }); + + beforeEach(async () => { + await resetPgliteState(engine); + }); + + afterEach(() => { + while (repos.length) { + const d = repos.pop(); + if (d) rmSync(d, { recursive: true, force: true }); + } + }); + + function personMd(title: string, body: string): string { + return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n'); + } + + function mkRepo(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'gbrain-heartbeat-')); + repos.push(dir); + execSync('git init', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' }); + for (const [rel, content] of Object.entries(files)) { + mkdirSync(join(dir, rel, '..'), { recursive: true }); + writeFileSync(join(dir, rel), content); + } + execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' }); + return dir; + } + + const SYNC_OPTS = { noPull: true, noEmbed: true, noExtract: true, sourceId: 'default' } as const; + + async function lastSyncAt(): Promise { + const rows = await engine.executeRaw<{ last_sync_at: string | null }>( + `SELECT last_sync_at FROM sources WHERE id = 'default'`, + ); + return rows[0]?.last_sync_at ?? null; + } + + test('consecutive 0-changes syncs advance last_sync_at without advancing last_commit', async () => { + const { performSync } = await import('../src/commands/sync.ts'); + const repo = mkRepo({ + 'people/alice.md': personMd('Alice', 'Alice is a person.'), + }); + + const first = await performSync(engine, { repoPath: repo, ...SYNC_OPTS }); + expect(first.status).toBe('first_sync'); + const afterFirst = await lastSyncAt(); + expect(afterFirst).not.toBeNull(); + const firstRows = await engine.executeRaw<{ last_commit: string | null }>( + `SELECT last_commit FROM sources WHERE id = 'default'`, + ); + const lastCommit = firstRows[0]?.last_commit; + expect(lastCommit).not.toBeNull(); + + // Wait 1.1s so the DB clock will tick past `afterFirst`. + await new Promise((r) => setTimeout(r, 1100)); + + const second = await performSync(engine, { repoPath: repo, ...SYNC_OPTS }); + expect(second.status).toBe('up_to_date'); + const afterSecond = await lastSyncAt(); + expect(afterSecond).not.toBeNull(); + expect(afterSecond).not.toEqual(afterFirst); // heartbeat bumped + + // D4 invariant: last_commit is unchanged on 0-changes sync. + const lastCommitRows = await engine.executeRaw<{ last_commit: string | null }>( + `SELECT last_commit FROM sources WHERE id = 'default'`, + ); + expect(lastCommitRows[0]?.last_commit).toEqual(lastCommit); + }); +});