mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix: 8 root-cause fixes from /investigate wave
Consolidated bundle of bug fixes from /investigate on the 8 deferred bugs.
Each fix was designed to go at the structural gap, not the symptom. Codex
verified 20 load-bearing claims on the plan; 12 triggered plan revisions.
Bug 2 — GBRAIN_POOL_SIZE env knob + init finally blocks (no auto-detect).
Covers both the singleton pool (db.ts) and instance pool (import.ts:140).
Bug 3 — Centralize migration ledger writes in apply-migrations runner.
Removed appendCompletedMigration from v0_11_0, v0_12_0, v0_12_2,
v0_13_0, v0_13_1. Added 3-partial wedge cap + --force-retry reset.
'complete wins' preserved; no partial can regress a completed migration.
Bug 5 — v0.14.0 migration registered. src/commands/migrations/v0_14_0.ts
ships Phase A (ALTER minion_jobs.max_stalled SET DEFAULT 3) + Phase B
(pending-host-work ping for shell-jobs adoption).
Bug 6/10 — jsonb_agg(DISTINCT ...) in legacy traverseGraph (both engines).
Presentation-level dedup; schema still preserves provenance rows.
Bug 7 — doctor --fast reads DB URL source via getDbUrlSource() in config.ts.
Precise message: 'Skipping DB checks (--fast mode, URL present from env)'
replaces the misleading 'No database configured'.
Bug 8 — max_stalled default bumped 1→3 in schema-embedded.ts, pglite-schema.ts,
schema.sql (new installs). v0_14_0 Phase A ALTER for existing installs.
autopilot-cycle handler yields to event loop between phases so the
worker's lock-renewal timer fires on huge brains. (Deep AbortSignal
threading through runEmbedCore/runExtractCore/runBacklinksCore/performSync
deferred to v0.15 queue polish.)
Bug 9 — Gate sync.last_commit on no-failures across all three sync paths
(incremental, full via runImport, gbrain import git continuity).
recordSyncFailures() helper + ~/.gbrain/sync-failures.jsonl with
dedup key path+commit+error-hash. New flags: --skip-failed (ack) +
--retry-failed (re-attempt). Doctor surfaces unacknowledged failures.
Bug 11 — brain_score breakdown fields on BrainHealth (embed_coverage_score,
link_density_score, timeline_coverage_score, no_orphans_score,
no_dead_links_score); sum equals brain_score by construction.
dead_links now on the type (resolves featuresTeaserForDoctor drift).
orphan_pages kept as 'islanded' (no inbound AND no outbound) and
docs updated to match — explicit semantic instead of doc drift.
New tests: test/traverse-graph-dedup.test.ts, test/sync-failures.test.ts,
test/brain-score-breakdown.test.ts, test/migration-resume.test.ts,
test/migrations-v0_14_0.test.ts. Extended: migrate, doctor, apply-migrations.
All 1696 unit tests pass locally. postgres-jsonb E2E regression unchanged
(none of these touch the JSONB write surface).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: v0.14.2 CHANGELOG + CLAUDE.md; align migration-flow E2E with runner-owned ledger
CHANGELOG: v0.14.2 entry in the standard release-summary format
(two-line headline + lead + numbers table + "what this means" +
"To take advantage of v0.14.2" self-repair block + itemized
changes grouped by reliability / observability / graph correctness /
new migration / tests / deferred-to-v0.15).
CLAUDE.md: new "Key commands added in v0.14.2" section covers
--skip-failed, --retry-failed, --force-retry, GBRAIN_POOL_SIZE env,
and the new doctor checks (sync_failures, brain_score breakdown).
Migration orchestrator docs updated to describe v0_14_0.ts + the
runner-owned ledger contract from Bug 3.
test/e2e/migration-flow.test.ts: three assertions updated to match
the Bug 3 contract — orchestrators no longer append to completed.jsonl
directly, so direct-orchestrator E2E calls leave the ledger empty.
Preferences assertions remain (that's still the orchestrator's side
of the contract). Runner's ledger write is covered by the unit suite
(test/apply-migrations.test.ts + test/migration-resume.test.ts).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
/**
|
|
* E2E: v0.11.0 migration-flow against real Postgres + temp $HOME.
|
|
*
|
|
* Exercises the full orchestrator from Phase A (schema apply via
|
|
* `gbrain init --migrate-only`) through Phase G (completed.jsonl append),
|
|
* skipping Phase F (autopilot install) to avoid writing a launchd plist
|
|
* or crontab entry on the CI host. Worker supervision + autopilot-cycle
|
|
* handler are covered by the unit-layer tests (test/handlers.test.ts and
|
|
* test/autopilot-resolve-cli.test.ts); this E2E locks the schema → prefs
|
|
* → host-rewrite → completed.jsonl chain against a live database.
|
|
*
|
|
* Gated by DATABASE_URL — skips gracefully when unset per CLAUDE.md
|
|
* lifecycle.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import {
|
|
mkdtempSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
statSync,
|
|
} from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
|
|
import { v0_11_0 } from '../../src/commands/migrations/v0_11_0.ts';
|
|
import { loadPreferences, loadCompletedMigrations } from '../../src/core/preferences.ts';
|
|
import { hasDatabase } from './helpers.ts';
|
|
|
|
const SKIP = !hasDatabase();
|
|
const describeE2E = SKIP ? describe.skip : describe;
|
|
const DATABASE_URL = process.env.DATABASE_URL ?? '';
|
|
|
|
let tmp: string;
|
|
let origHome: string | undefined;
|
|
let origPath: string | undefined;
|
|
let fakeBinDir: string;
|
|
const CLI_PATH = join(import.meta.dir, '..', '..', 'src', 'cli.ts');
|
|
|
|
// Module-level PATH shim. The orchestrator shells out to `gbrain init
|
|
// --migrate-only` and `gbrain jobs smoke`. On source-install test envs
|
|
// there's no `gbrain` on $PATH. Install a tiny bash shim that `exec`s
|
|
// `bun run src/cli.ts` and prepend it to $PATH before any tests import
|
|
// the orchestrator. Running at module-init (not beforeAll) guarantees
|
|
// the shim exists before Bun's test runner loads described blocks.
|
|
if (!SKIP) {
|
|
origPath = process.env.PATH;
|
|
fakeBinDir = mkdtempSync(join(tmpdir(), 'gbrain-e2e-bin-'));
|
|
const shim = join(fakeBinDir, 'gbrain');
|
|
writeFileSync(
|
|
shim,
|
|
`#!/usr/bin/env bash\nexec bun run "${CLI_PATH}" "$@"\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
process.env.PATH = `${fakeBinDir}:${origPath ?? ''}`;
|
|
console.log('[migration-flow.e2e] shim installed at', shim, 'PATH prepended');
|
|
}
|
|
|
|
function freshTempHome(label: string) {
|
|
const dir = mkdtempSync(join(tmpdir(), `gbrain-e2e-migration-${label}-`));
|
|
process.env.HOME = dir;
|
|
// Seed config so Phase A's `gbrain init --migrate-only` has a target.
|
|
mkdirSync(join(dir, '.gbrain'), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, '.gbrain', 'config.json'),
|
|
JSON.stringify({ engine: 'postgres', database_url: DATABASE_URL }, null, 2) + '\n',
|
|
{ mode: 0o600 },
|
|
);
|
|
return dir;
|
|
}
|
|
|
|
beforeAll(() => {
|
|
if (SKIP) {
|
|
console.log('[migration-flow.e2e] DATABASE_URL not set — skipping.');
|
|
return;
|
|
}
|
|
origHome = process.env.HOME;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (SKIP) return;
|
|
if (origHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = origHome;
|
|
if (origPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = origPath;
|
|
try { if (fakeBinDir) rmSync(fakeBinDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
});
|
|
|
|
beforeEach(() => {
|
|
if (SKIP) return;
|
|
try { if (tmp) rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
});
|
|
|
|
const COMMON_OPTS = {
|
|
yes: true,
|
|
mode: 'pain_triggered' as const,
|
|
dryRun: false,
|
|
hostDir: undefined,
|
|
noAutopilotInstall: true, // critical: don't install launchd/systemd/crontab on CI
|
|
};
|
|
|
|
describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
|
|
test('fresh install flow: schema → smoke → prefs → host-rewrite → completed', async () => {
|
|
tmp = freshTempHome('fresh');
|
|
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
|
|
|
// Orchestrator returns a structured result (status is `complete` when
|
|
// no pending-host-work TODOs fired, `partial` otherwise).
|
|
expect(result.version).toBe('0.11.0');
|
|
expect(['complete', 'partial']).toContain(result.status);
|
|
|
|
// Phase D: preferences.json exists with 0o600 + mode=pain_triggered.
|
|
const prefsPath = join(tmp, '.gbrain', 'preferences.json');
|
|
expect(existsSync(prefsPath)).toBe(true);
|
|
expect(statSync(prefsPath).mode & 0o777).toBe(0o600);
|
|
const prefs = loadPreferences();
|
|
expect(prefs.minion_mode).toBe('pain_triggered');
|
|
expect(prefs.set_at).toBeTruthy();
|
|
expect(prefs.set_in_version).toBeTruthy();
|
|
|
|
// Bug 3 (v0.14.2) — orchestrator no longer writes completed.jsonl.
|
|
// The runner (apply-migrations.ts) persists the result after the
|
|
// orchestrator returns. A direct orchestrator call in E2E leaves the
|
|
// ledger empty; the runner path is tested separately in
|
|
// test/apply-migrations.test.ts + test/migration-resume.test.ts.
|
|
const completed = loadCompletedMigrations();
|
|
const v0110Entries = completed.filter(e => e.version === '0.11.0');
|
|
expect(v0110Entries.length).toBe(0);
|
|
|
|
// Phase F is skipped per COMMON_OPTS — autopilot should NOT have been
|
|
// installed on this host.
|
|
expect(result.autopilot_installed).toBe(false);
|
|
}, 60_000);
|
|
|
|
test('idempotent rerun: second invocation is a safe no-op', async () => {
|
|
tmp = freshTempHome('rerun');
|
|
const first = await v0_11_0.orchestrator(COMMON_OPTS);
|
|
expect(['complete', 'partial']).toContain(first.status);
|
|
|
|
const second = await v0_11_0.orchestrator(COMMON_OPTS);
|
|
expect(['complete', 'partial']).toContain(second.status);
|
|
|
|
// Bug 3 (v0.14.2) — orchestrator does not write completed.jsonl, so
|
|
// repeated direct invocations don't accumulate ledger entries. Assert
|
|
// the preferences state stays stable (the real idempotency signal for
|
|
// this orchestrator is "running again doesn't corrupt preferences").
|
|
expect(loadPreferences().minion_mode).toBe('pain_triggered');
|
|
const completed = loadCompletedMigrations();
|
|
expect(completed.filter(e => e.version === '0.11.0').length).toBe(0);
|
|
}, 90_000);
|
|
|
|
test('host rewrite: builtin handlers auto-rewritten, non-builtins queued as JSONL TODOs', async () => {
|
|
tmp = freshTempHome('host-rewrite');
|
|
// Fixture: AGENTS.md + cron/jobs.json with a mix of gbrain-builtin and
|
|
// non-builtin handlers.
|
|
const claudeDir = join(tmp, '.claude');
|
|
mkdirSync(claudeDir, { recursive: true });
|
|
writeFileSync(
|
|
join(claudeDir, 'AGENTS.md'),
|
|
'# Test AGENTS.md\n\nSome existing content referencing sessions_spawn routing.\n',
|
|
);
|
|
mkdirSync(join(claudeDir, 'cron'), { recursive: true });
|
|
writeFileSync(
|
|
join(claudeDir, 'cron', 'jobs.json'),
|
|
JSON.stringify({
|
|
jobs: [
|
|
{ schedule: '*/5 * * * *', kind: 'agentTurn', skill: 'sync' }, // builtin
|
|
{ schedule: '0 */30 * * *', kind: 'agentTurn', skill: 'ea-inbox-sweep' }, // non-builtin
|
|
{ schedule: '*/10 * * * *', kind: 'agentTurn', skill: 'embed' }, // builtin
|
|
{ schedule: '0 8 * * *', kind: 'agentTurn', skill: 'morning-briefing' }, // non-builtin
|
|
],
|
|
}, null, 2) + '\n',
|
|
);
|
|
|
|
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
|
|
|
// Builtins rewritten in place; non-builtins left alone.
|
|
const cronAfter = JSON.parse(readFileSync(join(claudeDir, 'cron', 'jobs.json'), 'utf-8'));
|
|
expect(cronAfter.jobs[0].kind).toBe('shell'); // sync (builtin)
|
|
expect(cronAfter.jobs[0].cmd).toContain('gbrain jobs submit sync');
|
|
expect(cronAfter.jobs[1].kind).toBe('agentTurn'); // ea-inbox-sweep (non-builtin)
|
|
expect(cronAfter.jobs[2].kind).toBe('shell'); // embed (builtin)
|
|
expect(cronAfter.jobs[3].kind).toBe('agentTurn'); // morning-briefing (non-builtin)
|
|
|
|
// files_rewritten counts the 2 builtin rewrites.
|
|
expect(result.files_rewritten).toBeGreaterThanOrEqual(2);
|
|
|
|
// pending_host_work counts the 2 non-builtin TODOs.
|
|
expect(result.pending_host_work).toBe(2);
|
|
|
|
// Status is "partial" because non-builtin TODOs remain.
|
|
expect(result.status).toBe('partial');
|
|
|
|
// AGENTS.md got the marker injected.
|
|
const agentsMdAfter = readFileSync(join(claudeDir, 'AGENTS.md'), 'utf-8');
|
|
expect(agentsMdAfter).toContain('gbrain:subagent-routing v0.11.0');
|
|
expect(agentsMdAfter).toContain('skills/conventions/subagent-routing.md');
|
|
|
|
// JSONL TODO file written under ~/.gbrain/migrations/.
|
|
const jsonlPath = join(tmp, '.gbrain', 'migrations', 'pending-host-work.jsonl');
|
|
expect(existsSync(jsonlPath)).toBe(true);
|
|
const lines = readFileSync(jsonlPath, 'utf-8').split('\n').filter(l => l.trim());
|
|
expect(lines.length).toBe(2);
|
|
const todos = lines.map(l => JSON.parse(l));
|
|
const handlers = todos.map(t => t.handler).sort();
|
|
expect(handlers).toEqual(['ea-inbox-sweep', 'morning-briefing']);
|
|
for (const todo of todos) {
|
|
expect(todo.type).toBe('cron-handler-needs-host-registration');
|
|
expect(todo.status).toBe('pending');
|
|
expect(todo.manifest_path).toContain('cron/jobs.json');
|
|
}
|
|
}, 90_000);
|
|
|
|
test('resumable: partial run → orchestrator re-run → complete', async () => {
|
|
tmp = freshTempHome('resumable');
|
|
// Simulate a stopgap-written partial entry BEFORE running the orchestrator.
|
|
mkdirSync(join(tmp, '.gbrain', 'migrations'), { recursive: true });
|
|
writeFileSync(
|
|
join(tmp, '.gbrain', 'migrations', 'completed.jsonl'),
|
|
JSON.stringify({
|
|
version: '0.11.0',
|
|
status: 'partial',
|
|
apply_migrations_pending: true,
|
|
mode: 'pain_triggered',
|
|
source: 'fix-v0.11.0.sh',
|
|
ts: new Date().toISOString(),
|
|
}) + '\n',
|
|
);
|
|
|
|
// Orchestrator re-running on a partial → should succeed (schema apply
|
|
// and smoke are idempotent; prefs are preserved from the partial
|
|
// record; host-rewrite runs its safe-skip pass). Per Bug 3 (v0.14.2),
|
|
// the orchestrator itself doesn't append to completed.jsonl — the
|
|
// runner does. The stopgap's partial entry stays unchanged here.
|
|
const result = await v0_11_0.orchestrator(COMMON_OPTS);
|
|
expect(['complete', 'partial']).toContain(result.status);
|
|
|
|
const completed = loadCompletedMigrations();
|
|
const v0110 = completed.filter(e => e.version === '0.11.0');
|
|
// Just the stopgap partial — orchestrator doesn't add its own entry.
|
|
expect(v0110.length).toBe(1);
|
|
expect(v0110[0].status).toBe('partial');
|
|
expect(v0110[0].source).toBe('fix-v0.11.0.sh');
|
|
}, 90_000);
|
|
});
|