mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 15:09:00 +02:00
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:
co-authored by
Claude Fable 5
parent
8e4662e930
commit
da06ef1504
+54
-16
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user