Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 921048827a fix(remediation): pass LINK_EXTRACTOR_VERSION_TS to the extraction-lag gate
countExtractionLag omitted versionTs, so its predicate diverged from the
counter it claims to share with doctor's links_extraction_lag check and
the extract --stale walk: pages stamped before an extractor version bump
(links_extracted_at < LINK_EXTRACTOR_VERSION_TS) lagged for doctor and
extract but never tripped the sync.repo/extract.all gate. Pass the stamp;
pin the version-bump arm with a backdated-page test (fails without it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:53:55 -07:00
0dff84b16a fix(remediation): gate sync/extract recs on real extraction lag, refresh at D7 recheck
Takeover of #2363. The sync.repo/extract.all recommendations gated on
health.stale_pages — a proxy (updated_at predates newest timeline entry)
that stopped meaning anything after migration v10 dropped the trigger
behind it. Gate them on the honest counter instead:
engine.countStalePagesForExtraction, the same staleness `gbrain extract
--stale` and doctor's links_extraction_lag use.

On top of the original PR, two repairs:

- runRemediation loads RecommendationContext once, but the D7 per-step
  recheck reused the frozen extractionLagPages — a completed sync/extract
  step could never clear the gate, so the pipeline re-fired every recheck
  until maxJobs. The recheck now refreshes the gate alongside getHealth
  via the shared countExtractionLag() helper (extracted into
  remediation/context.ts). Pinned by
  test/remediation-run-d7-refresh.serial.test.ts (serial: mock.module).
- autopilot builds its own RecommendationContext by hand; without wiring,
  it would silently never fire sync.repo/extract.all again. It now
  populates extractionLagPages from the same helper.

Co-authored-by: DarkNightForge <DarkNightForge@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 15:06:34 -07:00
11 changed files with 239 additions and 85 deletions
+4
View File
@@ -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
+15 -33
View File
@@ -107,14 +107,6 @@ export interface EmbedOpts {
* runs lock every source in sorted order. dryRun skips it.
*/
singleFlight?: boolean;
/**
* #394: suppress human stdout summaries (the `[dry-run] Would embed ...` /
* `Embedded N chunks ...` slog lines). Set by structured-output callers —
* the cycle's embed phase (dream --json must keep stdout JSON-clean per
* docs/progress-events.md) reports counts via its own PhaseResult instead.
* Errors/warnings still go to stderr regardless.
*/
quiet?: boolean;
}
/**
@@ -261,7 +253,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
for (const s of opts.slugs) {
if (isAborted(opts.signal)) break; // #1737: stop the per-slug loop on abort
try {
await embedPage(engine, s, !!opts.dryRun, result, opts.sourceId, opts.signal, opts.quiet);
await embedPage(engine, s, !!opts.dryRun, result, opts.sourceId, opts.signal);
} catch (e: unknown) {
serr(` Error embedding ${s}: ${e instanceof Error ? e.message : e}`);
}
@@ -355,7 +347,6 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
catchUp: opts.catchUp,
pacer,
paceMaxConcurrency,
quiet: opts.quiet,
}, opts.signal);
} finally {
// E1: surface pacing telemetry (human + structured) when pacing was on.
@@ -385,7 +376,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
return result;
}
if (opts.slug) {
await embedPage(engine, opts.slug, !!opts.dryRun, result, opts.sourceId, opts.signal, opts.quiet);
await embedPage(engine, opts.slug, !!opts.dryRun, result, opts.sourceId, opts.signal);
return result;
}
throw new Error('No embed target specified. Pass { slug }, { slugs }, { all }, or { stale }.');
@@ -530,7 +521,6 @@ async function embedPage(
result: EmbedResult,
sourceId?: string,
signal?: AbortSignal,
quiet?: boolean,
) {
const opts = sourceId ? { sourceId } : undefined;
const page = await engine.getPage(slug, opts);
@@ -575,7 +565,7 @@ async function embedPage(
result.skipped += chunks.length - toEmbed.length;
if (toEmbed.length === 0) {
if (!quiet) slog(`${slug}: all ${chunks.length} chunks already embedded`);
slog(`${slug}: all ${chunks.length} chunks already embedded`);
result.pages_processed++;
return;
}
@@ -612,7 +602,7 @@ async function embedPage(
}
result.embedded += toEmbed.length;
result.pages_processed++;
if (!quiet) slog(`${slug}: embedded ${toEmbed.length} chunks`);
slog(`${slug}: embedded ${toEmbed.length} chunks`);
}
async function embedAll(
@@ -630,8 +620,6 @@ async function embedAll(
pacer?: DbPacer;
/** Resolved concurrency cap (E-1: the worker count, no separate permit). */
paceMaxConcurrency?: number;
/** #394: suppress human stdout summaries (structured-output callers). */
quiet?: boolean;
},
signal?: AbortSignal,
) {
@@ -775,12 +763,10 @@ async function embedAll(
});
// Stdout summary preserved for scripts/tests that grep for counts.
if (!staleOpts?.quiet) {
if (dryRun) {
slog(`[dry-run] Would embed ${result.would_embed} chunks across ${pages.length} pages`);
} else {
slog(`Embedded ${result.embedded} chunks across ${pages.length} pages`);
}
if (dryRun) {
slog(`[dry-run] Would embed ${result.would_embed} chunks across ${pages.length} pages`);
} else {
slog(`Embedded ${result.embedded} chunks across ${pages.length} pages`);
}
}
@@ -816,8 +802,6 @@ async function embedAllStale(
pacer?: DbPacer;
/** Resolved concurrency cap (E-1: the worker count, no separate permit). */
paceMaxConcurrency?: number;
/** #394: suppress human stdout summaries (structured-output callers). */
quiet?: boolean;
},
signature?: string,
externalSignal?: AbortSignal,
@@ -835,7 +819,7 @@ async function embedAllStale(
signature,
...(sourceId && { sourceId }),
});
if (invalidated > 0 && !staleOpts?.quiet) {
if (invalidated > 0) {
slog(`[embed] invalidated ${invalidated} chunk(s) embedded under a prior model signature`);
}
}
@@ -846,12 +830,10 @@ async function embedAllStale(
dryRun && signature ? { ...sourceOpt, signature } : sourceOpt,
);
if (staleCount === 0) {
if (!staleOpts?.quiet) {
if (dryRun) {
slog('[dry-run] Would embed 0 chunks (0 stale found)');
} else {
slog('Embedded 0 chunks (0 stale found)');
}
if (dryRun) {
slog('[dry-run] Would embed 0 chunks (0 stale found)');
} else {
slog('Embedded 0 chunks (0 stale found)');
}
return;
}
@@ -860,7 +842,7 @@ async function embedAllStale(
result.would_embed += staleCount;
result.total_chunks += staleCount;
if (onProgress) onProgress(1, 1, 0);
if (!staleOpts?.quiet) slog(`[dry-run] Would embed ${staleCount} stale chunks`);
slog(`[dry-run] Would embed ${staleCount} stale chunks`);
return;
}
@@ -1100,7 +1082,7 @@ async function embedAllStale(
if (budgetTimer) clearTimeout(budgetTimer);
}
if (!staleOpts?.quiet) slog(`Embedded ${result.embedded} chunks across ${totalProcessedPages} pages`);
slog(`Embedded ${result.embedded} chunks across ${totalProcessedPages} pages`);
// #1946 (OV2a): a catch-up pass that completed without being aborted but left
// chunks unembedded means those chunks are stuck (a non-transient embed
+26 -8
View File
@@ -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',
});
}
+1 -3
View File
@@ -1214,9 +1214,7 @@ async function runPhaseEmbed(engine: BrainEngine, dryRun: boolean, signal?: Abor
// 10-15 min one) bails within a batch instead of running to completion
// after the job was killed — which left gbrain_cycle_locks held and
// wedged every subsequent autopilot cycle.
// #394: quiet — the cycle reports embed counts via its own PhaseResult;
// raw `[dry-run] Would embed ...` stdout lines would corrupt `dream --json`.
const result = await runEmbedCore(engine, { stale: true, dryRun, signal, quiet: true });
const result = await runEmbedCore(engine, { stale: true, dryRun, signal });
const embeddedCount = dryRun ? result.would_embed : result.embedded;
return {
phase: 'embed',
+25
View File
@@ -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;
}
}
+7 -2
View File
@@ -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');
}
};
+9
View File
@@ -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
+9 -12
View File
@@ -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));
-27
View File
@@ -292,33 +292,6 @@ describe('runDream — output format', () => {
expect(parsed).toHaveProperty('totals');
});
// #394 / takeover of #854: the embed phase's `[dry-run] Would embed ...`
// summary must not leak onto stdout ahead of the JSON CycleReport.
test('--dry-run --json emits only JSON even when embed has stale chunks', async () => {
await engine.putPage('concepts/testing', {
type: 'concept',
title: 'Testing',
compiled_truth: 'Testing keeps JSON contracts honest.',
timeline: '',
});
await engine.upsertChunks('concepts/testing', [
{ chunk_index: 0, chunk_text: 'Testing keeps JSON contracts honest.', chunk_source: 'compiled_truth' },
]);
const lines: string[] = [];
const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => { lines.push(String(msg)); });
await runDream(engine, ['--dir', repo, '--phase', 'embed', '--dry-run', '--json']);
logSpy.mockRestore();
const output = lines.join('\n');
expect(output.trimStart().startsWith('{')).toBe(true);
const parsed = JSON.parse(output);
expect(parsed.schema_version).toBe('1');
expect(parsed.phases[0].phase).toBe('embed');
// The stale chunk was still counted in the structured report.
expect(parsed.phases[0].details.would_embed).toBe(1);
});
test('human output for clean status mentions "Brain is healthy"', async () => {
const lines: string[] = [];
const logSpy = spyOn(console, 'log').mockImplementation((msg: string) => { lines.push(String(msg)); });
@@ -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);
});
});