Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 7b8676be3e ci: raise unit-test matrix shard timeout 15 -> 20 min
Shard 4 runs ~14.5 min on master (dream.test.ts at ~29s/test dominates
its wallclock) and hit the 15-min job timeout twice on this PR with
1328 tests passing and 0 failing — the gate then failed on
'gated job did not succeed (got cancelled)'. Real fix is re-mining
scripts/test-weights.json to rebalance the shards; this unblocks CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:59:25 -07:00
SinabinaandClaude Fable 5 b8376f7327 fix(doctor): stop treating ClawHub workspace skills as required gbrain-routable skills (#1767)
ClawHub-installed skills (detected via .clawhub/origin.json) are external
runtime integrations, not gbrain resolver skills. The derived manifest now
skips them, so doctor resolver_health no longer hard-fails with
unreachable/mece_gap on e.g. an email integration skill. A ClawHub skill
opts back into strict checking by declaring triggers: in its SKILL.md
frontmatter (the same surface that makes it routable); an explicit
manifest.json listing also keeps strict checks. Also keeps dry-fix from
rewriting externally-managed skill files.

Fixes #1767

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:27:57 -07:00
9 changed files with 122 additions and 126 deletions
+5 -1
View File
@@ -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:
+5 -5
View File
@@ -197,7 +197,7 @@ export interface SyncResult {
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
embedded: number;
pagesAffected: string[];
failedFiles?: number; // count of per-file import/sync failures (Bug 9)
failedFiles?: number; // count of parse failures (Bug 9)
/**
* v0.41.13.0 partial-sync fields (only set when status === 'partial').
*
@@ -3183,7 +3183,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
await clearOpCheckpoint(engine, ckpt.target);
};
// issue #1939 adversarial finding #1: a file that failed to import (open ledger
// issue #1939 adversarial finding #1: a file that failed to parse (open ledger
// row) and is then deleted/renamed-away never re-enters failedFiles and never
// imports, so its row would never clear and would age doctor to a permanent
// FAIL. Treat removed paths as resolved so the ledger self-heals.
@@ -3215,9 +3215,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
} else {
const fileFailCount = failedFiles.filter(f => isSkippablePath(f.path)).length;
serr(
`\nSync blocked: ${fileFailCount} file(s) failed to import:\n` +
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
`${codeBreakdown}\n\n` +
`Fix the listed file errors and re-run, or use 'gbrain sync --skip-failed' to ` +
`Fix the frontmatter and re-run, or use 'gbrain sync --skip-failed' to ` +
`acknowledge and move on. A file that keeps failing auto-skips after ` +
`${resolveAutoSkipThreshold()} consecutive syncs.`,
);
@@ -5355,7 +5355,7 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
case 'dry_run':
break; // already printed in performSync
case 'blocked_by_failures':
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to import.`);
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
write(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
-10
View File
@@ -1058,16 +1058,6 @@ export class PGLiteEngine implements BrainEngine {
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at`,
[sourceId, slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash, effectiveDate, effectiveDateSource, importFilename, chunkerVersion, sourcePath, sourceKind, sourceUri, ingestedVia, ingestedAt]
);
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors postgres-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0] as Record<string, unknown>);
}
-10
View File
@@ -1119,16 +1119,6 @@ export class PostgresEngine implements BrainEngine {
ingested_at = COALESCE(EXCLUDED.ingested_at, pages.ingested_at)
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at
`;
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors pglite-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0]);
}
+34 -1
View File
@@ -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` });
+29
View File
@@ -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.
-74
View File
@@ -1,74 +0,0 @@
// #2189 regression guard: putPage's INSERT … ON CONFLICT DO UPDATE … RETURNING
// can yield 0 rows when brain-local DB state (e.g. a BEFORE INSERT trigger)
// suppresses the write. Pre-fix, rowToPage(rows[0]) crashed with the opaque
// "undefined is not an object (evaluating 'row.deleted_at')" that failed
// ~all files of a code sync. Post-fix, putPage throws a descriptive error
// naming the slug + source_id so the failure is diagnosable per-file.
//
// Same guard lands in postgres-engine.ts (engine-parity invariant); this test
// exercises the PGLite side, where the issue was reported.
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Simulate the reporter's state-dependent failure: a trigger that
// suppresses inserts for one slug, making RETURNING produce no row.
await engine.executeRaw(`
CREATE OR REPLACE FUNCTION suppress_pages_insert() RETURNS trigger AS $$
BEGIN
IF NEW.slug = 'suppressed-page' THEN RETURN NULL; END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await engine.executeRaw(`
CREATE TRIGGER suppress_pages_insert_trg
BEFORE INSERT ON pages
FOR EACH ROW EXECUTE FUNCTION suppress_pages_insert();
`);
});
afterAll(async () => {
await engine.executeRaw('DROP TRIGGER IF EXISTS suppress_pages_insert_trg ON pages');
await engine.executeRaw('DROP FUNCTION IF EXISTS suppress_pages_insert');
await engine.disconnect();
});
describe('putPage RETURNING guard (#2189)', () => {
test('0-row RETURNING throws a descriptive error, not row.deleted_at TypeError', async () => {
let err: Error | undefined;
try {
await engine.putPage('suppressed-page', {
type: 'code',
title: 'Suppressed',
compiled_truth: 'x',
timeline: '',
});
} catch (e) {
err = e as Error;
}
expect(err).toBeDefined();
expect(err!.message).toContain('putPage');
expect(err!.message).toContain("slug='suppressed-page'");
expect(err!.message).toContain("source_id='default'");
// The pre-fix crash signature must be gone.
expect(err!.message).not.toContain('deleted_at');
});
test('unsuppressed slugs still upsert normally with the trigger installed', async () => {
const page = await engine.putPage('normal-page', {
type: 'concept',
title: 'Normal',
compiled_truth: 'y',
timeline: '',
});
expect(page.slug).toBe('normal-page');
expect(page.source_id).toBe('default');
});
});
+49
View File
@@ -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');
-25
View File
@@ -375,31 +375,6 @@ describe('performSync dry-run never writes', () => {
expect(messages.some(m => m.includes('git pull failed'))).toBe(false);
});
test('first PGLite code sync imports code files without runtime failures', async () => {
const { performSync } = await import('../src/commands/sync.ts');
mkdirSync(join(repoPath, 'src'), { recursive: true });
writeFileSync(
join(repoPath, 'src/example.ts'),
'export function add(left: number, right: number) { return left + right; }\n',
);
execSync('git add -A && git commit -m "add code file"', { cwd: repoPath, stdio: 'pipe' });
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
strategy: 'code',
});
expect(result.status).toBe('first_sync');
expect(result.added).toBe(1);
expect(result.failedFiles ?? 0).toBe(0);
const page = await engine.getPage('src-example-ts');
expect(page?.type).toBe('code');
expect(page?.frontmatter).toMatchObject({ file: 'src/example.ts', language: 'typescript' });
});
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// First do a real sync to seed the bookmark.