Compare commits

..
Author SHA1 Message Date
9391fb9317 fix(onboard): stop dropping onboard-check remediations on the --apply --auto path
Takeover of #2161. runRemediation ignored onboard-check extras in three
places: the pre-flight plan, the initial recommendation build, and the D7
mid-run recheck that rebuilds recs after every completed step. The --check
path threaded extras correctly, so `gbrain onboard --apply --auto` reported
"Nothing to do" when the only remediable work came from onboard checks —
and even with the first two sites fixed (the original PR diff), any plan
with 2+ steps dropped all remaining extras after step 1 via the recheck.

- Add RemediationOpts.extraRemediations; thread it through the pre-flight
  plan, initial recs, and the mid-run recheck.
- Recheck filters extras to ids not already processed this run: extras
  carry static status:'remediable', so unfiltered threading would resubmit
  completed extras forever.
- Wire the CLI --auto path (onboard.ts) AND the MCP run_onboard auto path
  (operations.ts), which already computed the scope-filtered allowedExtras
  and then dropped it.
- Regression test: extras-only plan on an empty brain runs BOTH extras
  exactly once and terminates (serial file: mock.module queue stub +
  GBRAIN_HOME tmpdir).

Co-authored-by: brettdavies <brettdavies@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:25:52 -07:00
10 changed files with 139 additions and 129 deletions
+5 -1
View File
@@ -142,12 +142,16 @@ export async function runOnboard(engine: BrainEngine, args: string[]): Promise<v
// --auto path: runs through the T2 library orchestrator. Hooks emit CLI
// progress to stderr; the final result lands as JSON on stdout (or human
// summary).
// summary). extraRemediations (gathered above from runAllOnboardChecks)
// is threaded into the runner so the onboard-check remediations
// (extract-ner, extract-timeline-from-meetings, etc.) reach the planner
// — the same wiring the --check path uses above.
const result = await runRemediation(
engine,
{
targetScore,
maxUsd,
extraRemediations,
// --auto --yes opts into the prompt_required tier too; library
// doesn't distinguish auto_apply vs prompt_required, it just runs
// every remediation in the plan. The plan-building side (T12 render)
+5 -5
View File
@@ -197,7 +197,7 @@ export interface SyncResult {
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
embedded: number;
pagesAffected: string[];
failedFiles?: number; // count of per-file import/sync failures (Bug 9)
failedFiles?: number; // count of parse failures (Bug 9)
/**
* v0.41.13.0 partial-sync fields (only set when status === 'partial').
*
@@ -3183,7 +3183,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
await clearOpCheckpoint(engine, ckpt.target);
};
// issue #1939 adversarial finding #1: a file that failed to import (open ledger
// issue #1939 adversarial finding #1: a file that failed to parse (open ledger
// row) and is then deleted/renamed-away never re-enters failedFiles and never
// imports, so its row would never clear and would age doctor to a permanent
// FAIL. Treat removed paths as resolved so the ledger self-heals.
@@ -3215,9 +3215,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
} else {
const fileFailCount = failedFiles.filter(f => isSkippablePath(f.path)).length;
serr(
`\nSync blocked: ${fileFailCount} file(s) failed to import:\n` +
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
`${codeBreakdown}\n\n` +
`Fix the listed file errors and re-run, or use 'gbrain sync --skip-failed' to ` +
`Fix the frontmatter and re-run, or use 'gbrain sync --skip-failed' to ` +
`acknowledge and move on. A file that keeps failing auto-skips after ` +
`${resolveAutoSkipThreshold()} consecutive syncs.`,
);
@@ -5355,7 +5355,7 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
case 'dry_run':
break; // already printed in performSync
case 'blocked_by_failures':
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to import.`);
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
write(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
+1 -1
View File
@@ -4957,7 +4957,7 @@ const run_onboard: Operation = {
// typo, the underlying queue.add would reject. Defense-in-depth.
const result = await runRemediation(
ctx.engine,
{ targetScore, maxUsd },
{ targetScore, maxUsd, extraRemediations: allowedExtras },
{},
);
-10
View File
@@ -1058,16 +1058,6 @@ export class PGLiteEngine implements BrainEngine {
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at`,
[sourceId, slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash, effectiveDate, effectiveDateSource, importFilename, chunkerVersion, sourcePath, sourceKind, sourceUri, ingestedVia, ingestedAt]
);
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors postgres-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0] as Record<string, unknown>);
}
-10
View File
@@ -1119,16 +1119,6 @@ export class PostgresEngine implements BrainEngine {
ingested_at = COALESCE(EXCLUDED.ingested_at, pages.ingested_at)
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at
`;
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
// Throw a diagnosable error naming the row instead. Mirrors pglite-engine.ts.
if (!rows[0]) {
throw new Error(
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
`A trigger or rule on the pages table may be suppressing the write.`
);
}
return rowToPage(rows[0]);
}
+10 -3
View File
@@ -66,9 +66,10 @@ export async function runRemediation(
} = await import('../remediation-checkpoint.ts');
const ctx = await loadRecommendationContext(engine);
const extraRemediations = opts.extraRemediations ?? [];
// Pre-flight ceiling check via the shared plan computation.
const initialPlan = await computeRemediationPlan(engine, { targetScore });
const initialPlan = await computeRemediationPlan(engine, { targetScore, extraRemediations });
if (initialPlan.target_unreachable) {
hooks.onTargetUnreachable?.(targetScore, initialPlan.max_reachable_score);
return {
@@ -87,7 +88,7 @@ export async function runRemediation(
}
const initialHealth = await engine.getHealth();
let recs: RemediationStep[] = computeRecommendations(initialHealth, ctx)
let recs: RemediationStep[] = computeRecommendations(initialHealth, ctx, extraRemediations)
.filter((r) => r.status === 'remediable');
if (recs.length === 0) {
hooks.onNothingToDo?.(initialHealth.brain_score, targetScore);
@@ -305,7 +306,13 @@ export async function runRemediation(
// steps with bumped retry suffix (D1).
if (recs.length === 0 || stepCount >= maxJobs) break;
const freshHealth = await engine.getHealth();
recs = computeRecommendations(freshHealth, ctx).filter((r) => r.status === 'remediable');
// Extras carry a static status:'remediable' — a fresh health snapshot
// never ages them out the way health-derived steps drop. Filter out
// ids this run already processed (any terminal status), or the recheck
// would resubmit completed extras every iteration, forever.
const processedIds = new Set(submitted.map((s) => s.id));
const pendingExtras = extraRemediations.filter((r) => !processedIds.has(r.id));
recs = computeRecommendations(freshHealth, ctx, pendingExtras).filter((r) => r.status === 'remediable');
}
};
+10
View File
@@ -63,6 +63,16 @@ export interface RemediationOpts {
resumePlanHash?: string;
/** Whether to attempt resume at all (default false). */
resume?: boolean;
/**
* Caller-supplied RemediationStep entries threaded into the planner.
* Mirrors RemediationPlanOpts.extraRemediations so onboard's --apply
* --auto path (and MCP run_onboard auto modes) forward the same
* onboard-check remediations the --check path already passes through
* computeRemediationPlan. Without this the runner saw only generic
* brain_score remediations and reported "Nothing to do" whenever the
* only applicable work was an extra (e.g. extract-ner).
*/
extraRemediations?: RemediationStep[];
}
/**
-74
View File
@@ -1,74 +0,0 @@
// #2189 regression guard: putPage's INSERT … ON CONFLICT DO UPDATE … RETURNING
// can yield 0 rows when brain-local DB state (e.g. a BEFORE INSERT trigger)
// suppresses the write. Pre-fix, rowToPage(rows[0]) crashed with the opaque
// "undefined is not an object (evaluating 'row.deleted_at')" that failed
// ~all files of a code sync. Post-fix, putPage throws a descriptive error
// naming the slug + source_id so the failure is diagnosable per-file.
//
// Same guard lands in postgres-engine.ts (engine-parity invariant); this test
// exercises the PGLite side, where the issue was reported.
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Simulate the reporter's state-dependent failure: a trigger that
// suppresses inserts for one slug, making RETURNING produce no row.
await engine.executeRaw(`
CREATE OR REPLACE FUNCTION suppress_pages_insert() RETURNS trigger AS $$
BEGIN
IF NEW.slug = 'suppressed-page' THEN RETURN NULL; END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
`);
await engine.executeRaw(`
CREATE TRIGGER suppress_pages_insert_trg
BEFORE INSERT ON pages
FOR EACH ROW EXECUTE FUNCTION suppress_pages_insert();
`);
});
afterAll(async () => {
await engine.executeRaw('DROP TRIGGER IF EXISTS suppress_pages_insert_trg ON pages');
await engine.executeRaw('DROP FUNCTION IF EXISTS suppress_pages_insert');
await engine.disconnect();
});
describe('putPage RETURNING guard (#2189)', () => {
test('0-row RETURNING throws a descriptive error, not row.deleted_at TypeError', async () => {
let err: Error | undefined;
try {
await engine.putPage('suppressed-page', {
type: 'code',
title: 'Suppressed',
compiled_truth: 'x',
timeline: '',
});
} catch (e) {
err = e as Error;
}
expect(err).toBeDefined();
expect(err!.message).toContain('putPage');
expect(err!.message).toContain("slug='suppressed-page'");
expect(err!.message).toContain("source_id='default'");
// The pre-fix crash signature must be gone.
expect(err!.message).not.toContain('deleted_at');
});
test('unsuppressed slugs still upsert normally with the trigger installed', async () => {
const page = await engine.putPage('normal-page', {
type: 'concept',
title: 'Normal',
compiled_truth: 'y',
timeline: '',
});
expect(page.slug).toBe('normal-page');
expect(page.source_id).toBe('default');
});
});
+108
View File
@@ -0,0 +1,108 @@
// test/remediation-run-extras.serial.test.ts
// Regression for PR #2161 takeover: `gbrain onboard --apply --auto` dropped
// onboard-check extraRemediations. Two distinct halves of the bug:
// 1. runRemediation built the pre-flight plan + initial recs WITHOUT the
// extras, so an extras-only plan reported "Nothing to do".
// 2. The D7 mid-run recheck rebuilt recs WITHOUT the extras after every
// completed step, so with 2+ plannable steps all remaining extras were
// dropped after step 1. The recheck must also filter out extras this
// run already processed — extras carry static status:'remediable', so
// unfiltered threading would resubmit completed extras forever.
//
// SERIAL: mock.module (queue + wait-for-completion stubs, R2) + GBRAIN_HOME
// env mutation so checkpoint files land in a tmpdir, not ~/.gbrain.
import { describe, expect, test, beforeAll, afterAll, mock } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { makeRemediationStep } from '../src/core/remediation-step.ts';
// Stub the Minion queue: every submitted job is immediately 'completed'.
// runRemediation only calls queue.add + waitForCompletion(queue, id).
let nextJobId = 1;
const submittedJobs: Array<{ name: string }> = [];
mock.module('../src/core/minions/queue.ts', () => ({
MinionQueue: class {
async add(name: string) {
submittedJobs.push({ name });
return { id: nextJobId++, status: 'completed' };
}
},
}));
mock.module('../src/core/minions/wait-for-completion.ts', () => ({
waitForCompletion: async () => ({ status: 'completed' }),
}));
let engine: PGLiteEngine;
let home: string;
const prevHome = process.env.GBRAIN_HOME;
beforeAll(async () => {
home = mkdtempSync(join(tmpdir(), 'gbrain-remextras-'));
process.env.GBRAIN_HOME = home;
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 120_000);
afterAll(async () => {
await engine.disconnect();
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = prevHome;
rmSync(home, { recursive: true, force: true });
});
function extra(id: string, job: string) {
return makeRemediationStep({
id,
job,
params: {},
severity: 'medium',
est_seconds: 5,
est_usd_cost: 0,
rationale: 'synthetic onboard-check extra',
status: 'remediable',
});
}
describe('runRemediation extraRemediations threading', () => {
test('extras-only plan runs BOTH extras and terminates (no Nothing-to-do, no resubmit loop)', async () => {
// Empty PGLite brain → zero health-derived recommendations. Without the
// fix, half 1 makes this run return submitted: [] via onNothingToDo.
// With only half 1 (the original PR #2161 diff), the mid-run recheck
// drops the second extra after step 1 — submitted has 1 entry, not 2.
const { runRemediation } = await import('../src/core/remediation/run.ts');
let nothingToDo = false;
const result = await runRemediation(
engine,
{
targetScore: 1,
extraRemediations: [
extra('onboard.extract_ner', 'extract-ner'),
extra('onboard.extract_timeline', 'extract-timeline-from-meetings'),
],
// Safety bound: an unfiltered recheck would resubmit completed
// extras forever; maxJobs turns that regression into a fast fail
// (extra count > 1 below) instead of a hung test.
maxJobs: 5,
},
{ onNothingToDo: () => { nothingToDo = true; } },
);
expect(nothingToDo).toBe(false);
const ids = result.submitted.map((s) => s.id);
expect(ids).toContain('onboard.extract_ner');
expect(ids).toContain('onboard.extract_timeline');
// Each extra ran exactly once — the recheck must not re-plan extras the
// run already processed.
expect(ids.filter((i) => i === 'onboard.extract_ner').length).toBe(1);
expect(ids.filter((i) => i === 'onboard.extract_timeline').length).toBe(1);
expect(result.submitted.every((s) => s.status === 'completed')).toBe(true);
expect(submittedJobs.map((j) => j.name).sort()).toEqual([
'extract-ner',
'extract-timeline-from-meetings',
]);
});
});
-25
View File
@@ -375,31 +375,6 @@ describe('performSync dry-run never writes', () => {
expect(messages.some(m => m.includes('git pull failed'))).toBe(false);
});
test('first PGLite code sync imports code files without runtime failures', async () => {
const { performSync } = await import('../src/commands/sync.ts');
mkdirSync(join(repoPath, 'src'), { recursive: true });
writeFileSync(
join(repoPath, 'src/example.ts'),
'export function add(left: number, right: number) { return left + right; }\n',
);
execSync('git add -A && git commit -m "add code file"', { cwd: repoPath, stdio: 'pipe' });
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
strategy: 'code',
});
expect(result.status).toBe('first_sync');
expect(result.added).toBe(1);
expect(result.failedFiles ?? 0).toBe(0);
const page = await engine.getPage('src-example-ts');
expect(page?.type).toBe('code');
expect(page?.frontmatter).toMatchObject({ file: 'src/example.ts', language: 'typescript' });
});
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// First do a real sync to seed the bookmark.