mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 15:09:00 +02:00
fix(gbrain): thin-client state — remote-MCP brains no longer classify as broken-config (#2051)
A thin client (remote-HTTP MCP brain, no local engine by design) probed `gbrain sources list`, which gbrain's dispatch guard REFUSES on thin clients (exit 1, no recognized error string), so the classifier fell to its defensive broken-config default and every suppression gate silently hid brain-aware blocks from exactly the users on a shared team brain. New 'thin-client' state, detected PRE-probe from gbrain's own remote_mcp config marker via the existing gbrainConfigPath() helper (mirrors gbrain's isThinClient(); honors GBRAIN_HOME; zero network, immune to error-string drift), with a /thin[- ]client/ stderr backstop in the probe catch. Remote reachability is deliberately NOT probed by the classifier — that is the #1964 pathology; gbrain calls degrade gracefully at use time, and the detect JSON says so honestly (gbrain_thin_client: {probed: false}). The state is admitted at every suppression gate — gstack-gbrain-detect --is-ok (drives setup + gbrain-refresh), gen-skill-docs' detection override, gstack-config gbrain-refresh — while the sync stages (code/memory/dream) SKIP with an accurate reason: code indexing runs on the brain server, memory syncs via the remote brain's artifacts pull. The two consumer classes need opposite answers, which is why this is a distinct state and not a skip-the-probe special case. sync-gbrain Step 1.5 and setup-gbrain prose route thin-client to proceed, never into broken-config remediation. detectMcpMode secondary generalization: url-match against the config's remote_mcp.mcp_url (deterministic — gbrain mounts at the generic /mcp path) -> name pattern gbrain[-_]* -> stdio command token; gbrain_mcp_mode stays a 3-value enum. Tripwires: end-to-end --is-ok exits 0 on a thin-client fixture AND still exits 1 on broken-config (the gate didn't widen); pre-probe + stderr-fallback classifier paths; 4 detectMcpMode identification cases incl. a non-matching url that must NOT false-positive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a7a25aa489
commit
e742648eda
+5
-3
@@ -411,9 +411,11 @@ case "${1:-}" in
|
||||
fi
|
||||
|
||||
case "$STATUS" in
|
||||
ok|timeout)
|
||||
# "timeout" = slow-but-healthy engine (#1964) — same treatment as
|
||||
# "ok", matching gstack-gbrain-detect --is-ok and gen-skill-docs.
|
||||
ok|timeout|thin-client)
|
||||
# "timeout" = slow-but-healthy engine (#1964); "thin-client" =
|
||||
# remote-HTTP MCP brain, no local engine by design (#2051) — same
|
||||
# treatment as "ok", matching gstack-gbrain-detect --is-ok and
|
||||
# gen-skill-docs.
|
||||
echo "Detected gbrain v$VERSION (local-status: $STATUS)."
|
||||
# Render brain-aware blocks INTO the global install so EVERY project's
|
||||
# Claude sessions get them (other projects read SKILL.md + sections from
|
||||
|
||||
+59
-11
@@ -175,10 +175,13 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
// Tier 2: `claude mcp list` text-grep
|
||||
// Tier 2: `claude mcp list` text-grep. Name-pattern generalized (#2051):
|
||||
// a gbrain server registered as e.g. "gbrain-remote" or "gbrain_work"
|
||||
// still counts. Anchored to the gbrain token so unrelated servers can't
|
||||
// false-positive.
|
||||
const list = tryExec("claude", ["mcp", "list"], 3_000);
|
||||
if (list) {
|
||||
const line = list.split("\n").find((l) => /^gbrain:/.test(l));
|
||||
const line = list.split("\n").find((l) => /^gbrain([-_][\w-]*)?:/.test(l));
|
||||
if (line) {
|
||||
if (/\b(http|HTTP)\b/.test(line)) return "remote-http";
|
||||
return "local-stdio";
|
||||
@@ -186,20 +189,56 @@ function detectMcpMode(): "local-stdio" | "remote-http" | "none" {
|
||||
}
|
||||
}
|
||||
// Tier 3: read ~/.claude.json directly
|
||||
interface McpServerEntry {
|
||||
type?: string;
|
||||
transport?: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
}
|
||||
const cj = tryReadJSON(CLAUDE_JSON) as
|
||||
| { mcpServers?: { gbrain?: { type?: string; transport?: string; command?: string; url?: string } } }
|
||||
| { mcpServers?: Record<string, McpServerEntry> }
|
||||
| null;
|
||||
const entry = cj?.mcpServers?.gbrain;
|
||||
if (entry) {
|
||||
const classify = (entry: McpServerEntry): "local-stdio" | "remote-http" | null => {
|
||||
const mtype = entry.type || entry.transport || "";
|
||||
if (mtype === "url" || mtype === "http" || mtype === "sse") return "remote-http";
|
||||
if (mtype === "stdio") return "local-stdio";
|
||||
if (entry.url) return "remote-http";
|
||||
if (entry.command) return "local-stdio";
|
||||
return null;
|
||||
};
|
||||
const servers = cj?.mcpServers || {};
|
||||
const exact = servers["gbrain"];
|
||||
if (exact) {
|
||||
const c = classify(exact);
|
||||
if (c) return c;
|
||||
}
|
||||
// #2051 generalization, deterministic identifiers first:
|
||||
// (a) a server whose url matches the config's remote_mcp.mcp_url is THE
|
||||
// thin-client brain regardless of its registered name (URL-path
|
||||
// heuristics are impossible — gbrain mounts at the generic /mcp);
|
||||
// (b) name pattern gbrain[-_]* ;
|
||||
// (c) a stdio server whose command mentions gbrain.
|
||||
const remoteMcpUrl = readRemoteMcpUrl();
|
||||
for (const [name, entry] of Object.entries(servers)) {
|
||||
if (remoteMcpUrl && entry.url && entry.url === remoteMcpUrl) return "remote-http";
|
||||
if (/^gbrain([-_][\w-]*)?$/.test(name)) {
|
||||
const c = classify(entry);
|
||||
if (c) return c;
|
||||
}
|
||||
if (entry.command && /\bgbrain\b/.test(entry.command)) return "local-stdio";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
/** remote_mcp.mcp_url from gbrain's own config (thin-client marker, #2051). */
|
||||
function readRemoteMcpUrl(): string {
|
||||
const gbrainHome = process.env.GBRAIN_HOME || join(userHome(), ".gbrain");
|
||||
const cfg = tryReadJSON(join(gbrainHome, "config.json")) as
|
||||
| { remote_mcp?: { mcp_url?: string } }
|
||||
| null;
|
||||
return cfg?.remote_mcp?.mcp_url || "";
|
||||
}
|
||||
|
||||
// --- artifacts remote URL with brain-* fallback during the rename migration window ---
|
||||
function detectArtifactsRemote(): string {
|
||||
const newPath = join(userHome(), ".gstack-artifacts-remote.txt");
|
||||
@@ -237,20 +276,29 @@ function main(): void {
|
||||
gbrain_pooler_mode: detectPoolerMode(),
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
||||
// #2051 honesty marker: on a thin client the classifier verified the CONFIG
|
||||
// (remote_mcp present), not the remote's reachability — that is checked at
|
||||
// use time, where gbrain calls degrade gracefully.
|
||||
const withThinClient =
|
||||
out.gbrain_local_status === "thin-client"
|
||||
? { ...out, gbrain_thin_client: { probed: false } }
|
||||
: out;
|
||||
|
||||
process.stdout.write(JSON.stringify(withThinClient, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// --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,
|
||||
// --is-ok: live engine-status gate. Exits 0 iff gbrain is usable ("ok";
|
||||
// "timeout" — a slow-but-healthy engine, #1964; or "thin-client" — remote-HTTP
|
||||
// MCP brain with no local engine by design, #2051 — neither slow nor remote
|
||||
// must 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";
|
||||
const status = localEngineStatus({ noCache });
|
||||
process.exit(status === "ok" || status === "timeout" ? 0 : 1);
|
||||
process.exit(status === "ok" || status === "timeout" || status === "thin-client" ? 0 : 1);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -719,6 +719,9 @@ function dreamMarkerPid(): number | null {
|
||||
* 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.
|
||||
* thin-client → remote-HTTP MCP brain, no local engine by design (#2051);
|
||||
* local sync stages skip (gbrain refuses sources/sync there),
|
||||
* but suppression gates treat the brain as USABLE.
|
||||
*/
|
||||
function skipStageForLocalStatus(
|
||||
stage: "code" | "memory" | "dream",
|
||||
@@ -735,6 +738,10 @@ function skipStageForLocalStatus(
|
||||
"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",
|
||||
"thin-client":
|
||||
"thin client (remote-HTTP MCP brain, no local engine by design, #2051); " +
|
||||
"code indexing runs on the brain server, memory syncs via the remote " +
|
||||
"brain's artifacts pull — nothing to do locally",
|
||||
};
|
||||
const reason = reasons[status as Exclude<LocalEngineStatus, "ok">];
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user