mirror of https://github.com/garrytan/gstack.git
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:
parent
8e4662e930
commit
da06ef1504
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue