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
parent 8e4662e930
commit da06ef1504
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
10 changed files with 278 additions and 54 deletions

View File

@ -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();

View File

@ -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<LocalEngineStatus, "ok">];
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> {
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
// 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<StageResult> {
}
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);
}

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)
* 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";

View File

@ -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 };
}

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`,
`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:

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`,
`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:

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -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<string, string> = {},
): { 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 {