feat(voyage): validate iterations_remaining in progress-validator

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 21:26:22 +02:00
commit e986b10431
2 changed files with 46 additions and 0 deletions

View file

@ -1,5 +1,11 @@
// lib/validators/progress-validator.mjs
// Validate progress.json shape + resume-readiness.
// Forward-compat: unknown keys are tolerated silently, so additive-optional fields land
// WITHOUT a schema_version bump. `iterations_remaining` (trekexecute's recovery/retry budget
// signal) is shape-checked here only when present (non-negative integer); it is NOT in
// PROGRESS_REQUIRED_TOP, so legacy progress.json without it still validates. The field's
// liveness (never-decremented) is enforced deterministically by the Phase 7.5 completion-gate
// cross-check in trekexecute, not by this per-validation shape check.
import { readFileSync, existsSync } from 'node:fs';
import { issue, fail } from '../util/result.mjs';
@ -45,6 +51,16 @@ export function validateProgressObject(parsed, opts = {}) {
}
}
// Additive-optional: iterations_remaining is trekexecute's recovery/retry budget signal.
// When present it must be a non-negative integer; absence is valid (backward-compat).
if (parsed.iterations_remaining !== undefined &&
!(Number.isInteger(parsed.iterations_remaining) && parsed.iterations_remaining >= 0)) {
errors.push(issue(
'PROGRESS_ITERATIONS_REMAINING_INVALID',
`iterations_remaining=${parsed.iterations_remaining} must be a non-negative integer`,
));
}
if (parsed.steps && typeof parsed.steps === 'object') {
const stepKeys = Object.keys(parsed.steps);
if (typeof parsed.total_steps === 'number' && stepKeys.length !== parsed.total_steps) {

View file

@ -65,6 +65,36 @@ test('validateProgress — step count mismatch is warning', () => {
assert.ok(r.warnings.find(w => w.code === 'PROGRESS_STEP_COUNT_MISMATCH'));
});
test('validateProgress — iterations_remaining non-negative integer is valid', () => {
const p = goodProgress();
p.iterations_remaining = 7;
const r = validateProgressObject(p);
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateProgress — iterations_remaining absent is valid (backward-compat)', () => {
const p = goodProgress();
delete p.iterations_remaining;
const r = validateProgressObject(p);
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateProgress — iterations_remaining negative is invalid', () => {
const p = goodProgress();
p.iterations_remaining = -1;
const r = validateProgressObject(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_ITERATIONS_REMAINING_INVALID'));
});
test('validateProgress — iterations_remaining non-integer is invalid', () => {
const p = goodProgress();
p.iterations_remaining = 3.5;
const r = validateProgressObject(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_ITERATIONS_REMAINING_INVALID'));
});
test('checkResumeReadiness — completed run cannot resume', () => {
const p = goodProgress();
p.status = 'completed';