diff --git a/src/commands/autopilot.ts b/src/commands/autopilot.ts index 82979fb12..5fa1197c1 100644 --- a/src/commands/autopilot.ts +++ b/src/commands/autopilot.ts @@ -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 diff --git a/src/core/brain-score-recommendations.ts b/src/core/brain-score-recommendations.ts index 810b43bed..c6f71c37b 100644 --- a/src/core/brain-score-recommendations.ts +++ b/src/core/brain-score-recommendations.ts @@ -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', }); } diff --git a/src/core/remediation/context.ts b/src/core/remediation/context.ts index 1fe8ae651..8599df699 100644 --- a/src/core/remediation/context.ts +++ b/src/core/remediation/context.ts @@ -68,5 +68,25 @@ 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). 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 { + try { + return await engine.countStalePagesForExtraction(); + } catch { + /* counter unavailable (very old brain / mid-migration) — treat as 0 */ + return 0; + } +} diff --git a/src/core/remediation/run.ts b/src/core/remediation/run.ts index 78f55d44b..c516dd46d 100644 --- a/src/core/remediation/run.ts +++ b/src/core/remediation/run.ts @@ -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'); } }; diff --git a/src/core/types.ts b/src/core/types.ts index 9464d16de..c5421d52b 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 diff --git a/test/brain-score-recommendations.test.ts b/test/brain-score-recommendations.test.ts index 998d5f1b4..5a3d55e2b 100644 --- a/test/brain-score-recommendations.test.ts +++ b/test/brain-score-recommendations.test.ts @@ -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)); diff --git a/test/remediation-context-extraction-lag.test.ts b/test/remediation-context-extraction-lag.test.ts new file mode 100644 index 000000000..94a0ad362 --- /dev/null +++ b/test/remediation-context-extraction-lag.test.ts @@ -0,0 +1,47 @@ +// 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); + }); +}); diff --git a/test/remediation-run-d7-refresh.serial.test.ts b/test/remediation-run-d7-refresh.serial.test.ts new file mode 100644 index 000000000..e8b56279c --- /dev/null +++ b/test/remediation-run-d7-refresh.serial.test.ts @@ -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); + }); +});