mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 01:42:23 +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:
|
||||
|
||||
@@ -186,18 +186,6 @@ export function isSourceStale(src: SourceRow, now = Date.now(), floorMin = FULL_
|
||||
return ageMin >= floorMin;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2060: count sources past the per-source cycle freshness floor. Consumed
|
||||
* by autopilot's dispatch decision — a stale source forces the fanout path
|
||||
* even when the doctor plan is small (score 70–94, plan ≤ 3, est < 300s),
|
||||
* so targeted mode can't leave cycle_freshness stale indefinitely.
|
||||
* dispatchPerSource's own throttles (skipped_fresh / fanoutMax / failure
|
||||
* cooldown) bound the resulting work.
|
||||
*/
|
||||
export function countStaleSources(sources: SourceRow[], now = Date.now(), floorMin = FULL_CYCLE_FLOOR_MIN): number {
|
||||
return sources.filter((s) => isSourceStale(s, now, floorMin)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent SUCCESSFUL cycle for a source. Prefers `last_source_cycle_at`
|
||||
* (per-source phases, written by the split cycle) and falls back to the legacy
|
||||
|
||||
@@ -901,27 +901,13 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
const FULL_CYCLE_FLOOR_MIN = 60;
|
||||
const minutesSinceLastFull = (Date.now() - lastFullCycleAt) / 60000;
|
||||
|
||||
// #2060: stale per-source cycle freshness is a dispatch input. Without
|
||||
// it, a brain sitting at score 70–94 with a small targeted plan (≤3
|
||||
// steps, <300s) stays in targeted mode indefinitely and no per-source
|
||||
// cycle is ever dispatched — cycle_freshness never advances. A stale
|
||||
// source forces the fanout path; dispatchPerSource's throttles
|
||||
// (skipped_fresh / fanoutMax / failure cooldown) bound the work.
|
||||
// Fail-open to 0: a read failure must not block dispatch.
|
||||
let staleCycleSources = 0;
|
||||
try {
|
||||
const { countStaleSources } = await import('./autopilot-fanout.ts');
|
||||
staleCycleSources = countStaleSources(await engine.listAllSources({ localPathOnly: true }));
|
||||
} catch { /* fail-open: freshness is a dispatch hint, not a gate */ }
|
||||
|
||||
const shouldFullCycle =
|
||||
(score >= 95 && plan.length === 0 && minutesSinceLastFull >= FULL_CYCLE_FLOOR_MIN) ||
|
||||
plan.length > 3 ||
|
||||
estTotal >= 300 ||
|
||||
score < 70 ||
|
||||
staleCycleSources > 0;
|
||||
score < 70;
|
||||
|
||||
const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN && staleCycleSources === 0;
|
||||
const shouldSleep = score >= 95 && plan.length === 0 && minutesSinceLastFull < FULL_CYCLE_FLOOR_MIN;
|
||||
|
||||
if (shouldSleep) {
|
||||
if (jsonMode) {
|
||||
|
||||
+10
-19
@@ -854,10 +854,7 @@ interface SyncPhaseResult extends PhaseResult {
|
||||
/**
|
||||
* Resolve the source id for a brain directory by looking up the sources
|
||||
* table. Returns undefined when no registered source matches (falls back
|
||||
* to pre-v0.18 global config.sync.* keys) OR when MORE than one source
|
||||
* claims the path — an ambiguous match must not scope phases or stamp
|
||||
* last_full_cycle_at for an arbitrarily-picked source (the "freshness
|
||||
* stamp that lies" this resolution exists to prevent).
|
||||
* to pre-v0.18 global config.sync.* keys).
|
||||
*/
|
||||
async function resolveSourceForDir(
|
||||
engine: BrainEngine,
|
||||
@@ -868,10 +865,10 @@ async function resolveSourceForDir(
|
||||
if (brainDir === null) return undefined;
|
||||
try {
|
||||
const rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE local_path = $1 LIMIT 2`,
|
||||
`SELECT id FROM sources WHERE local_path = $1 LIMIT 1`,
|
||||
[brainDir],
|
||||
);
|
||||
return rows.length === 1 ? rows[0]!.id : undefined;
|
||||
return rows[0]?.id;
|
||||
} catch {
|
||||
// sources table might not exist on very old brains — fall through.
|
||||
return undefined;
|
||||
@@ -2368,23 +2365,17 @@ export async function runCycle(
|
||||
}
|
||||
|
||||
// v0.38 (codex r1 P0-5): persist per-source cycle completion timestamp
|
||||
// when the cycle ran successfully against a resolvable source. Read by
|
||||
// autopilot's per-source freshness gate next tick.
|
||||
//
|
||||
// #1993: keyed off `cycleSourceId` (opts.sourceId ?? the source resolved
|
||||
// from brainDir) — the SAME id the cycle locked + scoped its phases to —
|
||||
// NOT raw opts.sourceId. The autopilot's inline cycle sets brainDir but
|
||||
// passes no explicit sourceId, so keying off opts.sourceId alone never
|
||||
// advanced last_full_cycle_at and cycle_freshness stayed stale even while
|
||||
// the autopilot cycled every interval. Skipped when:
|
||||
// - no source resolves (engine null, or no checkout AND no opts.sourceId)
|
||||
// when the cycle ran successfully against an explicit source. Read by
|
||||
// autopilot's per-source freshness gate next tick. Skipped when:
|
||||
// - opts.sourceId is unset (legacy callers — autopilot still here)
|
||||
// - engine is null (no-DB path)
|
||||
// - status is 'failed' or 'skipped' (don't mark a non-run as fresh)
|
||||
// - dryRun (writes are out of scope)
|
||||
//
|
||||
// Best-effort: a write failure does NOT change the CycleReport status.
|
||||
// The cost of writing the wrong timestamp post-failure is higher than
|
||||
// the cost of missing a successful write (next cycle will redo work).
|
||||
if (cycleSourceId && engine && !dryRun && !aborted && (status === 'ok' || status === 'clean' || status === 'partial')) {
|
||||
if (opts.sourceId && engine && !dryRun && !aborted && (status === 'ok' || status === 'clean' || status === 'partial')) {
|
||||
try {
|
||||
const nowIso = new Date().toISOString();
|
||||
// #2194 fix #3 (the cycle split): `last_source_cycle_at` is the NEW gate
|
||||
@@ -2394,13 +2385,13 @@ export async function runCycle(
|
||||
// phases (those gate on autopilot.last_global_at), so writing it on a
|
||||
// source-only cycle does not re-introduce the freshness poisoning codex
|
||||
// flagged in the rejected skip-based design.
|
||||
await engine.updateSourceConfig(cycleSourceId, {
|
||||
await engine.updateSourceConfig(opts.sourceId, {
|
||||
last_source_cycle_at: nowIso,
|
||||
last_full_cycle_at: nowIso,
|
||||
});
|
||||
} catch (e) {
|
||||
// Best-effort; cycle already succeeded by the time we get here.
|
||||
console.warn(`[cycle] failed to write last_source_cycle_at for source ${cycleSourceId}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
console.warn(`[cycle] failed to write last_source_cycle_at for source ${opts.sourceId}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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` });
|
||||
|
||||
@@ -54,20 +54,6 @@ describe('autopilot.ts ↔ dispatchPerSource wiring', () => {
|
||||
expect(AUTOPILOT_SRC).toMatch(/lastFullCycleAt\s*=\s*Date\.now\(\)/);
|
||||
});
|
||||
|
||||
test('stale per-source cycle freshness is a shouldFullCycle input (#2060)', () => {
|
||||
// Targeted mode (score 70–94, plan ≤3, est <300s) must not be able to
|
||||
// starve per-source cycle dispatch: a stale source (per countStaleSources
|
||||
// over listAllSources) forces the fanout path, and the sleep gate must
|
||||
// not fire while stale sources exist. Without these terms, cycle
|
||||
// freshness never advances for a brain that always lands in targeted mode.
|
||||
expect(AUTOPILOT_SRC).toMatch(/countStaleSources/);
|
||||
const fullCycleDeclIdx = AUTOPILOT_SRC.indexOf('const shouldFullCycle');
|
||||
expect(fullCycleDeclIdx).toBeGreaterThan(-1);
|
||||
const decl = AUTOPILOT_SRC.slice(fullCycleDeclIdx, fullCycleDeclIdx + 700);
|
||||
expect(decl).toMatch(/staleCycleSources\s*>\s*0/);
|
||||
expect(decl).toMatch(/const shouldSleep[^;]*staleCycleSources\s*===\s*0/);
|
||||
});
|
||||
|
||||
test('does NOT regress to the single-job dispatch on the full-cycle path', () => {
|
||||
// Pre-PR: the shouldFullCycle branch did:
|
||||
// const job = await queue.add('autopilot-cycle', { repoPath }, {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
readLastFullCycleAt,
|
||||
isSourceStale,
|
||||
countStaleSources,
|
||||
selectSourcesForDispatch,
|
||||
resolveFanoutMax,
|
||||
dispatchPerSource,
|
||||
@@ -75,23 +74,6 @@ describe('isSourceStale', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('countStaleSources (#2060 dispatch-decision input)', () => {
|
||||
const NOW = Date.parse('2026-05-22T12:00:00.000Z');
|
||||
test('counts never-cycled + past-floor sources, ignores fresh', () => {
|
||||
const sources = [
|
||||
src('never-cycled'), // stale (null)
|
||||
src('old', new Date(NOW - 2 * 60 * 60_000).toISOString()), // stale (2h)
|
||||
src('fresh', new Date(NOW - 30 * 60_000).toISOString()), // fresh (30min)
|
||||
];
|
||||
expect(countStaleSources(sources, NOW)).toBe(2);
|
||||
});
|
||||
test('returns 0 for all-fresh and for empty list', () => {
|
||||
const fresh = src('a', new Date(NOW - 10 * 60_000).toISOString());
|
||||
expect(countStaleSources([fresh], NOW)).toBe(0);
|
||||
expect(countStaleSources([], NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectSourcesForDispatch', () => {
|
||||
const NOW = Date.parse('2026-05-22T12:00:00.000Z');
|
||||
const fresh = (id: string, agoMin: number) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
* cycles. Closes codex round-1 P0-5 (write site for last_full_cycle_at
|
||||
* was unspecified pre-PR).
|
||||
*
|
||||
* Conditions for write (keyed off `cycleSourceId` = opts.sourceId ?? the
|
||||
* source resolved from brainDir, so the autopilot's inline cycle — brainDir
|
||||
* set, no explicit sourceId — also advances the timestamp, #1993):
|
||||
* - a source resolves (explicit sourceId, or brainDir matches a source)
|
||||
* Conditions for write:
|
||||
* - opts.sourceId is set (legacy callers without sourceId skip the write)
|
||||
* - engine is non-null (no-DB path skips)
|
||||
* - status is 'ok' | 'clean' | 'partial' (failed/skipped don't mark fresh)
|
||||
* - dryRun is false
|
||||
@@ -92,45 +90,17 @@ describe('runCycle last_full_cycle_at exit hook', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('no explicit sourceId but brainDir resolves a source → writes the resolved source timestamp', async () => {
|
||||
test('legacy caller (no sourceId) does NOT write any source timestamp', async () => {
|
||||
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
|
||||
// The autopilot's inline cycle sets brainDir but passes no sourceId.
|
||||
// runCycle resolves the source from brainDir (local_path match) into
|
||||
// cycleSourceId and stamps last_full_cycle_at for it — otherwise
|
||||
// cycle_freshness reports the brain stale even while the autopilot
|
||||
// cycles every interval (#1993).
|
||||
await seedSource('resolved-from-dir'); // local_path = brainDir
|
||||
expect(await readLastFullCycleAt('resolved-from-dir')).toBeNull();
|
||||
|
||||
const t0 = Date.now();
|
||||
const report = await runCycle(engine, {
|
||||
brainDir,
|
||||
phases: ['lint'],
|
||||
});
|
||||
expect(['ok', 'clean']).toContain(report.status);
|
||||
|
||||
const after = await readLastFullCycleAt('resolved-from-dir');
|
||||
expect(after).not.toBeNull();
|
||||
expect(new Date(after!).getTime()).toBeGreaterThanOrEqual(t0);
|
||||
});
|
||||
});
|
||||
|
||||
test('no sourceId and brainDir matches no source → does not write', async () => {
|
||||
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
|
||||
// A source exists but its local_path does NOT match brainDir, so
|
||||
// resolveSourceForDir returns undefined, cycleSourceId is undefined,
|
||||
// and no per-source timestamp is written.
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
|
||||
VALUES ('unmatched', 'unmatched', '/no/such/repo', '{}'::jsonb, false, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET local_path = EXCLUDED.local_path`,
|
||||
[],
|
||||
);
|
||||
await seedSource('default-like');
|
||||
// No sourceId passed; should remain untouched.
|
||||
await runCycle(engine, {
|
||||
brainDir,
|
||||
phases: ['lint'],
|
||||
});
|
||||
expect(await readLastFullCycleAt('unmatched')).toBeNull();
|
||||
// No per-source write happens; default source's config stays empty.
|
||||
const after = await readLastFullCycleAt('default-like');
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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