Files
gbrain/test/dream-dir-source-stamp.test.ts
f9349ba07f fix(doctor,cycle): stop permanent cycle_freshness FAILs on multi-source installs (#2540) (#3562)
Closes the paths #3382 left open (its author said it narrowed the issue
rather than closing it):

1. checkCycleFreshness iterates EVERY local_path source, so an install
   that nightly-dreams one vault via --dir showed a permanent FAIL for
   every other federated source — and for any source added minutes ago.
   'Never completed a full cycle' is now a WARN with the dream/autopilot
   hint; a source that HAS cycled and then went stale still escalates
   through the 6h warn / 24h fail thresholds (the regression signal the
   check exists for). This is the reporter's actual case: the permanent
   red eroded doctor's signal until real staleness hid inside it.

2. resolveSourceForDir's exact-match lookup had no archived filter and
   no ORDER BY, so an archived (or duplicate) alias of the same path
   could shadow the active source; dream's archived guard then refused
   the stamp and the ACTIVE source stayed unstamped forever. The lookup
   now excludes archived rows and orders deterministically, matching
   the canonical-path fallback's posture. The fallback's fail-closed
   ambiguity handling is deliberately unchanged.

3. #3382's own regression test (ii) was environment-sensitive: it
   assumed unsetting OPENAI_API_KEY/ANTHROPIC_API_KEY makes the embed
   phase fail, which is false wherever another embedding provider
   resolves (the cycle then reports 'clean' and the test flips). It now
   fails the sync phase against a vanished checkout — deterministic on
   every machine, same property pinned (a genuinely failing enabled
   phase must prevent the stamp).

New pins fail on unmodified master and pass here: never-cycled→warn
(x2, doctor) and the archived-alias shadow (dream --dir stamp).

Fixes #2540

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:50:44 -07:00

188 lines
8.2 KiB
TypeScript

/**
* #1869 — `gbrain dream --dir <path>` stamps cycle freshness when the path
* matches a registered source's local_path.
*
* Pre-fix, only `--source <id>` runs wrote last_source_cycle_at /
* last_full_cycle_at (runCycle's stamp gate reads opts.sourceId, and dream
* never derived one from --dir), so a path-scoped brain showed doctor's
* cycle_freshness as perpetually stale.
*
* The fix lives in dream.ts (derive the source id from the resolved brain
* dir via resolveSourceForDir), NOT in runCycle's stamp gate — a runCycle-
* wide change would make the autopilot-global-maintenance handler (global
* phases, brainDir set, no sourceId) falsely stamp per-source freshness
* (the #2194 poisoning class; see rejected PR #2549).
*
* Same real-PGLite/no-mocks discipline as test/dream.test.ts; same
* GBRAIN_HOME isolation as test/cycle-last-full-cycle-at.test.ts (the
* cycle's PGLite file lock lives under ~/.gbrain).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, symlinkSync, realpathSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { runDream } from '../src/commands/dream.ts';
import { withEnv } from './helpers/with-env.ts';
let engine: PGLiteEngine;
let brainDir: string;
let gbrainHome: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 60_000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
brainDir = mkdtempSync(join(tmpdir(), 'gbrain-dream-stamp-'));
gbrainHome = mkdtempSync(join(tmpdir(), 'gbrain-dream-stamp-home-'));
}, 60_000);
afterEach(() => {
rmSync(brainDir, { recursive: true, force: true });
rmSync(gbrainHome, { recursive: true, force: true });
});
async function seedSource(id: string, archived = false): Promise<void> {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
VALUES ($1, $2, $3, '{}'::jsonb, $4, NOW())`,
[id, id, brainDir, archived],
);
}
async function readLastFullCycleAt(sourceId: string): Promise<string | null> {
const rows = await engine.executeRaw<{ config: Record<string, unknown> | null }>(
`SELECT config FROM sources WHERE id = $1`,
[sourceId],
);
const raw = rows[0]?.config?.last_full_cycle_at;
return typeof raw === 'string' ? raw : null;
}
describe('gbrain dream --dir <path> freshness stamp (#1869)', () => {
test('--dir matching a source local_path stamps last_full_cycle_at', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
await seedSource('path-scoped');
expect(await readLastFullCycleAt('path-scoped')).toBeNull();
const report = await runDream(engine, ['--dir', brainDir, '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
if (report) expect(['ok', 'clean']).toContain(report.status);
// Pre-fix this stays null forever: dream never passed a sourceId, so
// runCycle's stamp gate skipped the write.
expect(await readLastFullCycleAt('path-scoped')).not.toBeNull();
});
}, 60_000);
test('--dir matching an ARCHIVED source does not stamp it', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
await seedSource('mothballed', true);
const report = await runDream(engine, ['--dir', brainDir, '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
// Stamping an archived source would mask data staleness when it is
// later restored (mirrors the explicit --source archived guard).
expect(await readLastFullCycleAt('mothballed')).toBeNull();
});
}, 60_000);
test('an ARCHIVED alias of the same path does not shadow the active source (#2540)', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
// Ordinary shape: a source was archived and re-added under a new id
// pointing at the same checkout. Seed the archived twin FIRST so a
// filterless `LIMIT 1` scan finds it first.
await seedSource('retired-twin', true);
await seedSource('active-twin', false);
const report = await runDream(engine, ['--dir', brainDir, '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
if (report) expect(['ok', 'clean']).toContain(report.status);
// Pre-fix, resolveSourceForDir's exact match had no `archived = false`
// filter and no ORDER BY, so the archived twin won the lookup; dream's
// archived guard then (correctly) refused to stamp it — and the ACTIVE
// source silently never got its stamp, leaving doctor's cycle_freshness
// permanently stale on a healthy install.
expect(await readLastFullCycleAt('active-twin')).not.toBeNull();
expect(await readLastFullCycleAt('retired-twin')).toBeNull();
});
}, 60_000);
});
/**
* #2540 — `--dir` matching against `sources.local_path` compares two path
* SPELLINGS, and neither side is canonicalized: `--dir` goes through
* `resolve()`, while `sources.local_path` stores whatever spelling the
* source was registered with (`--path` as typed, or `defaultCloneDir`).
* So a source registered through a symlink but dreamt via the real path
* (or vice versa) never string-matched — `resolveSourceForDir`
* (src/core/cycle.ts) derived no source, the #1869 freshness stamp never
* landed, and doctor's `cycle_freshness` stayed permanently stale on an
* otherwise healthy install. Symlinked vault locations are ordinary (a
* brain inside a synced cloud-storage folder, a /home -> /mnt relocation).
*
* Fix: on an exact-match miss, `resolveSourceForDir` retries with
* `realpathSync` applied to BOTH sides, so the match is symmetric no
* matter which side holds the link. Both directions are pinned below.
* Symlinks are constructed explicitly here (rather than relying on the
* host's own tmpdir layout) so the test is deterministic on macOS and
* Linux CI alike.
*/
describe('gbrain dream --dir <path> freshness stamp across a symlink (#2540)', () => {
test('local_path stored as the REAL path, --dir given the SYMLINKED path still stamps', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
const realTarget = mkdtempSync(join(tmpdir(), 'gbrain-2540-real-'));
const linkParent = mkdtempSync(join(tmpdir(), 'gbrain-2540-link-'));
const symlinkedDir = join(linkParent, 'vault');
symlinkSync(realTarget, symlinkedDir, 'dir');
expect(realpathSync(symlinkedDir)).not.toBe(symlinkedDir); // sanity: the symlink actually diverges
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
VALUES ($1, $2, $3, '{}'::jsonb, false, NOW())`,
['vault-real', 'vault-real', realTarget],
);
const report = await runDream(engine, ['--dir', symlinkedDir, '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
expect(await readLastFullCycleAt('vault-real')).not.toBeNull();
rmSync(realTarget, { recursive: true, force: true });
rmSync(linkParent, { recursive: true, force: true });
});
}, 60_000);
test('local_path stored as the SYMLINKED path, --dir given the REAL path still stamps', async () => {
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
const realTarget = mkdtempSync(join(tmpdir(), 'gbrain-2540-real2-'));
const linkParent = mkdtempSync(join(tmpdir(), 'gbrain-2540-link2-'));
const symlinkedDir = join(linkParent, 'vault');
symlinkSync(realTarget, symlinkedDir, 'dir');
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
VALUES ($1, $2, $3, '{}'::jsonb, false, NOW())`,
['vault-link', 'vault-link', symlinkedDir],
);
const report = await runDream(engine, ['--dir', realpathSync(symlinkedDir), '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
expect(await readLastFullCycleAt('vault-link')).not.toBeNull();
rmSync(realTarget, { recursive: true, force: true });
rmSync(linkParent, { recursive: true, force: true });
});
}, 60_000);
});