From e986b10431237c2e8c0c46e01e252cd24f5c8150 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 20 Jun 2026 21:26:22 +0200 Subject: [PATCH] feat(voyage): validate iterations_remaining in progress-validator Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/validators/progress-validator.mjs | 16 +++++++++++ tests/validators/progress-validator.test.mjs | 30 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/lib/validators/progress-validator.mjs b/lib/validators/progress-validator.mjs index 58175a4..c7a1217 100644 --- a/lib/validators/progress-validator.mjs +++ b/lib/validators/progress-validator.mjs @@ -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) { diff --git a/tests/validators/progress-validator.test.mjs b/tests/validators/progress-validator.test.mjs index 4ca31b6..2bed897 100644 --- a/tests/validators/progress-validator.test.mjs +++ b/tests/validators/progress-validator.test.mjs @@ -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';