diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f27772ac8..29840f180 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -3394,6 +3394,32 @@ export async function checkSyncFreshness( let hasWarnings = false; let hasFailures = false; + // BUG 4 (v0.42.x): a source with a LIVE, non-expired per-source sync lock is + // actively syncing RIGHT NOW — it must not read as stale or never-synced. + // The live lock is the only honest "in progress" signal. Checkpoint banking + // is NOT usable: a blocked sync banks the good files then writes no anchor + // (test/sync-resumable-import.serial.test.ts), so banking can't tell + // in-progress from wedged. A blocked/failed sync's process has exited (no + // lock row) and a wedged holder stops refreshing (TTL lapses), so either + // correctly falls through to the stale path and is NEVER masked. Same + // dynamic import as the stale_locks check; any throw (stub engine in unit + // tests, pre-lock-table brain) is swallowed to false, so this can only ADD + // an in-progress verdict, never suppress a real stale one. + let isActivelySyncing: (sourceId: string) => Promise = async () => false; + try { + const { inspectLock, syncLockId } = await import('../core/db-lock.ts'); + isActivelySyncing = async (sourceId: string): Promise => { + try { + const snap = await inspectLock(engine, syncLockId(sourceId)); + return !!snap && !snap.ttl_expired; + } catch { + return false; + } + }; + } catch { + /* db-lock unavailable — skip in-progress detection, staleness stands. */ + } + for (const source of sources) { // Embed source.id in user-visible messages so `gbrain sync --source ` // matches what the user copy-pastes. Show display name in parens when set. @@ -3401,6 +3427,13 @@ export async function checkSyncFreshness( ? `'${source.id}' (${source.name})` : `'${source.id}'`; + // BUG 4: actively syncing (live lock) → healthy, count as synced_recently + // and skip the staleness checks. Keeps the 3-bucket invariant intact. + if (await isActivelySyncing(source.id)) { + synced_recently_count++; + continue; + } + if (!source.last_sync_at) { issues.push(`Source ${display} has never been synced`); hasFailures = true; diff --git a/test/doctor.test.ts b/test/doctor.test.ts index fab1201d0..30d39a525 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -1330,3 +1330,110 @@ describe('v0.42 (#1699) — quarantined_pages + flagged_pages checks', () => { expect(source).toMatch(/name: 'flagged_pages'/); }); }); + +// ============================================================================ +// BUG 4 (v0.42.x) — doctor reports an actively-running sync via the live lock, +// not stale freshness. Uses a REAL PGLiteEngine so inspectLock/syncLockId run +// against actual gbrain_cycle_locks rows (the stub engine can't model a lock). +// ============================================================================ +describe('BUG 4 — in-progress sync via live lock, not stale freshness', () => { + let engine: any; + let syncLockId: (s: string) => string; + + beforeAll(async () => { + const { PGLiteEngine } = await import('../src/core/pglite-engine.ts'); + ({ syncLockId } = await import('../src/core/db-lock.ts')); + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); + }); + + afterAll(async () => { + await engine.disconnect(); + }); + + beforeEach(async () => { + const { resetPgliteState } = await import('./helpers/reset-pglite.ts'); + await resetPgliteState(engine); + await engine.executeRaw(`DELETE FROM gbrain_cycle_locks`); + }); + + const staleDate = () => new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5d ago + + async function addSource(id: string, lastSyncAt: Date | null) { + await engine.executeRaw( + `INSERT INTO sources (id, name, local_path, last_sync_at, config) + VALUES ($1, $1, $2, $3, '{"federated":true}'::jsonb) + ON CONFLICT (id) DO UPDATE SET last_sync_at = EXCLUDED.last_sync_at`, + [id, `/tmp/${id}`, lastSyncAt], + ); + } + + // ttlMinutes > 0 → live lock; <= 0 → already-expired (wedged) holder. + async function holdLock(sourceId: string, ttlMinutes: number) { + await engine.executeRaw( + `INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at) + VALUES ($1, 4242, 'testhost', now(), now() + ($2 || ' minutes')::interval, now())`, + [syncLockId(sourceId), String(ttlMinutes)], + ); + } + + test('stale source with NO live lock → fail (blocked/wedged is not masked)', async () => { + const { checkSyncFreshness } = await import('../src/commands/doctor.ts'); + await addSource('wiki', staleDate()); + const result = await checkSyncFreshness(engine); + expect(result.status).toBe('fail'); + expect(result.message).toContain(`'wiki'`); + }); + + test('stale source WITH a live (non-expired) lock → ok (sync in progress)', async () => { + const { checkSyncFreshness } = await import('../src/commands/doctor.ts'); + await addSource('wiki', staleDate()); + await holdLock('wiki', 30); + const result = await checkSyncFreshness(engine); + expect(result.status).toBe('ok'); + expect(result.details?.synced_recently_count).toBe(1); + expect(result.details?.stale_count).toBe(0); + }); + + test('never-synced source WITH a live lock → ok (initial sync in progress)', async () => { + const { checkSyncFreshness } = await import('../src/commands/doctor.ts'); + await addSource('wiki', null); + await holdLock('wiki', 30); + const result = await checkSyncFreshness(engine); + expect(result.status).toBe('ok'); + expect(result.details?.synced_recently_count).toBe(1); + }); + + test('never-synced source with NO lock → fail (unchanged behavior)', async () => { + const { checkSyncFreshness } = await import('../src/commands/doctor.ts'); + await addSource('wiki', null); + const result = await checkSyncFreshness(engine); + expect(result.status).toBe('fail'); + expect(result.message).toContain('never been synced'); + }); + + test('expired-TTL lock does NOT mask staleness (wedged-but-not-refreshing holder)', async () => { + const { checkSyncFreshness } = await import('../src/commands/doctor.ts'); + await addSource('wiki', staleDate()); + await holdLock('wiki', -5); // ttl_expires_at 5 min in the past + const result = await checkSyncFreshness(engine); + expect(result.status).toBe('fail'); + }); + + test('blocked source with banked checkpoint rows but NO live lock → still fail', async () => { + const { checkSyncFreshness } = await import('../src/commands/doctor.ts'); + await addSource('wiki', staleDate()); + // A blocked sync banks the good files then exits without an anchor and + // without a held lock. Banking must NOT be read as "in progress". + await engine.executeRaw( + `INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at) + VALUES ('sync', 'fp-blocked', '[]'::jsonb, now())`, + ); + await engine.executeRaw( + `INSERT INTO op_checkpoint_paths (op, fingerprint, path) VALUES ('sync', 'fp-blocked', 'banked.md')`, + ); + const result = await checkSyncFreshness(engine); + expect(result.status).toBe('fail'); + }); +});