Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/gstack-gbrain-detect
Original file line number Diff line number Diff line change
Expand Up @@ -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
* }
*
Expand Down
3 changes: 3 additions & 0 deletions bin/gstack-gbrain-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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",
};
Expand Down
21 changes: 21 additions & 0 deletions lib/gbrain-local-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -47,6 +49,7 @@ export type LocalEngineStatus =
| "missing-config"
| "broken-config"
| "broken-db"
| "engine-locked"
| "timeout";

export interface ClassifyOptions {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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() : "") || "";

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion setup-gbrain/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion setup-gbrain/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions sync-gbrain/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions sync-gbrain/SKILL.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions test/gbrain-local-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-"));
Expand Down Expand Up @@ -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.
Expand All @@ -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"
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 18 additions & 1 deletion test/gbrain-sync-skip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down