diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 4eee753da..54f0377c3 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", + * "gbrain_local_status": "ok"|"no-cli"|"missing-config"|"broken-config"|"broken-db"|"timeout", * "gbrain_pooler_mode": "transaction"|"session"|null * } * @@ -234,14 +234,17 @@ function main(): void { process.stdout.write(JSON.stringify(out, null, 2) + "\n"); } -// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok"), 1 -// otherwise. Runs detection live (never reads the possibly-stale -// gbrain-detection.json), so callers — setup, 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. +// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok", or +// "timeout" — a slow-but-healthy engine, #1964 — slow must not silently +// suppress brain features), 1 otherwise. Runs detection live (never reads +// the possibly-stale gbrain-detection.json), so callers — setup, +// 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")) { 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(); diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 1150d5c4e..e55cec0ad 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -717,6 +717,8 @@ 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" + * timeout → kept for Record totality; stages PROCEED on timeout (#1964) + * via the gate's warnProbeTimeout path, never this skip. */ function skipStageForLocalStatus( stage: "code" | "memory" | "dream", @@ -731,6 +733,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", + "timeout": + "engine probe timed out; raise GSTACK_GBRAIN_PROBE_TIMEOUT_MS if your pooler is slow", }; const reason = reasons[status as Exclude]; 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 { const t0 = Date.now(); @@ -773,7 +791,9 @@ async function runCodeImport(args: CliArgs): Promise { // when the local DB is dead. Skipped on --dry-run (above) since dry-run // never actually probes anything. 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); } @@ -1031,7 +1051,9 @@ function runMemoryIngest(args: CliArgs): StageResult { // not ok, SKIP cleanly so brain-sync (the only stage that doesn't depend // on local engine) still runs. 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); } @@ -1193,7 +1215,9 @@ export async function runDream(args: CliArgs): Promise { } 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); } diff --git a/lib/gbrain-local-status.ts b/lib/gbrain-local-status.ts index f6332cf6b..92db47bc4 100644 --- a/lib/gbrain-local-status.ts +++ b/lib/gbrain-local-status.ts @@ -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) * 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. * Uses the same stderr patterns as lib/gbrain-sources.ts:66-67. * - 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 * state-mutating operation so the next read sees fresh status. * * 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 * (or any non-recognized error — defensive default per codex #8). * 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. */ @@ -42,7 +46,8 @@ export type LocalEngineStatus = | "no-cli" | "missing-config" | "broken-config" - | "broken-db"; + | "broken-db" + | "timeout"; export interface ClassifyOptions { /** 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. */ key: { home: string; + gbrain_home: string; // honors GBRAIN_HOME (#1964 / codex D11) path_hash: string; gbrain_bin_path: string; gbrain_version: string; config_mtime: 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 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). */ -function userHome(): string { - return process.env.HOME || homedir(); +function userHome(env?: NodeJS.ProcessEnv): string { + return (env ?? process.env).HOME || homedir(); } /** 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 { - return join(userHome(), ".gbrain", "config.json"); +/** Honors GBRAIN_HOME (codex D11) — same resolution as buildGbrainEnv. */ +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 { @@ -146,9 +169,9 @@ export function readGbrainVersion(env?: NodeJS.ProcessEnv): string { return result; } -function configFingerprint(): { mtime: number; size: number } { +function configFingerprint(env?: NodeJS.ProcessEnv): { mtime: number; size: number } { try { - const st = statSync(gbrainConfigPath()); + const st = statSync(gbrainConfigPath(env)); return { mtime: Math.floor(st.mtimeMs), size: st.size }; } catch { return { mtime: 0, size: 0 }; @@ -161,25 +184,29 @@ function buildCacheKey( env?: NodeJS.ProcessEnv, ): CacheEntry["key"] { const e = env ?? process.env; - const config = configFingerprint(); + const config = configFingerprint(e); return { home: e.HOME || "", + gbrain_home: e.GBRAIN_HOME || "", path_hash: hashPath(e.PATH || ""), gbrain_bin_path: gbrainBin || "", gbrain_version: gbrainVersion, config_mtime: config.mtime, config_size: config.size, + probe_timeout_ms: probeTimeoutMs(e), }; } function keysEqual(a: CacheEntry["key"], b: CacheEntry["key"]): boolean { return ( a.home === b.home && + a.gbrain_home === b.gbrain_home && a.path_hash === b.path_hash && a.gbrain_bin_path === b.gbrain_bin_path && a.gbrain_version === b.gbrain_version && 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"; // 2. Config file present? - if (!existsSync(gbrainConfigPath())) return "missing-config"; + if (!existsSync(gbrainConfigPath(env))) return "missing-config"; // 3. Probe gbrain sources list. // @@ -240,14 +267,18 @@ function freshClassify(env?: NodeJS.ProcessEnv): LocalEngineStatus { try { execFileSync("gbrain", ["sources", "list", "--json"], { encoding: "utf-8", - timeout: PROBE_TIMEOUT_MS, + timeout: probeTimeoutMs(env), stdio: ["ignore", "pipe", "pipe"], env: buildGbrainEnv({ baseEnv: env ?? process.env }), shell: NEEDS_SHELL_ON_WINDOWS, // #1731: gbrain is a .cmd shim on Windows }); return "ok"; } 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() : "") || ""; // 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("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 // broken-config so the user sees the raw stderr surfaced upstream. return "broken-config"; diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 45f617a1b..71aa1a34c 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -47,7 +47,9 @@ function loadGbrainOverride(): { detected: boolean } { const detectionPath = path.join(stateDir, 'gbrain-detection.json'); try { 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 { return { detected: false }; } diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 2b6c3a666..f146d44fd 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -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`, `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`). +`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 one line so the user knows what you found: diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl index efc52c04c..f48581543 100644 --- a/setup-gbrain/SKILL.md.tmpl +++ b/setup-gbrain/SKILL.md.tmpl @@ -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`, `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`). +`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 one line so the user knows what you found: diff --git a/sync-gbrain/SKILL.md b/sync-gbrain/SKILL.md index 2098309ad..723951b9a 100644 --- a/sync-gbrain/SKILL.md +++ b/sync-gbrain/SKILL.md @@ -847,6 +847,10 @@ Read `gbrain_local_status` from the Step 1 detect output. Branch as follows BEFORE invoking the orchestrator: - **`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` 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 b6cc36816..2ec065472 100644 --- a/sync-gbrain/SKILL.md.tmpl +++ b/sync-gbrain/SKILL.md.tmpl @@ -135,6 +135,10 @@ Read `gbrain_local_status` from the Step 1 detect output. Branch as follows BEFORE invoking the orchestrator: - **`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` 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 90744bb2c..d85e3ea89 100644 --- a/test/gbrain-local-status.test.ts +++ b/test/gbrain-local-status.test.ts @@ -6,15 +6,17 @@ * on PATH that emits canned exit codes + stderr matching the patterns the * classifier looks for. * - * Five status cases: + * Six status cases: * 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" * 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. timeout — probe 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), - * --no-cache bypass. + * Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change, + * 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"; @@ -31,10 +33,14 @@ import { import { tmpdir } from "os"; import { join } from "path"; +import { spawnSync } from "child_process"; + import { localEngineStatus, cacheFilePath, + probeTimeoutMs, CACHE_TTL_MS, + DEFAULT_PROBE_TIMEOUT_MS, type LocalEngineStatus, } from "../lib/gbrain-local-status"; @@ -55,7 +61,7 @@ interface FakeEnv { */ function makeEnv(opts: { withGbrain?: boolean; - gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws"; + gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws" | "slow"; withConfig?: boolean; }): FakeEnv { const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-")); @@ -96,8 +102,24 @@ function makeEnv(opts: { } function makeFakeGbrainScript( - behavior: "ok" | "broken-db" | "broken-config" | "throws", + behavior: "ok" | "broken-db" | "broken-config" | "throws" | "slow", ): 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 = behavior === "broken-db" ? '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, PATH: process.env.PATH, 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.PATH = `${env.bindir}:/usr/bin:/bin`; process.env.GSTACK_HOME = env.gstackHome; + delete process.env.GBRAIN_HOME; + delete process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS; return () => { - if (prev.HOME === undefined) delete process.env.HOME; - else process.env.HOME = prev.HOME; - if (prev.PATH === undefined) delete process.env.PATH; - else process.env.PATH = prev.PATH; - if (prev.GSTACK_HOME === undefined) delete process.env.GSTACK_HOME; - else process.env.GSTACK_HOME = prev.GSTACK_HOME; + for (const [k, v] of Object.entries(prev)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } }; } @@ -206,6 +230,73 @@ describe("lib/gbrain-local-status — five status cases", () => { restoreEnv = applyEnv(env); 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", () => { @@ -275,6 +366,31 @@ describe("lib/gbrain-local-status — cache behavior", () => { 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)", () => { 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 c7fbabbe0..88ce924a8 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"; + gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "slow"; withConfig: boolean; }): FakeEnv { const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-")); @@ -65,19 +65,26 @@ function makeEnv(opts: { if (opts.withGbrain) { const behavior = opts.gbrainBehavior || "ok"; - const stderrLine = - behavior === "broken-db" - ? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2' - : behavior === "broken-config" - ? 'echo "Error: malformed config.json" >&2' - : ""; - const exitCode = behavior === "ok" ? 0 : 1; + // "slow": healthy engine, cold pooler connection (#1964) — sleeps past the + // (test-lowered) probe timeout on `sources list`, then answers fine. + const sourcesBlock = + behavior === "slow" + ? ` sleep 2 + echo '{"sources":[]}' + 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 if [ "$1" = "--version" ]; then echo "gbrain 0.33.1.0"; exit 0; fi if [ "$1 $2" = "sources list" ]; then - if [ ${exitCode} -eq 0 ]; then echo '{"sources":[]}'; exit 0; fi - ${stderrLine} - exit ${exitCode} +${sourcesBlock} fi if [ "$1" = "--help" ]; then echo " import"; exit 0; fi 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 = {}, +): { stdout: string; stderr: string; exitCode: number } { // 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). 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, GSTACK_HOME: env.gstackHome, PATH: `${env.bindir}:/usr/bin:/bin`, + ...extraEnv, }, }); return { @@ -123,6 +135,23 @@ function runOrchestrator(env: FakeEnv, args: string[]): { stdout: string; stderr } 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", () => { const env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true }); try {