diff --git a/bin/gstack-gbrain-detect b/bin/gstack-gbrain-detect index 1f9d1140a..548f61617 100755 --- a/bin/gstack-gbrain-detect +++ b/bin/gstack-gbrain-detect @@ -30,7 +30,8 @@ * 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}; + * {brain_ok, status, brain_failures, skill_failures, report, + * error?}; * exit 0 iff brain_ok. Skill checks never decide brain_ok (#3790). * * Env: @@ -39,7 +40,7 @@ * GSTACK_DETECT_NO_CACHE=1 — bypass the 60s local-status cache. */ -import { execFileSync, spawnSync } from "child_process"; +import { execFileSync } from "child_process"; import { existsSync, readFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; @@ -50,7 +51,7 @@ import { resolveGbrainBin, readGbrainVersion, } from "../lib/gbrain-local-status"; -import { gbrainConfigDir, isTransactionModePooler } from "../lib/gbrain-exec"; +import { gbrainConfigDir, isTransactionModePooler, spawnGbrain } from "../lib/gbrain-exec"; import { resolveStateRoot } from "../lib/state-root"; const STATE_DIR = resolveStateRoot(); @@ -140,20 +141,22 @@ interface DoctorVerdict { brain_failures: string[]; skill_failures: string[]; report: unknown; + error?: string; } 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"], - }); + const r = spawnGbrain(["doctor", "--json", ...extraArgs, "--scope=brain"], { timeout: timeoutMs }); 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 cause = r.error ? r.error.message : r.signal ? `killed by ${r.signal}` : `exit ${r.status}`; + const stderr = (r.stderr || "").trim().split("\n").slice(-5).join("\n"); + return { + brain_ok: false, status: null, brain_failures: [], skill_failures: [], report: null, + error: `gbrain doctor printed no JSON report (${cause})${stderr ? `: ${stderr}` : ""}`, + }; } 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"); @@ -161,7 +164,7 @@ function runDoctor(extraArgs: string[], timeoutMs: number): DoctorVerdict { 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), + brain_ok: brain_failures.length === 0 && (statusOk || (status === "unhealthy" && skill_failures.length > 0)), status, brain_failures, skill_failures, @@ -345,7 +348,7 @@ function main(): void { // --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); + const verdict = runDoctor([], 300_000); process.stdout.write(JSON.stringify(verdict, null, 2) + "\n"); process.exit(verdict.brain_ok ? 0 : 1); } diff --git a/setup-gbrain/SKILL.md b/setup-gbrain/SKILL.md index 6f6d785ea..814c67247 100644 --- a/setup-gbrain/SKILL.md +++ b/setup-gbrain/SKILL.md @@ -322,7 +322,7 @@ BRAIN_OK=$(echo "$DOCTOR" | jq -r .brain_ok) 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. +`echo "$DOCTOR" | jq .` and STOP. --- diff --git a/setup-gbrain/SKILL.md.tmpl b/setup-gbrain/SKILL.md.tmpl index 4de37a6b9..68fcbb28d 100644 --- a/setup-gbrain/SKILL.md.tmpl +++ b/setup-gbrain/SKILL.md.tmpl @@ -237,7 +237,7 @@ BRAIN_OK=$(echo "$DOCTOR" | jq -r .brain_ok) 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. +`echo "$DOCTOR" | jq .` and STOP. --- diff --git a/test/gbrain-spawn-windows-shell.test.ts b/test/gbrain-spawn-windows-shell.test.ts index b6f50c257..6677c321c 100644 --- a/test/gbrain-spawn-windows-shell.test.ts +++ b/test/gbrain-spawn-windows-shell.test.ts @@ -32,6 +32,12 @@ describe("#1731 gbrain spawns carry the Windows shell flag", () => { }); } + test("bin/gstack-gbrain-detect: gbrain doctor goes through spawnGbrain (no direct openers)", () => { + const src = read("bin/gstack-gbrain-detect"); + expect(src.match(/(spawnSync|spawn|execFileSync|tryExec)\(\s*["']gbrain["']/g)?.length ?? 0).toBe(0); + expect(src).toContain("spawnGbrain([\"doctor\""); + }); + // Not-yet-seamed file: every direct gbrain spawn must still carry the // #1731 shell flag. (Migrate to gbrainInvocation when next touched.) test("lib/gbrain-local-status.ts: every gbrain spawn has shell:NEEDS_SHELL_ON_WINDOWS", () => { diff --git a/test/setup-gbrain-doctor-gate.test.ts b/test/setup-gbrain-doctor-gate.test.ts index 8137cf1d4..5e703780b 100644 --- a/test/setup-gbrain-doctor-gate.test.ts +++ b/test/setup-gbrain-doctor-gate.test.ts @@ -34,7 +34,7 @@ const report = (checks: ReturnType[]) => ({ }); const withFailure = (name: string) => BRAIN_OK.map((c) => (c.name === name ? { ...c, status: 'fail' } : c)); -type Case = { full: object; scoped: object }; +type Case = { full: object | string; scoped: object | string }; const CASES: Record = { // gbrain >= v0.41.20: --scope=brain drops the skill group. 'skill-only failure': { @@ -54,6 +54,12 @@ const CASES: Record = { full: report([...withFailure('pgvector'), SKILL_FAIL]), scoped: report([...withFailure('pgvector'), SKILL_FAIL]), }, + 'doctor prints no JSON': { full: 'Error: engine crashed\n', scoped: 'Error: engine crashed\n' }, + 'doctor prints nothing': { full: '', scoped: '' }, + 'unhealthy status with no failed check': { + full: { ...report(BRAIN_OK), status: 'unhealthy' }, + scoped: { ...report(BRAIN_OK), status: 'unhealthy' }, + }, }; const HEALTHY = ['skill-only failure', 'skill-only failure, gbrain without --scope', 'all checks pass']; @@ -75,6 +81,7 @@ 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 +grep -q '^{' "$f" || exit 1 exit 0 `, { mode: 0o755 }); }); @@ -84,8 +91,9 @@ 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)); + const { full, scoped } = CASES[name]!; + fs.writeFileSync(path.join(dir, 'full.json'), typeof full === 'string' ? full : JSON.stringify(full)); + fs.writeFileSync(path.join(dir, 'scoped.json'), typeof scoped === 'string' ? scoped : JSON.stringify(scoped)); return { ...process.env, PATH: `${path.join(tmp, 'bin')}:${SAFE_PATH}`, @@ -119,6 +127,29 @@ describe('/setup-gbrain Step 5 doctor gate', () => { } }); +describe('gstack-gbrain-detect --doctor', () => { + test('exits 0 and names skill failures when only skill checks fail', () => { + const r = spawnSync(DETECT, ['--doctor'], { env: caseEnv('skill-only failure, gbrain without --scope'), encoding: 'utf-8', timeout: 30_000 }); + expect(r.status).toBe(0); + const v = JSON.parse(r.stdout); + expect(v).toMatchObject({ brain_ok: true, brain_failures: [], skill_failures: ['resolver_health'] }); + }); + + test('exits 1 and names the brain failure', () => { + const r = spawnSync(DETECT, ['--doctor'], { env: caseEnv('pgvector failure'), encoding: 'utf-8', timeout: 30_000 }); + expect(r.status).toBe(1); + expect(JSON.parse(r.stdout)).toMatchObject({ brain_ok: false, brain_failures: ['pgvector'] }); + }); + + test('exits 1 with the cause when doctor prints no JSON', () => { + const r = spawnSync(DETECT, ['--doctor'], { env: caseEnv('doctor prints no JSON'), encoding: 'utf-8', timeout: 30_000 }); + expect(r.status).toBe(1); + const v = JSON.parse(r.stdout); + expect(v.brain_ok).toBe(false); + expect(v.error).toContain('exit 1'); + }); +}); + describe('gstack-gbrain-detect gbrain_doctor_ok', () => { for (const name of Object.keys(CASES)) { const healthy = HEALTHY.includes(name);