diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index ad2380df2..2100f913b 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -18,7 +18,7 @@ * "gstack_brain_sync_mode": "off"|"artifacts-only"|"full", * "gstack_brain_git": true|false, * "gstack_artifacts_remote": "https://..." | "", - * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"timeout", + * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"engine-locked"|"timeout", * "gbrain_pooler_mode": "transaction"|"session"|null * } * diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index e55cec0ad..07a94af3f 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -717,6 +717,7 @@ function dreamMarkerPid(): number | null { * missing-config → "no local engine; run /setup-gbrain to add local PGLite" * broken-config → "config file at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5" * broken-db → "config points at unreachable DB; see /setup-gbrain Step 1.5" + * engine-locked → PGLite is busy; stop its holder or sync outside the live session * timeout → kept for Record totality; stages PROCEED on timeout (#1964) * via the gate's warnProbeTimeout path, never this skip. */ @@ -733,6 +734,8 @@ function skipStageForLocalStatus( "config at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5", "broken-db": "config points at unreachable DB; see /setup-gbrain Step 1.5", + "engine-locked": + "PGLite is busy (often held by gbrain serve); stop the holding process or run /sync-gbrain outside the live Claude session, then retry", "timeout": "engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow", }; diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index 9e26d5ba4..2b6caa780 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -19,6 +19,8 @@ * Broken-config → config exists but `gbrain sources list` fails with config parse error * (or any non-recognized error — defensive default per codex #8). * Broken-db → config exists, DB unreachable per stderr classification. + * Engine-locked → PGLite probe hit gbrain's own connect timeout, usually + * because another `gbrain serve` process owns the embedded DB. * Timeout → probe exceeded GSTACK_GBRAIN_PROBE_TIMEOUT_MS (default 15s) with no * recognized error — engine is likely healthy but slow (e.g. a cold * pooler connection, #1964). Consumers treat this as usable. @@ -47,6 +49,7 @@ export type LocalEngineStatus = | "missing-config" | "broken-config" | "broken-db" + | "engine-locked" | "timeout"; export interface ClassifyOptions { @@ -118,6 +121,15 @@ function gbrainConfigPath(env?: NodeJS.ProcessEnv): string { return join(gbrainHome, "config.json"); } +function configuredEngine(env?: NodeJS.ProcessEnv): "pglite" | "postgres" | null { + try { + const parsed = JSON.parse(readFileSync(gbrainConfigPath(env), "utf-8")) as { engine?: string }; + return parsed.engine === "pglite" || parsed.engine === "postgres" ? parsed.engine : null; + } catch { + return null; + } +} + function hashPath(p: string): string { return createHash("sha256").update(p).digest("hex").slice(0, 16); } @@ -281,6 +293,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { stderr?: Buffer | string; killed?: boolean; signal?: NodeJS.Signals | null; + status?: number | null; }; const stderr = (e.stderr ? e.stderr.toString() : "") || ""; @@ -292,6 +305,14 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { if (stderr.includes("Cannot connect to database")) return "broken-db"; if (stderr.includes("config.json")) return "broken-config"; + // PGLite is single-process. A long-lived `gbrain serve` can own the + // embedded database, causing the CLI to finish with its own exit 124 and + // "connect timed out" message. This is neither our watchdog timeout nor + // evidence that the valid config is malformed (#2194). + if (stderr.includes("connect timed out") || e.status === 124) { + return configuredEngine(env) === "pglite" ? "engine-locked" : "broken-db"; + } + // Probe killed by the timeout with no recognized error: the engine is // most likely healthy but slow (cold pooler connections measured at // 6.9-10.7s in #1964). Don't tell the user their config is malformed. diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 89c2ffbc3..a3892f1c9 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -820,7 +820,7 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`, `gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`, `gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`, -`missing-config`, `broken-config`, `broken-db`, `timeout`). Treat `timeout` +`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`). Treat `timeout` like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5 remediation. diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl index f48581543..f475987c1 100644 --- a/setup-gbrain/SKILL.md.tmpl +++ b/setup-gbrain/SKILL.md.tmpl @@ -66,7 +66,7 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`, `gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`, `gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`, -`missing-config`, `broken-config`, `broken-db`, `timeout`). Treat `timeout` +`missing-config`, `broken-config`, `broken-db`, `engine-locked`, `timeout`). Treat `timeout` like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5 remediation. diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index bfaf291d2..02055bb6f 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -893,6 +893,10 @@ BEFORE invoking the orchestrator: slow (cold pooler connection, #1964). Tell the user in one line: "Engine probe timed out (>15s) — proceeding; raise `GSTACK_GBRAIN_PROBE_TIMEOUT_MS` if your pooler is slow." Do NOT treat this as a broken config. +- **`engine-locked`**: STOP. "The local PGLite database is busy, usually + because `gbrain serve` from a live Claude session owns it. Stop that process + or run `/sync-gbrain` outside the live session, then retry. This identifies + the conflict but does not remove PGLite's single-process limit." - **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain` first." - **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user diff --git a/sync-gbrain/SKILL.md.tmpl b/sync-gbrain/SKILL.md.tmpl index 2ec065472..aa97f7eb7 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -139,6 +139,10 @@ BEFORE invoking the orchestrator: slow (cold pooler connection, #1964). Tell the user in one line: "Engine probe timed out (>15s) — proceeding; raise `GSTACK_GBRAIN_PROBE_TIMEOUT_MS` if your pooler is slow." Do NOT treat this as a broken config. +- **`engine-locked`**: STOP. "The local PGLite database is busy, usually + because `gbrain serve` from a live Claude session owns it. Stop that process + or run `/sync-gbrain` outside the live session, then retry. This identifies + the conflict but does not remove PGLite's single-process limit." - **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain` first." - **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user diff --git a/test/gbrain-local-status.test.ts b/test/gbrain-local-status.test.ts index 703adfcad..0d5a44539 100644 --- a/test/gbrain-local-status.test.ts +++ b/test/gbrain-local-status.test.ts @@ -6,13 +6,14 @@ * on PATH that emits canned exit codes + stderr matching the patterns the * classifier looks for. * - * Six status cases: + * Seven status cases: * 1. no-cli — gbrain absent from PATH * 2. missing-config — gbrain present, config.json absent (honors GBRAIN_HOME) * 3. broken-config — gbrain present, config exists, stderr contains "config.json" * 4. broken-db — gbrain present, config exists, stderr contains "Cannot connect to database" * 5. timeout — probe exceeds GSTACK_GBRAIN_PROBE_TIMEOUT_MS with no recognized error (#1964) - * 6. ok — gbrain present, config exists, sources list returns valid JSON + * 6. engine-locked — PGLite CLI exits 124 because another process owns the DB (#2194) + * 7. ok — gbrain present, config exists, sources list returns valid JSON * * Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change, * probe-timeout change), --no-cache bypass. Timeout tests keep runtime sane by @@ -61,7 +62,7 @@ interface FakeEnv { */ function makeEnv(opts: { withGbrain?: boolean; - gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws" | "slow"; + gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow"; withConfig?: boolean; }): FakeEnv { const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-")); @@ -102,7 +103,7 @@ function makeEnv(opts: { } function makeFakeGbrainScript( - behavior: "ok" | "broken-db" | "broken-config" | "throws" | "slow", + behavior: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow", ): string { // "slow": healthy engine on a cold pooler connection (#1964) — sleeps past // the (test-lowered) probe timeout, then would answer fine. @@ -125,10 +126,12 @@ exit 0 ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' : behavior === "broken-config" ? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2' + : behavior === "engine-locked" + ? 'echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2' : behavior === "throws" ? 'echo "unexpected gbrain failure" >&2' : ""; - const exitCode = behavior === "ok" ? 0 : 1; + const exitCode = behavior === "ok" ? 0 : behavior === "engine-locked" ? 124 : 1; return `#!/bin/sh if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0" @@ -225,6 +228,19 @@ describe("lib/gbrain-local-status — status classification", () => { expect(localEngineStatus({ noCache: true })).toBe("broken-config"); }); + it("returns 'engine-locked' when PGLite exits 124 with its own connect timeout (#2194)", () => { + env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true }); + restoreEnv = applyEnv(env); + expect(localEngineStatus({ noCache: true })).toBe("engine-locked"); + }); + + it("classifies a non-PGLite connect timeout as unreachable DB, not malformed config", () => { + env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true }); + restoreEnv = applyEnv(env); + writeFileSync(env.configPath, JSON.stringify({ engine: "postgres", database_url: "postgres://fake" })); + expect(localEngineStatus({ noCache: true })).toBe("broken-db"); + }); + it("returns 'ok' when sources list succeeds", () => { env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true }); restoreEnv = applyEnv(env); diff --git a/test/gbrain-sync-skip.test.ts b/test/gbrain-sync-skip.test.ts index 1d7423c98..902256b3b 100644 --- a/test/gbrain-sync-skip.test.ts +++ b/test/gbrain-sync-skip.test.ts @@ -42,7 +42,7 @@ interface FakeEnv { */ function makeEnv(opts: { withGbrain: boolean; - gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "slow"; + gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "slow"; withConfig: boolean; }): FakeEnv { const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-")); @@ -75,6 +75,9 @@ function makeEnv(opts: { : behavior === "ok" ? ` echo '{"sources":[]}' exit 0` + : behavior === "engine-locked" + ? ` echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2 + exit 124` : ` ${ behavior === "broken-db" ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' @@ -207,6 +210,20 @@ describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => { } }); + it("SKIPs with actionable guidance when PGLite is held by gbrain serve (#2194)", () => { + const env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true }); + try { + const r = runOrchestrator(env, ["--code-only"]); + const out = r.stdout + r.stderr; + expect(out).toContain("local engine engine-locked"); + expect(out).toContain("gbrain serve"); + expect(out).toContain("outside the live Claude session"); + expect(out).not.toContain("config.json is malformed"); + } finally { + env.cleanup(); + } + }); + it("SKIPs code stage when gbrain CLI is missing (no-cli)", () => { const env = makeEnv({ withGbrain: false, withConfig: false }); try {