Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ export const MigrationConfigSchema = z.object({
tokenBudget: z.number().int().optional(),
dryRun: z.boolean().default(false),
resume: z.boolean().default(false),
/**
* Reuse the knowledge base from a prior run when starting a fresh migration.
* When true, a fresh start (resume=false) will preserve the KB index (Phase 0),
* task graph (Phase 1), and knowledge base (Phase 2) artifacts if they exist
* and the source fingerprint has not changed. This avoids re-running expensive
* agent-based analysis when only the migration strategy, guidance, or later
* phases need to change.
* Default: false.
*/
reuseKb: z.boolean().default(false),
invocationDelayMs: z.number().int().min(0).default(0),
/**
* Maximum number of concurrent build/test commands per output path.
Expand Down Expand Up @@ -304,6 +314,7 @@ export const MigrationConfigSchema = z.object({
maxLinesPerTask: 1000,
dryRun: false,
resume: false,
reuseKb: false,
invocationDelayMs: 0,
buildConcurrency: 1,
executionMode: 'per-task',
Expand Down
65 changes: 62 additions & 3 deletions src/core/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,57 @@ export class CheckpointManager {
}

/** Read the current checkpoint, or create initial state */
async load(projectName: string, options: { fresh?: boolean } = {}): Promise<CheckpointState> {
async load(projectName: string, options: { fresh?: boolean; reuseKb?: boolean } = {}): Promise<CheckpointState> {
await ensureDir(this.stateDir);

if (options.fresh) {
this.logger.info('Fresh start requested (resume=false) — ignoring prior checkpoint state');
this.state = this.buildInitialState(projectName);
if (options.reuseKb) {
// Preserve KB-related state from the prior run while resetting everything else.
const prior = await this.loadPriorState();
this.state = this.buildInitialState(projectName);
if (prior) {
// Carry forward early-phase completion markers so Phases 0-2 can skip.
const kbPhases = [0, 1, 2];
this.state.completedPhases = prior.completedPhases.filter(p => kbPhases.includes(p));
this.state.currentPhase = this.state.completedPhases.length > 0
? Math.max(...this.state.completedPhases) + 1 : 0;
if (prior.phase0Fingerprint) this.state.phase0Fingerprint = prior.phase0Fingerprint;
// Preserve phase outputs for KB phases so artifact paths remain valid.
for (const p of kbPhases) {
if (prior.phaseOutputs?.[p]) this.state.phaseOutputs[p] = prior.phaseOutputs[p]!;
}
// Preserve scaffold state so Phase 4 doesn't re-scaffold.
if (prior.scaffoldComplete) this.state.scaffoldComplete = prior.scaffoldComplete;
// Carry the flow checkpoint forward so the flow runner knows which
// steps are already done. We filter its completedExecutionIds to
// only retain KB-related node completions.
if (prior.__flowCheckpoint && typeof prior.__flowCheckpoint === 'object') {
const fc = prior.__flowCheckpoint as Record<string, unknown>;
const KB_STEP_IDS = ['kb-index', 'task-graph-construction', 'kb-construction', 'budget-check-2'];
const priorCompleted = (fc.completedExecutionIds ?? []) as string[];
const kbCompleted = priorCompleted.filter(id => KB_STEP_IDS.some(s => id.endsWith('/' + s)));
if (kbCompleted.length > 0) {
this.state.__flowCheckpoint = {
...fc,
completedExecutionIds: kbCompleted,
status: 'running',
outputs: {},
executionOutputs: {},
error: undefined,
};
}
}
this.logger.info(
`Fresh start with reuseKb — preserved phases [${this.state.completedPhases.join(', ')}], ` +
`resuming from Phase ${this.state.currentPhase}`,
);
} else {
this.logger.info('Fresh start with reuseKb — no prior checkpoint found, starting from scratch');
}
} else {
this.logger.info('Fresh start requested (resume=false) — ignoring prior checkpoint state');
this.state = this.buildInitialState(projectName);
}
await this.save(this.state);
return this.state;
}
Expand Down Expand Up @@ -530,4 +575,18 @@ export class CheckpointManager {
}
return undefined;
}

/** Attempt to load the prior checkpoint state without modifying this.state. */
private async loadPriorState(): Promise<CheckpointState | undefined> {
const path = await this.resolveCheckpointReadPath()
?? await this.resolveBackupReadPath();
if (!path) return undefined;
try {
const state = await readJson<CheckpointState>(path);
this.applyBackwardCompatibleDefaults(state);
return state;
} catch {
return undefined;
}
}
}
1 change: 1 addition & 0 deletions src/core/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export class MigrationRuntime {
const impliedResume = this.fromPhase !== undefined;
await this.checkpoint.load(this.config.projectName, {
fresh: !this.config.options.resume && !impliedResume,
reuseKb: this.config.options.reuseKb,
});

// Apply --from-phase reset before anything else
Expand Down
86 changes: 86 additions & 0 deletions tests/core/checkpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,4 +787,90 @@ describe('CheckpointManager', () => {
expect(reset.adjudicationEvents).toEqual([]);
expect(reset.terminalExhaustion).toBeUndefined();
});

it('fresh start with reuseKb should preserve KB phases and reset everything else', async () => {
// Set up a prior run with phases 0-4 completed
const state = await manager.load('test-project');
for (let p = 0; p <= 4; p++) {
await manager.completePhase(p, `/out/${p}`);
}
state.phase0Fingerprint = 'abc123';
state.scaffoldComplete = true;
state.completedTasks = [{ taskId: 'task-1', attempts: 1, lastError: '', recoveryAttempted: false }];
state.__flowCheckpoint = {
flowId: 'aamf-migration',
status: 'completed',
completedExecutionIds: [
'aamf-migration/kb-index',
'aamf-migration/task-graph-construction',
'aamf-migration/kb-construction',
'aamf-migration/budget-check-2',
'aamf-migration/migration-planning',
'aamf-migration/budget-check-3',
],
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await manager.save(state);

// Fresh start with reuseKb
const manager2 = new CheckpointManager(tempDir, logger);
const reused = await manager2.load('test-project', { fresh: true, reuseKb: true });

// KB phases preserved
expect(reused.completedPhases).toContain(0);
expect(reused.completedPhases).toContain(1);
expect(reused.completedPhases).toContain(2);
expect(reused.completedPhases).not.toContain(3);
expect(reused.completedPhases).not.toContain(4);
expect(reused.currentPhase).toBe(3);
expect(reused.phase0Fingerprint).toBe('abc123');
expect(reused.scaffoldComplete).toBe(true);

// Phase outputs preserved for KB phases only
expect(reused.phaseOutputs[0]).toBe('/out/0');
expect(reused.phaseOutputs[1]).toBe('/out/1');
expect(reused.phaseOutputs[2]).toBe('/out/2');
expect(reused.phaseOutputs[3]).toBeUndefined();
expect(reused.phaseOutputs[4]).toBeUndefined();

// Migration state reset
expect(reused.completedTasks).toEqual([]);
expect(reused.failedTasks).toEqual([]);
expect(reused.resumeCount).toBe(0);

// Flow checkpoint filtered to KB steps only
const fc = reused.__flowCheckpoint as Record<string, unknown>;
const completedIds = fc.completedExecutionIds as string[];
expect(completedIds).toContain('aamf-migration/kb-index');
expect(completedIds).toContain('aamf-migration/task-graph-construction');
expect(completedIds).toContain('aamf-migration/kb-construction');
expect(completedIds).toContain('aamf-migration/budget-check-2');
expect(completedIds).not.toContain('aamf-migration/migration-planning');
expect(completedIds).not.toContain('aamf-migration/budget-check-3');
});

it('fresh start with reuseKb but no prior checkpoint should start from scratch', async () => {
const state = await manager.load('test-project', { fresh: true, reuseKb: true });
expect(state.completedPhases).toEqual([]);
expect(state.currentPhase).toBe(0);
expect(state.phase0Fingerprint).toBeUndefined();
});

it('fresh start without reuseKb should ignore prior state entirely', async () => {
// Set up a prior run
const state = await manager.load('test-project');
for (let p = 0; p <= 2; p++) {
await manager.completePhase(p, `/out/${p}`);
}
state.phase0Fingerprint = 'abc123';
await manager.save(state);

// Fresh start without reuseKb
const manager2 = new CheckpointManager(tempDir, logger);
const fresh = await manager2.load('test-project', { fresh: true });
expect(fresh.completedPhases).toEqual([]);
expect(fresh.currentPhase).toBe(0);
expect(fresh.phase0Fingerprint).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions tests/fixtures/zstd-c-project/migration.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"qualityPolicy": "strict",
"maxParallelAgents": 12,
"maxRetriesPerTask": 7,
"reuseKb": true,
"maxLinesPerTask": 1000,
"tokenBudget": 400000000,
"executionMode": "sync-epoch",
Expand Down