fix(gbrain): classify probe timeout as its own status; sync proceeds instead of skipping (#1964)

The 5s engine probe misclassified healthy-but-slow engines (cold Supabase
pooler connections measured at 6.9-10.7s) as broken-config, so /sync-gbrain
silently skipped code+memory and told the user their config was malformed.

- New "timeout" status: probe killed at the deadline with no recognized
  stderr pattern. Default deadline is now 15s, overridable via
  GSTACK_GBRAIN_PROBE_TIMEOUT_MS (tests set 300ms against a fake that
  sleeps 2s).
- Sync stages PROCEED on timeout with a stderr warning naming the env knob;
  a genuinely-dead engine surfaces its real error at the first operation
  instead of a false config diagnosis.
- Consistency everywhere "ok" gated behavior: gstack-gbrain-detect --is-ok
  exits 0 on timeout, and gen-skill-docs' detection gate accepts it, so a
  slow engine no longer silently suppresses brain-aware features.
- Status cache: key now includes the effective probe timeout (raising it
  invalidates a cached timeout) and GBRAIN_HOME; config detection honors
  GBRAIN_HOME so relocated-home users stop being misclassified as
  missing-config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-11 20:27:46 -07:00
co-authored by Claude Fable 5
parent 8e4662e930
commit da06ef1504
10 changed files with 278 additions and 54 deletions
+10 -7
View File
@@ -18,7 +18,7 @@
* "gstack_brain_sync_mode": "off"|"artifacts-only"|"full", * "gstack_brain_sync_mode": "off"|"artifacts-only"|"full",
* "gstack_brain_git": true|false, * "gstack_brain_git": true|false,
* "gstack_artifacts_remote": "https://..." | "", * "gstack_artifacts_remote": "https://..." | "",
* "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db", * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"timeout",
* "gbrain_pooler_mode": "transaction"|"session"|null * "gbrain_pooler_mode": "transaction"|"session"|null
* } * }
* *
@@ -234,14 +234,17 @@ function main(): void {
process.stdout.write(JSON.stringify(out, null, 2) + "\n"); process.stdout.write(JSON.stringify(out, null, 2) + "\n");
} }
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok"), 1 // --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok", or
// otherwise. Runs detection live (never reads the possibly-stale // "timeout" — a slow-but-healthy engine, #1964 — slow must not silently
// gbrain-detection.json), so callers — setup, bin/dev-setup, and // suppress brain features), 1 otherwise. Runs detection live (never reads
// `gstack-config gbrain-refresh` — can decide whether to render the gbrain // the possibly-stale gbrain-detection.json), so callers — setup,
// :user variant without duplicating the JSON grep. Prints nothing on stdout. // bin/dev-setup, and `gstack-config gbrain-refresh` — can decide whether to
// render the gbrain :user variant without duplicating the JSON grep.
// Prints nothing on stdout.
if (process.argv.includes("--is-ok")) { if (process.argv.includes("--is-ok")) {
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1"; const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
process.exit(localEngineStatus({ noCache }) === "ok" ? 0 : 1); const status = localEngineStatus({ noCache });
process.exit(status === "ok" || status === "timeout" ? 0 : 1);
} }
main(); main();
+27 -3
View File
@@ -717,6 +717,8 @@ function dreamMarkerPid(): number | null {
* missing-config → "no local engine; run /setup-gbrain to add local PGLite" * 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-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" * broken-db → "config points at unreachable DB; see /setup-gbrain Step 1.5"
* timeout → kept for Record totality; stages PROCEED on timeout (#1964)
* via the gate's warnProbeTimeout path, never this skip.
*/ */
function skipStageForLocalStatus( function skipStageForLocalStatus(
stage: "code" | "memory" | "dream", stage: "code" | "memory" | "dream",
@@ -731,6 +733,8 @@ function skipStageForLocalStatus(
"config at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5", "config at ~/.gbrain/config.json is malformed; see /setup-gbrain Step 1.5",
"broken-db": "broken-db":
"config points at unreachable DB; see /setup-gbrain Step 1.5", "config points at unreachable DB; see /setup-gbrain Step 1.5",
"timeout":
"engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow",
}; };
const reason = reasons[status as Exclude<LocalEngineStatus, "ok">]; const reason = reasons[status as Exclude<LocalEngineStatus, "ok">];
return { return {
@@ -742,6 +746,20 @@ function skipStageForLocalStatus(
}; };
} }
/**
* "timeout" means the probe hit its deadline with no recognized error — the
* engine is most likely healthy but slow (#1964: cold pooler connections
* measured at 6.9-10.7s). Stages proceed; a genuinely-dead engine surfaces
* its REAL error at the first actual operation instead of a false
* "config malformed" skip.
*/
function warnProbeTimeout(stage: "code" | "memory" | "dream"): void {
process.stderr.write(
`[gstack-gbrain-sync] ${stage}: engine probe timed out — proceeding anyway; ` +
`raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow\n`,
);
}
async function runCodeImport(args: CliArgs): Promise<StageResult> { async function runCodeImport(args: CliArgs): Promise<StageResult> {
const t0 = Date.now(); const t0 = Date.now();
@@ -773,7 +791,9 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
// when the local DB is dead. Skipped on --dry-run (above) since dry-run // when the local DB is dead. Skipped on --dry-run (above) since dry-run
// never actually probes anything. // never actually probes anything.
const localStatus = localEngineStatus({ noCache: false }); const localStatus = localEngineStatus({ noCache: false });
if (localStatus !== "ok") { if (localStatus === "timeout") {
warnProbeTimeout("code"); // #1964: slow-but-healthy — proceed
} else if (localStatus !== "ok") {
return skipStageForLocalStatus("code", localStatus, t0); return skipStageForLocalStatus("code", localStatus, t0);
} }
@@ -1031,7 +1051,9 @@ function runMemoryIngest(args: CliArgs): StageResult {
// not ok, SKIP cleanly so brain-sync (the only stage that doesn't depend // not ok, SKIP cleanly so brain-sync (the only stage that doesn't depend
// on local engine) still runs. // on local engine) still runs.
const localStatus = localEngineStatus({ noCache: false }); const localStatus = localEngineStatus({ noCache: false });
if (localStatus !== "ok") { if (localStatus === "timeout") {
warnProbeTimeout("memory"); // #1964: slow-but-healthy — proceed
} else if (localStatus !== "ok") {
return skipStageForLocalStatus("memory", localStatus, t0); return skipStageForLocalStatus("memory", localStatus, t0);
} }
@@ -1193,7 +1215,9 @@ export async function runDream(args: CliArgs): Promise<StageResult> {
} }
const localStatus = localEngineStatus({ noCache: false }); const localStatus = localEngineStatus({ noCache: false });
if (localStatus !== "ok") { if (localStatus === "timeout") {
warnProbeTimeout("dream"); // #1964: slow-but-healthy — proceed
} else if (localStatus !== "ok") {
return skipStageForLocalStatus("dream", localStatus, t0); return skipStageForLocalStatus("dream", localStatus, t0);
} }
+54 -16
View File
@@ -1,5 +1,5 @@
/** /**
* gbrain-local-status — classify the local gbrain engine into 5 states. * gbrain-local-status — classify the local gbrain engine into 6 states.
* *
* Shared between bin/gstack-gbrain-detect (preamble probe on every skill start) * Shared between bin/gstack-gbrain-detect (preamble probe on every skill start)
* and bin/gstack-gbrain-sync.ts (orchestrator SKIP-when-not-ok semantics). * and bin/gstack-gbrain-sync.ts (orchestrator SKIP-when-not-ok semantics).
@@ -9,15 +9,19 @@
* - Probe: `gbrain sources list --json`. Cheap (~80ms), actually hits the DB. * - Probe: `gbrain sources list --json`. Cheap (~80ms), actually hits the DB.
* Uses the same stderr patterns as lib/gbrain-sources.ts:66-67. * Uses the same stderr patterns as lib/gbrain-sources.ts:66-67.
* - Cache: 60s TTL at ~/.gstack/.gbrain-local-status-cache.json, keyed on * - Cache: 60s TTL at ~/.gstack/.gbrain-local-status-cache.json, keyed on
* {home, path_hash, gbrain_bin_path, gbrain_version, config_mtime}. * {home, gbrain_home, path_hash, gbrain_bin_path, gbrain_version,
* config_mtime, probe_timeout_ms}.
* - --no-cache bypass: /setup-gbrain and /sync-gbrain pass it after any * - --no-cache bypass: /setup-gbrain and /sync-gbrain pass it after any
* state-mutating operation so the next read sees fresh status. * state-mutating operation so the next read sees fresh status.
* *
* No-cli → gbrain not on PATH. * No-cli → gbrain not on PATH.
* Missing → CLI present, ~/.gbrain/config.json absent. * Missing → CLI present, config.json absent (honors GBRAIN_HOME).
* Broken-config → config exists but `gbrain sources list` fails with config parse error * Broken-config → config exists but `gbrain sources list` fails with config parse error
* (or any non-recognized error — defensive default per codex #8). * (or any non-recognized error — defensive default per codex #8).
* Broken-db → config exists, DB unreachable per stderr classification. * Broken-db → config exists, DB unreachable per stderr classification.
* 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.
* Ok → DB reachable, sources list returned valid JSON. * Ok → DB reachable, sources list returned valid JSON.
*/ */
@@ -42,7 +46,8 @@ export type LocalEngineStatus =
| "no-cli" | "no-cli"
| "missing-config" | "missing-config"
| "broken-config" | "broken-config"
| "broken-db"; | "broken-db"
| "timeout";
export interface ClassifyOptions { export interface ClassifyOptions {
/** Bypass the 60s cache. Used after any state-mutating operation. */ /** Bypass the 60s cache. Used after any state-mutating operation. */
@@ -64,20 +69,35 @@ interface CacheEntry {
/** Cache invariants — entry is invalidated if any of these change between writes. */ /** Cache invariants — entry is invalidated if any of these change between writes. */
key: { key: {
home: string; home: string;
gbrain_home: string; // honors GBRAIN_HOME (#1964 / codex D11)
path_hash: string; path_hash: string;
gbrain_bin_path: string; gbrain_bin_path: string;
gbrain_version: string; gbrain_version: string;
config_mtime: number; // 0 when config absent config_mtime: number; // 0 when config absent
config_size: number; // 0 when config absent config_size: number; // 0 when config absent
probe_timeout_ms: number; // raising the timeout invalidates a cached "timeout"
}; };
} }
export const CACHE_TTL_MS = 60_000; export const CACHE_TTL_MS = 60_000;
export const PROBE_TIMEOUT_MS = 5_000; export const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
/**
* Effective probe timeout. `GSTACK_GBRAIN_PROBE_TIMEOUT_MS` overrides the
* 15s default (tests set it low; users with slow poolers raise it).
* Non-numeric or non-positive values fall back to the default.
*/
export function probeTimeoutMs(env?: NodeJS.ProcessEnv): number {
const raw = (env ?? process.env).GSTACK_GBRAIN_PROBE_TIMEOUT_MS;
if (!raw) return DEFAULT_PROBE_TIMEOUT_MS;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PROBE_TIMEOUT_MS;
return Math.floor(parsed);
}
/** Effective user home — respects HOME env override (used by tests). */ /** Effective user home — respects HOME env override (used by tests). */
function userHome(): string { function userHome(env?: NodeJS.ProcessEnv): string {
return process.env.HOME || homedir(); return (env ?? process.env).HOME || homedir();
} }
/** Cache path computed fresh on each call so tests can mutate GSTACK_HOME per case. */ /** Cache path computed fresh on each call so tests can mutate GSTACK_HOME per case. */
@@ -88,8 +108,11 @@ export function cacheFilePath(): string {
); );
} }
function gbrainConfigPath(): string { /** Honors GBRAIN_HOME (codex D11) — same resolution as buildGbrainEnv. */
return join(userHome(), ".gbrain", "config.json"); function gbrainConfigPath(env?: NodeJS.ProcessEnv): string {
const e = env ?? process.env;
const gbrainHome = e.GBRAIN_HOME || join(userHome(e), ".gbrain");
return join(gbrainHome, "config.json");
} }
function hashPath(p: string): string { function hashPath(p: string): string {
@@ -146,9 +169,9 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string {
return result; return result;
} }
function configFingerprint(): { mtime: number; size: number } { function configFingerprint(env?: NodeJS.ProcessEnv): { mtime: number; size: number } {
try { try {
const st = statSync(gbrainConfigPath()); const st = statSync(gbrainConfigPath(env));
return { mtime: Math.floor(st.mtimeMs), size: st.size }; return { mtime: Math.floor(st.mtimeMs), size: st.size };
} catch { } catch {
return { mtime: 0, size: 0 }; return { mtime: 0, size: 0 };
@@ -161,25 +184,29 @@ function buildCacheKey(
env?: NodeJS.ProcessEnv, env?: NodeJS.ProcessEnv,
): CacheEntry["key"] { ): CacheEntry["key"] {
const e = env ?? process.env; const e = env ?? process.env;
const config = configFingerprint(); const config = configFingerprint(e);
return { return {
home: e.HOME || "", home: e.HOME || "",
gbrain_home: e.GBRAIN_HOME || "",
path_hash: hashPath(e.PATH || ""), path_hash: hashPath(e.PATH || ""),
gbrain_bin_path: gbrainBin || "", gbrain_bin_path: gbrainBin || "",
gbrain_version: gbrainVersion, gbrain_version: gbrainVersion,
config_mtime: config.mtime, config_mtime: config.mtime,
config_size: config.size, config_size: config.size,
probe_timeout_ms: probeTimeoutMs(e),
}; };
} }
function keysEqual(a: CacheEntry["key"], b: CacheEntry["key"]): boolean { function keysEqual(a: CacheEntry["key"], b: CacheEntry["key"]): boolean {
return ( return (
a.home === b.home && a.home === b.home &&
a.gbrain_home === b.gbrain_home &&
a.path_hash === b.path_hash && a.path_hash === b.path_hash &&
a.gbrain_bin_path === b.gbrain_bin_path && a.gbrain_bin_path === b.gbrain_bin_path &&
a.gbrain_version === b.gbrain_version && a.gbrain_version === b.gbrain_version &&
a.config_mtime === b.config_mtime && a.config_mtime === b.config_mtime &&
a.config_size === b.config_size a.config_size === b.config_size &&
a.probe_timeout_ms === b.probe_timeout_ms
); );
} }
@@ -226,7 +253,7 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
if (!gbrainBin) return "no-cli"; if (!gbrainBin) return "no-cli";
// 2. Config file present? // 2. Config file present?
if (!existsSync(gbrainConfigPath())) return "missing-config"; if (!existsSync(gbrainConfigPath(env))) return "missing-config";
// 3. Probe gbrain sources list. // 3. Probe gbrain sources list.
// //
@@ -240,14 +267,18 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
try { try {
execFileSync("gbrain", ["sources", "list", "--json"], { execFileSync("gbrain", ["sources", "list", "--json"], {
encoding: "utf-8", encoding: "utf-8",
timeout: PROBE_TIMEOUT_MS, timeout: probeTimeoutMs(env),
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: buildGbrainEnv({ baseEnv: env ?? process.env }), env: buildGbrainEnv({ baseEnv: env ?? process.env }),
shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows
}); });
return "ok"; return "ok";
} catch (err) { } catch (err) {
const e = err as NodeJS.ErrnoException & { stderr?: Buffer | string }; const e = err as NodeJS.ErrnoException & {
stderr?: Buffer | string;
killed?: boolean;
signal?: NodeJS.Signals | null;
};
const stderr = (e.stderr ? e.stderr.toString() : "") || ""; const stderr = (e.stderr ? e.stderr.toString() : "") || "";
// ENOENT can happen if gbrain disappeared between resolveGbrainBin and now. // ENOENT can happen if gbrain disappeared between resolveGbrainBin and now.
@@ -258,6 +289,13 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus {
if (stderr.includes("Cannot connect to database")) return "broken-db"; if (stderr.includes("Cannot connect to database")) return "broken-db";
if (stderr.includes("config.json")) return "broken-config"; if (stderr.includes("config.json")) return "broken-config";
// 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.
if (e.killed === true || e.signal === "SIGTERM" || e.code === "ETIMEDOUT") {
return "timeout";
}
// Defensive default per codex #8: unrecognized failures classify as // Defensive default per codex #8: unrecognized failures classify as
// broken-config so the user sees the raw stderr surfaced upstream. // broken-config so the user sees the raw stderr surfaced upstream.
return "broken-config"; return "broken-config";
+3 -1
View File
@@ -47,7 +47,9 @@ function loadGbrainOverride(): { detected: boolean } {
const detectionPath = path.join(stateDir, 'gbrain-detection.json'); const detectionPath = path.join(stateDir, 'gbrain-detection.json');
try { try {
const json = JSON.parse(fs.readFileSync(detectionPath, 'utf-8')) as { gbrain_local_status?: string }; const json = JSON.parse(fs.readFileSync(detectionPath, 'utf-8')) as { gbrain_local_status?: string };
return { detected: json.gbrain_local_status === 'ok' }; // "timeout" = slow-but-healthy engine (#1964) — same treatment as "ok",
// matching gstack-gbrain-detect --is-ok.
return { detected: json.gbrain_local_status === 'ok' || json.gbrain_local_status === 'timeout' };
} catch { } catch {
return { detected: false }; return { detected: false };
} }
+3 -1
View File
@@ -778,7 +778,9 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`,
`gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`, `gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`,
`gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and `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`, the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`,
`missing-config`, `broken-config`, `broken-db`). `missing-config`, `broken-config`, `broken-db`, `timeout`). Treat `timeout`
like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5
remediation.
Skip downstream steps that are already done. Report the detected state in Skip downstream steps that are already done. Report the detected state in
one line so the user knows what you found: one line so the user knows what you found:
+3 -1
View File
@@ -66,7 +66,9 @@ Capture the JSON output. It contains: `gbrain_on_path`, `gbrain_version`,
`gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`, `gbrain_config_exists`, `gbrain_engine`, `gbrain_doctor_ok`, `gbrain_mcp_mode`,
`gstack_brain_sync_mode`, `gstack_brain_git`, `gstack_artifacts_remote`, and `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`, the v1.34.0.0+ `gbrain_local_status` field (one of: `ok`, `no-cli`,
`missing-config`, `broken-config`, `broken-db`). `missing-config`, `broken-config`, `broken-db`, `timeout`). Treat `timeout`
like `ok` (slow-but-healthy engine, #1964) — it never triggers Step 1.5
remediation.
Skip downstream steps that are already done. Report the detected state in Skip downstream steps that are already done. Report the detected state in
one line so the user knows what you found: one line so the user knows what you found:
+4
View File
@@ -847,6 +847,10 @@ Read `gbrain_local_status` from the Step 1 detect output. Branch as follows
BEFORE invoking the orchestrator: BEFORE invoking the orchestrator:
- **`ok`**: proceed to Step 2 normally. - **`ok`**: proceed to Step 2 normally.
- **`timeout`**: proceed to Step 2 — the engine is most likely healthy but
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.
- **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain` - **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain`
first." first."
- **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user - **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user
+4
View File
@@ -135,6 +135,10 @@ Read `gbrain_local_status` from the Step 1 detect output. Branch as follows
BEFORE invoking the orchestrator: BEFORE invoking the orchestrator:
- **`ok`**: proceed to Step 2 normally. - **`ok`**: proceed to Step 2 normally.
- **`timeout`**: proceed to Step 2 — the engine is most likely healthy but
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.
- **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain` - **`no-cli`**: STOP. "Local gbrain CLI not installed. Run `/setup-gbrain`
first." first."
- **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user - **`missing-config`** AND `gbrain_mcp_mode == "remote-http"`: tell the user
+129 -13
View File
@@ -6,15 +6,17 @@
* on PATH that emits canned exit codes + stderr matching the patterns the * on PATH that emits canned exit codes + stderr matching the patterns the
* classifier looks for. * classifier looks for.
* *
* Five status cases: * Six status cases:
* 1. no-cli — gbrain absent from PATH * 1. no-cli — gbrain absent from PATH
* 2. missing-config — gbrain present, ~/.gbrain/config.json absent * 2. missing-config — gbrain present, config.json absent (honors GBRAIN_HOME)
* 3. broken-config — gbrain present, config exists, stderr contains "config.json" * 3. broken-config — gbrain present, config exists, stderr contains "config.json"
* 4. broken-db — gbrain present, config exists, stderr contains "Cannot connect to database" * 4. broken-db — gbrain present, config exists, stderr contains "Cannot connect to database"
* 5. ok gbrain present, config exists, sources list returns valid JSON * 5. timeoutprobe exceeds GSTACK_GBRAIN_PROBE_TIMEOUT_MS with no recognized error (#1964)
* 6. ok — gbrain present, config exists, sources list returns valid JSON
* *
* Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change), * Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change,
* --no-cache bypass. * probe-timeout change), --no-cache bypass. Timeout tests keep runtime sane by
* setting GSTACK_GBRAIN_PROBE_TIMEOUT_MS=300 against a fake gbrain that sleeps 2s.
*/ */
import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { describe, it, expect, beforeEach, afterEach } from "bun:test";
@@ -31,10 +33,14 @@ import {
import { tmpdir } from "os"; import { tmpdir } from "os";
import { join } from "path"; import { join } from "path";
import { spawnSync } from "child_process";
import { import {
localEngineStatus, localEngineStatus,
cacheFilePath, cacheFilePath,
probeTimeoutMs,
CACHE_TTL_MS, CACHE_TTL_MS,
DEFAULT_PROBE_TIMEOUT_MS,
type LocalEngineStatus, type LocalEngineStatus,
} from "../lib/gbrain-local-status"; } from "../lib/gbrain-local-status";
@@ -55,7 +61,7 @@ interface FakeEnv {
*/ */
function makeEnv(opts: { function makeEnv(opts: {
withGbrain?: boolean; withGbrain?: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws"; gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws" | "slow";
withConfig?: boolean; withConfig?: boolean;
}): FakeEnv { }): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-")); const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
@@ -96,8 +102,24 @@ function makeEnv(opts: {
} }
function makeFakeGbrainScript( function makeFakeGbrainScript(
behavior: "ok" | "broken-db" | "broken-config" | "throws", behavior: "ok" | "broken-db" | "broken-config" | "throws" | "slow",
): string { ): string {
// "slow": healthy engine on a cold pooler connection (#1964) — sleeps past
// the (test-lowered) probe timeout, then would answer fine.
if (behavior === "slow") {
return `#!/bin/sh
if [ "$1" = "--version" ]; then
echo "gbrain 0.33.1.0"
exit 0
fi
if [ "$1 $2" = "sources list" ]; then
sleep 2
echo '{"sources":[]}'
exit 0
fi
exit 0
`;
}
const stderrLine = const stderrLine =
behavior === "broken-db" behavior === "broken-db"
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
@@ -136,17 +158,19 @@ function applyEnv(env: FakeEnv): () => void {
HOME: process.env.HOME, HOME: process.env.HOME,
PATH: process.env.PATH, PATH: process.env.PATH,
GSTACK_HOME: process.env.GSTACK_HOME, GSTACK_HOME: process.env.GSTACK_HOME,
GBRAIN_HOME: process.env.GBRAIN_HOME,
GSTACK_GBRAIN_PROBE_TIMEOUT_MS: process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS,
}; };
process.env.HOME = env.home; process.env.HOME = env.home;
process.env.PATH = `${env.bindir}:/usr/bin:/bin`; process.env.PATH = `${env.bindir}:/usr/bin:/bin`;
process.env.GSTACK_HOME = env.gstackHome; process.env.GSTACK_HOME = env.gstackHome;
delete process.env.GBRAIN_HOME;
delete process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS;
return () => { return () => {
if (prev.HOME === undefined) delete process.env.HOME; for (const [k, v] of Object.entries(prev)) {
else process.env.HOME = prev.HOME; if (v === undefined) delete process.env[k];
if (prev.PATH === undefined) delete process.env.PATH; else process.env[k] = v;
else process.env.PATH = prev.PATH; }
if (prev.GSTACK_HOME === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = prev.GSTACK_HOME;
}; };
} }
@@ -206,6 +230,73 @@ describe("lib/gbrain-local-status — five status cases", () => {
restoreEnv = applyEnv(env); restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("ok"); expect(localEngineStatus({ noCache: true })).toBe("ok");
}); });
it("returns 'timeout' (not broken-config) when the probe exceeds the deadline (#1964)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
restoreEnv = applyEnv(env);
process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS = "300";
expect(localEngineStatus({ noCache: true })).toBe("timeout");
});
it("honors GBRAIN_HOME for config detection (codex D11)", () => {
// Config lives ONLY at the alternate GBRAIN_HOME; ~/.gbrain has none.
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
restoreEnv = applyEnv(env);
const altHome = join(env.tmp, "alt-gbrain");
mkdirSync(altHome, { recursive: true });
writeFileSync(
join(altHome, "config.json"),
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
);
// Without GBRAIN_HOME: misclassified as missing-config.
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
// With GBRAIN_HOME: the relocated config is found.
process.env.GBRAIN_HOME = altHome;
expect(localEngineStatus({ noCache: true })).toBe("ok");
});
});
describe("gstack-gbrain-detect --is-ok — timeout is usable (eng review D1)", () => {
it("exits 0 when the engine probe times out (slow-but-healthy must not suppress brain features)", () => {
const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
try {
const detect = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const r = spawnSync(process.execPath, [detect, "--is-ok"], {
encoding: "utf-8",
timeout: 20_000,
env: {
...process.env,
HOME: env.home,
GSTACK_HOME: env.gstackHome,
PATH: `${env.bindir}:/usr/bin:/bin`,
GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300",
GSTACK_DETECT_NO_CACHE: "1",
GBRAIN_HOME: "",
},
});
expect(r.status).toBe(0);
} finally {
env.cleanup();
}
});
});
describe("probeTimeoutMs — env override parsing", () => {
it("defaults to 15s when unset", () => {
expect(probeTimeoutMs({})).toBe(DEFAULT_PROBE_TIMEOUT_MS);
expect(DEFAULT_PROBE_TIMEOUT_MS).toBe(15_000);
});
it("parses a numeric override", () => {
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300" })).toBe(300);
});
it("falls back to the default on non-numeric, empty, and non-positive values", () => {
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "fast" })).toBe(DEFAULT_PROBE_TIMEOUT_MS);
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "" })).toBe(DEFAULT_PROBE_TIMEOUT_MS);
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "0" })).toBe(DEFAULT_PROBE_TIMEOUT_MS);
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "-5" })).toBe(DEFAULT_PROBE_TIMEOUT_MS);
});
}); });
describe("lib/gbrain-local-status — cache behavior", () => { describe("lib/gbrain-local-status — cache behavior", () => {
@@ -275,6 +366,31 @@ describe("lib/gbrain-local-status — cache behavior", () => {
expect(localEngineStatus({ noCache: false })).toBe("broken-db"); expect(localEngineStatus({ noCache: false })).toBe("broken-db");
}); });
it("caches a 'timeout' result (sync probes 3x/run — uncached would cost 3 deadlines)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
restoreEnv = applyEnv(env);
process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS = "300";
expect(localEngineStatus({ noCache: false })).toBe("timeout");
// Swap the fake to a fast-ok binary; the cached timeout should still win
// within TTL (same key — proving the result was cached, not re-probed).
writeFileSync(join(env.bindir, "gbrain"), makeFakeGbrainScript("ok"));
chmodSync(join(env.bindir, "gbrain"), 0o755);
expect(localEngineStatus({ noCache: false })).toBe("timeout");
});
it("invalidates a cached 'timeout' when GSTACK_GBRAIN_PROBE_TIMEOUT_MS changes (key invariant, codex D13)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
restoreEnv = applyEnv(env);
process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS = "300";
expect(localEngineStatus({ noCache: false })).toBe("timeout");
// User raises the timeout past the fake's 2s sleep: cache key changes,
// re-probe succeeds.
process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS = "5000";
expect(localEngineStatus({ noCache: false })).toBe("ok");
});
it("invalidates cache when HOME changes (key invariant)", () => { it("invalidates cache when HOME changes (key invariant)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true }); env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env); restoreEnv = applyEnv(env);
+41 -12
View File
@@ -42,7 +42,7 @@ interface FakeEnv {
*/ */
function makeEnv(opts: { function makeEnv(opts: {
withGbrain: boolean; withGbrain: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config"; gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "slow";
withConfig: boolean; withConfig: boolean;
}): FakeEnv { }): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-")); const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-"));
@@ -65,19 +65,26 @@ function makeEnv(opts: {
if (opts.withGbrain) { if (opts.withGbrain) {
const behavior = opts.gbrainBehavior || "ok"; const behavior = opts.gbrainBehavior || "ok";
const stderrLine = // "slow": healthy engine, cold pooler connection (#1964) — sleeps past the
behavior === "broken-db" // (test-lowered) probe timeout on `sources list`, then answers fine.
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' const sourcesBlock =
: behavior === "broken-config" behavior === "slow"
? 'echo "Error: malformed config.json" >&2' ? ` sleep 2
: ""; echo '{"sources":[]}'
const exitCode = behavior === "ok" ? 0 : 1; exit 0`
: behavior === "ok"
? ` echo '{"sources":[]}'
exit 0`
: ` ${
behavior === "broken-db"
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
: 'echo "Error: malformed config.json" >&2'
}
exit 1`;
const fake = `#!/bin/sh const fake = `#!/bin/sh
if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi
if [ "$1 $2" = "sources list" ]; then if [ "$1 $2" = "sources list" ]; then
if [ ${exitCode} -eq 0 ]; then echo '{"sources":[]}'; exit 0; fi ${sourcesBlock}
${stderrLine}
exit ${exitCode}
fi fi
if [ "$1" = "--help" ]; then echo " import"; exit 0; fi if [ "$1" = "--help" ]; then echo " import"; exit 0; fi
exit 0 exit 0
@@ -95,7 +102,11 @@ exit 0
}; };
} }
function runOrchestrator(env: FakeEnv, args: string[]): { stdout: string; stderr: string; exitCode: number } { function runOrchestrator(
env: FakeEnv,
args: string[],
extraEnv: Record<string, string> = {},
): { stdout: string; stderr: string; exitCode: number } {
// Initialize a git repo in the sandbox so repoRoot() finds it (otherwise // Initialize a git repo in the sandbox so repoRoot() finds it (otherwise
// code stage skips with "not in git repo" before our check ever fires). // code stage skips with "not in git repo" before our check ever fires).
spawnSync("git", ["init", "-q", env.home], { encoding: "utf-8" }); spawnSync("git", ["init", "-q", env.home], { encoding: "utf-8" });
@@ -113,6 +124,7 @@ function runOrchestrator(env: FakeEnv, args: string[]): { stdout: string; stderr
HOME: env.home, HOME: env.home,
GSTACK_HOME: env.gstackHome, GSTACK_HOME: env.gstackHome,
PATH: `${env.bindir}:/usr/bin:/bin`, PATH: `${env.bindir}:/usr/bin:/bin`,
...extraEnv,
}, },
}); });
return { return {
@@ -123,6 +135,23 @@ function runOrchestrator(env: FakeEnv, args: string[]): { stdout: string; stderr
} }
describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => { describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => {
it("PROCEEDS (with warning) when the engine probe times out — slow is not broken (#1964)", () => {
const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
try {
const r = runOrchestrator(env, ["--code-only"], {
GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300",
});
const out = r.stdout + r.stderr;
// The stage must NOT be skipped with the local-engine reason...
expect(out).not.toContain("local engine timeout");
expect(out).not.toContain("config.json is malformed");
// ...and the proceed-with-warning line must name the env knob.
expect(out).toContain("GSTACK_GBRAIN_PROBE_TIMEOUT_MS");
} finally {
env.cleanup();
}
}, 30_000); // proceeding runs the real code-import path against the slow fake (~11s)
it("SKIPs code stage when local engine is broken-db; brain-sync still attempted", () => { it("SKIPs code stage when local engine is broken-db; brain-sync still attempted", () => {
const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true }); const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true });
try { try {