mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-18 21:47:20 +02:00
fix(gbrain): classify PGLite connect locks
This commit is contained in:
@@ -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
|
||||
* }
|
||||
*
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user