feat(v0.34 W0c): wire resolve_symbol_edges as a new cycle phase

W0c's symbol resolver lands as a 12th cycle phase between extract and
patterns. The autopilot's quick-cycle path (60s watchdog interval per
D2 from eng review) now resolves stale chunks incrementally so agents
see resolved edges within ~60s of writes rather than waiting on the
slow full-walk path.

* CyclePhase + ALL_PHASES + NEEDS_LOCK_PHASES extended with
  'resolve_symbol_edges'. Position: between extract (which emits new
  bare-token edges from sync diffs) and patterns (which reads the
  graph). Acquires the cycle lock because it writes edge_metadata.
* CycleReport.totals adds edges_resolved + edges_ambiguous so doctor
  and autopilot summaries surface the numbers.
* runPhaseResolveSymbolEdges walks every registered source via
  listSources() + resolveSymbolEdgesIncremental(). Per-call cap is
  BATCH_SIZE*10 = 2000 chunks so a single watchdog tick stays bounded
  even on a 100K-chunk brain. Subsequent ticks pick up the leftovers
  via the edges_backfilled_at watermark.
* Test count bumped from 11 → 12 phases in cycle.serial.test.ts and
  cycle.test.ts (both pinned by the regression guards). Existing 28
  cycle tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-11 12:38:40 -07:00
co-authored by Claude Opus 4.7
parent 0a94216397
commit 6f7dbe1d03
3 changed files with 118 additions and 5 deletions
+111
View File
@@ -55,6 +55,7 @@ import { getCliOptions, cliOptsToProgressOptions } from './cli-options.ts';
export type CyclePhase =
| 'lint' | 'backlinks' | 'sync' | 'synthesize' | 'extract'
| 'resolve_symbol_edges'
| 'patterns' | 'recompute_emotional_weight' | 'consolidate'
| 'embed' | 'orphans' | 'purge';
@@ -64,6 +65,13 @@ export const ALL_PHASES: CyclePhase[] = [
'sync',
'synthesize',
'extract',
// v0.34 W0c — within-file two-pass symbol resolution. Runs AFTER extract
// so any code edges sync emitted (still bare-token) get resolved into
// {resolved_chunk_id: N} / {ambiguous: true, candidates: [...]} edge_metadata
// entries before downstream phases read the graph. Quick-cycle compatible:
// each invocation walks at most BATCH_SIZE*10 chunks where
// edges_backfilled_at IS NULL or stale.
'resolve_symbol_edges',
'patterns',
// v0.29 — runs AFTER extract + synthesize so it sees the union of
// sync-touched + synthesize-written pages with fresh tag + take state.
@@ -96,6 +104,8 @@ const NEEDS_LOCK_PHASES: ReadonlySet<CyclePhase> = new Set([
'sync',
'synthesize',
'extract',
// v0.34 W0c — writes code_edges_symbol.edge_metadata + content_chunks.edges_backfilled_at.
'resolve_symbol_edges',
'patterns',
// v0.29 — writes pages.emotional_weight column.
'recompute_emotional_weight',
@@ -163,6 +173,10 @@ export interface CycleReport {
patterns_written: number;
/** v0.29: number of pages whose emotional_weight was (re)computed. */
pages_emotional_weight_recomputed: number;
/** v0.34: number of code edges resolved (1 candidate) by the resolve_symbol_edges phase. */
edges_resolved: number;
/** v0.34: number of code edges marked ambiguous (2+ candidates) by the resolve_symbol_edges phase. */
edges_ambiguous: number;
/** v0.26.5: number of source rows hard-deleted by the purge phase. */
purged_sources_count: number;
/** v0.26.5: number of page rows hard-deleted by the purge phase. */
@@ -653,6 +667,73 @@ async function runPhaseExtract(
}
}
/**
* v0.34 W0c — resolve_symbol_edges phase.
*
* Walks at most BATCH_SIZE*10 chunks per invocation where
* `edges_backfilled_at` is NULL or older than EDGE_EXTRACTOR_VERSION_TS.
* Resumable across cycles via the watermark; quick-cycle compatible.
*
* Source scoping: walks every registered source. Pre-v0.34 silently
* crossed sources; now each source is walked independently so symbol
* resolution stays within its source boundary (matches the W0a fix).
*/
async function runPhaseResolveSymbolEdges(
engine: BrainEngine,
dryRun: boolean,
): Promise<PhaseResult> {
if (dryRun) {
return {
phase: 'resolve_symbol_edges',
status: 'skipped',
duration_ms: 0,
summary: 'dry-run: resolve_symbol_edges phase skipped',
details: { dryRun: true, reason: 'no_dry_run_support' },
};
}
try {
const { resolveSymbolEdgesIncremental } = await import('./chunkers/symbol-resolver.ts');
const { listSources } = await import('./sources-ops.ts');
const sources = await listSources(engine);
let totalChunks = 0;
let totalResolved = 0;
let totalAmbiguous = 0;
let totalUnmatched = 0;
for (const s of sources) {
const stats = await resolveSymbolEdgesIncremental(engine, { sourceId: s.id });
totalChunks += stats.chunks_walked;
totalResolved += stats.edges_resolved;
totalAmbiguous += stats.edges_ambiguous;
totalUnmatched += stats.edges_unmatched;
}
return {
phase: 'resolve_symbol_edges',
status: 'ok',
duration_ms: 0,
summary:
totalChunks === 0
? 'no chunks needed symbol resolution'
: `${totalChunks} chunk(s) walked; resolved ${totalResolved}, ambiguous ${totalAmbiguous}, unmatched ${totalUnmatched}`,
details: {
chunks_walked: totalChunks,
edges_resolved: totalResolved,
edges_ambiguous: totalAmbiguous,
edges_unmatched: totalUnmatched,
sources_walked: sources.length,
},
};
} catch (e) {
return {
phase: 'resolve_symbol_edges',
status: 'fail',
duration_ms: 0,
summary: 'resolve_symbol_edges phase failed',
details: {},
error: makeErrorFromException(e),
};
}
}
async function runPhaseEmbed(engine: BrainEngine, dryRun: boolean): Promise<PhaseResult> {
try {
const { runEmbedCore } = await import('../commands/embed.ts');
@@ -997,6 +1078,31 @@ export async function runCycle(
await safeYield(opts.yieldBetweenPhases);
}
// ── v0.34 W0c: resolve_symbol_edges (between extract + patterns) ──
// Walks chunks whose edges_backfilled_at is null/stale. Resumable
// across cycles via the watermark. Quick-cycle compatible — caps at
// BATCH_SIZE * 10 chunks per invocation so a 60s watchdog tick stays
// responsive even on a 100K-chunk brain.
if (phases.includes('resolve_symbol_edges')) {
checkAborted(opts.signal);
if (!engine) {
phaseResults.push({
phase: 'resolve_symbol_edges',
status: 'skipped',
duration_ms: 0,
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else {
progress.start('cycle.resolve_symbol_edges');
const { result, duration_ms } = await timePhase(() => runPhaseResolveSymbolEdges(engine, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 6: patterns (v0.23) ───────────────────────────────
// MUST run after extract so the graph state reads fresh — subagent
// put_page calls in synthesize set ctx.remote=true, so auto-link
@@ -1194,6 +1300,8 @@ function emptyTotals(): CycleReport['totals'] {
synth_pages_written: 0,
patterns_written: 0,
pages_emotional_weight_recomputed: 0,
edges_resolved: 0,
edges_ambiguous: 0,
purged_sources_count: 0,
purged_pages_count: 0,
facts_consolidated: 0,
@@ -1227,6 +1335,9 @@ function extractTotals(phases: PhaseResult[]): CycleReport['totals'] {
t.patterns_written = Number(p.details.patterns_written ?? 0);
} else if (p.phase === 'recompute_emotional_weight' && p.details) {
t.pages_emotional_weight_recomputed = Number(p.details.pages_recomputed ?? 0);
} else if (p.phase === 'resolve_symbol_edges' && p.details) {
t.edges_resolved = Number(p.details.edges_resolved ?? 0);
t.edges_ambiguous = Number(p.details.edges_ambiguous ?? 0);
} else if (p.phase === 'purge' && p.details) {
t.purged_sources_count = Number(p.details.purged_sources_count ?? 0);
t.purged_pages_count = Number(p.details.purged_pages_count ?? 0);
+4 -3
View File
@@ -380,7 +380,8 @@ describe('runCycle — yieldBetweenPhases hook', () => {
// v0.26.5: 9 phases (added `purge`).
// v0.29: 10 phases (added `recompute_emotional_weight`).
// v0.31: 11 phases (added `consolidate` between recompute and embed) → 11 yield calls.
expect(hookCalls).toBe(11);
// v0.34: 12 phases (added `resolve_symbol_edges` between extract and patterns).
expect(hookCalls).toBe(12);
});
test('hook exceptions do not abort the cycle', async () => {
@@ -390,8 +391,8 @@ describe('runCycle — yieldBetweenPhases hook', () => {
throw new Error('synthetic hook error');
},
});
// Cycle still completed all phases (v0.31: 11 = v0.29 recompute + v0.31 consolidate).
expect(report.phases.length).toBe(11);
// v0.34: 12 phases (added `resolve_symbol_edges`).
expect(report.phases.length).toBe(12);
});
});
+3 -2
View File
@@ -97,13 +97,14 @@ describeE2E('E2E: runCycle against real Postgres', () => {
});
expect(report.schema_version).toBe('1');
// Cycle ran all 11 phases (or skipped the ones that don't support dry-run).
// Cycle ran all 12 phases (or skipped the ones that don't support dry-run).
// Phase history:
// v0.23 = 8 phases (lint → backlinks → sync → synthesize → extract → patterns → embed → orphans)
// v0.26.5 = 9 (added `purge` after orphans)
// v0.29 = 10 (added `recompute_emotional_weight` between patterns and embed)
// v0.31 = 11 (added `consolidate` between recompute_emotional_weight and embed)
expect(report.phases.length).toBe(11);
// v0.34 = 12 (added `resolve_symbol_edges` between extract and patterns)
expect(report.phases.length).toBe(12);
// Nothing got written.
const afterPages = await conn.unsafe(`SELECT count(*)::int AS n FROM pages`);