mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 02:12:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b8676be3e | ||
|
|
b8376f7327 |
@@ -206,7 +206,11 @@ jobs:
|
||||
needs: cache-check
|
||||
if: needs.cache-check.outputs.hit != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# 20 (was 15): shard 4 runs ~14.5 min on master (dream.test.ts ~29s/test
|
||||
# dominates it) and hits the 15-min ceiling on slower runners, cancelling
|
||||
# mid-run with 0 test failures. Rebalancing via
|
||||
# scripts/mine-shard-weights.ts is the real fix; this stops the bleeding.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -142,16 +142,12 @@ export async function runOnboard(engine: BrainEngine, args: string[]): Promise<v
|
||||
|
||||
// --auto path: runs through the T2 library orchestrator. Hooks emit CLI
|
||||
// progress to stderr; the final result lands as JSON on stdout (or human
|
||||
// summary). extraRemediations (gathered above from runAllOnboardChecks)
|
||||
// is threaded into the runner so the onboard-check remediations
|
||||
// (extract-ner, extract-timeline-from-meetings, etc.) reach the planner
|
||||
// — the same wiring the --check path uses above.
|
||||
// summary).
|
||||
const result = await runRemediation(
|
||||
engine,
|
||||
{
|
||||
targetScore,
|
||||
maxUsd,
|
||||
extraRemediations,
|
||||
// --auto --yes opts into the prompt_required tier too; library
|
||||
// doesn't distinguish auto_apply vs prompt_required, it just runs
|
||||
// every remediation in the plan. The plan-building side (T12 render)
|
||||
|
||||
@@ -4957,7 +4957,7 @@ const run_onboard: Operation = {
|
||||
// typo, the underlying queue.add would reject. Defense-in-depth.
|
||||
const result = await runRemediation(
|
||||
ctx.engine,
|
||||
{ targetScore, maxUsd, extraRemediations: allowedExtras },
|
||||
{ targetScore, maxUsd },
|
||||
{},
|
||||
);
|
||||
|
||||
|
||||
@@ -66,10 +66,9 @@ export async function runRemediation(
|
||||
} = await import('../remediation-checkpoint.ts');
|
||||
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
const extraRemediations = opts.extraRemediations ?? [];
|
||||
|
||||
// Pre-flight ceiling check via the shared plan computation.
|
||||
const initialPlan = await computeRemediationPlan(engine, { targetScore, extraRemediations });
|
||||
const initialPlan = await computeRemediationPlan(engine, { targetScore });
|
||||
if (initialPlan.target_unreachable) {
|
||||
hooks.onTargetUnreachable?.(targetScore, initialPlan.max_reachable_score);
|
||||
return {
|
||||
@@ -88,7 +87,7 @@ export async function runRemediation(
|
||||
}
|
||||
|
||||
const initialHealth = await engine.getHealth();
|
||||
let recs: RemediationStep[] = computeRecommendations(initialHealth, ctx, extraRemediations)
|
||||
let recs: RemediationStep[] = computeRecommendations(initialHealth, ctx)
|
||||
.filter((r) => r.status === 'remediable');
|
||||
if (recs.length === 0) {
|
||||
hooks.onNothingToDo?.(initialHealth.brain_score, targetScore);
|
||||
@@ -306,13 +305,7 @@ export async function runRemediation(
|
||||
// steps with bumped retry suffix (D1).
|
||||
if (recs.length === 0 || stepCount >= maxJobs) break;
|
||||
const freshHealth = await engine.getHealth();
|
||||
// Extras carry a static status:'remediable' — a fresh health snapshot
|
||||
// never ages them out the way health-derived steps drop. Filter out
|
||||
// ids this run already processed (any terminal status), or the recheck
|
||||
// would resubmit completed extras every iteration, forever.
|
||||
const processedIds = new Set(submitted.map((s) => s.id));
|
||||
const pendingExtras = extraRemediations.filter((r) => !processedIds.has(r.id));
|
||||
recs = computeRecommendations(freshHealth, ctx, pendingExtras).filter((r) => r.status === 'remediable');
|
||||
recs = computeRecommendations(freshHealth, ctx).filter((r) => r.status === 'remediable');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -63,16 +63,6 @@ export interface RemediationOpts {
|
||||
resumePlanHash?: string;
|
||||
/** Whether to attempt resume at all (default false). */
|
||||
resume?: boolean;
|
||||
/**
|
||||
* Caller-supplied RemediationStep entries threaded into the planner.
|
||||
* Mirrors RemediationPlanOpts.extraRemediations so onboard's --apply
|
||||
* --auto path (and MCP run_onboard auto modes) forward the same
|
||||
* onboard-check remediations the --check path already passes through
|
||||
* computeRemediationPlan. Without this the runner saw only generic
|
||||
* brain_score remediations and reported "Nothing to do" whenever the
|
||||
* only applicable work was an extra (e.g. extract-ner).
|
||||
*/
|
||||
extraRemediations?: RemediationStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,15 @@
|
||||
* hold conventions and shared rule files, not skills. Files like
|
||||
* `_brain-filing-rules.md` live at the root and are not considered
|
||||
* skills by either loader.
|
||||
*
|
||||
* ClawHub-installed workspace skills (#1767): a skill dir carrying
|
||||
* `.clawhub/origin.json` is an externally-managed runtime integration
|
||||
* (e.g. an email or catalog skill), not a gbrain-routable skill. The
|
||||
* derive path SKIPS those so `gbrain doctor` resolver_health doesn't
|
||||
* hard-fail on them — UNLESS the skill's SKILL.md frontmatter declares
|
||||
* `triggers:`, which is the explicit opt-in to gbrain routing (and the
|
||||
* same surface that makes it reachable). An explicit manifest.json that
|
||||
* lists a ClawHub skill also keeps strict checking (verbatim path).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
@@ -60,9 +69,27 @@ function parseSkillName(skillMdPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the SKILL.md frontmatter declare a `triggers:` key? A ClawHub-
|
||||
* installed skill that ships gbrain `triggers:` has explicitly opted in
|
||||
* to gbrain routing and gets full resolver checks (#1767).
|
||||
*/
|
||||
function declaresTriggers(skillMdPath: string): boolean {
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, 'utf-8');
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return false;
|
||||
return /^triggers:/m.test(fmMatch[1]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk skillsDir, return every `<skillsDir>/<dir>/SKILL.md` as a
|
||||
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped.
|
||||
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped, as
|
||||
* are ClawHub-installed external skills that haven't opted in to gbrain
|
||||
* routing via `triggers:` frontmatter (#1767).
|
||||
*/
|
||||
function deriveManifest(skillsDir: string): ManifestEntry[] {
|
||||
const out: ManifestEntry[] = [];
|
||||
@@ -93,6 +120,12 @@ function deriveManifest(skillsDir: string): ManifestEntry[] {
|
||||
const skillMd = join(subdirAbs, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) continue;
|
||||
|
||||
// ClawHub-installed external skill (#1767): skip unless it opts in
|
||||
// to gbrain routing by declaring `triggers:` in its frontmatter.
|
||||
if (existsSync(join(subdirAbs, '.clawhub', 'origin.json')) && !declaresTriggers(skillMd)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const frontmatterName = parseSkillName(skillMd);
|
||||
const name = frontmatterName && frontmatterName !== '' ? frontmatterName : entry;
|
||||
out.push({ name, path: `${entry}/SKILL.md` });
|
||||
|
||||
@@ -382,6 +382,35 @@ describe("DRY detection — checkResolvable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#1767 — ClawHub workspace skills are not resolver-required", () => {
|
||||
let dir: string;
|
||||
afterEachCleanup(() => dir && rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
test("ClawHub skill without gbrain metadata produces no unreachable/mece_gap", () => {
|
||||
dir = mkdtempSync(join(tmpdir(), "gbrain-clawhub-"));
|
||||
// Native gbrain skill: routable via frontmatter triggers. No manifest.json
|
||||
// (the OpenClaw derive path from the issue repro).
|
||||
mkdirSync(join(dir, "query"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "query", "SKILL.md"),
|
||||
`---\nname: query\ndescription: test\ntriggers:\n - "what do we know"\n---\n\n# query\n`
|
||||
);
|
||||
// ClawHub-installed integration: no triggers, no resolver row.
|
||||
mkdirSync(join(dir, "agentmail", ".clawhub"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, "agentmail", ".clawhub", "origin.json"),
|
||||
JSON.stringify({ registry: "https://clawhub.ai", slug: "agentmail" })
|
||||
);
|
||||
writeFileSync(join(dir, "agentmail", "SKILL.md"), `---\nname: agentmail\ndescription: email integration\n---\n\n# agentmail\n`);
|
||||
|
||||
const report = checkResolvable(dir);
|
||||
const agentmailIssues = report.issues.filter(i => i.skill === "agentmail");
|
||||
expect(agentmailIssues).toEqual([]);
|
||||
expect(report.ok).toBe(true);
|
||||
expect(report.summary.total_skills).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
|
||||
test("repo skills/ pass check-resolvable cleanly (zero errors AND zero warnings)", () => {
|
||||
// The v0.22.4 (Part A) contract was zero warnings AND zero errors.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
// test/remediation-run-extras.serial.test.ts
|
||||
// Regression for PR #2161 takeover: `gbrain onboard --apply --auto` dropped
|
||||
// onboard-check extraRemediations. Two distinct halves of the bug:
|
||||
// 1. runRemediation built the pre-flight plan + initial recs WITHOUT the
|
||||
// extras, so an extras-only plan reported "Nothing to do".
|
||||
// 2. The D7 mid-run recheck rebuilt recs WITHOUT the extras after every
|
||||
// completed step, so with 2+ plannable steps all remaining extras were
|
||||
// dropped after step 1. The recheck must also filter out extras this
|
||||
// run already processed — extras carry static status:'remediable', so
|
||||
// unfiltered threading would resubmit completed extras forever.
|
||||
//
|
||||
// SERIAL: mock.module (queue + wait-for-completion stubs, R2) + GBRAIN_HOME
|
||||
// env mutation so checkpoint files land in a tmpdir, not ~/.gbrain.
|
||||
|
||||
import { describe, expect, test, beforeAll, afterAll, mock } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { makeRemediationStep } from '../src/core/remediation-step.ts';
|
||||
|
||||
// Stub the Minion queue: every submitted job is immediately 'completed'.
|
||||
// runRemediation only calls queue.add + waitForCompletion(queue, id).
|
||||
let nextJobId = 1;
|
||||
const submittedJobs: Array<{ name: string }> = [];
|
||||
mock.module('../src/core/minions/queue.ts', () => ({
|
||||
MinionQueue: class {
|
||||
async add(name: string) {
|
||||
submittedJobs.push({ name });
|
||||
return { id: nextJobId++, status: 'completed' };
|
||||
}
|
||||
},
|
||||
}));
|
||||
mock.module('../src/core/minions/wait-for-completion.ts', () => ({
|
||||
waitForCompletion: async () => ({ status: 'completed' }),
|
||||
}));
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let home: string;
|
||||
const prevHome = process.env.GBRAIN_HOME;
|
||||
|
||||
beforeAll(async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'gbrain-remextras-'));
|
||||
process.env.GBRAIN_HOME = home;
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
|
||||
else process.env.GBRAIN_HOME = prevHome;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function extra(id: string, job: string) {
|
||||
return makeRemediationStep({
|
||||
id,
|
||||
job,
|
||||
params: {},
|
||||
severity: 'medium',
|
||||
est_seconds: 5,
|
||||
est_usd_cost: 0,
|
||||
rationale: 'synthetic onboard-check extra',
|
||||
status: 'remediable',
|
||||
});
|
||||
}
|
||||
|
||||
describe('runRemediation extraRemediations threading', () => {
|
||||
test('extras-only plan runs BOTH extras and terminates (no Nothing-to-do, no resubmit loop)', async () => {
|
||||
// Empty PGLite brain → zero health-derived recommendations. Without the
|
||||
// fix, half 1 makes this run return submitted: [] via onNothingToDo.
|
||||
// With only half 1 (the original PR #2161 diff), the mid-run recheck
|
||||
// drops the second extra after step 1 — submitted has 1 entry, not 2.
|
||||
const { runRemediation } = await import('../src/core/remediation/run.ts');
|
||||
let nothingToDo = false;
|
||||
const result = await runRemediation(
|
||||
engine,
|
||||
{
|
||||
targetScore: 1,
|
||||
extraRemediations: [
|
||||
extra('onboard.extract_ner', 'extract-ner'),
|
||||
extra('onboard.extract_timeline', 'extract-timeline-from-meetings'),
|
||||
],
|
||||
// Safety bound: an unfiltered recheck would resubmit completed
|
||||
// extras forever; maxJobs turns that regression into a fast fail
|
||||
// (extra count > 1 below) instead of a hung test.
|
||||
maxJobs: 5,
|
||||
},
|
||||
{ onNothingToDo: () => { nothingToDo = true; } },
|
||||
);
|
||||
|
||||
expect(nothingToDo).toBe(false);
|
||||
const ids = result.submitted.map((s) => s.id);
|
||||
expect(ids).toContain('onboard.extract_ner');
|
||||
expect(ids).toContain('onboard.extract_timeline');
|
||||
// Each extra ran exactly once — the recheck must not re-plan extras the
|
||||
// run already processed.
|
||||
expect(ids.filter((i) => i === 'onboard.extract_ner').length).toBe(1);
|
||||
expect(ids.filter((i) => i === 'onboard.extract_timeline').length).toBe(1);
|
||||
expect(result.submitted.every((s) => s.status === 'completed')).toBe(true);
|
||||
expect(submittedJobs.map((j) => j.name).sort()).toEqual([
|
||||
'extract-ner',
|
||||
'extract-timeline-from-meetings',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -166,6 +166,55 @@ describe('loadOrDeriveManifest', () => {
|
||||
expect(r.skills.map(s => s.name)).toEqual(['apple', 'mango', 'zebra']);
|
||||
});
|
||||
|
||||
// #1767 — ClawHub-installed workspace skills are external integrations,
|
||||
// not gbrain-routable skills. The derive path skips them unless they
|
||||
// opt in via `triggers:` frontmatter.
|
||||
it('skips ClawHub-origin skills without triggers frontmatter (#1767)', () => {
|
||||
const dir = scratch();
|
||||
writeSkill(dir, 'query', 'query');
|
||||
writeSkill(dir, 'agentmail', 'agentmail');
|
||||
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'agentmail', '.clawhub', 'origin.json'),
|
||||
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
|
||||
);
|
||||
const r = loadOrDeriveManifest(dir);
|
||||
expect(r.derived).toBe(true);
|
||||
expect(r.skills.map(s => s.name)).toEqual(['query']);
|
||||
});
|
||||
|
||||
it('includes ClawHub-origin skills that opt in via triggers frontmatter (#1767)', () => {
|
||||
const dir = scratch();
|
||||
writeSkill(dir, 'agentmail', 'agentmail');
|
||||
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'agentmail', '.clawhub', 'origin.json'),
|
||||
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'agentmail', 'SKILL.md'),
|
||||
`---\nname: agentmail\ndescription: test\ntriggers:\n - "send email"\n---\n\n# agentmail\n`
|
||||
);
|
||||
const r = loadOrDeriveManifest(dir);
|
||||
expect(r.derived).toBe(true);
|
||||
expect(r.skills.map(s => s.name)).toEqual(['agentmail']);
|
||||
});
|
||||
|
||||
it('keeps ClawHub-origin skills listed in an explicit manifest.json (#1767)', () => {
|
||||
// Explicit manifest.json is a deliberate declaration — strict checking stays.
|
||||
const dir = scratch();
|
||||
writeSkill(dir, 'agentmail', 'agentmail');
|
||||
mkdirSync(join(dir, 'agentmail', '.clawhub'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'agentmail', '.clawhub', 'origin.json'),
|
||||
JSON.stringify({ registry: 'https://clawhub.ai', slug: 'agentmail' })
|
||||
);
|
||||
writeManifest(dir, { skills: [{ name: 'agentmail', path: 'agentmail/SKILL.md' }] });
|
||||
const r = loadOrDeriveManifest(dir);
|
||||
expect(r.derived).toBe(false);
|
||||
expect(r.skills.map(s => s.name)).toEqual(['agentmail']);
|
||||
});
|
||||
|
||||
it('treats dirs without SKILL.md as not-a-skill', () => {
|
||||
const dir = scratch();
|
||||
writeSkill(dir, 'query', 'query');
|
||||
|
||||
Reference in New Issue
Block a user