mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* fix(dream): stamp path-derived sources so --dir runs land cycle freshness (#1869) gbrain dream --dir <path> (and the configured sync.repo_path fallback) never wrote last_source_cycle_at / last_full_cycle_at because runCycle's stamp gate reads opts.sourceId and dream only set it from --source. Doctor's cycle_freshness stayed perpetually stale on path-scoped brains. Fix at the command level: dream derives the source id from the resolved brain dir via resolveSourceForDir (now exported from cycle.ts) and passes it as opts.sourceId. runCycle's stamp/lock semantics are untouched, so legacy global callers (autopilot-global-maintenance runs GLOBAL_PHASES with a brainDir and no sourceId) cannot falsely stamp per-source freshness — the flaw that sank the runCycle-wide variant in PR #2549. A derived match on an archived source is skipped (mirrors the explicit --source archived guard). Takeover of #2549. Co-authored-by: javieraldape <javieraldape@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(autopilot): close the engine on SIGTERM/SIGINT instead of hard-exiting (#1872) systemctl stop (SIGTERM) previously hard-exited autopilot without ever closing the engine. On PGLite the cycle steps run INLINE in the autopilot process, so a mid-write exit kills WASM Postgres with the WAL dirty and can corrupt the brain. Now both exit paths close the engine first: - autopilot's own shutdown() (SIGINT + internal stops like max_crashes / cycle-failure-cap) aborts the in-flight inline cycle via an AbortController threaded into runCycle, drains it briefly, and awaits engine.disconnect() before process.exit(0). - process-cleanup's SIGTERM handler (installed at cli.ts module load, exits within its 3s cleanup deadline) reaches the same closeEngine via a registered 'autopilot-engine-close' cleanup callback. PGLite's disconnect() drains the pending query and checkpoints before closing; a second call is a no-op, so both paths firing is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(dream): conform dream-dir-source-stamp to canonical PGLite isolation pattern check:test-isolation R3/R4 flagged the new test file: engine was created in beforeEach (outside beforeAll) and never disconnected in afterAll. Switch to the canonical shared-engine pattern (beforeAll create, beforeEach resetPgliteState, afterAll disconnect) per test/helpers/reset-pglite.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: javieraldape <javieraldape@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
100 lines
3.8 KiB
TypeScript
100 lines
3.8 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 } 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);
|
|
});
|