Files
gbrain/test/cycle-abort.test.ts
T
7ffac65c62 fix(extract,ingest,cycle): source-provenance wave — thread source identity through ingest_capture, fs-walk links, and cycle extract (#1522 #1747 #1503) (#2920)
* fix(extract,ingest,cycle): source-provenance wave — thread source identity through ingest_capture, fs-walk links, and cycle extract (#1522 #1747 #1503)

Three fixes in the same invariant class (source identity silently dropped
on the write path, collapsing to the 'default' source):

- #1522: the ingest_capture Minion handler validated IngestionEvent
  provenance (source_id/source_kind/source_uri) then dropped it on the
  importFromContent call. Now threads source_kind/source_uri +
  ingested_via='ingest_capture' into the page write, and routes the write
  under event.source_id when it names a registered source AND the event
  is trusted (fail-closed: untrusted webhook payloads carry a
  caller-controlled x-gbrain-source-id header and must not choose their
  write source; unregistered emitter ids keep default routing so the
  webhook path can't FK-fail).

- #1747: the fs-walk extractors (extractLinksFromDir /
  extractTimelineFromDir / extractForSlugs) built batch rows with no
  source_id, so addLinksBatch/addTimelineEntriesBatch mapped missing →
  'default' and the pages JOIN dropped every row on a non-default source
  ("Links: created 0 from N pages", no error). ExtractOpts gains
  sourceId; the CLI fs path resolves it via resolveSourceId
  (--source-id > env > dotfile > registered path > sole-non-default)
  and rows are stamped from/to/origin_source_id + timeline source_id.

- #1503: the cycle's extract phase (runPhaseExtract) never passed a
  sourceId, so federated-brain dream/autopilot cycles persisted nothing
  every night. It now threads cycleSourceId (explicit --source or
  resolveSourceForDir(brainDir) — the same seam runPhaseSync uses) into
  runExtractCore for both the incremental and full-walk paths.

Regression tests: handler provenance write-through (registered/
unregistered/untrusted routing), fs-walk + CLI resolution + incremental
cycle route landing edges/timeline in the right source (all red on
unfixed master), and a negative control pinning the pre-fix JOIN-drop
shape.

Reuses the ExtractOpts.sourceId threading approach from PR #1719,
rebased onto the current signal-aware signatures and extended to the
cycle + timeline paths.

Fixes #1522
Fixes #1747
Fixes #1503

Co-authored-by: seungsu <kss530c@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update cycle source pin for cycleSourceId threading

The #1972 source-pin test asserts the literal runPhaseExtract call site in
cycle.ts. The #1503 fix appended cycleSourceId after opts.signal; signal
threading is unchanged (still the 5th arg, forwarded to runExtractCore).
Pin updated to the new literal — invariant intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: seungsu <kss530c@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:36:43 -07:00

202 lines
8.5 KiB
TypeScript

/**
* test/cycle-abort.test.ts — Verify runCycle respects AbortSignal.
*
* Regression test for the 2026-04-24 incident where 98 jobs piled up
* because autopilot-cycle's handler didn't propagate AbortSignal to
* runCycle, and runCycle had no signal-checking between phases.
*
* Tests the three-layer fix:
* 1. CycleOpts.signal — runCycle checks signal between phases
* 2. Handler wiring — autopilot-cycle passes job.signal
* 3. Worker force-eviction — last resort if handler ignores abort
*
* Layer 3 is tested in minions.test.ts (worker-level). This file
* covers layers 1 and 2 via the cycle interface.
*/
import { describe, test, expect } from 'bun:test';
// We can't easily import runCycle with a real engine for unit tests,
// but we CAN test the checkAborted pattern and CycleOpts contract.
describe('CycleOpts.signal contract (v0.20.5)', () => {
test('signal field exists on CycleOpts interface', async () => {
// Type-level test: importing the type should work
const mod = await import('../src/core/cycle.ts');
// runCycle exists and is callable
expect(typeof mod.runCycle).toBe('function');
});
test('runCycle accepts signal in opts without error', async () => {
// Verify runCycle doesn't crash when signal is passed but no engine
const { runCycle } = await import('../src/core/cycle.ts');
const abort = new AbortController();
// Call with null engine + minimal opts — should return a report
// (phases that need engine will be skipped)
const report = await runCycle(null, {
brainDir: '/nonexistent-for-test',
phases: [], // empty phases = no work
signal: abort.signal,
});
expect(report.schema_version).toBe('1');
expect(report.status).toBeDefined();
});
test('runCycle bails on pre-aborted signal', async () => {
const { runCycle } = await import('../src/core/cycle.ts');
const abort = new AbortController();
abort.abort(new Error('timeout'));
// With a pre-aborted signal and phases that would run, it should
// throw or return failed (depending on which phase catches it first)
try {
const report = await runCycle(null, {
brainDir: '/nonexistent-for-test',
phases: ['lint'], // lint doesn't need engine, would normally run
signal: abort.signal,
});
// If it returns instead of throwing, status should reflect the abort
expect(['failed', 'partial']).toContain(report.status);
} catch (err) {
// checkAborted threw — this is the expected behavior
expect(err instanceof Error).toBe(true);
expect((err as Error).message).toContain('aborted');
}
});
test('runCycle bails mid-flight when signal fires between phases', async () => {
const { runCycle } = await import('../src/core/cycle.ts');
const abort = new AbortController();
// Abort after 50ms — should catch between phases
setTimeout(() => abort.abort(new Error('timeout')), 50);
try {
const report = await runCycle(null, {
brainDir: '/nonexistent-for-test',
phases: ['lint', 'backlinks', 'orphans'],
signal: abort.signal,
yieldBetweenPhases: async () => {
// Slow yield to give the abort time to fire
await new Promise(r => setTimeout(r, 100));
},
});
// If it returned cleanly, not all phases should have run
// (abort should have prevented later phases)
const completedPhases = report.phases.length;
expect(completedPhases).toBeLessThan(3);
} catch (err) {
// checkAborted threw between phases — expected
expect(err instanceof Error).toBe(true);
expect((err as Error).message).toContain('aborted');
}
});
});
describe('autopilot-cycle handler contract (v0.20.5)', () => {
test('handler registration passes signal to runCycle', async () => {
// Verify the handler code in jobs.ts includes job.signal
const fs = await import('fs');
const jobsSource = fs.readFileSync(
new URL('../src/commands/jobs.ts', import.meta.url),
'utf8',
);
// The autopilot-cycle handler MUST pass signal to runCycle.
// Source-level regression guard.
//
// The slice window was bumped to 6000 in v0.39 — the v0.38 wave added
// source_id validation + archive recheck + pull-flag threading at the
// top of the handler, which pushed the runCycle({signal:...}) call past
// the original 2000-char ceiling. The intent of the guard is unchanged:
// "the autopilot-cycle handler passes job.signal to runCycle." The
// window just needs to be wide enough to span any reasonable handler.
const handlerStart = jobsSource.indexOf("worker.register('autopilot-cycle'");
expect(handlerStart).toBeGreaterThan(-1);
const handlerBlock = jobsSource.slice(handlerStart, handlerStart + 6000);
expect(handlerBlock).toContain('signal: job.signal');
});
test('worker.ts has force-eviction safety net after timeout', async () => {
// Verify the worker code includes the grace timer
const fs = await import('fs');
const workerSource = fs.readFileSync(
new URL('../src/core/minions/worker.ts', import.meta.url),
'utf8',
);
// Must have the force-eviction pattern
expect(workerSource).toContain('Force-evicting from inFlight');
expect(workerSource).toContain('graceTimer');
expect(workerSource).toContain('handler ignored abort signal');
});
test('cycle.ts has checkAborted calls between phases', async () => {
// Verify the cycle code checks abort between every phase
const fs = await import('fs');
const cycleSource = fs.readFileSync(
new URL('../src/core/cycle.ts', import.meta.url),
'utf8',
);
// Count checkAborted calls in the runCycle function body
const runCycleBody = cycleSource.slice(
cycleSource.indexOf('export async function runCycle'),
);
const checkCalls = (runCycleBody.match(/checkAborted\(opts\.signal\)/g) || []).length;
// Should have at least 6 (one per phase)
expect(checkCalls).toBeGreaterThanOrEqual(6);
});
});
describe('#1972 — complete cooperative-abort coverage', () => {
test('aborted signal makes runCycle report partial + reason "aborted" (terminal guard)', async () => {
// phases:[] means no between-phase checkAborted fires, so execution reaches
// the TERMINAL guard (Codex #9). With a pre-aborted signal it must NOT
// report success — status 'partial', reason 'aborted'.
const { runCycle } = await import('../src/core/cycle.ts');
const abort = new AbortController();
abort.abort(new Error('timeout'));
const report = await runCycle(null, {
brainDir: '/nonexistent-for-test',
phases: [],
signal: abort.signal,
});
expect(report.status).toBe('partial');
expect(report.reason).toBe('aborted');
});
test('cycle.ts threads opts.signal into every long phase + guards the success stamp', async () => {
const fs = await import('fs');
const src = fs.readFileSync(new URL('../src/core/cycle.ts', import.meta.url), 'utf8');
const body = src.slice(src.indexOf('export async function runCycle'));
// Each long phase receives the signal.
expect(body).toContain('runPhaseExtract(engine, brainDir, dryRun, syncPagesAffected, opts.signal, cycleSourceId)');
expect(body).toMatch(/runPhaseExtractFacts\([^)]*opts\.signal\)/);
expect(body).toContain('signal: opts.signal'); // consolidate opts
expect(body).toContain('runPhaseLint(brainDir, dryRun, engine, opts.signal)');
// Reaper runs at cycle start.
expect(body).toContain('reapDeadHolderLocks(engine)');
// Terminal guard: the success stamp is gated on !aborted, and the report
// carries reason 'aborted'.
expect(body).toContain('!aborted');
expect(body).toContain("reason: 'aborted'");
// Phase-duration force-evict attribution log (T11).
expect(body).toContain('FORCE_EVICT_DEADLINE_MS');
});
test('every long phase core checks isAborted in its batch loop', async () => {
const fs = await import('fs');
const read = (p: string) => fs.readFileSync(new URL(p, import.meta.url), 'utf8');
expect(read('../src/commands/extract.ts')).toContain('if (isAborted(signal)) return;');
expect(read('../src/core/cycle/extract-facts.ts')).toContain('if (isAborted(opts.signal)) break;');
expect(read('../src/core/cycle/phases/consolidate.ts')).toContain('if (isAborted(opts.signal)) break;');
expect(read('../src/core/cycle/phantom-redirect.ts')).toContain('if (isAborted(signal)) break;');
expect(read('../src/commands/lint.ts')).toContain('if (isAborted(opts.signal)) break;');
});
});