mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: восстанавливать потерянные запросы ревью (#555)
Issue: #555 User-Visible: no
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
name: Сверка очереди ревью
|
||||
run-name: "reconcile process queue · ${{ github.event_name }}"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '7,37 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
apply:
|
||||
description: "Повторно будить потерянные запросы и публиковать диагностику"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
name: "Один снимок S4/S7 без polling модели"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: process-reconcile
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
# Расписание читается из main, а исполняемый reconciler — из dev: так
|
||||
# после штатного merge действует та же версия кода, которую проверил CI.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Сопоставить labels, requests, runs и sealed evidence
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
APPLY: ${{ github.event_name == 'schedule' || inputs.apply == true }}
|
||||
run: |
|
||||
mkdir -p artifacts/process-reconcile
|
||||
node scripts/process-reconcile.mjs \
|
||||
--repo "$REPO" \
|
||||
--apply="$APPLY" \
|
||||
--max-actions=5 \
|
||||
--output=artifacts/process-reconcile/summary.json
|
||||
- name: Опубликовать компактный machine-readable итог
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: process-reconcile-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: artifacts/process-reconcile/summary.json
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Ревью-конвейер
|
||||
run-name: "process #${{ github.event.issue.number }} · ${{ github.event.label.name }} · ${{ github.event.issue.title }}"
|
||||
|
||||
# Событийный конвейер процесса (PROCESS.md). Смена статусной метки — это
|
||||
# сообщение: она порождает событие, событие запускает следующий шаг.
|
||||
|
||||
@@ -268,6 +268,15 @@ What the new label means:
|
||||
**After a review run the label always changes.** If it did not, the run itself
|
||||
failed rather than the work — say so to the owner instead of polling on.
|
||||
|
||||
The repository also has a bounded queue reconciler (#555). It takes one S4/S7
|
||||
snapshot every thirty minutes and exits. It may re-apply the same review label
|
||||
only when the matching event was lost or its run ended with a transient
|
||||
cancellation/timeout before a sealed result existed. It never applies verdicts or
|
||||
merges. Running work, `blocked`, `review-4`, a foreign material/stage/attempt, a
|
||||
guard failure, or an unintegrated sealed model result is left untouched and gets
|
||||
at most one machine-keyed diagnostic. This is a safety net, not permission for an
|
||||
agent to stop waiting for the result of the review it started.
|
||||
|
||||
**A failed pre-release gate does not send the issue back to review.** The
|
||||
implementation loop runs only typecheck, unit and build; golden, browser smokes,
|
||||
performance and the full HA harness run before a beta, which is after the code
|
||||
|
||||
+19
@@ -1069,6 +1069,25 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
|
||||
Если метка не сменилась, значит упал сам прогон, а не работа: смотреть логи и
|
||||
сообщать владельцу, а не продолжать опрос.
|
||||
|
||||
**Очередь S4/S7 сверяется отдельным bounded controller (#555).** Workflow
|
||||
`process-reconcile.yml` раз в полчаса делает один снимок открытых задач и
|
||||
завершается — активного polling одинакового состояния и вызова модели на каждый
|
||||
тик нет. Он сопоставляет последнюю постановку `S4`/`S7` с run по номеру задачи и
|
||||
этапу; если стадия успела подготовить материал, дополнительно проверяет
|
||||
запечатанные run/attempt, SHA/tree, список блобов ТЗ и номер раунда. Текущий
|
||||
`blocked`, `review-4` или снятая review-метка всегда сильнее старого события.
|
||||
|
||||
Автоматически и не более одного раза повторно применяется только сама review-метка после доказанно
|
||||
потерянного события либо transient-исхода до получения запечатанного результата
|
||||
(`cancelled`, `timed_out`, `stale`, `startup_failure`, `skipped`). Это не применяет
|
||||
вердикт и не расходует цикл: обычный конвейер заново читает актуальные labels и
|
||||
материал. Здоровый running run не трогается. Failure guard, неизвестная связь,
|
||||
чужой material/stage/attempt, success без смены метки и сбой после появления
|
||||
`review-result`, а также потеря повторного события получают один дедуплицированный диагностический комментарий и
|
||||
эскалацию человеку — второй вызов модели или S8 по догадке запрещены. Перед любой
|
||||
записью controller перечитывает состояние; итог каждого прохода публикуется как
|
||||
`houseplan-process-reconcile/v1` artifact.
|
||||
|
||||
**Конвейер — идемпотентный контроллер, а событие лишь будит его** (#499). Guard
|
||||
читает метки issue текущими, а не из снимка события: прогон мог простоять в очереди,
|
||||
пока владелец снял метку — отозванный запрос не исполняется, и комментария об этом
|
||||
|
||||
@@ -7893,6 +7893,61 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' никогда — именно оно в этом процессе заменяет тестирование.',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-reconcile-restarts-healthy-run',
|
||||
guard: 'node --test test/process-reconcile.test.mjs',
|
||||
because: '#555: bounded reconciliation must not duplicate a healthy queued/running review '
|
||||
+ 'or turn metadata polling into another model invocation',
|
||||
patches: [{
|
||||
file: 'scripts/process-reconcile.mjs',
|
||||
find: " if (age <= activeLimitMs) return result('wait', 'matching process run is healthy and active', { label, stage, run });",
|
||||
replace: " if (age <= activeLimitMs) return result('retry', 'mutant: restart healthy run', { label, stage, run });",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-reconcile-ignores-owner-stop',
|
||||
guard: 'node --test test/process-reconcile.test.mjs',
|
||||
because: '#555: blocked/review-4 is a current owner decision and always wins over an old '
|
||||
+ 'lost-event or cancelled-run observation',
|
||||
patches: [{
|
||||
file: 'scripts/process-reconcile.mjs',
|
||||
find: " if (labels.includes('blocked') || labels.includes('review-4')) {",
|
||||
replace: " if (false && (labels.includes('blocked') || labels.includes('review-4'))) {",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-reconcile-accepts-foreign-prepared-evidence',
|
||||
guard: 'node --test test/process-reconcile.test.mjs',
|
||||
because: '#555: an artifact from another issue, stage, run or attempt is diagnosis only and '
|
||||
+ 'must never authorize recovery or verdict application',
|
||||
patches: [{
|
||||
file: 'scripts/process-reconcile.mjs',
|
||||
find: ' if (badIdentity) return \'prepared artifact belongs to another issue/stage/run attempt\';',
|
||||
replace: ' if (false && badIdentity) return \'mutant: foreign identity accepted\';',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-reconcile-reruns-sealed-model-result',
|
||||
guard: 'node --test test/process-reconcile.test.mjs',
|
||||
because: '#555: a sealed model artifact must be escalated for deterministic integration, not '
|
||||
+ 'paid for a second time by an automatic relabel',
|
||||
patches: [{
|
||||
file: 'scripts/process-reconcile.mjs',
|
||||
find: ' if (run.resultArtifact) {',
|
||||
replace: ' if (false && run.resultArtifact) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'process-reconcile-retries-lost-event-forever',
|
||||
guard: 'node --test test/process-reconcile.test.mjs',
|
||||
because: '#555: one lost wake-up gets one automatic relabel; if that event is lost too, '
|
||||
+ 'reconciliation escalates instead of generating a run/comment every schedule tick',
|
||||
patches: [{
|
||||
file: 'scripts/process-reconcile.mjs',
|
||||
find: ' if (retryAlreadyIssued) {',
|
||||
replace: ' if (false && retryAlreadyIssued) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'review-integration-skips-evidence-checksum',
|
||||
guard: 'node --test --test-name-pattern="#551" test/review-doc-guard.test.mjs',
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env node
|
||||
// Bounded reconciliation for the event-driven S4/S7 controller (#555).
|
||||
//
|
||||
// The label remains the state. This script never publishes a verdict and never
|
||||
// merges anything; it only wakes the ordinary controller after a proven lost or
|
||||
// transiently terminated request, or leaves one deduplicated diagnostic when
|
||||
// recovery would require guessing. One invocation reads one snapshot and exits:
|
||||
// it does not poll and therefore cannot turn a healthy wait into model usage.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
|
||||
export const REVIEW_LABELS = ['S4-spec-review', 'S7-code-review'];
|
||||
export const ACTIVE_RUN_STATES = new Set(['queued', 'in_progress', 'pending', 'requested', 'waiting']);
|
||||
export const RETRYABLE_CONCLUSIONS = new Set(['cancelled', 'timed_out', 'stale', 'startup_failure', 'skipped']);
|
||||
export const DEFAULT_GRACE_MS = 5 * 60_000;
|
||||
export const DEFAULT_ACTIVE_LIMIT_MS = 4 * 60 * 60_000;
|
||||
|
||||
const STAGE = { 'S4-spec-review': 'spec', 'S7-code-review': 'code' };
|
||||
const RUN_TITLE = /^process #(\d+) · (S4-spec-review|S7-code-review)(?: ·|$)/;
|
||||
const MARKER_PREFIX = 'houseplan-process-reconcile:v1';
|
||||
const RETRY_COMMENT = /houseplan-process-reconcile:v1:[^\s]+[\s\S]*Автосверка процесса повторно/;
|
||||
|
||||
const at = (value) => {
|
||||
const parsed = Date.parse(String(value || ''));
|
||||
return Number.isFinite(parsed) ? parsed : NaN;
|
||||
};
|
||||
|
||||
const labelsOf = (issue) => (issue?.labels || []).map((label) =>
|
||||
typeof label === 'string' ? label : label?.name).filter(Boolean);
|
||||
|
||||
export function parseProcessRun(run = {}) {
|
||||
const match = String(run.displayTitle || run.display_title || run.name || '').match(RUN_TITLE);
|
||||
if (!match) return null;
|
||||
return {
|
||||
id: Number(run.databaseId ?? run.id),
|
||||
attempt: Number(run.attempt ?? run.run_attempt ?? 1),
|
||||
issue: Number(match[1]),
|
||||
label: match[2],
|
||||
stage: STAGE[match[2]],
|
||||
status: String(run.status || ''),
|
||||
conclusion: run.conclusion == null ? '' : String(run.conclusion),
|
||||
createdAt: run.createdAt || run.created_at || run.run_started_at || null,
|
||||
updatedAt: run.updatedAt || run.updated_at || run.completedAt || null,
|
||||
url: run.url || run.html_url || null,
|
||||
prepared: run.prepared || null,
|
||||
preparedArtifact: Boolean(run.preparedArtifact),
|
||||
resultArtifact: Boolean(run.resultArtifact),
|
||||
evidenceError: run.evidenceError || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function latestReviewRequest(events = [], label = null) {
|
||||
const requests = events
|
||||
.filter((event) => event?.event === 'labeled' && REVIEW_LABELS.includes(event?.label?.name))
|
||||
.map((event) => ({
|
||||
id: String(event.id || event.node_id || event.createdAt || event.created_at || ''),
|
||||
at: event.createdAt || event.created_at || null,
|
||||
label: event.label.name,
|
||||
actor: event.actor?.login || null,
|
||||
}))
|
||||
.filter((request) => Number.isFinite(at(request.at)) && (!label || request.label === label));
|
||||
requests.sort((a, b) => at(a.at) - at(b.at) || a.id.localeCompare(b.id));
|
||||
return requests.at(-1) || null;
|
||||
}
|
||||
|
||||
export function preparedEvidenceError(prepared, { issue, stage, run }) {
|
||||
if (!prepared) return null;
|
||||
const badIdentity = prepared.schema !== 1
|
||||
|| String(prepared.run_id) !== String(run.id)
|
||||
|| String(prepared.run_attempt) !== String(run.attempt)
|
||||
|| String(prepared.issue) !== String(issue.number)
|
||||
|| prepared.stage !== stage;
|
||||
if (badIdentity) return 'prepared artifact belongs to another issue/stage/run attempt';
|
||||
if (!/^[0-9a-f]{40}$/i.test(String(prepared.material_sha || ''))
|
||||
|| !/^[0-9a-f]{40}$/i.test(String(prepared.material_tree || ''))
|
||||
|| typeof prepared.material_specs !== 'string'
|
||||
|| !/^[1-9][0-9]*$/.test(String(prepared.cycle || ''))) {
|
||||
return 'prepared artifact has incomplete material SHA/tree/spec hash or round';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function result(action, reason, context = {}) {
|
||||
return { action, reason, ...context };
|
||||
}
|
||||
|
||||
/** Pure controller decision. It never interprets or applies a model verdict. */
|
||||
export function decideReconciliation({
|
||||
issue, request = null, runs = [], now = Date.now(), graceMs = DEFAULT_GRACE_MS,
|
||||
activeLimitMs = DEFAULT_ACTIVE_LIMIT_MS,
|
||||
}) {
|
||||
const labels = labelsOf(issue);
|
||||
const statuses = REVIEW_LABELS.filter((label) => labels.includes(label));
|
||||
if (statuses.length === 0) return result('noop', 'verdict already applied or issue is not awaiting review');
|
||||
if (statuses.length !== 1) return result('escalate', 'ambiguous review status labels', { label: null });
|
||||
const label = statuses[0];
|
||||
const stage = STAGE[label];
|
||||
if (labels.includes('blocked') || labels.includes('review-4')) {
|
||||
return result('noop', 'owner intentionally stopped review', { label, stage });
|
||||
}
|
||||
if (!request || request.label !== label || !Number.isFinite(at(request.at))) {
|
||||
return result('escalate', 'current review label has no unambiguous timeline request', { label, stage });
|
||||
}
|
||||
|
||||
const requestAt = at(request.at);
|
||||
const matching = runs
|
||||
.map((run) => run.issue ? run : parseProcessRun(run))
|
||||
.filter(Boolean)
|
||||
.filter((run) => run.issue === Number(issue.number) && run.label === label
|
||||
&& Number.isFinite(at(run.createdAt)) && at(run.createdAt) >= requestAt - 120_000)
|
||||
.sort((a, b) => at(b.createdAt) - at(a.createdAt) || Number(b.id) - Number(a.id));
|
||||
const run = matching[0] || null;
|
||||
|
||||
if (!run) {
|
||||
if (now - requestAt < graceMs) return result('wait', 'label event is still within delivery grace', { label, stage });
|
||||
const retryAlreadyIssued = (issue?.comments || []).some((comment) =>
|
||||
RETRY_COMMENT.test(String(comment?.body || '')) && at(comment?.createdAt) >= requestAt);
|
||||
if (retryAlreadyIssued) {
|
||||
return result('escalate', 'one automatic retry was already issued but still has no matching run', { label, stage });
|
||||
}
|
||||
return result('retry', 'label event has no matching process run', { label, stage });
|
||||
}
|
||||
if (run.evidenceError) {
|
||||
return result('escalate', `run evidence is invalid: ${run.evidenceError}`, { label, stage, run });
|
||||
}
|
||||
const evidenceError = preparedEvidenceError(run.prepared, { issue, stage, run });
|
||||
if (evidenceError) return result('escalate', evidenceError, { label, stage, run });
|
||||
|
||||
if (ACTIVE_RUN_STATES.has(run.status)) {
|
||||
const age = now - at(run.createdAt);
|
||||
if (age <= activeLimitMs) return result('wait', 'matching process run is healthy and active', { label, stage, run });
|
||||
return result('escalate', 'matching process run is active beyond the controller budget', { label, stage, run });
|
||||
}
|
||||
if (run.status !== 'completed') {
|
||||
return result('escalate', `unknown process run status: ${run.status || 'missing'}`, { label, stage, run });
|
||||
}
|
||||
const settledAt = at(run.updatedAt || run.createdAt);
|
||||
if (Number.isFinite(settledAt) && now - settledAt < graceMs) {
|
||||
return result('wait', 'completed run is still within label-application grace', { label, stage, run });
|
||||
}
|
||||
if (run.conclusion === 'success') {
|
||||
return result('escalate', 'successful run did not move the review label', { label, stage, run });
|
||||
}
|
||||
if (run.resultArtifact) {
|
||||
return result('escalate', 'sealed model result exists but was not integrated; automatic rerun would spend the model twice', { label, stage, run });
|
||||
}
|
||||
if (RETRYABLE_CONCLUSIONS.has(run.conclusion)) {
|
||||
return result('retry', `transient process run conclusion: ${run.conclusion}`, { label, stage, run });
|
||||
}
|
||||
return result('escalate', `non-transient process run conclusion: ${run.conclusion || 'missing'}`, { label, stage, run });
|
||||
}
|
||||
|
||||
export function reconciliationKey(issue, request, decision) {
|
||||
const raw = [issue.number, request?.id || 'no-request', decision.label || 'ambiguous',
|
||||
decision.run?.id || 'no-run', decision.run?.attempt || '0', decision.action, decision.reason].join('|');
|
||||
return createHash('sha256').update(raw).digest('hex').slice(0, 20);
|
||||
}
|
||||
|
||||
export function markerFor(key) {
|
||||
return `<!-- ${MARKER_PREFIX}:${key} -->`;
|
||||
}
|
||||
|
||||
export function alreadyReported(issue, key) {
|
||||
const marker = markerFor(key);
|
||||
return (issue?.comments || []).some((comment) => String(comment?.body || '').includes(marker));
|
||||
}
|
||||
|
||||
function gh(args, { allowFailure = false } = {}) {
|
||||
const result = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
if (!allowFailure && (result.error || result.status !== 0)) {
|
||||
throw new Error(`gh ${args.join(' ')} → ${(result.stderr || result.error?.message || '').trim()}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function ghJson(args) {
|
||||
const output = gh(args).stdout || 'null';
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function issueView(repo, number) {
|
||||
return ghJson(['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,labels,comments,updatedAt']);
|
||||
}
|
||||
|
||||
function issueEvents(repo, number) {
|
||||
const pages = ghJson(['api', '--paginate', '--slurp', `repos/${repo}/issues/${number}/events?per_page=100`]);
|
||||
return Array.isArray(pages?.[0]) ? pages.flat() : (Array.isArray(pages) ? pages : []);
|
||||
}
|
||||
|
||||
function openReviewIssues(repo) {
|
||||
const fields = 'number,title,labels,comments,updatedAt';
|
||||
const rows = REVIEW_LABELS.flatMap((label) => ghJson([
|
||||
'issue', 'list', '--repo', repo, '--state', 'open', '--label', label,
|
||||
'--limit', '500', '--json', fields,
|
||||
]));
|
||||
return [...new Map(rows.map((issue) => [issue.number, issue])).values()]
|
||||
.sort((a, b) => a.number - b.number);
|
||||
}
|
||||
|
||||
function processRuns(repo, issues = []) {
|
||||
const pages = [1, 2].flatMap((page) => {
|
||||
const response = ghJson(['api', `repos/${repo}/actions/workflows/process.yml/runs?event=issues&per_page=100&page=${page}`]);
|
||||
return response.workflow_runs || [];
|
||||
});
|
||||
return pages.map((raw) => {
|
||||
const stable = parseProcessRun(raw);
|
||||
if (stable) return stable;
|
||||
// Runs created before #555 used the issue title as display_title. Accept
|
||||
// that legacy identity only when it names exactly one current S4/S7 issue;
|
||||
// title ambiguity must never wake a potentially different task.
|
||||
const title = String(raw.display_title || raw.displayTitle || '');
|
||||
const matches = issues.filter((issue) => issue.title === title);
|
||||
if (matches.length !== 1) return null;
|
||||
const labels = labelsOf(matches[0]);
|
||||
const statuses = REVIEW_LABELS.filter((label) => labels.includes(label));
|
||||
if (statuses.length !== 1) return null;
|
||||
const label = statuses[0];
|
||||
return parseProcessRun({
|
||||
...raw,
|
||||
display_title: `process #${matches[0].number} · ${label} · ${title}`,
|
||||
});
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function artifactNames(repo, run) {
|
||||
const response = ghJson(['api', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]);
|
||||
return response.artifacts || [];
|
||||
}
|
||||
|
||||
function loadPreparedArtifact(repo, run, artifact) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'houseplan-process-reconcile-'));
|
||||
try {
|
||||
const downloaded = gh(['run', 'download', String(run.id), '--repo', repo,
|
||||
'--name', artifact.name, '--dir', dir], { allowFailure: true });
|
||||
if (downloaded.status !== 0) throw new Error(`artifact download failed: ${(downloaded.stderr || '').trim()}`);
|
||||
const file = join(dir, 'prepared.json');
|
||||
const manifest = join(dir, 'manifest.sha256');
|
||||
if (!existsSync(file) || !existsSync(manifest)) throw new Error('prepared artifact is incomplete');
|
||||
const body = readFileSync(file);
|
||||
const expected = readFileSync(manifest, 'utf8').trim().split(/\s+/)[0];
|
||||
const actual = createHash('sha256').update(body).digest('hex');
|
||||
if (expected !== actual) throw new Error('prepared artifact checksum mismatch');
|
||||
return JSON.parse(body.toString('utf8'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateRunEvidence(repo, issue, run) {
|
||||
if (!run || run.status !== 'completed') return run;
|
||||
try {
|
||||
const artifacts = artifactNames(repo, run);
|
||||
const preparedName = `review-prepared-${issue.number}-${run.id}-${run.attempt}`;
|
||||
const resultName = `review-result-${issue.number}-${run.id}-${run.attempt}`;
|
||||
const preparedArtifacts = artifacts.filter((artifact) => artifact.name === preparedName && !artifact.expired);
|
||||
const resultArtifacts = artifacts.filter((artifact) => artifact.name === resultName && !artifact.expired);
|
||||
if (preparedArtifacts.length > 1 || resultArtifacts.length > 1) {
|
||||
return { ...run, evidenceError: 'duplicate prepared/result artifacts' };
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
preparedArtifact: preparedArtifacts.length === 1,
|
||||
resultArtifact: resultArtifacts.length === 1,
|
||||
prepared: preparedArtifacts.length === 1
|
||||
? loadPreparedArtifact(repo, run, preparedArtifacts[0]) : null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { ...run, evidenceError: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function commentBody(issue, request, decision, key) {
|
||||
const run = decision.run;
|
||||
const evidence = run?.prepared
|
||||
? ` Материал: \`${String(run.prepared.material_sha).slice(0, 12)}\`, tree \`${String(run.prepared.material_tree).slice(0, 12)}\`, ТЗ \`${run.prepared.material_specs || 'нет файлового ТЗ'}\`, раунд r${run.prepared.cycle}.`
|
||||
: '';
|
||||
const link = run?.url ? ` [Прогон](${run.url}).` : '';
|
||||
if (decision.action === 'retry') {
|
||||
return `${markerFor(key)}\nАвтосверка процесса повторно разбудила \`${decision.label}\`: ${decision.reason}.${link}${evidence}\n\n`
|
||||
+ 'Вердикт не применялся, цикл ревью не расходуется самой сверкой; новый запуск заново проверит актуальные метки и материал.';
|
||||
}
|
||||
return `${markerFor(key)}\n**Автосверка процесса не стала угадывать результат.** ${decision.reason}.${link}${evidence}\n\n`
|
||||
+ `Запрос: \`${request?.id || 'не найден'}\`, текущая метка: \`${decision.label || labelsOf(issue).join(', ') || 'нет'}\`. `
|
||||
+ 'Метка и вердикт не изменены; требуется разбор человеком.';
|
||||
}
|
||||
|
||||
function addComment(repo, issue, body) {
|
||||
gh(['issue', 'comment', String(issue.number), '--repo', repo, '--body', body]);
|
||||
}
|
||||
|
||||
function relabel(repo, issue, label) {
|
||||
gh(['issue', 'edit', String(issue.number), '--repo', repo, '--remove-label', label]);
|
||||
const added = gh(['issue', 'edit', String(issue.number), '--repo', repo, '--add-label', label], { allowFailure: true });
|
||||
if (added.status !== 0) {
|
||||
// Best-effort rollback: leaving an issue without its review state is worse
|
||||
// than a loud failed reconciliation.
|
||||
gh(['issue', 'edit', String(issue.number), '--repo', repo, '--add-label', label], { allowFailure: true });
|
||||
throw new Error(`could not restore ${label}: ${(added.stderr || '').trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshot(repo, baseRuns, issue) {
|
||||
const fresh = issueView(repo, issue.number);
|
||||
const events = issueEvents(repo, issue.number);
|
||||
const labels = labelsOf(fresh);
|
||||
const label = REVIEW_LABELS.find((candidate) => labels.includes(candidate)) || null;
|
||||
const request = latestReviewRequest(events, label);
|
||||
const candidates = baseRuns.filter((run) => run.issue === issue.number && run.label === label)
|
||||
.sort((a, b) => at(b.createdAt) - at(a.createdAt));
|
||||
const hydrated = candidates.length ? [hydrateRunEvidence(repo, fresh, candidates[0]), ...candidates.slice(1)] : candidates;
|
||||
return { issue: fresh, request, runs: hydrated };
|
||||
}
|
||||
|
||||
export async function reconcileAll({ repo, apply = false, maxActions = 5, now = Date.now() }) {
|
||||
const issues = openReviewIssues(repo);
|
||||
const runs = processRuns(repo, issues);
|
||||
const records = [];
|
||||
let mutations = 0;
|
||||
for (const listed of issues) {
|
||||
const first = await snapshot(repo, runs, listed);
|
||||
const decision = decideReconciliation({ ...first, now });
|
||||
const key = reconciliationKey(first.issue, first.request, decision);
|
||||
const record = {
|
||||
issue: first.issue.number,
|
||||
title: first.issue.title,
|
||||
request: first.request,
|
||||
action: decision.action,
|
||||
reason: decision.reason,
|
||||
run: decision.run ? {
|
||||
id: decision.run.id, attempt: decision.run.attempt, status: decision.run.status,
|
||||
conclusion: decision.run.conclusion, url: decision.run.url,
|
||||
materialSha: decision.run.prepared?.material_sha || null,
|
||||
materialTree: decision.run.prepared?.material_tree || null,
|
||||
materialSpecs: decision.run.prepared?.material_specs || null,
|
||||
round: decision.run.prepared?.cycle || null,
|
||||
} : null,
|
||||
key,
|
||||
applied: false,
|
||||
};
|
||||
if (apply && ['retry', 'escalate'].includes(decision.action) && mutations < maxActions
|
||||
&& !alreadyReported(first.issue, key)) {
|
||||
// Re-read immediately before a write. A label/body/owner decision may have
|
||||
// changed while runs and artifacts were inspected.
|
||||
const freshRuns = processRuns(repo, [first.issue]);
|
||||
const second = await snapshot(repo, freshRuns, first.issue);
|
||||
const confirmed = decideReconciliation({ ...second, now: Date.now() });
|
||||
const confirmedKey = reconciliationKey(second.issue, second.request, confirmed);
|
||||
if (confirmed.action === decision.action && confirmedKey === key && !alreadyReported(second.issue, key)) {
|
||||
if (decision.action === 'retry') relabel(repo, second.issue, decision.label);
|
||||
addComment(repo, second.issue, commentBody(second.issue, second.request, confirmed, key));
|
||||
record.applied = true;
|
||||
mutations++;
|
||||
} else {
|
||||
record.reason = `state changed before write: ${confirmed.action}/${confirmed.reason}`;
|
||||
record.action = 'noop';
|
||||
}
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
return {
|
||||
schema: 'houseplan-process-reconcile/v1',
|
||||
generatedAt: new Date(now).toISOString(),
|
||||
repo,
|
||||
apply,
|
||||
counts: records.reduce((out, row) => ({ ...out, [row.action]: (out[row.action] || 0) + 1 }), {}),
|
||||
mutations,
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const value = (name, fallback = '') => {
|
||||
const eq = args.find((arg) => arg.startsWith(`--${name}=`));
|
||||
if (eq) return eq.slice(name.length + 3);
|
||||
const index = args.indexOf(`--${name}`);
|
||||
return index >= 0 ? (args[index + 1] || 'true') : fallback;
|
||||
};
|
||||
const repo = value('repo', process.env.GITHUB_REPOSITORY || 'Matysh/houseplan-card');
|
||||
const apply = value('apply', 'false') === 'true';
|
||||
const maxActions = Number(value('max-actions', '5'));
|
||||
const output = value('output', '');
|
||||
reconcileAll({ repo, apply, maxActions }).then((summary) => {
|
||||
const body = `${JSON.stringify(summary, null, 2)}\n`;
|
||||
if (output) {
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
writeFileSync(output, body);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({ schema: summary.schema, counts: summary.counts, mutations: summary.mutations })}\n`);
|
||||
}).catch((error) => {
|
||||
console.error(`process-reconcile: ${error instanceof Error ? error.stack || error.message : String(error)}`);
|
||||
process.exitCode = 2;
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user