fix(setup-gbrain): judge doctor health from brain checks only (#3790)

Step 5 and gstack-gbrain-detect's gbrain_doctor_ok read gbrain doctor's
overall status, so a failing skill check against a skills dir gbrain
auto-detected (~/.openclaw/workspace) stopped setup on a working brain.
Both now run doctor with --scope=brain and ignore category:skill failures
from gbrain versions that predate the flag. gbrain's 'healthy' status is
accepted too; only 'ok' and 'warnings' were before.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-01 14:11:58 +00:00
1 parent 6bffa200a5
commit b6d3adc29c
4 files changed
+203 -18

No files matched your search

+60 -10
View File
@@ -27,13 +27,19 @@
* gbrain_local_status. Key order may differ from the bash version's `jq -n`
* output — downstream parsers must not depend on key order (none currently do).
*
* Flags:
* --is-ok exit 0 iff the local engine is usable (see below).
* --doctor run `gbrain doctor --json --scope=brain` and print
* {brain_ok, status, brain_failures, skill_failures, report};
* exit 0 iff brain_ok. Skill checks never decide brain_ok (#3790).
*
* Env:
* GSTACK_HOME — override ~/.gstack for state lookups (used by tests).
* HOME — effective user home (drives ~/.gbrain/config.json path).
* GSTACK_DETECT_NO_CACHE=1 — bypass the 60s local-status cache.
*/
import { execFileSync } from "child_process";
import { execFileSync, spawnSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
@@ -118,21 +124,57 @@ function detectPoolerMode(): "transaction" | "session" | "unknown" | null {
return isTransactionModePooler(parsed.database_url) ? "transaction" : "session";
}
// --- gbrain doctor health (any nonzero exit or non-"ok"/"warnings" status → false) ---
// --- gbrain doctor health, judged from brain checks only (#3790) ---
//
// gbrain doctor's skill checks (category "skill": resolver_health,
// skill_conformance, ...) grade whatever skills dir gbrain auto-detects, which
// can be another agent's workspace such as ~/.openclaw/workspace. They say
// nothing about the brain, so they never decide health here. `--scope=brain`
// (gbrain v0.41.20+) skips that group; older gbrain ignores the flag, so
// failed checks tagged "skill" are dropped as well. gbrain's status is
// "healthy"/"warnings"/"unhealthy"; "ok" is kept for older reports. doctor
// exits 1 when any check fails, so stdout is read whatever the exit code.
interface DoctorVerdict {
brain_ok: boolean;
status: string | null;
brain_failures: string[];
skill_failures: string[];
report: unknown;
}
function runDoctor(extraArgs: string[], timeoutMs: number): DoctorVerdict {
const r = spawnSync("gbrain", ["doctor", "--json", ...extraArgs, "--scope=brain"], {
encoding: "utf-8",
timeout: timeoutMs,
stdio: ["ignore", "pipe", "ignore"],
});
let report: { status?: string; checks?: { name?: string; status?: string; category?: string }[] } | null = null;
try {
report = JSON.parse(r.stdout || "null");
} catch {}
if (!report || typeof report !== "object") {
return { brain_ok: false, status: null, brain_failures: [], skill_failures: [], report: null };
}
const failed = (Array.isArray(report.checks) ? report.checks : []).filter((c) => c?.status === "fail");
const skill_failures = failed.filter((c) => c.category === "skill").map((c) => c.name ?? "unnamed");
const brain_failures = failed.filter((c) => c.category !== "skill").map((c) => c.name ?? "unnamed");
const status = report.status ?? null;
const statusOk = status === "healthy" || status === "ok" || status === "warnings";
return {
brain_ok: brain_failures.length === 0 && (statusOk || skill_failures.length > 0),
status,
brain_failures,
skill_failures,
report,
};
}
// Uses --fast to avoid hanging on a dead DB. Per the local-status classifier
// (which probes DB directly via `gbrain sources list`), gbrain_doctor_ok is a
// coarse health summary, not engine-reachability — that's gbrain_local_status.
function detectDoctor(onPath: boolean): boolean {
if (!onPath) return false;
const out = tryExec("gbrain", ["doctor", "--json", "--fast"], 3_000);
if (!out) return false;
try {
const parsed = JSON.parse(out) as { status?: string };
return parsed.status === "ok" || parsed.status === "warnings";
} catch {
return false;
}
return runDoctor(["--fast"], 3_000).brain_ok;
}
// --- artifacts sync mode ---
@@ -300,6 +342,14 @@ function main(): void {
// 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.
// --doctor: the full brain-health gate /setup-gbrain Step 5 runs. Prints the
// DoctorVerdict as JSON and exits 0 iff brain_ok.
if (process.argv.includes("--doctor")) {
const verdict = runDoctor([], 120_000);
process.stdout.write(JSON.stringify(verdict, null, 2) + "\n");
process.exit(verdict.brain_ok ? 0 : 1);
}
if (process.argv.includes("--is-ok")) {
const noCache = process.env.GSTACK_DETECT_NO_CACHE === "1";
const status = localEngineStatus({ noCache });
+6 -4
View File
@@ -315,12 +315,14 @@ compatible MCP version.
For Paths 1, 2a, 2b, 3, switch:
```bash
doctor=$(gbrain doctor --json)
status=$(echo "$doctor" | jq -r .status)
DOCTOR=$(~/.claude/skills/gstack/bin/gstack-gbrain-detect --doctor)
BRAIN_OK=$(echo "$DOCTOR" | jq -r .brain_ok)
```
If status is `ok` or `warnings`, proceed. Anything else → surface the full
doctor output and STOP.
If `BRAIN_OK` is `true`, proceed; only brain checks count. Note any
`skill_failures`: gbrain graded a skills dir it found, maybe another agent's
(`~/.openclaw/workspace`). Never edit that workspace to pass. Otherwise show
`echo "$DOCTOR" | jq .report` and STOP.
---
+6 -4
View File
@@ -230,12 +230,14 @@ compatible MCP version.
For Paths 1, 2a, 2b, 3, switch:
```bash
doctor=$(gbrain doctor --json)
status=$(echo "$doctor" | jq -r .status)
DOCTOR=$(~/.claude/skills/gstack/bin/gstack-gbrain-detect --doctor)
BRAIN_OK=$(echo "$DOCTOR" | jq -r .brain_ok)
```
If status is `ok` or `warnings`, proceed. Anything else → surface the full
doctor output and STOP.
If `BRAIN_OK` is `true`, proceed; only brain checks count. Note any
`skill_failures`: gbrain graded a skills dir it found, maybe another agent's
(`~/.openclaw/workspace`). Never edit that workspace to pass. Otherwise show
`echo "$DOCTOR" | jq .report` and STOP.
---
+131
View File
@@ -0,0 +1,131 @@
/**
* /setup-gbrain Step 5 and gstack-gbrain-detect judge brain health from brain
* checks only (#3790).
*
* gbrain doctor's skill checks (category "skill": resolver_health,
* skill_conformance, ...) grade whatever skills dir gbrain auto-detects, which
* can be another agent's workspace such as ~/.openclaw/workspace. A failure
* there made a working brain read "unhealthy", Step 5 stopped, and the agent
* proposed editing the other agent's workspace. Real brain failures
* (connection, schema_version, pgvector) must still stop Step 5.
*
* A stub gbrain on PATH serves canned doctor JSON; no real gbrain runs.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
const DETECT = path.join(ROOT, 'bin', 'gstack-gbrain-detect');
const SKILL_MD = path.join(ROOT, 'setup-gbrain', 'SKILL.md');
const check = (name: string, category: string, status = 'ok') => ({ name, category, status, message: '' });
const BRAIN_OK = [
check('connection', 'ops'), check('pgvector', 'ops'), check('schema_version', 'meta'), check('embeddings', 'brain'),
];
const SKILL_FAIL = check('resolver_health', 'skill', 'fail');
const report = (checks: ReturnType<typeof check>[]) => ({
schema_version: 2,
status: checks.some((c) => c.status === 'fail') ? 'unhealthy' : checks.some((c) => c.status === 'warn') ? 'warnings' : 'healthy',
checks,
});
const withFailure = (name: string) => BRAIN_OK.map((c) => (c.name === name ? { ...c, status: 'fail' } : c));
type Case = { full: object; scoped: object };
const CASES: Record<string, Case> = {
// gbrain >= v0.41.20: --scope=brain drops the skill group.
'skill-only failure': {
full: report([...BRAIN_OK, SKILL_FAIL]),
scoped: report([...BRAIN_OK, check('harness_wiring', 'ops', 'warn')]),
},
// Older gbrain ignores --scope=brain and still runs the skill group.
'skill-only failure, gbrain without --scope': {
full: report([...BRAIN_OK, SKILL_FAIL]),
scoped: report([...BRAIN_OK, SKILL_FAIL]),
},
'all checks pass': { full: report(BRAIN_OK), scoped: report(BRAIN_OK) },
'connection failure': { full: report([...withFailure('connection'), SKILL_FAIL]), scoped: report(withFailure('connection')) },
'schema_version failure': { full: report([...withFailure('schema_version'), SKILL_FAIL]), scoped: report(withFailure('schema_version')) },
'pgvector failure': { full: report([...withFailure('pgvector'), SKILL_FAIL]), scoped: report(withFailure('pgvector')) },
'pgvector failure, gbrain without --scope': {
full: report([...withFailure('pgvector'), SKILL_FAIL]),
scoped: report([...withFailure('pgvector'), SKILL_FAIL]),
},
};
const HEALTHY = ['skill-only failure', 'skill-only failure, gbrain without --scope', 'all checks pass'];
let tmp: string;
const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin';
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-doctor-gate-'));
fs.mkdirSync(path.join(tmp, 'bin'));
fs.symlinkSync(process.execPath, path.join(tmp, 'bin', 'bun'));
// gbrain doctor exits 1 when any check fails and prints the report anyway.
fs.writeFileSync(path.join(tmp, 'bin', 'gbrain'), `#!/bin/sh
case "$1" in
--version) echo "gbrain 0.60.537.0"; exit 0 ;;
doctor) ;;
*) exit 2 ;;
esac
f="$FAKE_DOCTOR_DIR/full.json"
for a in "$@"; do [ "$a" = "--scope=brain" ] && f="$FAKE_DOCTOR_DIR/scoped.json"; done
cat "$f"
grep -q '"status":"unhealthy"' "$f" && exit 1
exit 0
`, { mode: 0o755 });
});
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
function caseEnv(name: string) {
const dir = path.join(tmp, name.replace(/[^a-z0-9]+/gi, '-'));
fs.mkdirSync(path.join(dir, 'home'), { recursive: true });
fs.writeFileSync(path.join(dir, 'full.json'), JSON.stringify(CASES[name]!.full));
fs.writeFileSync(path.join(dir, 'scoped.json'), JSON.stringify(CASES[name]!.scoped));
return {
...process.env,
PATH: `${path.join(tmp, 'bin')}:${SAFE_PATH}`,
HOME: path.join(dir, 'home'),
GSTACK_HOME: path.join(dir, 'state'),
GSTACK_DETECT_NO_CACHE: '1',
FAKE_DOCTOR_DIR: dir,
};
}
function step5Fence(): string {
const md = fs.readFileSync(SKILL_MD, 'utf-8');
const start = md.indexOf('## Step 5: Verify gbrain doctor');
const end = md.indexOf('\n## ', start + 1);
expect(start).toBeGreaterThan(-1);
const fence = md.slice(start, end).match(/\`\`\`bash\n([\s\S]*?)\`\`\`/);
expect(fence).not.toBeNull();
return fence![1]!.replaceAll('~/.claude/skills/gstack/bin/', `${ROOT}/bin/`);
}
describe('/setup-gbrain Step 5 doctor gate', () => {
for (const name of Object.keys(CASES)) {
const healthy = HEALTHY.includes(name);
test(`${name}: Step 5 ${healthy ? 'proceeds' : 'stops'}`, () => {
const r = spawnSync('bash', ['-c', `${step5Fence()}\necho "BRAIN_OK=\${BRAIN_OK:-unset} status=\${status:-unset}"`], {
env: caseEnv(name), encoding: 'utf-8', timeout: 30_000,
});
const verdict = r.stdout.trim().split('\n').pop();
expect(verdict).toStartWith(`BRAIN_OK=${healthy}`);
});
}
});
describe('gstack-gbrain-detect gbrain_doctor_ok', () => {
for (const name of Object.keys(CASES)) {
const healthy = HEALTHY.includes(name);
test(`${name}: gbrain_doctor_ok is ${healthy}`, () => {
const r = spawnSync(DETECT, [], { env: caseEnv(name), encoding: 'utf-8', timeout: 30_000 });
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).gbrain_doctor_ok).toBe(healthy);
});
}
});