mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 01:42:23 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
921048827a | ||
|
|
0dff84b16a |
@@ -206,11 +206,7 @@ jobs:
|
||||
needs: cache-check
|
||||
if: needs.cache-check.outputs.hit != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# 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
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -686,6 +686,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
try {
|
||||
const { MinionQueue } = await import('../core/minions/queue.ts');
|
||||
const { computeRecommendations, embeddingProviderConfigured, HOSTED_EMBED_KEY_CONFIG } = await import('../core/brain-score-recommendations.ts');
|
||||
const { countExtractionLag } = await import('../core/remediation/context.ts');
|
||||
const queue = new MinionQueue(engine);
|
||||
const slotMs = Math.floor(Date.now() / (baseInterval * 1000)) * baseInterval * 1000;
|
||||
const slot = new Date(slotMs).toISOString();
|
||||
@@ -877,6 +878,9 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
return !!(process.env[envVar] || (cfgField ? embedKeyCfg[cfgField] : undefined));
|
||||
}),
|
||||
hasChatApiKey: !!(process.env.ANTHROPIC_API_KEY || await engine.getConfig('anthropic_api_key')),
|
||||
// Real extraction-lag gate for sync.repo/extract.all — same counter
|
||||
// loadRecommendationContext uses (replaces the health.stale_pages proxy).
|
||||
extractionLagPages: await countExtractionLag(engine),
|
||||
};
|
||||
// v0.41.18.0 (A5 + A19 + A22, T15): consult onboard recommendations
|
||||
// ALONGSIDE doctor's brain-score recommendations. Onboard's 4 new
|
||||
|
||||
@@ -146,6 +146,16 @@ export interface RecommendationContext {
|
||||
chatModel?: string;
|
||||
/** Whether the chat provider has a usable API key. */
|
||||
hasChatApiKey?: boolean;
|
||||
/**
|
||||
* Count of pages needing link/timeline extraction — the SAME staleness the
|
||||
* `gbrain extract --stale` walk and doctor's `links_extraction_lag` check use
|
||||
* (`engine.countStalePagesForExtraction`). Gates the sync→extract pipeline
|
||||
* (sync.repo / extract.all). Replaces the old `health.stale_pages` gate, which
|
||||
* counted "pages whose updated_at predates their newest timeline entry" — a
|
||||
* proxy that broke when the updated_at-on-timeline-insert trigger was dropped
|
||||
* (migration v10) and never reflected real extraction work.
|
||||
*/
|
||||
extractionLagPages?: number;
|
||||
}
|
||||
|
||||
/** Triage result for one check. */
|
||||
@@ -192,20 +202,28 @@ export function computeRecommendations(
|
||||
const source = ctx.sourceId ?? 'default';
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// sync.repo — fires when sync hasn't run recently OR pages are stale
|
||||
// sync.repo + extract.all — the materialization pipeline, gated on the REAL
|
||||
// extraction lag (pages whose link/timeline edges are stale), NOT on the
|
||||
// legacy `health.stale_pages` proxy. `extractionLagPages` comes from the same
|
||||
// counter the `extract --stale` walk + doctor's `links_extraction_lag` use, so
|
||||
// the recommendation can only fire when running extract will actually reduce
|
||||
// it (and clear the rec). See RecommendationContext.extractionLagPages.
|
||||
// sync.repo is the prerequisite: re-sync so pages are current before extract
|
||||
// materializes their edges.
|
||||
// ---------------------------------------------------------------------
|
||||
if (ctx.repoPath && health.stale_pages > 0) {
|
||||
const extractionLag = ctx.extractionLagPages ?? 0;
|
||||
if (ctx.repoPath && extractionLag > 0) {
|
||||
const params = { repoPath: ctx.repoPath, sourceId: ctx.sourceId, noEmbed: true };
|
||||
out.push({
|
||||
id: 'sync.repo',
|
||||
job: 'sync',
|
||||
params,
|
||||
idempotency_key: idemKey(source, 'sync', params),
|
||||
severity: health.stale_pages > 50 ? 'high' : 'medium',
|
||||
est_seconds: Math.min(600, 30 + health.stale_pages * 0.5),
|
||||
severity: extractionLag > 50 ? 'high' : 'medium',
|
||||
est_seconds: Math.min(600, 30 + extractionLag * 0.5),
|
||||
est_usd_cost: 0, // sync is fs+DB only
|
||||
depends_on: [],
|
||||
rationale: `${health.stale_pages} stale page${health.stale_pages === 1 ? '' : 's'} on disk`,
|
||||
rationale: `Sync before extracting ${extractionLag} page${extractionLag === 1 ? '' : 's'} with stale link/timeline edges`,
|
||||
status: 'remediable',
|
||||
});
|
||||
}
|
||||
@@ -237,7 +255,7 @@ export function computeRecommendations(
|
||||
est_seconds: Math.min(3600, 5 + health.missing_embeddings * 0.05),
|
||||
est_usd_cost,
|
||||
// sync should run first so embed sees fresh pages.
|
||||
depends_on: ctx.repoPath && health.stale_pages > 0 ? ['sync.repo'] : [],
|
||||
depends_on: ctx.repoPath && extractionLag > 0 ? ['sync.repo'] : [],
|
||||
rationale: `${health.missing_embeddings} chunk${health.missing_embeddings === 1 ? '' : 's'} invisible to vector search`,
|
||||
status: 'remediable',
|
||||
});
|
||||
@@ -267,7 +285,7 @@ export function computeRecommendations(
|
||||
// Triggered when sync.repo fires (because sync was set to noEmbed:true,
|
||||
// and noExtract:true after T5 lands → extract job is the materializer).
|
||||
// ---------------------------------------------------------------------
|
||||
if (ctx.repoPath && health.stale_pages > 0) {
|
||||
if (ctx.repoPath && extractionLag > 0) {
|
||||
const params = { mode: 'all', dir: ctx.repoPath };
|
||||
out.push({
|
||||
id: 'extract.all',
|
||||
@@ -278,7 +296,7 @@ export function computeRecommendations(
|
||||
est_seconds: Math.min(600, 30 + health.page_count * 0.01),
|
||||
est_usd_cost: 0,
|
||||
depends_on: ['sync.repo'],
|
||||
rationale: 'Materialize link + timeline edges from fresh pages',
|
||||
rationale: `Materialize link + timeline edges for ${extractionLag} page${extractionLag === 1 ? '' : 's'} with stale extraction`,
|
||||
status: 'remediable',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { RecommendationContext } from '../brain-score-recommendations.ts';
|
||||
import { LINK_EXTRACTOR_VERSION_TS } from '../link-extraction.ts';
|
||||
|
||||
// Re-export so consumers can `import { RecommendationContext } from '../remediation'`
|
||||
// — the canonical RecommendationContext type still lives in
|
||||
@@ -68,5 +69,29 @@ export async function loadRecommendationContext(
|
||||
embeddingDimensions,
|
||||
embeddingProviderConfigured: embeddingConfigured,
|
||||
hasChatApiKey: !!(process.env.ANTHROPIC_API_KEY || fileCfg?.anthropic_api_key),
|
||||
extractionLagPages: await countExtractionLag(engine),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real extraction-lag count — the SAME staleness `gbrain extract --stale`
|
||||
* processes (engine.countStalePagesForExtraction with
|
||||
* versionTs=LINK_EXTRACTOR_VERSION_TS, matching doctor's links_extraction_lag
|
||||
* check — without versionTs, pages stamped before an extractor version bump
|
||||
* would lag for doctor/extract but never trip this gate). Drives the
|
||||
* sync→extract recommendation pipeline; replaces the legacy
|
||||
* `health.stale_pages` proxy that no longer reflected real extraction work
|
||||
* after the v10 trigger drop.
|
||||
*
|
||||
* Shared by loadRecommendationContext AND the D7 per-step recheck in
|
||||
* runRemediation — the recheck MUST refresh this gate alongside getHealth,
|
||||
* or a completed extract step keeps re-firing off the frozen initial count.
|
||||
*/
|
||||
export async function countExtractionLag(engine: BrainEngine): Promise<number> {
|
||||
try {
|
||||
return await engine.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
|
||||
} catch {
|
||||
/* counter unavailable (very old brain / mid-migration) — treat as 0 */
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
computeRecommendations,
|
||||
} from '../brain-score-recommendations.ts';
|
||||
import type { RemediationStep } from '../remediation-step.ts';
|
||||
import { loadRecommendationContext } from './context.ts';
|
||||
import { countExtractionLag, loadRecommendationContext } from './context.ts';
|
||||
import { computeRemediationPlan } from './plan.ts';
|
||||
import type {
|
||||
RemediationHooks,
|
||||
@@ -65,7 +65,7 @@ export async function runRemediation(
|
||||
clearRemediationCheckpoint,
|
||||
} = await import('../remediation-checkpoint.ts');
|
||||
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
let ctx = await loadRecommendationContext(engine);
|
||||
|
||||
// Pre-flight ceiling check via the shared plan computation.
|
||||
const initialPlan = await computeRemediationPlan(engine, { targetScore });
|
||||
@@ -305,6 +305,11 @@ export async function runRemediation(
|
||||
// steps with bumped retry suffix (D1).
|
||||
if (recs.length === 0 || stepCount >= maxJobs) break;
|
||||
const freshHealth = await engine.getHealth();
|
||||
// Refresh the extraction-lag gate alongside health: ctx was loaded once
|
||||
// before the loop, and a completed sync/extract step is exactly what
|
||||
// drives the count down. Reusing the frozen initial count would re-fire
|
||||
// sync.repo/extract.all every recheck until maxJobs.
|
||||
ctx = { ...ctx, extractionLagPages: await countExtractionLag(engine) };
|
||||
recs = computeRecommendations(freshHealth, ctx).filter((r) => r.status === 'remediable');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,15 +23,6 @@
|
||||
* 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';
|
||||
@@ -69,27 +60,9 @@ 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, as
|
||||
* are ClawHub-installed external skills that haven't opted in to gbrain
|
||||
* routing via `triggers:` frontmatter (#1767).
|
||||
* ManifestEntry. Dotfile and underscore-prefixed dirs are skipped.
|
||||
*/
|
||||
function deriveManifest(skillsDir: string): ManifestEntry[] {
|
||||
const out: ManifestEntry[] = [];
|
||||
@@ -120,12 +93,6 @@ 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` });
|
||||
|
||||
@@ -1423,6 +1423,15 @@ export interface BrainStats {
|
||||
export interface BrainHealth {
|
||||
page_count: number;
|
||||
embed_coverage: number;
|
||||
/**
|
||||
* LEGACY proxy: count of pages whose `updated_at` predates their newest
|
||||
* timeline entry. This bumped meaningfully only while a trigger updated
|
||||
* `pages.updated_at` on timeline insert; that trigger was dropped in
|
||||
* migration v10, so the metric no longer reflects real "needs work" state.
|
||||
* NO LONGER gates remediations — the sync→extract pipeline now gates on
|
||||
* `RecommendationContext.extractionLagPages` (the real extraction-lag from
|
||||
* `countStalePagesForExtraction`). Retained for the CLI health line + back-compat.
|
||||
*/
|
||||
stale_pages: number;
|
||||
/**
|
||||
* Islanded pages — zero inbound AND zero outbound links. A hub page
|
||||
|
||||
@@ -119,13 +119,12 @@ describe('computeRecommendations', () => {
|
||||
expect(recs.find((r) => r.id === 'embed.stale')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('stale pages + dead links produce sync + backlinks + extract', () => {
|
||||
test('extraction lag + dead links produce sync + backlinks + extract', () => {
|
||||
const health = makeHealth({
|
||||
stale_pages: 25,
|
||||
dead_links: 8,
|
||||
brain_score: 70,
|
||||
});
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 25 });
|
||||
const ids = recs.map((r) => r.id);
|
||||
expect(ids).toContain('sync.repo');
|
||||
expect(ids).toContain('backlinks.fix');
|
||||
@@ -133,18 +132,17 @@ describe('computeRecommendations', () => {
|
||||
});
|
||||
|
||||
test('extract.all depends on sync.repo (D14: stable ids)', () => {
|
||||
const health = makeHealth({ stale_pages: 10 });
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
|
||||
const health = makeHealth();
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 10 });
|
||||
const extract = recs.find((r) => r.id === 'extract.all');
|
||||
expect(extract?.depends_on).toContain('sync.repo');
|
||||
});
|
||||
|
||||
test('embed.stale depends on sync.repo when sync also needed', () => {
|
||||
test('embed.stale depends on sync.repo when extraction also needed', () => {
|
||||
const health = makeHealth({
|
||||
stale_pages: 10,
|
||||
missing_embeddings: 100,
|
||||
});
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 10 });
|
||||
const embed = recs.find((r) => r.id === 'embed.stale');
|
||||
expect(embed?.depends_on).toContain('sync.repo');
|
||||
});
|
||||
@@ -159,9 +157,9 @@ describe('computeRecommendations', () => {
|
||||
test('severity ordering: critical before high before medium', () => {
|
||||
const health = makeHealth({
|
||||
missing_embeddings: 100, // critical
|
||||
stale_pages: 80, // high
|
||||
});
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true });
|
||||
// extractionLagPages > 50 → sync.repo fires at 'high' severity.
|
||||
const recs = computeRecommendations(health, { repoPath: '/brain', embeddingProviderConfigured: true, extractionLagPages: 80 });
|
||||
const critIdx = recs.findIndex((r) => r.severity === 'critical');
|
||||
const highIdx = recs.findIndex((r) => r.severity === 'high');
|
||||
expect(critIdx).toBeLessThan(highIdx);
|
||||
@@ -170,11 +168,10 @@ describe('computeRecommendations', () => {
|
||||
// D6 #5 — THE critical regression test for the agent contract.
|
||||
test('D6 #5: determinism — same input twice produces identical output', () => {
|
||||
const health = makeHealth({
|
||||
stale_pages: 10,
|
||||
missing_embeddings: 50,
|
||||
dead_links: 3,
|
||||
});
|
||||
const ctx = { repoPath: '/brain', embeddingProviderConfigured: true, sourceId: 'default' };
|
||||
const ctx = { repoPath: '/brain', embeddingProviderConfigured: true, sourceId: 'default', extractionLagPages: 10 };
|
||||
const run1 = computeRecommendations(health, ctx);
|
||||
const run2 = computeRecommendations(health, ctx);
|
||||
expect(JSON.stringify(run1)).toBe(JSON.stringify(run2));
|
||||
|
||||
@@ -382,35 +382,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// test/remediation-context-extraction-lag.test.ts
|
||||
//
|
||||
// Pins the v-next fix: the sync→extract remediation pipeline gates on REAL
|
||||
// extraction lag, not the legacy `health.stale_pages` proxy (which counted
|
||||
// "updated_at predates newest timeline entry" — meaningless after the v10
|
||||
// trigger drop). loadRecommendationContext now populates `extractionLagPages`
|
||||
// from `engine.countStalePagesForExtraction` — the SAME counter the
|
||||
// `gbrain extract --stale` walk and doctor's `links_extraction_lag` use — so a
|
||||
// recommendation can only fire when running extract will actually reduce it.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { loadRecommendationContext } from '../src/core/remediation/context.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
describe('loadRecommendationContext — extractionLagPages wiring', () => {
|
||||
it('is 0 on an empty brain (nothing to extract)', async () => {
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
expect(ctx.extractionLagPages).toBe(0);
|
||||
});
|
||||
|
||||
it('reflects the real extraction-lag count once a page needs extraction', async () => {
|
||||
// A freshly-imported page has links_extracted_at = NULL, which the canonical
|
||||
// countStalePagesForExtraction predicate counts as stale-for-extraction.
|
||||
await engine.putPage('p0', {
|
||||
title: 'p0',
|
||||
type: 'note' as never,
|
||||
compiled_truth: 'body that is long enough to pass any minimum-length guards in the codebase',
|
||||
timeline: '',
|
||||
frontmatter: {},
|
||||
source_path: 'p0.md',
|
||||
});
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
expect(ctx.extractionLagPages).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('counts pages stamped before LINK_EXTRACTOR_VERSION_TS (version-bump arm)', async () => {
|
||||
// Backdate p0 so BOTH the NULL arm and the updated_at arm are quiet:
|
||||
// updated_at < links_extracted_at, but links_extracted_at predates the
|
||||
// extractor version stamp. doctor's links_extraction_lag and
|
||||
// `extract --stale` both count this page; the remediation gate must too.
|
||||
await engine.executeRaw(
|
||||
`UPDATE pages SET updated_at = '2020-01-01T00:00:00Z'::timestamptz,
|
||||
links_extracted_at = '2020-01-02T00:00:00Z'::timestamptz
|
||||
WHERE slug = 'p0'`,
|
||||
[],
|
||||
);
|
||||
const ctx = await loadRecommendationContext(engine);
|
||||
expect(ctx.extractionLagPages).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// test/remediation-run-d7-refresh.serial.test.ts
|
||||
//
|
||||
// Pins the D7-recheck half of the extraction-lag gate fix: runRemediation
|
||||
// loads RecommendationContext ONCE before the step loop, and the per-step
|
||||
// recheck (D7) must REFRESH ctx.extractionLagPages alongside getHealth.
|
||||
// Without the refresh, a completed sync/extract step keeps re-firing off
|
||||
// the frozen initial count — the plan never converges and the loop burns
|
||||
// steps until maxJobs.
|
||||
//
|
||||
// SERIAL (R2): uses top-level mock.module for the minion queue +
|
||||
// wait-for-completion so no real worker is needed — mocks leak across
|
||||
// files in a shard process, so this file must run in its own process.
|
||||
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
// The fake brain: sync.repo clears the extraction lag when it "runs"
|
||||
// (today's sync materializes link/timeline edges; extract.all is the
|
||||
// explicit re-materializer). The frozen-ctx bug makes runRemediation
|
||||
// ignore that and resubmit sync.repo on every D7 recheck.
|
||||
let extractionLag = 25;
|
||||
const submittedJobs: string[] = [];
|
||||
|
||||
mock.module('../src/core/minions/queue.ts', () => ({
|
||||
MinionQueue: class {
|
||||
constructor(_engine: unknown) {}
|
||||
async add(job: string): Promise<{ id: number }> {
|
||||
submittedJobs.push(job);
|
||||
if (job === 'sync' || job === 'extract') extractionLag = 0;
|
||||
return { id: submittedJobs.length };
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('../src/core/minions/wait-for-completion.ts', () => ({
|
||||
waitForCompletion: async () => ({ status: 'completed' }),
|
||||
}));
|
||||
|
||||
const health = () => ({
|
||||
page_count: 100,
|
||||
embed_coverage: 1.0,
|
||||
stale_pages: 0, // legacy proxy stays 0 — the real counter drives the gate
|
||||
orphan_pages: 0,
|
||||
missing_embeddings: 0,
|
||||
brain_score: 70,
|
||||
dead_links: 0,
|
||||
link_coverage: 1.0,
|
||||
timeline_coverage: 1.0,
|
||||
most_connected: [],
|
||||
embed_coverage_score: 35,
|
||||
link_density_score: 25,
|
||||
timeline_coverage_score: 15,
|
||||
no_orphans_score: 15,
|
||||
no_dead_links_score: 10,
|
||||
});
|
||||
|
||||
const fakeEngine = {
|
||||
kind: 'pglite' as const,
|
||||
getHealth: async () => health(),
|
||||
getConfig: async (key: string) =>
|
||||
key === 'sync.repo_path' ? '/tmp/brain-example' : null,
|
||||
countStalePagesForExtraction: async () => extractionLag,
|
||||
};
|
||||
|
||||
describe('runRemediation D7 recheck — extraction-lag gate refresh', () => {
|
||||
test('a completed materializer step clears the gate; the pipeline is not resubmitted', async () => {
|
||||
const { runRemediation } = await import('../src/core/remediation/run.ts');
|
||||
const result = await runRemediation(
|
||||
// Only the methods the orchestrator touches are needed.
|
||||
fakeEngine as never,
|
||||
{ targetScore: 0, maxJobs: 6 },
|
||||
);
|
||||
// Frozen-ctx bug: extractionLagPages stays 25 forever, so every D7
|
||||
// recheck re-introduces the sync/extract pipeline and the loop burns
|
||||
// all 6 maxJobs. With the refresh, the plan converges after the first
|
||||
// completed step: no step id is ever submitted twice.
|
||||
const ids = result.submitted.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(submittedJobs.length).toBeLessThan(3);
|
||||
expect(extractionLag).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -166,55 +166,6 @@ 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