mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Стадия prepare спала ≈ 28 минут на раунд, пока шёл Validate с мутантами на материале (модель работает 10–12); за неделю ≈ 420–500 job-минут простоя и потолок бюджета стадии 55 минут. - validate-gate.mjs: `--no-wait` — гейт диспатчит прогон, убеждается, что тот встал на материал (#539 сохранён), и возвращает `pending` (код 2) вместо ожидания; завершённый зелёный/красный отдаёт сразу, как прежде. - process.yml prepare: третий исход `proceed=pending`: запечатанный маркер `review-pending-<issue>-<run>-<attempt>` (issue, stage, branch, material_sha, validate run) и выход; модель и интеграция не запускаются; возврат автору — только на явном `false`. - process-resume.yml + scripts/process-resume.mjs: на `workflow_run: completed` Validate по ветке issue/* — если метка S7 стоит, активного прогона нет и последний прогон оставил маркер на этот SHA, переставить S7 (HP_PROCESS_TOKEN); новый прогон находит завершённый dispatch сразу. Без маркера не будит. - process-reconcile.mjs: читает маркер и состояние Validate на материале; идёт — wait, завершился/пропал без продолжения — retry; без маркера — прежний escalate. Общий loadSealedArtifact, экспорт processRuns/artifactNames. - preflight сверяет process-resume.yml между main и dev наравне с process.yml. - Тесты: validate-gate (4), process-resume (8, включая контракт трёх workflow), process-reconcile (2); мутанты gate-no-wait-still-sleeps, resume-wakes-round-without-marker, resume-ignores-active-run, reconcile-wakes-pending-while-validate-active. PROCESS.md §10.4, AGENTS.md. Issue: #636 User-Visible: no
257 lines
12 KiB
JavaScript
257 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
validateStateOnMaterial,
|
|
alreadyReported, applyReconciliationDecision, decideReconciliation, latestReviewRequest, markerFor,
|
|
parseProcessRun, preparedEvidenceError, reconcileAll, reconciliationKey, relabel,
|
|
} from '../scripts/process-reconcile.mjs';
|
|
|
|
const NOW = Date.parse('2026-09-13T12:00:00Z');
|
|
const request = { id: 'event-7', at: '2026-09-13T10:00:00Z', label: 'S7-code-review' };
|
|
const issue = (labels = ['S7-code-review']) => ({ number: 555, title: 'fixture', labels, comments: [] });
|
|
const run = (overrides = {}) => ({
|
|
id: 70,
|
|
attempt: 1,
|
|
issue: 555,
|
|
label: 'S7-code-review',
|
|
stage: 'code',
|
|
status: 'completed',
|
|
conclusion: 'cancelled',
|
|
createdAt: '2026-09-13T10:01:00Z',
|
|
updatedAt: '2026-09-13T10:20:00Z',
|
|
url: 'https://example.test/runs/70',
|
|
prepared: null,
|
|
resultArtifact: false,
|
|
...overrides,
|
|
});
|
|
|
|
test('#555 maps a stable process run-name to issue and stage', () => {
|
|
assert.deepEqual(parseProcessRun({
|
|
id: 70,
|
|
run_attempt: 2,
|
|
display_title: 'process #555 · S7-code-review · fixture title',
|
|
status: 'in_progress',
|
|
created_at: '2026-09-13T10:00:01Z',
|
|
}), {
|
|
id: 70, attempt: 2, issue: 555, label: 'S7-code-review', stage: 'code',
|
|
status: 'in_progress', conclusion: '', createdAt: '2026-09-13T10:00:01Z',
|
|
updatedAt: null, url: null, prepared: null, preparedArtifact: false,
|
|
resultArtifact: false, pending: null, pendingValidate: null, evidenceError: null,
|
|
});
|
|
assert.equal(parseProcessRun({ display_title: 'unrelated label event' }), null);
|
|
});
|
|
|
|
test('#555 latest label request is stage-specific and deterministic', () => {
|
|
const events = [
|
|
{ id: 1, event: 'labeled', created_at: '2026-09-13T08:00:00Z', label: { name: 'S7-code-review' } },
|
|
{ id: 2, event: 'labeled', created_at: '2026-09-13T09:00:00Z', label: { name: 'P2' } },
|
|
{ id: 3, event: 'labeled', created_at: '2026-09-13T10:00:00Z', label: { name: 'S4-spec-review' } },
|
|
{ id: 4, event: 'labeled', created_at: '2026-09-13T11:00:00Z', label: { name: 'S7-code-review' }, actor: { login: 'owner' } },
|
|
];
|
|
assert.deepEqual(latestReviewRequest(events, 'S7-code-review'), {
|
|
id: '4', at: '2026-09-13T11:00:00Z', label: 'S7-code-review', actor: 'owner',
|
|
});
|
|
});
|
|
|
|
test('#555 fixture matrix: lost/cancelled/timeout retry, running waits, applied verdict is noop', () => {
|
|
const lost = decideReconciliation({ issue: issue(), request, runs: [], now: NOW });
|
|
assert.equal(lost.action, 'retry');
|
|
assert.match(lost.reason, /no matching process run/);
|
|
|
|
for (const conclusion of ['cancelled', 'timed_out', 'startup_failure']) {
|
|
const decision = decideReconciliation({ issue: issue(), request, runs: [run({ conclusion })], now: NOW });
|
|
assert.equal(decision.action, 'retry', conclusion);
|
|
}
|
|
|
|
const running = decideReconciliation({
|
|
issue: issue(), request,
|
|
runs: [run({ status: 'in_progress', conclusion: '', createdAt: '2026-09-13T11:00:00Z' })], now: NOW,
|
|
});
|
|
assert.equal(running.action, 'wait');
|
|
|
|
const applied = decideReconciliation({ issue: issue(['S8-merged']), request, runs: [run()], now: NOW });
|
|
assert.equal(applied.action, 'noop');
|
|
});
|
|
|
|
test('#555 guard failure and ambiguous state escalate without automatic retry', () => {
|
|
const failed = decideReconciliation({ issue: issue(), request, runs: [run({ conclusion: 'failure' })], now: NOW });
|
|
assert.equal(failed.action, 'escalate');
|
|
assert.match(failed.reason, /non-transient/);
|
|
|
|
const tooLong = decideReconciliation({
|
|
issue: issue(), request: { ...request, at: '2026-09-13T05:00:00Z' },
|
|
runs: [run({ status: 'in_progress', conclusion: '', createdAt: '2026-09-13T06:00:00Z' })], now: NOW,
|
|
});
|
|
assert.equal(tooLong.action, 'escalate');
|
|
|
|
assert.equal(decideReconciliation({
|
|
issue: issue(['S7-code-review', 'blocked']), request, runs: [], now: NOW,
|
|
}).action, 'noop');
|
|
assert.equal(decideReconciliation({
|
|
issue: issue(['S7-code-review', 'review-4']), request, runs: [], now: NOW,
|
|
}).action, 'noop');
|
|
assert.equal(decideReconciliation({
|
|
issue: issue(['S4-spec-review', 'S7-code-review']), request, runs: [], now: NOW,
|
|
}).action, 'escalate');
|
|
});
|
|
|
|
test('#555 sealed result and successful-but-unapplied run never trigger a second model', () => {
|
|
const sealed = decideReconciliation({
|
|
issue: issue(), request, runs: [run({ conclusion: 'timed_out', resultArtifact: true })], now: NOW,
|
|
});
|
|
assert.equal(sealed.action, 'escalate');
|
|
assert.match(sealed.reason, /model result/);
|
|
|
|
const unapplied = decideReconciliation({
|
|
issue: issue(), request, runs: [run({ conclusion: 'success' })], now: NOW,
|
|
});
|
|
assert.equal(unapplied.action, 'escalate');
|
|
assert.match(unapplied.reason, /did not move/);
|
|
});
|
|
|
|
test('#555 prepared proof is bound to issue, stage, attempt, material and round', () => {
|
|
const good = {
|
|
schema: 1, run_id: '70', run_attempt: '1', issue: '555', stage: 'code', cycle: '2',
|
|
material_sha: 'a'.repeat(40), material_tree: 'b'.repeat(40), material_specs: 'c'.repeat(40) + ' docs/specs/555.md;',
|
|
};
|
|
assert.equal(preparedEvidenceError(good, { issue: issue(), stage: 'code', run: run() }), null);
|
|
for (const patch of [
|
|
{ issue: '556' }, { stage: 'spec' }, { run_attempt: '2' }, { material_sha: 'bad' }, { cycle: '0' },
|
|
]) {
|
|
assert.ok(preparedEvidenceError({ ...good, ...patch }, { issue: issue(), stage: 'code', run: run() }), JSON.stringify(patch));
|
|
}
|
|
const foreign = decideReconciliation({
|
|
issue: issue(), request, runs: [run({ prepared: { ...good, stage: 'spec' } })], now: NOW,
|
|
});
|
|
assert.equal(foreign.action, 'escalate');
|
|
assert.match(foreign.reason, /another issue\/stage/);
|
|
});
|
|
|
|
test('#555 repeat reconciliation has a stable dedupe marker', () => {
|
|
const decision = decideReconciliation({ issue: issue(), request, runs: [], now: NOW });
|
|
const key = reconciliationKey(issue(), request, decision);
|
|
assert.equal(key, reconciliationKey(issue(), request, decision));
|
|
assert.equal(alreadyReported(issue(), key), false);
|
|
assert.equal(alreadyReported({ ...issue(), comments: [{ body: `${markerFor(key)}\ndone` }] }, key), true);
|
|
|
|
const afterRetry = {
|
|
...issue(),
|
|
comments: [{
|
|
createdAt: '2026-09-13T10:05:00Z',
|
|
body: `${markerFor(key)}\nАвтосверка процесса пытается повторно разбудить \`S7-code-review\``,
|
|
}],
|
|
};
|
|
const retriedRequest = { ...request, id: 'event-8', at: '2026-09-13T10:04:59Z' };
|
|
const stopped = decideReconciliation({ issue: afterRetry, request: retriedRequest, runs: [], now: NOW });
|
|
assert.equal(stopped.action, 'escalate');
|
|
assert.match(stopped.reason, /one automatic retry/);
|
|
});
|
|
|
|
test('#555 write path persists the marker before relabel and never mutates after comment failure', async () => {
|
|
const decision = decideReconciliation({ issue: issue(), request, runs: [], now: NOW });
|
|
const key = reconciliationKey(issue(), request, decision);
|
|
const calls = [];
|
|
await assert.rejects(applyReconciliationDecision({
|
|
repo: 'owner/repo', issue: issue(), request, decision, key,
|
|
ops: {
|
|
comment: async () => { calls.push('comment'); throw new Error('comment unavailable'); },
|
|
relabel: async () => { calls.push('relabel'); },
|
|
},
|
|
}), /comment unavailable/);
|
|
assert.deepEqual(calls, ['comment']);
|
|
|
|
calls.length = 0;
|
|
await applyReconciliationDecision({
|
|
repo: 'owner/repo', issue: issue(), request, decision, key,
|
|
ops: {
|
|
comment: async (_repo, _issue, body) => { calls.push('comment'); assert.match(body, new RegExp(markerFor(key))); },
|
|
relabel: async () => { calls.push('relabel'); },
|
|
},
|
|
});
|
|
assert.deepEqual(calls, ['comment', 'relabel']);
|
|
});
|
|
|
|
test('#555 relabel checks the bounded restore attempt instead of throwing the first error blindly', () => {
|
|
const calls = [];
|
|
let adds = 0;
|
|
const recovered = (args) => {
|
|
calls.push(args.at(-2));
|
|
if (args.includes('--remove-label')) return { status: 0, stderr: '' };
|
|
adds++;
|
|
return adds === 1 ? { status: 1, stderr: 'transient' } : { status: 0, stderr: '' };
|
|
};
|
|
assert.doesNotThrow(() => relabel('owner/repo', issue(), 'S7-code-review', recovered));
|
|
assert.equal(adds, 2);
|
|
assert.equal(calls.length, 3);
|
|
|
|
const broken = (args) => args.includes('--remove-label')
|
|
? { status: 0, stderr: '' } : { status: 1, stderr: 'still broken' };
|
|
assert.throws(() => relabel('owner/repo', issue(), 'S7-code-review', broken), /still broken/);
|
|
});
|
|
|
|
test('#555 one write failure stays in machine summary and does not abort the snapshot', async () => {
|
|
const snapshot = { issue: issue(), request, runs: [] };
|
|
const runtime = {
|
|
openReviewIssues: async () => [snapshot.issue],
|
|
processRuns: async () => [],
|
|
snapshot: async () => snapshot,
|
|
applyDecision: async () => { throw new Error('write failed after durable diagnosis'); },
|
|
};
|
|
const summary = await reconcileAll({ repo: 'owner/repo', apply: true, now: NOW, runtime });
|
|
assert.equal(summary.failures, 1);
|
|
assert.equal(summary.mutations, 0);
|
|
assert.equal(summary.records.length, 1);
|
|
assert.equal(summary.records[0].applied, false);
|
|
assert.match(summary.records[0].error, /write failed/);
|
|
});
|
|
|
|
test('#555 a fresh label/run completion stays inside grace instead of duplicating work', () => {
|
|
assert.equal(decideReconciliation({
|
|
issue: issue(),
|
|
request: { ...request, at: '2026-09-13T11:58:00Z' },
|
|
runs: [], now: NOW,
|
|
}).action, 'wait');
|
|
assert.equal(decideReconciliation({
|
|
issue: issue(), request,
|
|
runs: [run({ conclusion: 'cancelled', updatedAt: '2026-09-13T11:58:00Z' })], now: NOW,
|
|
}).action, 'wait');
|
|
});
|
|
|
|
// #636: подготовка вышла успешно, оставив маркер ожидания — Validate на
|
|
// материале ещё шёл. Это не «успешный прогон без вердикта»: пока Validate идёт,
|
|
// reconcile ждёт; завершился или пропал, а событие раунд не разбудило —
|
|
// повторная метка. Маркер обязателен: без него правило прежнее (escalate),
|
|
// иначе любой успешный прогон без вердикта будил бы модель второй раз.
|
|
test('#636 pending marker: running Validate waits, finished Validate retries, no marker still escalates', () => {
|
|
const pending = { schema: 1, issue: 636, material_sha: 'a'.repeat(40) };
|
|
const waiting = decideReconciliation({
|
|
issue: issue(), request, runs: [run({ conclusion: 'success', pending, pendingValidate: 'active' })], now: NOW,
|
|
});
|
|
assert.equal(waiting.action, 'wait');
|
|
assert.match(waiting.reason, /still running/);
|
|
|
|
for (const state of ['completed', 'missing']) {
|
|
const done = decideReconciliation({
|
|
issue: issue(), request, runs: [run({ conclusion: 'success', pending, pendingValidate: state })], now: NOW,
|
|
});
|
|
assert.equal(done.action, 'retry', state);
|
|
assert.match(done.reason, /was not resumed/);
|
|
}
|
|
|
|
const noMarker = decideReconciliation({ issue: issue(), request, runs: [run({ conclusion: 'success' })], now: NOW });
|
|
assert.equal(noMarker.action, 'escalate');
|
|
const parsed = parseProcessRun({ display_title: 'process #555 · S7-code-review · x', id: 1, pending, pendingValidate: 'active' });
|
|
assert.equal(parsed.pending, pending);
|
|
assert.equal(parsed.pendingValidate, 'active');
|
|
});
|
|
|
|
test('#636 validateStateOnMaterial reads only dispatch runs and prefers active over completed', () => {
|
|
assert.equal(validateStateOnMaterial([]), 'missing');
|
|
assert.equal(validateStateOnMaterial([{ event: 'push', status: 'completed' }]), 'missing');
|
|
assert.equal(validateStateOnMaterial([{ event: 'workflow_dispatch', status: 'completed' }]), 'completed');
|
|
assert.equal(validateStateOnMaterial([
|
|
{ event: 'workflow_dispatch', status: 'completed' }, { event: 'workflow_dispatch', status: 'in_progress' },
|
|
]), 'active');
|
|
assert.equal(validateStateOnMaterial([{ event: 'workflow_dispatch', status: 'queued' }]), 'active');
|
|
});
|