mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 11:10:13 +01:00
fix(gbrain-detect): spawn doctor through spawnGbrain, fail closed with a cause
Review pass 1 batch: route the doctor spawn through lib/gbrain-exec so the Windows gbrain.cmd shim works, report why doctor printed no JSON, raise --doctor's timeout to 300 s, count skill failures only when doctor says unhealthy, and cover the fail-closed paths and exit codes in tests. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
b6d3adc29c
commit
0a06cfbb86
5 files changed
+56
-16
No files matched your search
+14
-11
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ const report = (checks: ReturnType<typeof check>[]) => ({
|
||||
});
|
||||
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<string, Case> = {
|
||||
// gbrain >= v0.41.20: --scope=brain drops the skill group.
|
||||
'skill-only failure': {
|
||||
@@ -54,6 +54,12 @@ const CASES: Record<string, Case> = {
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user