ci: восстанавливать потерянные запросы ревью (#555)

Issue: #555
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-13 15:26:13 +03:00
parent dbd435ad1a
commit e3bf893e3d
8 changed files with 721 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
alreadyReported, decideReconciliation, latestReviewRequest, markerFor,
parseProcessRun, preparedEvidenceError, reconciliationKey,
} 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, 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 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');
});
+22
View File
@@ -770,6 +770,28 @@ test('#553: канон разделяет review и исполнение тес
'точные runtime pins читаются из исполняемых источников, не из памятки');
});
test('#555: bounded reconciler wakes only lost review requests and emits one machine summary', () => {
const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8');
const workflow = read('.github/workflows/process-reconcile.yml');
const processWorkflow = read('.github/workflows/process.yml');
const process = read('PROCESS.md');
const agents = read('AGENTS.md');
assert.match(processWorkflow,
/run-name: "process #\$\{\{ github\.event\.issue\.number \}\} · \$\{\{ github\.event\.label\.name \}\}/,
'run identity includes issue and requested stage');
assert.match(workflow, /cron: '7,37 \* \* \* \*'/);
assert.match(workflow, /workflow_dispatch:/);
assert.match(workflow, /ref: dev/);
assert.match(workflow, /secrets\.HP_PROCESS_TOKEN/);
assert.match(workflow, /node scripts\/process-reconcile\.mjs[\s\S]*--apply="\$APPLY"/);
assert.match(workflow, /--max-actions=5/);
assert.match(workflow, /process-reconcile-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
assert.match(process, /houseplan-process-reconcile\/v1/);
assert.match(process, /второй вызов модели или S8 по догадке запрещены/);
assert.match(agents, /bounded queue reconciler \(#555\)/);
});
test('#551: gates, модель и интеграция имеют независимые jobs, contracts и бюджеты', () => {
const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8');
const job = (name, next) => {