fix(process): bind Validate resume to the current pending round

Issue: #775
User-Visible: no
This commit is contained in:
Matysh
2026-10-01 16:44:28 +00:00
committed by claude[bot]
parent dd86bf955a
commit 09fb02cf15
7 changed files with 374 additions and 63 deletions
+175
View File
@@ -0,0 +1,175 @@
// #775: real incident identities; list-window/order variants below are synthetic
// witnesses, not a claim about GitHub's unsaved historical API responses.
import test from 'node:test';
import assert from 'node:assert/strict';
import { decideReconciliation, pendingEvidenceError, processRuns } from '../scripts/process-reconcile.mjs';
import { decideResume, resume } from '../scripts/process-resume.mjs';
const LABEL = 'S7-code-review';
const incidents = [
[740, 'stairs-floor-switch', 36877086418, 36877204584, 'f23cca8f65a9641f9cc409b749347ba167cf9c65', '14:32:05', 'success'],
[748, 'canon-reconcile', 36883983363, 36884081445, '395210117e242b5b16118cdfd1985f9a3d620cef', '15:24:18', 'success'],
[744, 'floor-geometry-key', 36875649994, 36875756451, '62bd32538132462d4f5222a36db3bdf1e660b80b', '14:21:07', 'failure'],
];
function fixture(row = incidents[0]) {
const [issue, slug, id, validateId, sha, time, conclusion] = row;
const branch = `issue/${issue}-${slug}`;
const createdAt = `2026-10-01T${time}Z`;
const run = { id, attempt: 1, issue, label: LABEL, stage: 'code', status: 'completed', conclusion: 'success', createdAt };
const request = { id: 'request-1', label: LABEL, at: createdAt };
const pending = { schema: 1, issue: String(issue), run_id: String(id), run_attempt: '1', stage: 'code', branch,
material_sha: sha, validate_run_id: String(validateId) };
return { issue, branch, sha, headSha: sha, request, labels: [LABEL], run, pending,
runs: [run], validateRun: { id: validateId, event: 'workflow_dispatch', status: 'completed', headSha: sha, conclusion },
pendingOf: (candidate) => candidate.id === id ? pending : null };
}
const raw = (run, extra = {}) => ({ ...run, event: 'issues',
display_title: `process #${run.issue} · ${run.label} · fixture`, created_at: run.createdAt, ...extra });
test('#775 all three incident markers resume, including red Validate (ordinary prepare owns S6/comment)', () => {
for (const row of incidents) assert.equal(decideResume(fixture(row)).action, 'resume', `#${row[0]}`);
});
test('#775 history is paged to the request, not capped at 200 or filtered-search 1000 runs', () => {
for (const row of incidents) {
const f = fixture(row);
const unrelated = Array.from({ length: 1100 }, (_, n) => raw({ ...f.run, id: n + 1, issue: 999,
createdAt: '2026-10-01T16:00:00Z' }, { display_title: 'process #999 · P2 · unrelated label' }));
const rows = [...unrelated, raw(f.run), raw({ ...f.run, id: 2, createdAt: '2026-09-30T00:00:00Z' })];
const calls = [];
const runs = processRuns('o/r', [{ number: f.issue }], { since: f.request.at, getJson: (args) => {
const url = args[1];
calls.push(url);
assert.ok(!url.includes('event='), 'unfiltered endpoint avoids the search-result cap');
const page = Number(new URL(`https://api.test/${url}`).searchParams.get('page'));
return { workflow_runs: rows.slice((page - 1) * 100, page * 100) };
} });
assert.equal(calls.length, 12);
assert.equal(decideResume({ ...f, runs }).action, 'resume', `#${f.issue}`);
}
});
test('#775 history errors/repeated pages are not silently interpreted as a lost request', () => {
const f = fixture();
const page = Array.from({ length: 100 }, (_, n) => raw({ ...f.run, id: n + 1 }));
let calls = 0;
assert.throws(() => processRuns('o/r', [], { since: f.request.at, getJson: () => {
if (++calls === 2) throw new Error('API unavailable');
return { workflow_runs: page };
} }), /API unavailable/);
assert.throws(() => processRuns('o/r', [], { since: f.request.at,
getJson: () => ({ workflow_runs: page }) }), /pagination/);
});
test('#775 stop at the request time, deduplicate overlapping pages, ignore non-issue workflow events', () => {
const f = fixture();
const first = Array.from({ length: 100 }, (_, n) => raw({ ...f.run, id: n + 1, createdAt: '2026-10-01T16:00:00Z' }));
const second = [first[99], raw(f.run), raw({ ...f.run, id: 3, createdAt: '2026-09-30T00:00:00Z' }),
raw({ ...f.run, id: 200 }, { event: 'workflow_dispatch' })];
let count = 0;
const runs = processRuns('o/r', [], { since: f.request.at,
getJson: () => ({ workflow_runs: [first, second][count++] }) });
assert.equal(count, 2);
assert.equal(runs.length, 101);
assert.equal(new Set(runs.map((run) => run.id)).size, runs.length);
});
test('#775 skipped noise can be ignored but newer attempted reviews are barriers', () => {
const f = fixture();
const newer = { ...f.run, id: f.run.id + 1, createdAt: '2026-10-01T16:00:00Z' };
assert.equal(decideResume({ ...f, runs: [{ ...newer, conclusion: 'skipped' }, f.run] }).action, 'resume');
for (const conclusion of ['failure', 'cancelled', 'success']) {
assert.equal(decideResume({ ...f, runs: [{ ...newer, conclusion }, f.run] }).action, 'noop', conclusion);
}
assert.equal(decideResume({ ...f, runs: [{ ...newer, status: 'in_progress' }, f.run] }).action, 'noop');
});
test('#775 a new request, branch head, different dispatch or damaged identity never wakes the old round', () => {
const f = fixture();
for (const overrides of [
{ request: null }, { request: { ...f.request, at: '2026-10-01T16:00:00Z' } },
{ headSha: 'b'.repeat(40) }, { issue: 999 }, { branch: 'issue/740-other-branch' },
{ validateRun: { ...f.validateRun, id: f.validateRun.id + 1 } },
{ labels: [LABEL, 'S4-spec-review'] },
]) assert.equal(decideResume({ ...f, ...overrides }).action, 'noop', JSON.stringify(overrides));
for (const overrides of [{ schema: 2 }, { issue: '999' }, { run_id: '1' }, { run_attempt: '2' },
{ stage: 'spec' }, { branch: 'other' }, { validate_run_id: '' }]) {
assert.equal(decideResume({ ...f, pendingOf: () => ({ ...f.pending, ...overrides }) }).action, 'noop', JSON.stringify(overrides));
}
});
test('#775 write path rechecks current request, stop labels, head and runs; duplicates cannot relabel twice', async () => {
const f = fixture();
for (const changed of [{ labels: [] }, { labels: [LABEL, 'blocked'] }, { headSha: 'b'.repeat(40) },
{ request: { ...f.request, id: 'request-2' } }]) {
let reads = 0;
let writes = 0;
const ops = { state: () => ++reads === 1 ? f : { ...f, ...changed }, runs: () => f.runs,
pendingOf: f.pendingOf, relabel: () => writes++ };
const result = await resume({ ...f, repo: 'o/r', ops, sleep: async () => {} });
assert.equal(result.applied, false);
assert.equal(writes, 0);
}
let reads = 0;
let writes = 0;
let state = f;
const ops = { state: () => state, runs: () => { reads++; return f.runs; }, pendingOf: f.pendingOf,
relabel: () => { writes++; state = { ...f, request: { ...f.request, id: 'request-2', at: '2026-10-01T17:00:00Z' } }; } };
assert.equal((await resume({ ...f, repo: 'o/r', ops, sleep: async () => {} })).applied, true);
assert.equal((await resume({ ...f, repo: 'o/r', ops, sleep: async () => {} })).applied, false);
assert.equal(writes, 1);
assert.ok(reads >= 2);
});
test('#775 transient missing/old/no-marker snapshots recover without waiting for the schedule', async () => {
for (const row of incidents) {
const f = fixture(row);
let reads = 0;
let writes = 0;
const waits = [];
const ops = { state: () => f, runs: () => ++reads === 1
? (f.issue === 744 ? [] : [{ ...f.run, id: 1, createdAt: f.issue === 748 ? '2026-09-30T00:00:00Z' : f.run.createdAt }])
: f.runs, pendingOf: f.pendingOf, relabel: () => writes++ };
const result = await resume({ ...f, repo: 'o/r', ops, sleep: async (ms) => waits.push(ms) });
assert.equal(result.applied, true, `#${f.issue}`);
assert.equal(writes, 1);
assert.deepEqual(waits, [5000]);
}
});
test('#775 absent evidence gets a bounded grace, never a guessed relabel', async () => {
const f = fixture();
let reads = 0;
const waits = [];
const result = await resume({ ...f, repo: 'o/r', sleep: async (ms) => waits.push(ms),
ops: { state: () => f, runs: () => { reads++; return []; }, pendingOf: f.pendingOf,
relabel: () => assert.fail('no evidence') } });
assert.equal(result.action, 'noop');
assert.equal(result.applied, false);
assert.equal(reads, 3);
assert.deepEqual(waits, [5000, 5000]);
});
test('#775 reconciler uses the same request boundary, skipped-run handling and sealed identity', () => {
const f = fixture();
const now = Date.parse('2026-10-01T17:00:00Z');
const base = { issue: { number: f.issue, labels: [LABEL] }, request: f.request, now };
const ready = { ...f.run, pending: f.pending, pendingValidate: 'completed' };
const skipped = { ...f.run, id: 999, createdAt: '2026-10-01T16:00:00Z', conclusion: 'skipped' };
assert.equal(decideReconciliation({ ...base, runs: [skipped, ready] }).action, 'retry');
assert.equal(decideReconciliation({ ...base, runs: [{ ...ready, resultArtifact: true }] }).action, 'escalate');
const justReapplied = { ...f.request, at: '2026-10-01T14:32:06Z' };
assert.equal(decideReconciliation({ ...base, request: justReapplied, now: Date.parse(justReapplied.at), runs: [ready] }).action, 'wait',
'previous run one second before the new label cannot be recovered as the new request');
assert.equal(pendingEvidenceError(f.pending, { issue: f.issue, run: f.run }), null);
assert.match(pendingEvidenceError({ ...f.pending, run_attempt: '2' }, { issue: f.issue, run: f.run }), /attempt/);
});
test('#775 a run starting between snapshots prevents the write', async () => {
const f = fixture();
let reads = 0;
const ops = { state: () => f, runs: () => ++reads === 1 ? f.runs
: [{ ...f.run, id: f.run.id + 1, status: 'in_progress', createdAt: '2026-10-01T16:00:00Z' }, f.run],
pendingOf: f.pendingOf, relabel: () => assert.fail('new process already running') };
assert.equal((await resume({ ...f, repo: 'o/r', ops })).applied, false);
});
+9 -4
View File
@@ -5,16 +5,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { decideResume, issueNumberFromBranch, resume, REVIEW_LABEL } from '../scripts/process-resume.mjs';
import { decideResume as decide, issueNumberFromBranch, resume, REVIEW_LABEL } from '../scripts/process-resume.mjs';
const SHA = 'a'.repeat(40);
const OTHER = 'b'.repeat(40);
const validateRun = (over = {}) => ({ event: 'workflow_dispatch', status: 'completed', headSha: SHA, ...over });
const validateRun = (over = {}) => ({ id: 80, event: 'workflow_dispatch', status: 'completed', headSha: SHA, ...over });
const request = { id: 'event-1', at: '2026-09-23T09:00:00Z', label: REVIEW_LABEL };
const decideResume = (args) => decide({ issue: 636, branch: 'issue/636-x', request, headSha: SHA, ...args });
const processRun = (over = {}) => ({
id: 70, attempt: 1, issue: 636, label: REVIEW_LABEL, stage: 'code', status: 'completed', conclusion: 'success',
createdAt: '2026-09-23T10:00:00Z', ...over,
});
const pending = { schema: 1, issue: 636, run_id: 70, material_sha: SHA };
const pending = { schema: 1, issue: 636, run_id: 70, run_attempt: 1, stage: 'code', branch: 'issue/636-x',
material_sha: SHA, validate_run_id: 80 };
const pendingOf = (run) => (run.id === 70 ? pending : null);
test('#636 branch → issue number', () => {
@@ -75,7 +78,7 @@ test('#636 the newest process run decides, not an older pending one', () => {
test('#636 resume() relabels only on a resume decision and only with apply', async () => {
const relabels = [];
const ops = {
labels: () => [REVIEW_LABEL],
state: () => ({ labels: [REVIEW_LABEL], request, headSha: SHA }),
runs: () => [processRun()],
pendingOf,
relabel: () => relabels.push(REVIEW_LABEL),
@@ -107,6 +110,8 @@ test('#636 workflows: prepare exits pending with a sealed marker, resume relabel
assert.match(resumeWf, /github\.event\.workflow_run\.event == 'workflow_dispatch' && startsWith\(github\.event\.workflow_run\.head_branch, 'issue\/'\)/);
assert.match(resumeWf, /GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}/);
assert.match(resumeWf, /node scripts\/process-resume\.mjs/);
assert.match(resumeWf, /VALIDATE_RUN_ID: \$\{\{ github\.event\.workflow_run\.id \}\}/);
assert.match(resumeWf, /--run-id="\$VALIDATE_RUN_ID"/);
assert.ok(!/issues: write/.test(resumeWf), 'resume relabels with HP_PROCESS_TOKEN only');
assert.ok(!/issues: write/.test(resumeCaller), 'the caller ceiling does not grant issues: write either');
assert.match(validate, /for file in process\.yml mutation-gate\.yml process-resume\.yml [^\n]*; do/);