1687 lines
129 KiB
JavaScript
1687 lines
129 KiB
JavaScript
// НАДЗИРАТЕЛЬ, часть 1: кирпичи. Отрезки времени по состояниям работника, признаки жизни,
|
||
// чтение его почтового ящика, карточки работников прогона и итог прогона.
|
||
// Сам ночной круг, который всё это зовёт, — в задаче 12, в этом же файле.
|
||
//
|
||
// 🔴 Зачем он есть. Пределы и замки — чистые функции в tools/night/*.mjs. Работник живёт
|
||
// ДРУГИМ процессом в ДРУГОМ каталоге: он их не видит и позвать не может. Без надзирателя
|
||
// восемь часов, отсечка 8:00, «служба не ответила» и признаки жизни позеленели бы
|
||
// на проверках и не сработали ни разу.
|
||
//
|
||
// 🔴 Чего здесь нет намеренно: недельного запаса и замеров прибора (кусок 2), места на диске
|
||
// (кусок 2), чтения команд из .night/commands/ (кусок 3), очереди пунктов (кусок 4),
|
||
// вопросов и ответов владельца (кусок 5), приёмщика (кусок 7).
|
||
import { execFileSync } from 'node:child_process';
|
||
import { existsSync, readdirSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { writeJsonAtomic, readJson, readLines } from './state.mjs';
|
||
import { touchLife, readLife } from './heartbeat.mjs';
|
||
import { workerInboxDir, lastRunPath, resolveBin, SUMMARY_LINES_FILE } from './paths.mjs';
|
||
// Русские слова для итоговых состояний живут в одном месте — в сводке (задача 9).
|
||
import { STATUS_WORD } from './summary.mjs';
|
||
// --- дополнение к ввозам в шапке файла (задача 12) ----------------------------
|
||
import { spawn } from 'node:child_process';
|
||
import { mkdirSync, openSync } from 'node:fs';
|
||
import { appendLine } from './state.mjs';
|
||
import { isStalled, logEvent } from './heartbeat.mjs';
|
||
// 🪤 Плановая заготовка «было» не называла `SERVICE_WAIT_LIMIT_HOURS» — но она в файле
|
||
// уже стояла (блок 4, «служба не ответила», ниже её зовут как умолчание) и используется
|
||
// дальше в круге. Убрать её значило бы сломать существующий ввоз ради буквы плана.
|
||
import { billableMs, serviceWaitHit, checkSelfLimits, morningCutoff, SERVICE_WAIT_LIMIT_HOURS } from './limits.mjs';
|
||
import { startWorker, parseStreamLine, isStepBoundary, isRealSuccess, rateLimitFromEvent } from './spawn.mjs';
|
||
import { recordMeasure, budgetState, vidPredela } from './budget.mjs';
|
||
import { freeGb, diskState } from './disk.mjs';
|
||
import { REPO_ROOT, supervisorPath, runDir, workerDir, configPath, budgetMeasuresPath, commandsDir } from './paths.mjs';
|
||
// --- дополнение к ввозам в шапке файла (кусок 3, задача 5) ---------------------
|
||
// 🔴 Правило «кого откатывать» — в claims-audit.mjs, правило «кто какой файл занял» —
|
||
// в file-claims.mjs. Здесь только ЗОВУЩИЙ: до этой задачи оба правила зеленели
|
||
// на проверках и в живой ночи не звались ни разу (договор о стыках §5 п. 3).
|
||
import { auditWorkerFiles } from './claims-audit.mjs';
|
||
// 🔴 `podZamkom` дописан куском 4 (задача 5): карточку прогона `run.json` правят все семеро
|
||
// надзирателей чтением-и-записью, и замок на это в затее ОДИН — тот самый, что кусок 3
|
||
// доказал живьём на четырёх процессах. Свой второй здесь был бы второй дорогой к одному
|
||
// правилу: разъедутся имена файла-замка хоть на знак — и оба замка станут украшением.
|
||
import { claimFiles, nextFreeItem, releaseClaims, podZamkom } from './file-claims.mjs';
|
||
// 🪤 «Файла нет» и «файл есть, но не читается» — разные вещи; для каталога прогона
|
||
// это разница между «подали не тот каталог» и «каталог тот, но карточка порвана».
|
||
import { readJsonOrBroken } from './state.mjs';
|
||
// --- дополнение к ввозам в шапке файла (кусок 3, задача 7) ---------------------
|
||
// 🔴 Правило «что делать с командой» — `commandVerdict` — объявлено В ЭТОМ ЖЕ файле
|
||
// (кусок 1, задача 11), и ввозить его не надо: второй такой же рядом развёл бы два
|
||
// правила на одно слово владельца. Здесь ввозятся только руки — кто читает каталог
|
||
// команд, кто снимает исполненную и кто называет непонятое слово.
|
||
import { readCommand, consumeCommand, unknownCommands, COMMAND_WORDS, closeItem, afterItemDone, afterTaskDone } from './items.mjs';
|
||
// --- дополнение к ввозам в шапке файла (кусок 4, задача 5) ---------------------
|
||
// 🔴 Замок «пункт закрыт» написан в item-audit.mjs, а ЗОВЁТ его этот файл, каждый круг,
|
||
// у каждого работника. До этой задачи он зеленел на своих проверках и в живой ночи
|
||
// не звался ни разу — тот самый класс беды, что за три куска нашёлся пять раз.
|
||
import { auditClosedItem, progonProverokPunkta, gryazenLiUgol } from './item-audit.mjs';
|
||
// След отложенной задачи (задача 3). Где он лежит, знает `deferredDir()` — второго
|
||
// места, считающего этот путь, здесь не заводится: два таких разойдутся в первый же месяц.
|
||
import { markDeferred, deferredDir } from './deferred.mjs';
|
||
// 🔴 Команда проверок считается ЗДЕСЬ, на каждом судимом пункте. `plan-model.mjs` не ввозит
|
||
// из затеи ничего, поэтому кольца ввозов не будет.
|
||
import { komandaProverokPunkta } from './plan-model.mjs';
|
||
|
||
// Сколько ждать после вежливого «остановись» до безусловного «умри».
|
||
export const KILL_GRACE_MS = 10_000;
|
||
|
||
// Сколько кругов подряд замер места обязан сказать «плохо», прежде чем гасить и будить
|
||
// владельца среди ночи.
|
||
// 🔴 Правило «не замерили — значит нельзя» этим НЕ отменяется: замер по-прежнему судит
|
||
// строго (disk.mjs, checkSelfLimits), и одно «нельзя» остаётся «нельзя». Меняется другое:
|
||
// что считать ответом. Каталог мигает, антивирус держит его секунду, угол убирают — место
|
||
// при этом целое. Одна такая осечка гасила работника и будила владельца ночью; первая же
|
||
// ложная побудка обесценивает единственный сигнал, ради которого сделано исключение
|
||
// из тихих часов. Поэтому «плохо» обязано ПОДТВЕРДИТЬСЯ, а не случиться однажды.
|
||
// Само число — настройкой `disk_bad_probes`, здесь только умолчание.
|
||
export const DISK_BAD_PROBES_DEFAULT = 2;
|
||
|
||
// Состояния, которые работник объявляет о себе САМ, — закрытый список, менять нельзя.
|
||
export const WORKER_STATUSES = [
|
||
'working', 'stopping', 'waiting_answer_idle', 'waiting_answer_busy', 'waiting_service',
|
||
];
|
||
|
||
// Итоговые состояния, которые ставит НАДЗИРАТЕЛЬ. Живого «working» у мертвеца не остаётся —
|
||
// иначе ворота «от прошлого прогона никого» после первой же ночи закрыли бы затею навсегда.
|
||
export const FINAL_STATUSES = ['extinguished', 'paused', 'stalled'];
|
||
|
||
// Как эти три состояния называются человеческими словами в утренней сводке.
|
||
// 🔴 Своего списка русских слов здесь НЕТ намеренно: он объявлен в `summary.mjs` (задача 9),
|
||
// и надзиратель его оттуда ввозит и передаёт дальше. Два списка разошлись бы, и владелец
|
||
// читал бы про одного работника два разных слова.
|
||
// 🪤 Ввоз стоит наверху файла, а здесь только вывоз — двумя строками, а не одной
|
||
// `export … from`: одна строка вывезла бы имя наружу, но внутри самого файла его бы
|
||
// не было, а задача 12 зовёт `STATUS_WORD[final_status]` своими руками — и упала бы
|
||
// на «STATUS_WORD is not defined».
|
||
export { STATUS_WORD };
|
||
|
||
// --- отрезки времени по состояниям работника ---------------------------------
|
||
// По ним считаются восемь часов: платят не все состояния (задача 8, `billableMs`).
|
||
|
||
export function newSpans(startedAt, kind = 'working') {
|
||
return { closed: [], kind, since: startedAt };
|
||
}
|
||
|
||
export function setSpanKind(tracker, kind, now) {
|
||
if (kind === tracker.kind) return tracker;
|
||
tracker.closed.push({ kind: tracker.kind, ms: Math.max(0, now - tracker.since) });
|
||
tracker.kind = kind;
|
||
tracker.since = now;
|
||
return tracker;
|
||
}
|
||
|
||
export function spansNow(tracker, now) {
|
||
return [...tracker.closed, { kind: tracker.kind, ms: Math.max(0, now - tracker.since) }];
|
||
}
|
||
|
||
// --- признаки жизни ----------------------------------------------------------
|
||
|
||
export function headSha(worktreeRoot, run = execFileSync) {
|
||
try {
|
||
// 🪤 Голым именем не зовём ничего: из программы это ENOENT — класс, убивший
|
||
// 334 фоновых прогона с мая. Полный путь даёт `resolveBin`.
|
||
const out = run(resolveBin('git'), ['rev-parse', 'HEAD'], { cwd: worktreeRoot, encoding: 'utf8' });
|
||
return String(out).trim() || null;
|
||
} catch {
|
||
return null; // git не ответил — это отсутствие сведений, а не признак жизни
|
||
}
|
||
}
|
||
|
||
export function refreshLife({ workerDirPath, worktreeRoot, seen, now, head = headSha }) {
|
||
// 🪤 Признака «файл шевелится» здесь нет намеренно: проверен живьём 20.07.2026
|
||
// и оказался ложным — поднял тревогу над работающими. Признаков ровно три,
|
||
// и все три — то, что работник делает сам и что видно снаружи.
|
||
const kinds = [];
|
||
const inbox = workerInboxDir(worktreeRoot);
|
||
|
||
const sha = head(worktreeRoot);
|
||
if (sha && sha !== seen.commit_sha) {
|
||
touchLife(workerDirPath, 'commit', { sha }, now);
|
||
seen.commit_sha = sha;
|
||
kinds.push('commit');
|
||
}
|
||
|
||
const state = readJson(join(inbox, 'state.json'));
|
||
if (state && state.saved_at && state.saved_at !== seen.state_saved_at) {
|
||
touchLife(workerDirPath, 'state_write', {}, now);
|
||
seen.state_saved_at = state.saved_at;
|
||
kinds.push('state_write');
|
||
}
|
||
|
||
const closed = readLines(join(inbox, 'closed-items.jsonl'));
|
||
const last = closed.length ? closed[closed.length - 1] : null;
|
||
if (last && last.item && last.item !== seen.closed_item) {
|
||
touchLife(workerDirPath, 'item_closed', { item: last.item }, now);
|
||
seen.closed_item = last.item;
|
||
kinds.push('item_closed');
|
||
}
|
||
|
||
return kinds;
|
||
}
|
||
|
||
export function readWorkerStatus(worktreeRoot) {
|
||
const s = readJson(join(workerInboxDir(worktreeRoot), 'status.json'));
|
||
// Работник назвал состояние не из закрытого списка — считаем, что он просто работает.
|
||
// Гадать по незнакомому слову нельзя: по этим словам считается, съедает он время или нет.
|
||
return WORKER_STATUSES.includes(s?.status) ? s.status : 'working';
|
||
}
|
||
|
||
export function currentItem(worktreeRoot) {
|
||
return readJson(join(workerInboxDir(worktreeRoot), 'status.json'))?.item ?? null;
|
||
}
|
||
|
||
// --- команды владельца: правило -----------------------------------------------
|
||
// 🔴 Здесь только ПРАВИЛО — что делать с командой. Кто её читает из `.night/commands/`
|
||
// и кто снимает общую команду «стоп всё» со всего состава прогона — кусок 3.
|
||
|
||
export function commandVerdict(cmd) {
|
||
if (cmd === 'stopall') {
|
||
return {
|
||
extinguish: true, wait_for_step: false, final_status: 'extinguished',
|
||
reason: 'Команда владельца «стоп всё» — гаснет весь прогон немедленно, недоделанное названо честно.',
|
||
};
|
||
}
|
||
if (cmd === 'stop') {
|
||
return {
|
||
extinguish: true, wait_for_step: false, final_status: 'extinguished',
|
||
reason: 'Команда владельца «стоп» — гашение немедленное, без доделывания.',
|
||
};
|
||
}
|
||
if (cmd === 'pause') {
|
||
return {
|
||
extinguish: true, wait_for_step: true, final_status: 'paused',
|
||
reason: 'Команда владельца «пауза» — работник доделал текущий шаг и заглох.',
|
||
};
|
||
}
|
||
if (cmd === 'resume') {
|
||
return {
|
||
extinguish: false, wait_for_step: false, final_status: null,
|
||
reason: 'Команда «продолжить» к живому работнику не применяется — он и так работает.',
|
||
};
|
||
}
|
||
return null; // слово не из закрытого списка — ответа нет вовсе
|
||
}
|
||
|
||
// --- кого правило «встал» не касается ------------------------------------------
|
||
// 🔴 Признаков жизни ровно три (коммит, запись состояния, закрытый пункт), и все три —
|
||
// то, что работник ДЕЛАЕТ. У ждущего чужую службу, стоящего в чистом простое и стоящего
|
||
// на паузе их нет по построению: они не работают нарочно. Позови на них «встал» —
|
||
// исправный работник, честно ждущий чужой банк или ответ хозяина, будет утром назван
|
||
// вставшим, и хозяин пойдёт искать поломку там, где её нет.
|
||
//
|
||
// 🔴 Списка этих состояний здесь НЕТ намеренно — он не переписан рядом, а СПРОШЕН у правила
|
||
// отсечки (`morningCutoff`, limits.mjs): те, кого бережёт отсечка 8:00, — ровно те, кого
|
||
// нельзя звать вставшими. Свой список рядом разъехался бы с правилом при первой же правке,
|
||
// и разъезд был бы молчаливым.
|
||
//
|
||
// Пробный миг подобран так, что отсечка в него гасит ВСЯКОГО, кого она вообще трогает:
|
||
// прогон начат в тихие часы и осмотрен в десять утра. Кого она и в этот миг не гасит —
|
||
// того она бережёт поимённо.
|
||
const STALL_PROBE_RUN_STARTED_AT = Date.parse('2026-01-01T02:00:00+03:00');
|
||
const STALL_PROBE_NOW = Date.parse('2026-01-01T10:00:00+03:00');
|
||
|
||
export function stallExempt(status, cutoff = morningCutoff) {
|
||
return !cutoff({ status, runStartedAt: STALL_PROBE_RUN_STARTED_AT }, STALL_PROBE_NOW).extinguish;
|
||
}
|
||
|
||
// --- что работник трогал -----------------------------------------------------
|
||
// 🔴 Признак ПРИБЛИЗИТЕЛЬНЫЙ, и это сказано вслух — полем `approximate`. Складывается
|
||
// из двух источников: что работник объявил сам (`touched.json` в ящике) и что видно
|
||
// по именам файлов в его коммитах. Без этого строка сводки «трогал деньги / права /
|
||
// видимое клиенту» навсегда говорила бы «нет, нет, нет», то есть врала бы владельцу.
|
||
|
||
export const TOUCH_MARKS = {
|
||
money: [/billing/i, /wallet/i, /payment/i, /invoice/i, /tariff/i, /ledger/i, /yookassa/i, /charge/i, /balance/i],
|
||
rights: [/polic(y|ies)/i, /permission/i, /\bgates?\b/i, /role/i, /\brls\b/i, /auth/i, /token/i],
|
||
client_visible: [/resources[\\/]js/i, /\.vue$/i, /\.blade\.php$/i, /[\\/]lang[\\/]/i, /[\\/]public[\\/]/i],
|
||
};
|
||
|
||
export function touchedFromFiles(files) {
|
||
const t = { money: false, rights: false, client_visible: false };
|
||
for (const f of files ?? []) {
|
||
for (const key of Object.keys(TOUCH_MARKS)) {
|
||
if (TOUCH_MARKS[key].some(re => re.test(String(f)))) t[key] = true;
|
||
}
|
||
}
|
||
return t;
|
||
}
|
||
|
||
// 🔴 Пустой список означает НАСТОЯЩУЮ чистоту — «работник ничего не трогал». Поэтому
|
||
// «не смогли посмотреть» пустым списком отвечать нельзя: сбой обязан быть отличим
|
||
// от чистоты (договор о стыках). Не ответил git, не с чем сравнивать — ответ `null`,
|
||
// то есть «не знаю», и он доходит до владельца словами «сведений нет».
|
||
export function changedFilesOfWorker(worktreeRoot, baseBranch, run = execFileSync) {
|
||
if (!baseBranch) return null; // не с чем сравнивать — это «не знаю», а не «чисто»
|
||
try {
|
||
const out = String(run(
|
||
resolveBin('git'),
|
||
['diff', '--name-only', `${baseBranch}...HEAD`],
|
||
{ cwd: worktreeRoot, encoding: 'utf8' },
|
||
));
|
||
return out.split('\n').map(s => s.trim()).filter(Boolean);
|
||
} catch {
|
||
return null; // git не ответил — это отсутствие сведений, а не «следов не видно»
|
||
}
|
||
}
|
||
|
||
export function whatWorkerTouched({ worktreeRoot, baseBranch, files = null, changedFiles = changedFilesOfWorker }) {
|
||
const list = files ?? changedFiles(worktreeRoot, baseBranch);
|
||
// 🔴 Списка нет вовсе — отвечаем «не знаю» (`null`), а не тремя «нет». Три «нет» владелец
|
||
// читает как спокойную ночь: «деньги — следов не видно». Сказать так про ночь, в которую
|
||
// мы просто не смотрели, — соврать ровно про деньги и права. У сводки (summary.mjs)
|
||
// ветка «сведений нет» готова и ждёт именно пустого ответа.
|
||
if (list === null || list === undefined) return null;
|
||
const declared = readJson(join(workerInboxDir(worktreeRoot), 'touched.json')) ?? {};
|
||
const byFiles = touchedFromFiles(list);
|
||
return {
|
||
money: Boolean(declared.money) || byFiles.money,
|
||
rights: Boolean(declared.rights) || byFiles.rights,
|
||
client_visible: Boolean(declared.client_visible) || byFiles.client_visible,
|
||
approximate: true,
|
||
sources: { declared, by_files: byFiles },
|
||
};
|
||
}
|
||
|
||
// --- что работник ВПРАВДУ тронул: сбой отличается от чистоты --------------------
|
||
// 🔴🔴 ПЛАН КУСКА 3 ЗДЕСЬ СОВРАЛ, И Я ЭТО ГОВОРЮ ВСЛУХ, А НЕ МОЛЧА ИСПРАВЛЯЮ.
|
||
// Он велел завести ОТДЕЛЬНУЮ функцию с той же командой `git`, потому что у
|
||
// `changedFilesOfWorker` куска 1 якобы стоит `catch { return []; }` — то есть сбой сверки
|
||
// отдаётся тем же ответом, что и честная чистота. Открыл файл: там `catch { return null; }`,
|
||
// и `if (!baseBranch) return null` в придачу. Кусок 2 починил это своей правкой «дыра 6»,
|
||
// а план куска 3 писался до неё и об этом не знал.
|
||
// ⇒ Второй копии команды `git` здесь НЕ заводится. Две копии одного правила расходятся
|
||
// молча — об этом хозяйство предупреждает в четырёх местах (file-claims.mjs, claims-audit.mjs,
|
||
// summary.mjs, paths.mjs), и заводить пятую копию ровно того класса ради буквы плана нельзя.
|
||
// `changedFilesOrNull` — ИМЯ, под которым круг зовёт это правило, и оно указывает на то же
|
||
// самое правило, а не на его двойника. Сторож на тождество стоит в проверках
|
||
// («правило про тронутое — ОДНО на затею, а не копия рядом»).
|
||
export const changedFilesOrNull = changedFilesOfWorker;
|
||
|
||
// ━━━ ОБЩИЙ ОТКАТЧИК ДО КОММИТА — ЕДИНСТВЕННЫЙ НА ВСЮ ЗАТЕЮ ━━━
|
||
//
|
||
// 🔴 РЕШЕНИЕ ДИСПЕТЧЕРА 3 (правка стыков 01.08). Сквозные ворота нашли (СВ-1), что кусок 6
|
||
// заводил ВТОРОЙ откатчик на то же самое дело — «вернуть дерево угла к коммиту, положив
|
||
// откат новым коммитом», — и оба назвали соседом кусок 3, а друг друга не назвали ни разу.
|
||
// Две копии одного правила разъезжаются МОЛЧА. ⇒ Прибор один, живёт здесь и ТОРЧИТ НАРУЖУ.
|
||
// Зовущих двое: блок 2в куска 5 (владелец ответил «нет») и шаг 11 дороги продолжения
|
||
// куска 6 (`sledOtkata` в `resume.mjs`). Своего откатчика ни тот, ни другой не заводят.
|
||
//
|
||
// 🔴 ОТКАТЫВАЕТ ВЕТКУ ЦЕЛИКОМ до `toSha`, а не названные файлы. Список того, что под откат
|
||
// ПОПАЛО, считается здесь же прибором `changedFilesOrNull` (кусок 3) — снаружи он больше
|
||
// не подаётся: это была вторая дорога к одному списку. Из ящика работника
|
||
// (`touched.json`, `closed-items.jsonl`) список не берётся НИ ПРИ КАКОМ СЛУЧАЕ: это заявка
|
||
// работника, а не доказательство.
|
||
//
|
||
// 🔴 ОТКАТ ЛОЖИТСЯ НОВЫМ КОММИТОМ, а история не переписывается: прежний коммит остаётся
|
||
// в ветке — это и есть «откаченное отложено в сторону», и владельцу его показывают.
|
||
export function otkatDoKommita({ worktreeRoot, toSha, run = execFileSync }) {
|
||
const otkaz = reason => ({ ok: false, reason, kept_aside_sha: null, rolled_back_files: null });
|
||
// 🔴 Отказ, который не стоит ни одного зова `git`: до какого коммита откатывать — не сказано.
|
||
if (!toSha) {
|
||
return otkaz('не сказано, до какого коммита откатывать (довод `toSha` пуст) — откат не делается');
|
||
}
|
||
// 🪤 Голым именем `git` звать нельзя — из программы это ENOENT. 334 фоновых прогона
|
||
// с мая упали ровно так, и три месяца этого никто не заметил.
|
||
let git;
|
||
try {
|
||
git = resolveBin('git');
|
||
} catch (err) {
|
||
return otkaz(`программу git найти не удалось: ${err?.message ?? err} — откат не делается`);
|
||
}
|
||
const zov = args => String(run(git, args, { cwd: worktreeRoot, encoding: 'utf8' })).trim();
|
||
|
||
// ━━━ 1. ГРЯЗНЫЙ УГОЛ ОТКАТА НЕ ПОЛУЧАЕТ ━━━
|
||
// Поведение ОДНО на обоих зовущих, и это строгая сторона куска 6. Прежде кусок 5 в этом
|
||
// случае откат ДЕЛАЛ, помечая границу нечистой, — на одном входе два разных поведения.
|
||
// Откат поверх несохранённого либо теряет его, либо упирается на середине; терять чужую
|
||
// работу молча нельзя. 🔴 Третий ответ прибора (`null` — спросить не удалось) считается
|
||
// «нельзя» наравне с «грязно»: это отсутствие сведений, а не чистота.
|
||
const gryaz = gryazenLiUgol(worktreeRoot, run);
|
||
if (gryaz === null) {
|
||
return otkaz('спросить про несохранённые правки в углу не удалось — откат не делается, чтобы не потерять чужую работу');
|
||
}
|
||
if (gryaz === true) {
|
||
return otkaz('в углу лежат несохранённые правки работника — откат не делается, чтобы не потерять чужую работу');
|
||
}
|
||
|
||
// ━━━ 2. ЧТО ПОПАДЁТ ПОД ОТКАТ — СЧИТАЕМ САМИ, ДО ОТКАТА ━━━
|
||
// 🪤 Считать это ПОСЛЕ отката нечем: разница схлопнется в пустоту, и владелец прочёл бы
|
||
// «откатили ничего» про откат, который вправду что-то снял.
|
||
// 🪤 Имя второго довода `changedFilesOrNull` врёт: он называется `baseBranch`, а подаётся
|
||
// сюда КОММИТ. `git diff --name-only <sha>...HEAD` это выдерживает; переименовать чужой
|
||
// довод в одиночку нельзя — назвал вслух и оставил.
|
||
const files = changedFilesOrNull(worktreeRoot, toSha, run);
|
||
if (files === null) {
|
||
return otkaz('спросить хранилище, что попало бы под откат, не удалось — откат не делается');
|
||
}
|
||
|
||
// Вершина ДО отката: она и есть «отложенное в сторону».
|
||
let keptAside;
|
||
try {
|
||
keptAside = zov(['rev-parse', 'HEAD']);
|
||
} catch (err) {
|
||
return otkaz(`спросить хранилище о вершине ветки не удалось: ${err?.message ?? err} — откат не делается`);
|
||
}
|
||
|
||
// ━━━ 3. ОТКАТЫВАТЬ НЕЧЕГО ━━━
|
||
// Это НЕ беда и НЕ отказ: дерево угла уже такое, каким его хотят видеть. Но и пустого
|
||
// коммита не заводим — он ничего не значит и только мусорит историю.
|
||
// 🪤 Тот же случай ловит раньше и `sledOtkata` (сравнением деревьев), и это не задвоение
|
||
// правила, а два разных вопроса: он спрашивает «надо ли вообще звать откатчика»,
|
||
// я — «есть ли что коммитить». Второй зов подряд обязан быть безвреден у обоих.
|
||
if (!files.length) {
|
||
return {
|
||
ok: true,
|
||
reason: 'откатывать нечего: дерево угла уже совпадает с деревом названного коммита',
|
||
kept_aside_sha: keptAside,
|
||
rolled_back_files: [],
|
||
};
|
||
}
|
||
|
||
// ━━━ 4. САМ ОТКАТ ━━━
|
||
// 🔴 `read-tree --reset -u` возвращает и указатель, и рабочий угол к дереву `toSha`,
|
||
// в том числе УДАЛЯЯ файлы, заведённые после него, — чего `checkout <sha> -- .` не делает
|
||
// вовсе (он кладёт старые поверх, а новые оставляет лежать). История при этом не трогается:
|
||
// вершина остаётся на месте, и следующий коммит ляжет поверх неё.
|
||
try {
|
||
zov(['read-tree', '--reset', '-u', toSha]);
|
||
} catch (err) {
|
||
return otkaz(`вернуть дерево угла к коммиту не удалось: ${err?.message ?? err} — откат не сделан`);
|
||
}
|
||
|
||
// 🔴 ПОДПИСЬ — НОЧНАЯ ЗАТЕЯ, А НЕ РАБОТНИК. Это не украшение: наутро владелец по подписи
|
||
// отличает, что снял он сам (вернее, ночь по его слову), а что написал работник.
|
||
// Тот же приём, что у `rollbackForeignFiles` (кусок 3).
|
||
// 🪤 Подпись задаётся ключами `-c` В ЭТОМ ЗОВЕ, а не `git config` в углу: настройка угла
|
||
// осталась бы там навсегда и подписала бы ночной затеёй следующую работу работника.
|
||
const podpis = [
|
||
'-c', 'user.name=ночная затея',
|
||
'-c', 'user.email=night@liderra.local',
|
||
];
|
||
try {
|
||
zov([...podpis, 'commit', '-qm', `откат до ${toSha}: снято то, что наработано после него`]);
|
||
} catch (err) {
|
||
return otkaz(`откат в дерево лёг, но коммитом не закрепился: ${err?.message ?? err}. Посмотрите угол ${worktreeRoot} глазами`);
|
||
}
|
||
|
||
// ━━━ 5. НЕ ВЕРИМ НА СЛОВО ДАЖЕ САМОМУ `git` ━━━
|
||
// Ни одна из команд выше не бросила — но «не бросила» это ещё не «сделала». Успех без
|
||
// нового коммита успехом не считается: тот же класс, что «успех с пустым ответом»,
|
||
// на котором молча умерли 334 прогона.
|
||
let vershinaPosle;
|
||
try {
|
||
vershinaPosle = zov(['rev-parse', 'HEAD']);
|
||
} catch (err) {
|
||
return otkaz(`после отката спросить хранилище о вершине не удалось: ${err?.message ?? err}`);
|
||
}
|
||
if (vershinaPosle === keptAside) {
|
||
return otkaz('git отчитался успехом, а нового коммита в ветке не появилось — на слово не верим');
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
reason: `откат лёг новым коммитом ${vershinaPosle}; прежний коммит ${keptAside} остался в истории ветки`,
|
||
// «Отложенное в сторону» — прежняя вершина: по ней владелец достанет откаченное.
|
||
kept_aside_sha: keptAside,
|
||
rolled_back_files: files,
|
||
};
|
||
}
|
||
|
||
// --- исполнение приказов замка ------------------------------------------------
|
||
// 🔴 Правило (кого откатывать) написано в `claims-audit.mjs`. Здесь только руки: они
|
||
// исполняют готовый список файлов и ничего не решают сами.
|
||
export function rollbackForeignFiles({ worktreeRoot, baseBranch, files = [], run = execFileSync }) {
|
||
// 🪤 Голым именем `git` звать нельзя — из программы это ENOENT. 334 фоновых прогона
|
||
// с мая упали ровно так, и три месяца этого никто не заметил.
|
||
const git = resolveBin('git');
|
||
const restored = [];
|
||
const deleted = [];
|
||
const errors = [];
|
||
|
||
for (const file of files) {
|
||
try {
|
||
run(git, ['checkout', baseBranch, '--', file], { cwd: worktreeRoot, encoding: 'utf8' });
|
||
restored.push(file);
|
||
continue;
|
||
} catch (err) {
|
||
// 🔴🔴 Второй заход — удаление — делаем ТОЛЬКО если файла в точке ветвления вправду
|
||
// не было (работник его сам завёл). Слепое `git rm -f` на любой сбой — это починка
|
||
// чужой беды своим вредительством: `checkout` падает и когда угол занят, и когда
|
||
// ветки-основы нет вовсе, и когда кончился диск. Во всех этих случаях мы удалили бы
|
||
// файл из ветки работника, хотя откатить его были не должны.
|
||
// Спрашиваем у хранилища двумя вопросами, а не гадаем по тексту ругани.
|
||
let vetkaEst = true;
|
||
try {
|
||
run(git, ['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`], { cwd: worktreeRoot, encoding: 'utf8' });
|
||
} catch { vetkaEst = false; }
|
||
let byloVTochke = false;
|
||
if (vetkaEst) {
|
||
try {
|
||
run(git, ['cat-file', '-e', `${baseBranch}:${file}`], { cwd: worktreeRoot, encoding: 'utf8' });
|
||
byloVTochke = true;
|
||
} catch { byloVTochke = false; }
|
||
}
|
||
if (!vetkaEst || byloVTochke) {
|
||
errors.push({
|
||
file,
|
||
error: `Откат не удался, а удалять файл нельзя: ${vetkaEst ? `в точке ветвления «${baseBranch}» он есть` : `точки ветвления «${baseBranch}» не нашлось вовсе`}. ${String(err?.message ?? err)}`,
|
||
});
|
||
continue;
|
||
}
|
||
// 🪤 Вот теперь удаление законно: ветка на месте, а файла в ней не было —
|
||
// работник его завёл сам. Без этой ветки чужой новый файл остался бы навсегда.
|
||
try {
|
||
run(git, ['rm', '-f', '--', file], { cwd: worktreeRoot, encoding: 'utf8' });
|
||
deleted.push(file);
|
||
} catch (err2) {
|
||
errors.push({ file, error: String(err2?.message ?? err2) });
|
||
}
|
||
}
|
||
}
|
||
|
||
const tronutye = [...restored, ...deleted];
|
||
let committed = false;
|
||
if (tronutye.length) {
|
||
// 🔴 Откат обязан стать КОММИТОМ. Сверка идёт по коммитам угла: верни мы файл только
|
||
// в рабочем каталоге — через тридцать секунд замок нашёл бы его снова, и так всю ночь,
|
||
// а в ветке чужое осталось бы целым. Коммиты работника при этом не переписываются:
|
||
// это съело бы его честную работу и порвало ветку прямо под ним.
|
||
// 🪤 Хуки в отдельном рабочем углу не запускаются вовсе (проверено живьём 21.07.2026),
|
||
// поэтому глушить их отдельным ключом не надо — и нельзя.
|
||
// 🔴 Подпись задаётся ЗДЕСЬ, а не берётся из настроек угла. Нет у машины общей
|
||
// подписи — `git commit` падает, откат уходит в ошибки, и чужой файл остаётся
|
||
// правленым всю ночь. Заодно в истории видно, что это рука ночи, а не работника.
|
||
try {
|
||
run(git, [
|
||
'-c', 'user.name=ночная затея',
|
||
'-c', 'user.email=noch@liderra.local',
|
||
'commit', '-m', `откат чужих файлов: ${tronutye.join(', ')}`, '--', ...tronutye,
|
||
], {
|
||
cwd: worktreeRoot, encoding: 'utf8',
|
||
});
|
||
committed = true;
|
||
} catch (err) {
|
||
errors.push({
|
||
file: tronutye.join(', '),
|
||
error: `Откат не удалось сохранить коммитом: ${String(err?.message ?? err)}`,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { restored, deleted, committed, errors };
|
||
}
|
||
|
||
// --- кто закрывает прогон ----------------------------------------------------
|
||
|
||
export function runWorkerCards(runDirPath) {
|
||
const dir = join(runDirPath, 'workers');
|
||
if (!existsSync(dir)) return [];
|
||
return readdirSync(dir).map(wid => readJson(join(dir, wid, 'worker.json'))).filter(Boolean);
|
||
}
|
||
|
||
// --- ЧЬИ ЖИВЫЕ УГЛЫ ВИДНЫ КРУГУ СЕЙЧАС — для факта поверх заявки (закрытие дыры 03.08.2026) --
|
||
//
|
||
// 🔴 ДЛЯ ЧЕГО. `claimFiles` (file-claims.mjs) умеет спрашивать хранилище про живых чужих
|
||
// углов ПЕРЕД тем как выдать «свободно», но своего понятия «кто вообще живой сосед» у него
|
||
// нет и заводить не должно — прибор факта и список соседей `claimFiles` принимает ЯВНЫМИ
|
||
// доводами (`corners`, `faktPriboru`), а не выдумывает сам. Здесь, в круге, эти сведения ЕСТЬ:
|
||
// карточки работников прогона (`runWorkerCards`, кусок этого же файла) уже несут и корень
|
||
// угла (`workspace`, кладёт `run.mjs` ДО старта работника), и точку отсчёта (`base_branch`,
|
||
// тот же источник). Второй разведки заводить не нужно — читаем то же самое, чем уже живут
|
||
// `amILast` и `writeRunOutcome`.
|
||
//
|
||
// 🪤 Угол без каталога на диске — НЕ считается живым. `workspace` в карточке мог указывать
|
||
// на угол, который уборка уже снесла (`workspace.mjs`, сметает по `FINAL_STATUSES`).
|
||
// Спросить хранилище о нём нечем: прибор факта (`changedFilesOrNull`) получил бы несуществующий
|
||
// путь, `git` там не отвечает НИКОГДА, и честный ответ — `null`. А `null` от прибора факта
|
||
// `claimFiles` толкует как отказ (задокументировано в file-claims.mjs) — значит включи мы
|
||
// такой угол в список, и замок отказывал бы ВСЕМ ровно потому, что соседа, которого физически
|
||
// больше нет ни в каком смысле, «не удалось спросить». Это не тот случай, который лечит задача
|
||
// 03.08.2026, и здесь такой угол просто не входит в список — тем же путём, каким
|
||
// `mayTouchFakt` уже пропускает угол без корня.
|
||
//
|
||
// 🪤 Угол без `base_branch` в карточке — тоже не входит в список, а не считается опасным
|
||
// для всех. `changedFilesOfWorker` отвечает `null` на пустую точку отсчёта («не с чем
|
||
// сравнивать» — это тоже отказ по правилу выше), и включать такой угол значило бы то же
|
||
// самое: отказывать всем из-за одного соседа, о котором нечего спросить. Пропущенный угол —
|
||
// это известная, названная вслух цена (пункт 3 в документации `mayTouchFakt`): файл в нём
|
||
// снова невидим фактом, как до этой правки.
|
||
export function liveForeignCorners(runDirPath, workerId) {
|
||
const corners = {};
|
||
for (const card of runWorkerCards(runDirPath)) {
|
||
if (!card?.worker_id || card.worker_id === workerId) continue;
|
||
if (!card.workspace || !card.base_branch) continue;
|
||
if (!existsSync(card.workspace)) continue;
|
||
corners[card.worker_id] = { root: card.workspace, baseSha: card.base_branch };
|
||
}
|
||
return corners;
|
||
}
|
||
|
||
export function amILast(runDirPath) {
|
||
// «Я последний» — когда ни одной живой карточки в прогоне не осталось. Пока жив хоть один,
|
||
// итог писать рано: он ещё изменится.
|
||
//
|
||
// 🔴 Сверяемся с ОБЪЯВЛЕННЫМ составом прогона, а не только с тем, что уже лежит в каталоге.
|
||
// Углы заводятся минутами, по одному: пока строится угол седьмого, карточка в каталоге
|
||
// может быть одна — первого. Погасни он в эту минуту, «по карточкам» ответ был бы «да»,
|
||
// и весь прогон закрылся бы итогом из одного работника, а письмо ушло бы про одного
|
||
// из семи. Состав объявляет оркестратор ДО первого надзирателя (задача 14).
|
||
const cards = runWorkerCards(runDirPath);
|
||
const declared = readJson(join(runDirPath, 'run.json'))?.workers;
|
||
if (Array.isArray(declared) && declared.length && cards.length < declared.length) return false;
|
||
return cards.length > 0 && cards.every(c => FINAL_STATUSES.includes(c.status));
|
||
}
|
||
|
||
export function writeRunOutcome({
|
||
runDirPath, runId, lastRunFile = lastRunPath(), now = Date.now(),
|
||
// 🪤 Умолчания НАСТОЯЩИЕ: подставка подаётся явно и только в проверках. Иначе
|
||
// проверки писали бы след в боевой `.night/deferred/`, и следующая живая ночь
|
||
// упёрлась бы в отказ, оставленный проверкой.
|
||
markDeferredFn = markDeferred,
|
||
deferredPath = deferredDir(),
|
||
}) {
|
||
// 🔴 `.night/last-run.json` утренняя сводка ЧИТАЕТ, а писать его было некому — и сводка
|
||
// рассказывала бы про пустоту. Пишет его надзиратель ПОСЛЕДНЕГО погасшего работника:
|
||
// каталога прогона к утру может уже не быть, а итог нужен.
|
||
const run = readJson(join(runDirPath, 'run.json')) ?? {};
|
||
const outcome = {
|
||
run_id: runId,
|
||
finished_at: now,
|
||
plan_path: run.plan_path ?? null,
|
||
task_id: run.task_id ?? null,
|
||
workers: runWorkerCards(runDirPath).map(c => {
|
||
// Сводке нужно, что уже закрыто, а кнопке «продолжить» (кусок 6) — с чего продолжать.
|
||
// Каталога прогона и рабочего угла к утру может не быть, поэтому кладём это прямо в итог.
|
||
const life = c.worker_id ? readLife(join(runDirPath, 'workers', c.worker_id)) : null;
|
||
const inbox = c.workspace ? workerInboxDir(c.workspace) : null;
|
||
return {
|
||
worker_id: c.worker_id ?? null,
|
||
status: c.status ?? null,
|
||
reason: c.finish_reason ?? null,
|
||
at_item: c.at_item ?? null,
|
||
touched: c.touched ?? null,
|
||
last_closed_item: life?.last_closed_item ?? null,
|
||
closed_items: inbox
|
||
? readLines(join(inbox, 'closed-items.jsonl')).map(r => r.item).filter(Boolean)
|
||
: [],
|
||
};
|
||
}),
|
||
};
|
||
// 🔴 Через того же единственного помощника и тот же единственный замок затеи. Кладём
|
||
// только ДВА поменявшихся поля, а не весь снимок: остальное перечитается внутри замка,
|
||
// и чужие правки останутся на месте. Голых записей карточки в этом файле не осталось
|
||
// НИ ОДНОЙ — пока хоть одна стоит вне замка, семеро правят `run.json` двумя дорогами.
|
||
pravitKartochkuProgona(runDirPath, { status: 'finished', finished_at: now });
|
||
// 🪤 `last-run.json` — другой файл, его пишет один надзиратель, и замка ему не нужно.
|
||
writeJsonAtomic(lastRunFile, outcome);
|
||
|
||
// 🔴 СЛЕД ОТЛОЖЕННОЙ ЗАДАЧИ (проверка 28а). Прогон кончился, а пункты остались —
|
||
// задача не влезла в ночь. Назавтра она сама не подхватится: след её держит, и снимает
|
||
// его только слово владельца (задача 6). Кладём след ЗДЕСЬ и только здесь.
|
||
// 🪤 Следа НЕТ, когда все пункты засчитаны, — и это не мелочь: положи мы его всегда,
|
||
// следующая ночь начиналась бы с отказа по задаче, которая давно готова и ждёт приёмки.
|
||
const vsePunkty = (run.items ?? []).map(i => String(i?.id)).filter(id => id && id !== 'undefined');
|
||
const zaschitany = new Set(closedItemsOfRun(runDirPath));
|
||
const ostalos = vsePunkty.filter(id => !zaschitany.has(id));
|
||
if (ostalos.length) {
|
||
markDeferredFn(deferredPath, {
|
||
task_id: run.task_id ?? runId,
|
||
plan_path: run.plan_path ?? null,
|
||
reason: 'not_finished',
|
||
left_items: ostalos,
|
||
at: now,
|
||
});
|
||
}
|
||
return outcome;
|
||
}
|
||
|
||
// Какие пункты прогона уже ЗАСЧИТАНЫ. Имя и подпись — куска 3, источник — куска 4.
|
||
// 🔴🔴 Раньше читалось слово работника (`closed-items.jsonl` в его углу) — то есть очередь
|
||
// пунктов кормилась ровно тем, чему решение владельца 96 велит не верить. Теперь читаются
|
||
// ПРИГОВОРЫ надзирателя, и лежат они в каталоге прогона, а не в углу работника: сторож
|
||
// не хранит улики там, где живёт подозреваемый — тот файл работник пишет сам и может
|
||
// переписать.
|
||
export function closedItemsOfRun(runDirPath) {
|
||
const out = [];
|
||
const dir = join(runDirPath, 'workers');
|
||
if (!existsSync(dir)) return out;
|
||
for (const wid of readdirSync(dir)) {
|
||
for (const row of readLines(join(dir, wid, 'items-verified.jsonl'))) {
|
||
// 🔴 Считаем сделанным ТОЛЬКО то, у чего проверки вправду зелены. Строка без этого
|
||
// слова — не приговор: попади она сюда, пункт ушёл бы в «сделано» незасчитанным,
|
||
// работник не получил бы его больше никогда, а задача объявилась бы законченной.
|
||
if (row?.item && row.tests_green === true) out.push(String(row.item));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Правка карточки прогона ПОД ЗАМКОМ и с перечитыванием внутри него.
|
||
//
|
||
// 🔴🔴 Карточку прогона правят все семеро надзирателей, и правят её чтением-и-записью:
|
||
// прочёл целиком, поменял пару полей, записал целиком. Двое, дошедшие до конца задачи
|
||
// в одну секунду, прочтут одно и то же, а запишут по очереди — и второй вернёт на диск
|
||
// снимок, снятый ДО правки первого. Правка первого исчезнет молча, при полностью зелёных
|
||
// проверках. Ровно эту беду кусок 3 доказал живьём на четырёх процессах для
|
||
// `file-claims.json`, и лечится она тем же единственным замком затеи.
|
||
//
|
||
// 🪤 Перечитывать обязательно ВНУТРИ замка. Возьми мы снимок, прочитанный раньше,
|
||
// замок не помог бы ничем: мы всё равно записали бы поверх чужой правки устаревший снимок.
|
||
function pravitKartochkuProgona(runDirPath, pravki) {
|
||
return podZamkom(runDirPath, () => {
|
||
const bylo = readJson(join(runDirPath, 'run.json')) ?? {};
|
||
const stalo = { ...bylo, ...pravki };
|
||
writeJsonAtomic(join(runDirPath, 'run.json'), stalo);
|
||
return stalo;
|
||
});
|
||
}
|
||
|
||
// --- сама ночная жизнь -------------------------------------------------------
|
||
|
||
export async function supervise({
|
||
runId, workerId, worktreeRoot, workerDirPath, runDirPath, cfg,
|
||
// 🔴 Здесь раньше стояли шесть параметров «на вырост» (measuresPath, commandsDirPath,
|
||
// qPath, repliesPath, probeDisk, startReviewerFn) — принятые, но не звавшиеся в этом
|
||
// куске ни разу. Проверка их не сторожила (передавала и не проверяла), и это выглядело
|
||
// работающей кнопкой «стоп», которая на деле никого не слышит. Правило хозяйства:
|
||
// принимать параметр можно только вместе с зовом в том же куске.
|
||
// `measuresPath` и `probeDisk` возвращены этим куском (2) — оба зовутся ниже (`eatLine`
|
||
// и блок «пять пределов одним зовом») и накрыты проверками, которые краснеют без зова.
|
||
// Кто добавит обратно оставшиеся три — вместе с зовом и проверкой на зов:
|
||
// qPath, repliesPath — кусок 5 (вопросы и ответы владельца);
|
||
// startReviewerFn — кусок 7 (приёмщик), место зова — внутри closeRunIfLast.
|
||
measuresPath,
|
||
// 🔴 Возвращён куском 3 (задача 7) — ВМЕСТЕ с зовом: блоки 2а и 2б круга читают его
|
||
// каждый круг, и без зова на них краснеют десять проверок. Каталог команд владельца:
|
||
// `.night/commands`, имя даёт `commandsDir()` из paths.mjs. Живой запуск подаёт его
|
||
// в `main()`; проверки — руками.
|
||
commandsDirPath,
|
||
lastRunFile = lastRunPath(),
|
||
spawnWorker,
|
||
now = () => Date.now(),
|
||
sleep = ms => new Promise(r => setTimeout(r, ms)),
|
||
tickMs = (cfg?.supervisor_tick_seconds ?? 30) * 1000,
|
||
// 🔴 Кусок 1 держал здесь заглушку «всё хорошо»: мерить было ещё нечем. Теперь есть.
|
||
// Замок, который всегда отвечает «можно», — это не замок, а вид замка.
|
||
probeDisk = async () => diskState(await freeGb(worktreeRoot), cfg?.disk_free_min_gb ?? 5),
|
||
head = headSha,
|
||
// 🔴 Умолчание круга — `changedFilesOrNull`: прибор, у которого сбой `git` (`null`)
|
||
// отличим от честной чистоты (пустой список). На пустом списке `auditWorkerFiles`
|
||
// объявил бы сверку СДЕЛАННОЙ и чистой, то есть замок открылся бы молча — а `git`
|
||
// в углу падает не в теории: он дерётся за `index.lock` с самим работником, который
|
||
// в эту секунду коммитит. Сторож на это умолчание — в проверках, по тексту файла:
|
||
// все проверки круга подают свой `changedFiles`, и до умолчания у них дело не доходит.
|
||
changedFiles = changedFilesOrNull,
|
||
// 🪤 Умолчание — НАСТОЯЩИЙ откат. Подставка подаётся явно и только в проверках круга:
|
||
// пустышка, отвечающая «всё хорошо», за два куска нашлась дважды, и оба раза она стояла
|
||
// умолчанием. Сам настоящий откат проверяется отдельно, на живом хранилище.
|
||
rollbackFn = rollbackForeignFiles,
|
||
// 🪤 Умолчание — НАСТОЯЩИЙ прогон проверок. Пустышка «всё зелено» умолчанием запрещена:
|
||
// за три куска она находилась пять раз, и здесь была бы страшнее всего — каждый пункт
|
||
// засчитывался бы без единого прогона, при полностью зелёных проверках.
|
||
// 🪤 В проверках круга подставка подаётся ЯВНО и обязательно: позови они настоящий
|
||
// `npm run test:tools`, проверки затеи запустили бы сами себя без конца.
|
||
runTests = progonProverokPunkta,
|
||
// Умолчание тоже настоящее: судим по коммиту, а не по каталогу под руками.
|
||
dirty = gryazenLiUgol,
|
||
// 🔴 В какой базе гонять проверки — приходит ИЗ КАРТОЧКИ работника через `main()`.
|
||
// Не дойдёт — проверки пойдут в ОБЩУЮ базу, семеро затрут друг другу ответ, и замок
|
||
// будет засчитывать пункты по чужому прогону. Живой путь проверяется отдельно.
|
||
// 🔴🔴 Довода `testCommand` здесь НЕТ и быть не должно: карточка работника пишется ОДИН
|
||
// раз, по его ПЕРВОМУ пункту, а пунктов за ночь он берёт много. Возьми круг команду
|
||
// оттуда — со второго пункта он гонял бы проверки первого. Команда считается на
|
||
// каждом судимом пункте заново, правилом `komandaProverokPunkta`.
|
||
dbName = null,
|
||
// 🔴🔴 ЧЕТВЁРТЫЙ ДОВОД, КОТОРОГО В ПЛАНЕ ЗАДАЧИ 5 НЕТ, — ГОВОРЮ ВСЛУХ, ЗАЧЕМ ОН.
|
||
// Итог прогона (`writeRunOutcome`) с этого куска кладёт СЛЕД отложенной задачи,
|
||
// а зовут его ДВОЕ: проверки — напрямую (и подставляют себе каталог), и сам круг —
|
||
// через `closeRunIfLast`, куда подставить было нечем. Оставь я это как в плане —
|
||
// каждая проверка круга, доходящая до гашения при незасчитанных пунктах, писала бы
|
||
// след в БОЕВОЙ `.night/deferred/`, и следующая живая ночь упёрлась бы в отказ,
|
||
// оставленный проверкой. Умолчание настоящее, то есть живая ночь не изменилась.
|
||
deferredPath = deferredDir(),
|
||
}) {
|
||
const startedAt = now();
|
||
const seen = {};
|
||
// Про несделанную сверку говорим ОДИН раз за жизнь работника, а не каждые тридцать секунд:
|
||
// иначе к утру сводка была бы из шестисот одинаковых строк, и в ней потерялось бы всё.
|
||
let skazanoProSboySverki = false;
|
||
// То же и про ПРОВАЛ отката. Причина провала (нет подписи, занят угол) сама не проходит,
|
||
// а работник живёт часами — без этого в сводке была бы сотня одинаковых строк.
|
||
// 🪤 Строка про сам факт «полез в чужой файл» ограничителя НЕ имеет и иметь не должна:
|
||
// это разные попытки в разные минуты, и владелец обязан видеть каждую.
|
||
let skazanoProProvalOtkata = false;
|
||
// 🔴 И один-единственный след «сверка состоялась, чисто» — тоже раз за жизнь работника.
|
||
// Без него молчание ЧИСТОГО замка неотличимо от молчания замка, которого никто не позвал:
|
||
// ровно тот класс беды, ради которого этот кусок и построен (договор §5 п. 3, долг 17).
|
||
let skazanoProChistuyuSverku = false;
|
||
// Про каждый непонятый файл команды говорим ОДИН раз за жизнь работника. Владелец
|
||
// положил его и ушёл спать, файл никуда не денется — а работник живёт часами, и без
|
||
// этого к утру в сводке было бы шестьсот одинаковых строк.
|
||
const skazanoProNeponyatnye = new Set();
|
||
// На каком коммите мы этот пункт уже судили. Прогон проверок идёт минутами, а круг —
|
||
// каждые тридцать секунд: суди мы заявку каждый круг, надзиратель до утра гонял бы один
|
||
// и тот же набор, а работник стоял бы. Сделал новый коммит — судим снова.
|
||
const sudiliPriKommite = new Map();
|
||
// Сколько раз подряд пункт не сошёлся. Распоряжение диспетчера: вторая неудача — пункт
|
||
// откладывается, работник берёт другой.
|
||
const popytkiPoPunktu = new Map();
|
||
// 🪤 Отложенное помнит только ЭТОТ надзиратель, и это честная дыра: перезапустится он —
|
||
// счёт начнётся заново. Беда невелика (работник получит ещё две попытки), а чинить её
|
||
// нечем без пятого файла состояния, которого затея не заводила.
|
||
const otlozhennyePunkty = new Set();
|
||
// Про несохранённые правки в углу говорим ОДИН раз за жизнь работника: он живёт часами,
|
||
// и без этого к утру в сводке было бы шестьсот одинаковых строк.
|
||
let skazanoProGryaznyyUgol = false;
|
||
const spans = newSpans(startedAt);
|
||
let result = null;
|
||
let stepCount = 0;
|
||
let sawStream = false;
|
||
let stderr = '';
|
||
let diskBadInARow = 0;
|
||
|
||
// 🔴 Отсечка 8:00 считается от старта ПРОГОНА, а не от старта надзирателя. Углы работников
|
||
// строятся по одному и долго: седьмой надзиратель заводится, когда прогон идёт уже час
|
||
// и больше. Возьми он своё время старта — прогон, начатый в 22:00, для него начался бы
|
||
// в 8:10, тихих часов «не задел», отсечка к нему не применилась бы вовсе, и он жёг бы
|
||
// запас днём вместе с владельцем. Настоящее время старта лежит в `run.json` — там его
|
||
// пишет оркестратор (run.mjs) ДО первого надзирателя.
|
||
const runStartedAt = readJson(join(runDirPath, 'run.json'))?.started_at;
|
||
const runStartedAtKnown = Number.isFinite(runStartedAt);
|
||
if (!runStartedAtKnown) {
|
||
// 🔴 Своего времени вместо чужого не подставляем МОЛЧА. Молча подставить — это ровно
|
||
// та беда, из-за которой затеян этот разбор: владелец читал бы спокойную сводку про
|
||
// ночь, в которой отсечка не работала. Говорим вслух, строкой в сводку и в ленту.
|
||
const line = `${workerId}: время старта прогона не записано в run.json. Отсечка 8:00 считается от старта надзирателя — он мог завестись позже прогона, и тогда отсечка сработает позже, чем должна.`;
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at: startedAt, worker_id: workerId, line });
|
||
logEvent(runDirPath, { kind: 'run_started_at_missing', worker: workerId }, startedAt);
|
||
}
|
||
|
||
function closeRunIfLast(at) {
|
||
// 🔴 Прогон закрывает надзиратель ПОСЛЕДНЕГО погасшего работника — больше некому:
|
||
// оркестратор к этому времени давно умер вместе с сессией помощника.
|
||
// Заведение приёмщика по флагу `reviewer_starts` дописывает кусок 7 — ровно сюда.
|
||
if (!amILast(runDirPath)) return;
|
||
// 🔴 Каталог следов передаётся ЯВНО: иначе итог прогона взял бы своё умолчание —
|
||
// боевой `.night/deferred/`, — и проверки круга оставляли бы там настоящие отказы.
|
||
// Поймано живьём 02.08.2026: вырез этой строки положил в хранилище восемь настоящих
|
||
// отказов по задачам, которых никто не откладывал.
|
||
writeRunOutcome({ runDirPath, runId, lastRunFile, now: at, deferredPath });
|
||
}
|
||
|
||
// 🔴🔴 ЗАКРЫТИЕ ДЫРЫ 03.08.2026, ВТОРОЙ КРУГ, ВРЕЗАНО В ГОРЯЧЕЕ МЕСТО. `claimFiles`
|
||
// зовётся кругом трижды (блоки 5, 6, 7 — все три раза выдают работнику свободный пункт
|
||
// взамен отобранного), и это дверь, в которую живой код вправду ходит — не спорный вопрос
|
||
// `mayTouch`, который живьём почти не звучит. Один помощник — не три копии одной и той же
|
||
// сборки доводов: три места ниже зовут ЕГО, а не собирают `corners`/`faktPriboru` заново
|
||
// каждое на свой лад (та беда, от которой предостерегают все три копии правила «сравнение
|
||
// путей» в этом хозяйстве).
|
||
// 🔴 Прибор факта — ТОТ ЖЕ `changedFiles`, каким круг уже меряет СВОЁ пересечение
|
||
// (`whatWorkerTouched` в блоке 5 ниже, довод `changedFiles(worktreeRoot, baseBranch)`).
|
||
// Второго прибора не заводится: подпись `changedFilesOrNull`/`changedFilesOfWorker`
|
||
// (`root, baseBranch, run`) и подпись, которую ждёт `faktPriboru` в `claimFiles`
|
||
// (`root, baseSha, run`), — один и тот же вызов, разница только в имени довода.
|
||
// 🔴 Соседей на каждый заход собираем ЗАНОВО (`liveForeignCorners`, дешёвые локальные
|
||
// чтения JSON, без похода в git), а не одним снимком на весь круг: за минуты между заходами
|
||
// состав живых соседей мог поменяться, а сама сборка стоит закрытия одного каталога,
|
||
// не похода в хранилище.
|
||
function zapertLiFaktom(files) {
|
||
return claimFiles(runDirPath, workerId, files ?? [], {
|
||
corners: liveForeignCorners(runDirPath, workerId),
|
||
faktPriboru: changedFiles,
|
||
}).ok;
|
||
}
|
||
|
||
function finish({ final_status, reason, at = now() }) {
|
||
const prev = readJson(join(workerDirPath, 'worker.json')) ?? {};
|
||
const touched = whatWorkerTouched({
|
||
worktreeRoot,
|
||
baseBranch: prev.base_branch ?? cfg?.base_branch ?? null,
|
||
changedFiles,
|
||
});
|
||
// 🔴 «Не знаю» про деньги и права — это сведение, и оно обязано быть видно в ленте,
|
||
// а не только пустым местом в карточке.
|
||
if (!touched) {
|
||
logEvent(runDirPath, {
|
||
kind: 'touched_unknown',
|
||
worker: workerId,
|
||
note: 'Список изменённых файлов не пришёл (git не ответил или не с чем сравнивать) — что работник трогал, неизвестно. Это не «чисто».',
|
||
}, at);
|
||
}
|
||
const atItem = currentItem(worktreeRoot) ?? prev.at_item ?? null;
|
||
const card = {
|
||
...prev,
|
||
status: final_status,
|
||
finish_reason: reason,
|
||
finished_at: at,
|
||
at_item: atItem,
|
||
touched,
|
||
spans: spansNow(spans, at),
|
||
};
|
||
writeJsonAtomic(join(workerDirPath, 'worker.json'), card);
|
||
writeJsonAtomic(join(workerDirPath, 'exit.json'), { status: final_status, reason, result, touched, at });
|
||
|
||
// 🔴 Строка сводки обязана называть МЕСТО (проверка 5): «погашен» без места ничего
|
||
// не говорит владельцу — он не знает, откуда продолжать.
|
||
const line = `${workerId}: ${STATUS_WORD[final_status]} — ${reason} Остановился на пункте ${atItem ?? '(пункт не назван)'}.`;
|
||
// 🔴 Имя файла сводки знает ОДНО место — paths.mjs (SUMMARY_LINES_FILE). Своей строки-копии
|
||
// здесь нет намеренно: переименуют файл там — надзиратель писал бы в старое имя, а сводка
|
||
// читала бы новое и видела пустоту. Каталог — свой (`runDirPath`, дали на руки, testable),
|
||
// а не `summaryLinesPath(runId)`: та функция всегда считает от настоящего NIGHT_ROOT
|
||
// и подмену каталога в проверках не увидит.
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at, worker_id: workerId, line });
|
||
logEvent(runDirPath, { kind: 'worker_finish', worker: workerId, status: final_status, reason }, at);
|
||
|
||
// 🔴 Файлы освобождаются здесь, в одном месте на все выходы из круга. Расставь мы
|
||
// освобождение по каждому `return finish(...)` — свежая смена забыла бы на восьмом,
|
||
// и назавтра ночь началась бы с замков, которые держат мертвецы.
|
||
releaseClaims(runDirPath, workerId);
|
||
|
||
closeRunIfLast(at);
|
||
|
||
return { status: final_status, reason, touched, lines: [line] };
|
||
}
|
||
|
||
let child;
|
||
try {
|
||
child = spawnWorker();
|
||
} catch (err) {
|
||
// Провал запуска — тоже сведение, и записать его обязательно: 334 прежних прогона
|
||
// упали молча ровно потому, что провал никто не записывал.
|
||
logEvent(runDirPath, { kind: 'worker_spawn_failed', worker: workerId, error: String(err?.message ?? err) }, now());
|
||
return finish({ final_status: 'extinguished', reason: `Работника не удалось запустить: ${err?.message ?? err}` });
|
||
}
|
||
|
||
// 🪤 Поток приходит КУСКАМИ, и кусок рвётся где попало — хоть посередине слова.
|
||
// Разбирай каждый кусок сам по себе — строка, попавшая на стык, пропадёт целиком.
|
||
// А единственная строка, по которой судят «настоящий успех», — итоговая, и приходит она
|
||
// последней, то есть под самый разрыв. Живой работник записался бы «кончился, не отдав
|
||
// итога». Поэтому хвост без перевода строки копится и ждёт продолжения.
|
||
// 🔴🔴 ВИД ПРЕДЕЛА СПРАШИВАЕТСЯ ОДИН РАЗ, ЗДЕСЬ, А НЕ НА КАЖДОЙ СТРОКЕ ПОТОКА.
|
||
// Прежде он брался прямо в разборе строки с умолчанием НЕДЕЛЬНЫМ видом — то самое молчаливое
|
||
// умолчание, из-за которого ворота запаса не пропустили ни разу (замерено: этому счёту
|
||
// приходит `five_hour`). Теперь умолчания нет вовсе, а правило `vidPredela` на потерянной
|
||
// настройке ПАДАЕТ. Ронять надзирателя посреди ночи нельзя — он умрёт молча, и это ровно
|
||
// тот класс беды, из-за которого 334 прогона умирали три месяца незамеченными. Поэтому
|
||
// спрашиваем ДО потока и один раз: не вышло — замеров не пишем, но работник живёт дальше,
|
||
// а владелец читает об этом строкой в утренней сводке.
|
||
let vidZapasa = null;
|
||
try {
|
||
vidZapasa = vidPredela(cfg);
|
||
} catch (err) {
|
||
const line = `${workerId}: замеры запаса по ходу ночи НЕ пишутся — ${err?.message ?? err} Пределы прогона и работника по деньгам сегодня не сторожат ничего.`;
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at: startedAt, worker_id: workerId, line });
|
||
logEvent(runDirPath, { kind: 'rate_limit_kind_missing', worker: workerId, error: String(err?.message ?? err) }, startedAt);
|
||
}
|
||
|
||
let tail = '';
|
||
function eatLine(line) {
|
||
const event = parseStreamLine(line);
|
||
if (!event) return;
|
||
sawStream = true;
|
||
// 🔴 Замер ПРИБОРА СИСТЕМЫ приходит сам и бесплатно, прямо в потоке работника.
|
||
// Своего счёта в деньгах затея не ведёт вовсе: остаток запаса мы не считаем, а спрашиваем.
|
||
// Вид предела — из настройки владельца, спрошенной один раз выше: свой запас у каждого
|
||
// вида, и решать по чужому о деньгах владельца нельзя.
|
||
const m = vidZapasa ? rateLimitFromEvent(event, { kind: vidZapasa }) : null;
|
||
if (m) {
|
||
// 🪤 Мы внутри обработчика потока. Брошенная здесь ошибка убивает весь процесс
|
||
// надзирателя МОЛЧА — ровно тот класс беды, из-за которого 334 прогона умирали
|
||
// три месяца незамеченными. Поэтому неудача записи уходит строкой в ленту.
|
||
try {
|
||
recordMeasure(measuresPath, {
|
||
// 🔴 Доли в событии может не быть вовсе (система кладёт её только вместе с тревогой) —
|
||
// и это ПОЛНОЦЕННЫЙ замер, а не отсутствие замера. Светофор и вид идут обязательно:
|
||
// решение о запуске принимается теперь по светофору.
|
||
utilization: m.utilization,
|
||
status: m.status,
|
||
kind: m.kind,
|
||
resets_at: m.resets_at,
|
||
at: now(),
|
||
run_id: runId,
|
||
worker_id: workerId,
|
||
phase: 'tick',
|
||
});
|
||
} catch (err) {
|
||
logEvent(runDirPath, {
|
||
kind: 'budget_measure_failed',
|
||
worker: workerId,
|
||
error: String(err?.message ?? err),
|
||
}, now());
|
||
}
|
||
}
|
||
if (event.type === 'result') result = event;
|
||
if (isStepBoundary(event)) stepCount += 1;
|
||
}
|
||
child.stdout?.on('data', chunk => {
|
||
tail += String(chunk);
|
||
const parts = tail.split('\n');
|
||
tail = parts.pop() ?? ''; // последний кусок — без перевода строки, он ещё не дописан
|
||
for (const line of parts) eatLine(line);
|
||
});
|
||
child.stderr?.on('data', chunk => { stderr += String(chunk); });
|
||
|
||
const alive = () => child.exitCode === null && child.signalCode === null;
|
||
|
||
async function killWorker({ wait_for_step }) {
|
||
if (wait_for_step) {
|
||
// «Пауза» значит «доделай текущий шаг чисто и заглохни». Ждём границы шага
|
||
// по потоку, но не вечно: забытая пауза не должна держать дело.
|
||
writeJsonAtomic(join(workerDirPath, 'stopping.json'), { since: now(), why: 'пауза' });
|
||
const boundary = stepCount;
|
||
const until = now() + (cfg?.pause_wait_minutes ?? 20) * 60_000;
|
||
while (alive() && stepCount === boundary && now() < until) await sleep(1000);
|
||
}
|
||
try { child.kill('SIGTERM'); } catch { /* уже мёртв */ }
|
||
const until = now() + KILL_GRACE_MS;
|
||
while (alive() && now() < until) await sleep(500);
|
||
if (alive()) { try { child.kill('SIGKILL'); } catch { /* уже мёртв */ } }
|
||
}
|
||
|
||
while (alive()) {
|
||
const t = now();
|
||
|
||
// 1. Признаки жизни. Правило написано в heartbeat.mjs (задача 4), зовут его ЗДЕСЬ.
|
||
refreshLife({ workerDirPath, worktreeRoot, seen, now: t, head });
|
||
|
||
// 2. Что работник объявил о себе сам — по этому слову считается, съедает он время или нет.
|
||
const status = readWorkerStatus(worktreeRoot);
|
||
setSpanKind(spans, status, t);
|
||
|
||
const spansHere = spansNow(spans, t);
|
||
|
||
// 2а. КОМАНДА ВЛАДЕЛЬЦА (проверка 43, вторая половина). Стоит ПЕРЕД всеми пределами,
|
||
// и это не вкус: работник, у которого вышли восемь часов, и работник, которого
|
||
// остановил сам владелец, — два разных утренних разговора. Проверь мы пределы
|
||
// раньше, владелец прочёл бы «упёрся в восемь часов» про того, кого сам же
|
||
// и остановил, и пошёл бы чинить исправного работника.
|
||
const order = readCommand(commandsDirPath, workerId);
|
||
if (order) {
|
||
const v = commandVerdict(order.cmd);
|
||
if (v) {
|
||
// 🔴 Кого касается ОБЩАЯ команда — это состав прогона из `run.json` (его кладёт
|
||
// `startAll`, кусок 1). Личной команды это не касается вовсе: она снимается сразу,
|
||
// и список тут ни при чём.
|
||
const crewForCmd = order.common
|
||
? (readJson(join(runDirPath, 'run.json'))?.workers ?? [workerId])
|
||
: [];
|
||
// 🔴 Отметка «я эту команду исполнил» ставится ДО гашения. Поставь её после —
|
||
// и гашение, оборвавшее процесс, оставило бы общую команду лежать навсегда
|
||
// с неполным списком: следующая ночь начиналась бы с чужого «стоп всё».
|
||
consumeCommand(commandsDirPath, workerId, order.cmd, { concerns: crewForCmd });
|
||
if (v.extinguish) {
|
||
await killWorker({ wait_for_step: v.wait_for_step });
|
||
return finish({ final_status: v.final_status, reason: v.reason });
|
||
}
|
||
// «Продолжить» живому работнику ничего не меняет — но команду мы уже съели,
|
||
// иначе она читалась бы каждые тридцать секунд до самого утра.
|
||
}
|
||
}
|
||
|
||
// 2б. КОМАНДА, КОТОРУЮ НЕ ПОНЯЛИ (решение диспетчера). Слово не из закрытого списка
|
||
// не исполняется — но и не пропадает молча. Прежде не происходило ничего: владелец
|
||
// думал, что «пауза» нажата, а её никто не понял, и он узнавал об этом никогда.
|
||
// Молчание в ответ на команду хозяина недопустимо.
|
||
// 🔴 Ничего не гасим и ничего не удаляем: файл владельца остаётся лежать. Съешь мы
|
||
// его «за компанию» — пропала бы и сама улика, и владелец утром не понял бы, что
|
||
// вообще произошло с его командой.
|
||
// 🔴 Имя файла сводки — из одного места (`SUMMARY_LINES_FILE`), а не набрано здесь
|
||
// руками: на это в проверках стоит сторож «дыры 2».
|
||
for (const neponyatoe of unknownCommands(commandsDirPath, workerId)) {
|
||
if (skazanoProNeponyatnye.has(neponyatoe.file)) continue;
|
||
skazanoProNeponyatnye.add(neponyatoe.file);
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), {
|
||
at: t,
|
||
worker_id: workerId,
|
||
line: `${workerId}: команда «${neponyatoe.word}» НЕ ПОНЯТА — такого слова нет, понимаются только ${COMMAND_WORDS.join(', ')}. Ничего не сделано. Файл ${neponyatoe.file} оставлен на месте — переименуйте его правильным словом.`,
|
||
});
|
||
logEvent(runDirPath, {
|
||
kind: 'command_not_understood',
|
||
worker: workerId,
|
||
file: neponyatoe.file,
|
||
word: neponyatoe.word,
|
||
}, t);
|
||
}
|
||
|
||
// 3. ПЯТЬ ПРЕДЕЛОВ ОДНИМ ЗОВОМ (проверка 14 и её соседи). Порядок внутри — договорный:
|
||
// место на сервере → общий предел прогона (гасит ВСЕХ) → своя доля работника
|
||
// (гасит одного) → восемь часов → отсечка 8:00.
|
||
|
||
// Состав прогона нужен, чтобы поделить долю прогона между его работниками.
|
||
// Без него «своя доля» была бы правилом, которое зеленеет и не срабатывает ни разу.
|
||
const crew = readJson(join(runDirPath, 'run.json'))?.workers ?? [workerId];
|
||
|
||
// Остаток недельного запаса не считаем, а спрашиваем у журнала замеров прибора.
|
||
const budget = budgetState(measuresPath, cfg, t, {
|
||
runId,
|
||
workerId,
|
||
workersCount: crew.length || null,
|
||
});
|
||
|
||
const disk = await probeDisk();
|
||
// Осечка замера — не приговор с первого раза (см. DISK_BAD_PROBES_DEFAULT). Хороший
|
||
// замер счётчик обнуляет: подтверждением считаются только осечки ПОДРЯД.
|
||
diskBadInARow = disk?.ok ? 0 : diskBadInARow + 1;
|
||
const diskBadNeeded = cfg?.disk_bad_probes ?? DISK_BAD_PROBES_DEFAULT;
|
||
const diskBadUnconfirmed = !disk?.ok && diskBadInARow < diskBadNeeded;
|
||
|
||
const verdict = checkSelfLimits({
|
||
billable_ms: billableMs(spansHere),
|
||
hours_limit: cfg?.worker_hours_limit ?? 8,
|
||
budget,
|
||
disk,
|
||
worker: { status, runStartedAt: runStartedAtKnown ? runStartedAt : startedAt },
|
||
now: t,
|
||
current_item: currentItem(worktreeRoot),
|
||
});
|
||
|
||
if (verdict.extinguish && diskBadUnconfirmed) {
|
||
// Место — первое правило в `checkSelfLimits`, и пока оно не подтвердилось, приговора
|
||
// нет вовсе: остальные пределы досмотрятся на следующем круге, через полминуты.
|
||
logEvent(runDirPath, {
|
||
kind: 'disk_probe_unconfirmed',
|
||
worker: workerId,
|
||
bad_in_a_row: diskBadInARow,
|
||
need: diskBadNeeded,
|
||
reason: disk?.reason ?? null,
|
||
}, t);
|
||
} else if (verdict.extinguish) {
|
||
if (verdict.wake_owner) {
|
||
// 🔴 Единственный повод разбудить владельца в тихие часы — беда с местом на сервере:
|
||
// её чинит только человек и только руками. Будит его ОТПРАВЛЯЛКА по этому признаку,
|
||
// а не надзиратель: здесь кладётся строка, по которой она это сделает.
|
||
// 🔴 Имя файла сводки — из одного места (`SUMMARY_LINES_FILE`), а не строкой-копией:
|
||
// здесь она уже стояла один раз, в двадцати шести строках от собственного запрета,
|
||
// и переименуй файл в paths.mjs — эта строка ушла бы в старое имя, а сводка читала бы
|
||
// новое и видела пустоту ровно в самой громкой строке ночи. Сторож на возврат копии —
|
||
// в проверках («имя файла сводки не набрано в этом файле руками ни разу»).
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), {
|
||
at: t,
|
||
worker_id: workerId,
|
||
wake_owner: true,
|
||
line: `ТРЕВОГА: ${verdict.reason} Встало всё, до утра само ничего не сделается.`,
|
||
});
|
||
}
|
||
await killWorker({ wait_for_step: false });
|
||
return finish({ final_status: 'extinguished', reason: verdict.reason });
|
||
}
|
||
|
||
// 4. «СЛУЖБА НЕ ОТВЕТИЛА» (проверка 13). Ожидание чужой службы восемь часов не съедает —
|
||
// это видно строкой выше: `billableMs` отрезок `waiting_service` не считает.
|
||
// 🔴 Умолчание — из правила (limits.mjs), а не написано числом здесь ещё раз: совпадение
|
||
// цифр было бы случайностью, а поменяют порог в правиле — надзиратель как гасил через
|
||
// два часа, так и продолжал бы, тихо, до утра.
|
||
const wait = serviceWaitHit(spansHere, cfg?.service_wait_hours ?? SERVICE_WAIT_LIMIT_HOURS);
|
||
if (wait.extinguish) {
|
||
await killWorker({ wait_for_step: false });
|
||
return finish({ final_status: 'extinguished', reason: wait.reason });
|
||
}
|
||
|
||
// 5. ЗАМОК «ДВОЕ НЕ ПРАВЯТ ОДИН ФАЙЛ» (проверки 51 и 52). Сверяем по КОММИТАМ угла,
|
||
// а не по слову работника: решение владельца 96. Файл `touched.json`, который пишет
|
||
// он сам, для этого не годится — там виды правок, а не имена файлов, и пишет его
|
||
// ровно тот, кого мы и сторожим.
|
||
const baseBranch = readJson(join(workerDirPath, 'worker.json'))?.base_branch
|
||
?? cfg?.base_branch ?? null;
|
||
|
||
// 🔴🔴 ДОЛГ 17 (вписан строителем задачи 4, закрывается здесь). Замок судит работника
|
||
// по списку занятых файлов, лежащему В КАТАЛОГЕ ПРОГОНА. Подай ему не тот каталог —
|
||
// занятых файлов там не окажется вовсе, `mayTouch` разрешит всё, и замок объявит
|
||
// чистыми ВСЕХ работников разом, отчитавшись, что сверка состоялась. Никто не падает,
|
||
// все довольны, а сверки не было — тот же класс «успех с пустым ответом», только
|
||
// со стороны хранилища.
|
||
// 🔴 Починить это ВНУТРИ замка было нельзя: там пустой список занятий — законный случай
|
||
// (первый работник; все файлы освободились), и на этом стоят две его проверки.
|
||
// Отличить «законно пусто» от «смотрим не туда» может только зовущий, у которого
|
||
// есть, с чем сверить: имя своего прогона.
|
||
// 🪤 Спрашиваем не «есть ли файл занятий» (его законно может не быть), а «этот каталог
|
||
// вообще про НАШ идущий прогон?» — карточка прогона обязана лежать там и обязана
|
||
// называть нас. Каталог соседнего прогона выглядит настоящим до последнего знака.
|
||
const kartochkaProgona = readJsonOrBroken(join(runDirPath, 'run.json'));
|
||
const chuzhoyKatalog = !kartochkaProgona.found
|
||
? 'в нём нет run.json — карточку прогона кладёт оркестратор ДО первого надзирателя'
|
||
: kartochkaProgona.broken
|
||
? 'run.json в нём не читается'
|
||
: String(kartochkaProgona.value?.run_id ?? '') !== String(runId)
|
||
? `run.json в нём про прогон «${kartochkaProgona.value?.run_id ?? '(без имени)'}», а не про наш «${runId}»`
|
||
: null;
|
||
|
||
// 🪤 «Успех» с пустым ответом — не успех, и здесь ТРИ разные защиты, а не одна.
|
||
// Первая (выше): тот ли это каталог прогона вообще.
|
||
// Вторая: живо ли хранилище — нет точки ветвления или нет последнего коммита,
|
||
// значит спрашивать не о чем.
|
||
// Третья: сам вопрос `git` может упасть при живом хранилище — например, подравшись
|
||
// за `index.lock` с работником, который в эту секунду коммитит. `changedFilesOrNull`
|
||
// отдаёт в этом случае `null`, а не пустой список, и `auditWorkerFiles` объявляет
|
||
// сверку НЕСДЕЛАННОЙ.
|
||
const sverkaVozmozhna = Boolean(baseBranch) && Boolean(head(worktreeRoot));
|
||
const audit = chuzhoyKatalog
|
||
? {
|
||
ok: false,
|
||
audited: false,
|
||
wrong_dir: true,
|
||
intruded: [],
|
||
orders: [],
|
||
line: `${workerId}: сверка занятых файлов НЕ СДЕЛАНА — каталог прогона ${runDirPath} не тот: ${chuzhoyKatalog}. Список занятых файлов лежит В КАТАЛОГЕ ПРОГОНА, значит смотри замок не туда — он объявил бы чистыми всех работников разом. Чужой файл в эту минуту мог остаться неоткаченным. Смотреть руками.`,
|
||
}
|
||
: auditWorkerFiles({
|
||
dirPath: runDirPath,
|
||
workerId,
|
||
changedFiles: sverkaVozmozhna ? changedFiles(worktreeRoot, baseBranch) : null,
|
||
});
|
||
|
||
if (!audit.audited) {
|
||
if (!skazanoProSboySverki) {
|
||
skazanoProSboySverki = true;
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at: t, worker_id: workerId, line: audit.line });
|
||
logEvent(runDirPath, {
|
||
kind: audit.wrong_dir ? 'claims_audit_wrong_dir' : 'claims_audit_failed',
|
||
worker: workerId,
|
||
...(audit.wrong_dir ? { dir: runDirPath, why: chuzhoyKatalog } : {}),
|
||
}, t);
|
||
}
|
||
} else if (!audit.ok) {
|
||
// (1) Откат ровно чужого файла — своя работа работника остаётся на месте.
|
||
const otkat = rollbackFn({
|
||
worktreeRoot,
|
||
baseBranch,
|
||
files: audit.orders.map(o => o.file),
|
||
});
|
||
|
||
// (2) Строка о пересечении в сводку — её готовит правило, надзиратель только кладёт.
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at: t, worker_id: workerId, line: audit.line });
|
||
logEvent(runDirPath, {
|
||
kind: 'claims_intrusion',
|
||
worker: workerId,
|
||
files: audit.intruded.map(i => i.file),
|
||
rolled_back: otkat.committed,
|
||
}, t);
|
||
// 🔴 Молчать об этом нельзя: чужой файл остался правленым, и чинить это владельцу
|
||
// руками. Но и говорить каждые тридцать секунд нельзя: работник живёт часами,
|
||
// а причина провала (нет подписи, занят угол) сама не проходит — к утру в сводке
|
||
// были бы сотни одинаковых строк, и в них потерялось бы всё остальное.
|
||
// Поэтому — ровно так же, как про несделанную сверку: один раз за жизнь работника.
|
||
if (otkat.errors.length && !skazanoProProvalOtkata) {
|
||
skazanoProProvalOtkata = true;
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), {
|
||
at: t,
|
||
worker_id: workerId,
|
||
line: `${workerId}: откат чужого файла НЕ УДАЛСЯ — ${otkat.errors.map(e => `${e.file}: ${e.error}`).join('; ')}. Чужое осталось правленым, смотреть руками. Дальше об этом молчим, чтобы не забить сводку.`,
|
||
});
|
||
}
|
||
|
||
// (3) Работнику в ящик: что отклонено и какой пункт брать вместо. Куда смотреть,
|
||
// он знает из поручения (задача 8).
|
||
// 🔴🔴 `exceptItem` — пункт, на котором работник СТОИТ СЕЙЧАС; берётся из его же
|
||
// карточки. Без него `nextFreeItem` вернул бы ему его собственный пункт: файлы
|
||
// этого пункта заняты им самим, а свои занятые своему пункту не мешают. Работника,
|
||
// которого не пустили в чужое, отправили бы туда, откуда он пришёл, и гашение
|
||
// «брать больше нечего» не наступило бы никогда. В куске 3 других причин пропустить
|
||
// свой пункт нет: `done` тут всегда пуст, закрытие пунктов строит кусок 4.
|
||
const punktyProgona = readJson(join(runDirPath, 'run.json'))?.items ?? [];
|
||
const vyborPunkta = {
|
||
done: closedItemsOfRun(runDirPath),
|
||
exceptItem: readJson(join(workerDirPath, 'worker.json'))?.item?.id ?? null,
|
||
};
|
||
let svobodnyy = nextFreeItem(runDirPath, workerId, punktyProgona, vyborPunkta);
|
||
|
||
// 🔴🔴 Решение диспетчера (вопрос 6, путь «а»): свободный пункт, выданный работнику
|
||
// взамен отобранного, ЗАНИМАЕТ свои файлы В ТУ ЖЕ МИНУТУ. `nextFreeItem` только
|
||
// называет номер, и между «назвали» и «работник вправду взял» была дыра: двое
|
||
// надзирателей в одну секунду называют своим работникам ОДИН и тот же свободный
|
||
// пункт, и оба уходят в одни файлы — ровно то, от чего этот кусок и построен.
|
||
// Запертые файлы не простоят: гаснет работник — `releaseClaims` в `finish()`
|
||
// отпускает их сам, это уже написано.
|
||
// 🪤 Занятие может не удаться: другой надзиратель успел первым в ту же секунду.
|
||
// Тогда пункт больше не свободен — спрашиваем следующий. Круг счётом ограничен
|
||
// числом пунктов: каждый неудавшийся заход означает, что пункт ушёл другому
|
||
// и второй раз названным уже не будет.
|
||
let zaperto = false;
|
||
for (let popytka = 0; svobodnyy.item && !zaperto && popytka <= punktyProgona.length; popytka++) {
|
||
zaperto = zapertLiFaktom(svobodnyy.item.files);
|
||
if (!zaperto) svobodnyy = nextFreeItem(runDirPath, workerId, punktyProgona, vyborPunkta);
|
||
}
|
||
// 🪤 Пункт называется, а запереть его не вышло ни разу. Номер не выдаём вовсе:
|
||
// выдать НЕзапертый номер — это вернуть ту самую дыру целиком, только тихо.
|
||
if (svobodnyy.item && !zaperto) {
|
||
svobodnyy = {
|
||
item: null,
|
||
extinguish_reason: 'Свободный пункт называется, но занять его файлы не удалось — их уводят из-под руки. Работник гасится с этой пометкой, а не идёт в файлы, которые за ним не заперты.',
|
||
};
|
||
}
|
||
|
||
const inboxDir = workerInboxDir(worktreeRoot);
|
||
mkdirSync(inboxDir, { recursive: true });
|
||
writeJsonAtomic(join(inboxDir, 'claims.json'), {
|
||
denied: audit.intruded.map(i => ({ file: i.file, clash_with: i.clash_with, at: t })),
|
||
next_item: svobodnyy.item?.id ?? null,
|
||
updated_at: t,
|
||
});
|
||
|
||
// 🔴 Пойманного НЕ гасим (решение диспетчера): проверка 52 говорит, что он берёт
|
||
// другой пункт. Гашение — только когда брать нечего. Счётчика прощений нет:
|
||
// каждый круг одинаково.
|
||
if (!svobodnyy.item) {
|
||
await killWorker({ wait_for_step: false });
|
||
return finish({
|
||
final_status: 'extinguished',
|
||
reason: `${svobodnyy.extinguish_reason} Работника не пустили в чужой файл, а взять больше нечего — погашен с пометкой, чтобы не крутиться впустую.`,
|
||
});
|
||
}
|
||
} else if (!skazanoProChistuyuSverku) {
|
||
// 🔴 Сверка состоялась и работник чист. Один-единственный след за его жизнь —
|
||
// затем, что молчание ЧИСТОГО замка иначе неотличимо от молчания замка, которого
|
||
// никто не позвал. Каждый круг такое писать нельзя: за ночь набежало бы девятьсот
|
||
// строк на работника, и в ленте потерялось бы всё остальное.
|
||
skazanoProChistuyuSverku = true;
|
||
logEvent(runDirPath, { kind: 'claims_audit_ok', worker: workerId }, t);
|
||
}
|
||
|
||
// 5а. ЗАМОК «ПУНКТ ЗАКРЫТ» (проверки 24, 25, 38, 40). Решение владельца 96: слову
|
||
// работника не верим. Коммит берём у ХРАНИЛИЩА, проверки этого пункта гоняем САМИ,
|
||
// а строка его заявки и файл `touched.json` доказательством не считаются —
|
||
// их пишет ровно тот, кого мы и сторожим.
|
||
// 🔴 Место блока не случайно: ПОСЛЕ блока 5 — чтобы пункт, в котором лежал чужой файл,
|
||
// к этой минуте уже был откачен; ДО блока 6 — потому что заявка сама по себе признак
|
||
// жизни, и назвать вставшим только что закрывшего пункт было бы неправдой.
|
||
const yashchik = workerInboxDir(worktreeRoot);
|
||
const uzheSudili = new Set(
|
||
readLines(join(workerDirPath, 'items-verified.jsonl')).map(r => String(r?.item)),
|
||
);
|
||
const zayavka = readLines(join(yashchik, 'closed-items.jsonl')).find(z => z?.item
|
||
&& !uzheSudili.has(String(z.item))
|
||
&& !otlozhennyePunkty.has(String(z.item)));
|
||
|
||
if (zayavka) {
|
||
const nomerPunkta = String(zayavka.item);
|
||
const golovaSeychas = head(worktreeRoot) ?? null;
|
||
// 🔴 Судим по КОММИТУ. Тот же коммит — второй раз не судим: прогон проверок идёт
|
||
// минутами, и повторять его каждые тридцать секунд значит съесть ночь целиком.
|
||
const nadoSudit = sudiliPriKommite.get(nomerPunkta) !== golovaSeychas;
|
||
// 🪤 Каталог грязный — суд откладывается до следующего круга: иначе намеряли бы
|
||
// красноту от недописанного кода и не засчитали бы честно сделанный пункт.
|
||
// Спросить не удалось (`null`) — тоже откладываем: пустой ответ упавшего `git`
|
||
// неотличим от «всё сохранено», а судить по каталогу, который переписывают
|
||
// под руками, нельзя.
|
||
//
|
||
// 🟢 НАЙДЕНО ЖИВЫМ РАЗЫГРЫШЕМ КРУГА 02.08.2026, ЗАКРЫТО В ТОТ ЖЕ ДЕНЬ — НЕ ЗДЕСЬ.
|
||
// Беда была такая: ящик работника (`<угол>/.night-worker/`) лежит ВНУТРИ его угла
|
||
// и в `.gitignore` не записан. Стоит надзирателю положить туда первый же файл
|
||
// (`next-item.json`, `item-verdict.json`, `claims.json`) — `git status --porcelain`
|
||
// отвечает `?? .night-worker/`, угол считается ГРЯЗНЫМ навсегда, и с этой минуты
|
||
// КАЖДЫЙ следующий пункт получает «суд отложен до следующего круга» — до утра
|
||
// и МОЛЧА: строку про грязный угол мы говорим ровно один раз за жизнь работника.
|
||
// Цена — за восьмичасовую ночь засчитан один пункт вместо семи, без слова о причине.
|
||
// Замерено двумя способами: живым кругом (работник не получил второй попытки
|
||
// по пункту 2) и отдельным замером (чистый угол — `false`, после записи
|
||
// `next-item.json` — `true`).
|
||
//
|
||
// ✅ ЧЕМ И ГДЕ ЗАКРЫТО: `gryazenLiUgol` в `item-audit.mjs` теперь спрашивает `git`
|
||
// с отсевом своего же ящика — `git status --porcelain -- ':(exclude,top)<ящик>'`,
|
||
// имя ящика берётся у `paths.mjs` (`workerInboxDir`), второй копии имени не заведено.
|
||
// После починки живой круг доигрывается: работник получает ВТОРУЮ попытку и гаснет
|
||
// с честной причиной. Правило «что считать несохранённым» живёт там и только там —
|
||
// свой отсев в ЭТОМ файле был бы вторым таким правилом, и они разошлись бы в первый
|
||
// же месяц. Поэтому здесь по-прежнему только зов, и это правильно.
|
||
//
|
||
// 🪤 ПОЧЕМУ НЕ ЧЕРЕЗ `.gitignore` — довод замерен уже ПОСЛЕ находки, при мне его
|
||
// ещё не было, и он решающий: в живом круге работник сохраняется через `git add -A`
|
||
// и ВТЯГИВАЕТ ящик в историю. Значит запись в `.gitignore` молча поменяла бы
|
||
// поведение самого работника и все команды `git` у владельца на руках — а надо было
|
||
// поправить один прибор. Второй путь был хуже, хоть и выглядел проще.
|
||
//
|
||
// 🟡🟡 ГРАНИЦА, ЧЕСТНО НАЗВАННАЯ ПОЧИНИВШИМ, — ЗАКРЫТО НЕ ВСЁ. Несохранённое,
|
||
// спрятанное ВНУТРИ ящика, прибор теперь не видит ВОВСЕ. И это не пустяк: проверки
|
||
// пункта гоняются по ЖИВОМУ каталогу, а не по выписанному коммиту, — положи работник
|
||
// в ящик несохранённый файл, на который смотрит проверка его пункта, и замок намерял
|
||
// бы зелень по тому, чего в коммите нет. Дыра осознанная, чинится не там: сверкой
|
||
// тронутых файлов (`claims-audit.mjs`), которой ящик работнику не свой. Кто читает
|
||
// это завтра — «закрыто» относится к грязи ОТ САМОГО ящика, а не ко всему разом.
|
||
const gryazno = nadoSudit ? dirty(worktreeRoot) : false;
|
||
|
||
if (nadoSudit && gryazno !== false) {
|
||
if (!skazanoProGryaznyyUgol) {
|
||
skazanoProGryaznyyUgol = true;
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), {
|
||
at: t,
|
||
worker_id: workerId,
|
||
line: `${workerId}: пункт ${nomerPunkta} объявлен закрытым, но в углу есть несохранённые правки — суд отложен до следующего круга. Дальше об этом молчим, чтобы не забить сводку.`,
|
||
});
|
||
}
|
||
} else if (nadoSudit) {
|
||
const punktyProgona = readJson(join(runDirPath, 'run.json'))?.items ?? [];
|
||
const punkt = punktyProgona.find(i => String(i?.id) === nomerPunkta)
|
||
?? readJson(join(workerDirPath, 'worker.json'))?.item
|
||
?? null;
|
||
|
||
// 🔴 ЗАМОК. Имя своей базы подаёт надзиратель: оно у него на руках, из карточки
|
||
// работника, и в живую ночь его довозит `main()`. В саму подпись замка его
|
||
// не кладут — это обстоятельство зовущего, а не сведение о пункте.
|
||
//
|
||
// 🔴🔴 КОМАНДА ПРОВЕРОК СЧИТАЕТСЯ ЗДЕСЬ, ИЗ СУДИМОГО ПУНКТА, И КАЖДЫЙ РАЗ ЗАНОВО.
|
||
// Ни из карточки работника, ни из довода круга её не берут: карточка пишется
|
||
// один раз, по первому пункту, а пунктов у работника за ночь много. Возьми мы
|
||
// её оттуда — со второго пункта замок мерил бы проверки первого, и пункт
|
||
// засчитывался бы по ЧУЖОЙ зелени. Это и есть проверка 38 дословно: «зелёные
|
||
// проверки ПО ЭТОМУ пункту». Ломается ТИХО.
|
||
const prigovor = auditClosedItem({
|
||
worktreeRoot,
|
||
item: punkt,
|
||
claim: zayavka,
|
||
head,
|
||
runTests: dovody => runTests({ ...dovody, command: komandaProverokPunkta(dovody.item), dbName }),
|
||
timeoutMs: (cfg?.item_tests_timeout_minutes ?? 10) * 60_000,
|
||
});
|
||
|
||
// 🔴 ВТОРОЙ приговор — правило задачи 2. Оно знает то, чего замок не знает:
|
||
// пункт «в расчёте на ответ» без ответа не закрывается, сколько бы зелени
|
||
// ни было (проверка 40), и пункт без единой проверки в плане закрыть нельзя
|
||
// (проверка 39). Пункт засчитан, только когда оба сказали «да».
|
||
const pravilo = closeItem({
|
||
item: punkt,
|
||
commit_sha: prigovor.commit_sha,
|
||
tests_green: prigovor.tests_green,
|
||
on_assumption: Boolean(zayavka.on_assumption),
|
||
answer_received: Boolean(zayavka.answer_received),
|
||
});
|
||
|
||
sudiliPriKommite.set(nomerPunkta, golovaSeychas);
|
||
|
||
if (prigovor.closed && pravilo.closed) {
|
||
// ЗАСЧИТАНО. Приговор ложится в каталог ПРОГОНА, а не в угол работника.
|
||
appendLine(join(workerDirPath, 'items-verified.jsonl'), {
|
||
item: nomerPunkta,
|
||
at: t,
|
||
commit_sha: prigovor.commit_sha,
|
||
tests_green: true,
|
||
line: prigovor.line,
|
||
});
|
||
touchLife(workerDirPath, 'item_closed', { item: nomerPunkta }, t);
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at: t, worker_id: workerId, line: `${workerId}: ${prigovor.line}` });
|
||
logEvent(runDirPath, { kind: 'item_verified', worker: workerId, item: nomerPunkta, commit: prigovor.commit_sha }, t);
|
||
popytkiPoPunktu.delete(nomerPunkta);
|
||
|
||
// ОЧЕРЕДЬ (проверка 24). Зовутся ОБА правила, и в РАЗНЫХ ролях — граница
|
||
// записана в самом `items.mjs` и на неё стоят два сторожа в `items.test.mjs`:
|
||
// у `afterItemDone` спрашивают «есть ли что брать и не пора ли гаснуть»,
|
||
// а САМ ПУНКТ берут через `nextFreeItem` (кусок 3), потому что только она
|
||
// знает про занятые чужими файлы. Возьми мы пункт у первой — двое молча
|
||
// правили бы одни файлы всю ночь при зелёных проверках.
|
||
const sdelano = [...closedItemsOfRun(runDirPath), ...otlozhennyePunkty];
|
||
// 🟡 `answered_during_day` подаём жёстким `false`, а не умолчанием, нарочно:
|
||
// признак «владелец ответил днём» до куска 5 взять НЕОТКУДА.
|
||
const ochered = afterItemDone({ items: punktyProgona, done: sdelano, answered_during_day: false });
|
||
let svobodnyy = ochered.next_item
|
||
? nextFreeItem(runDirPath, workerId, punktyProgona, { done: sdelano, exceptItem: nomerPunkta })
|
||
: { item: null, extinguish_reason: ochered.extinguish_reason };
|
||
|
||
// 🔴 Выданный пункт ЗАНИМАЕТ свои файлы в ту же минуту — решение диспетчера,
|
||
// то же, что в блоке 5. Назвать номер и не запереть файлы значит позволить
|
||
// двум надзирателям в одну секунду выдать своим работникам ОДИН пункт.
|
||
let zaperto = false;
|
||
for (let popytka = 0; svobodnyy.item && !zaperto && popytka <= punktyProgona.length; popytka++) {
|
||
zaperto = zapertLiFaktom(svobodnyy.item.files);
|
||
if (!zaperto) svobodnyy = nextFreeItem(runDirPath, workerId, punktyProgona, { done: sdelano, exceptItem: nomerPunkta });
|
||
}
|
||
if (svobodnyy.item && !zaperto) {
|
||
svobodnyy = { item: null, extinguish_reason: 'Свободный пункт называется, но занять его файлы не удалось — их уводят из-под руки.' };
|
||
}
|
||
|
||
if (svobodnyy.item) {
|
||
mkdirSync(yashchik, { recursive: true });
|
||
writeJsonAtomic(join(yashchik, 'next-item.json'), {
|
||
item: svobodnyy.item,
|
||
at: t,
|
||
reason: `Пункт ${nomerPunkta} засчитан надзирателем: проверки прогнаны и зелены, работа сохранена. Берите следующий.`,
|
||
});
|
||
} else {
|
||
const run5a = readJson(join(runDirPath, 'run.json')) ?? {};
|
||
|
||
// 🔴🔴 ОТЛОЖЕННЫЙ ПУНКТ — ЭТО НЕ ЗАСЧИТАННЫЙ ПУНКТ. В список «сделано» он попадает
|
||
// только затем, чтобы очередь не выдавала его снова, — и на этом всё. Сочти мы
|
||
// «свободных пунктов нет» за «задача сделана», приёмщику поднялся бы флаг
|
||
// на НЕДОДЕЛАННОЙ задаче, а тот же самый прогон положил бы след «не влезла
|
||
// в ночь»: одна ночь оставила бы два противоречащих знака, и утром владелец
|
||
// не знал бы, которому верить. Знак должен быть ОДИН.
|
||
//
|
||
// 🔴🔴 ДОЛГ КУСКА 7 (позиция 7): ИМЕННО ЭТА СТРОКА заменяется на зов
|
||
// `zadachaKonchena({ items, verified, otlozhennye }).konchena`, и вместе
|
||
// с долгом 5 (невыполнимые пункты), у которого своего ленджера в затее
|
||
// пока нет вовсе. Не переводи её тот, кто правит блок 5а.
|
||
const vseZaschitany = otlozhennyePunkty.size === 0;
|
||
|
||
if (!vseZaschitany) {
|
||
const otlozheno = [...otlozhennyePunkty].join(', ');
|
||
pravitKartochkuProgona(runDirPath, {
|
||
reviewer_starts: false,
|
||
start_next_task: false,
|
||
items_all_verified: false,
|
||
});
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), {
|
||
at: t,
|
||
worker_id: workerId,
|
||
line: `${workerId}: брать больше нечего, но задача НЕ доделана — отложены пункты ${otlozheno}. Приёмщика не зовём: к отложенному возвращается владелец.`,
|
||
});
|
||
await killWorker({ wait_for_step: false });
|
||
return finish({
|
||
final_status: 'extinguished',
|
||
reason: `Свободных пунктов не осталось, но отложены пункты ${otlozheno} — задача не доделана, приёмщику не показывается.`,
|
||
});
|
||
}
|
||
|
||
// КОНЕЦ ЗАДАЧИ (проверка 25). Свободных пунктов нет и все засчитаны: работник
|
||
// гаснет, следующая задача в этом прогоне НЕ начинается, а флаг для приёмщика
|
||
// ложится в карточку прогона. Самого приёмщика по нему поднимает кусок 7.
|
||
// 🔴 `run5a.workers` — это состав прогона, а НЕ живые на эту минуту: сюда дошёл
|
||
// я сам, значит в составе есть хотя бы я, и громкое падение `afterTaskDone`
|
||
// на пустом списке живой ночи не грозит.
|
||
const konets = afterTaskDone({ task_id: run5a.task_id ?? runId, workers: run5a.workers ?? [workerId] });
|
||
pravitKartochkuProgona(runDirPath, {
|
||
reviewer_starts: konets.reviewer_starts,
|
||
start_next_task: konets.start_next_task,
|
||
items_all_verified: true,
|
||
});
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at: t, worker_id: workerId, line: `${workerId}: ${konets.note}` });
|
||
// 🔴 ЧЕСТНО И ВСЛУХ: `konets.extinguish_workers` называет ВСЕХ работников
|
||
// задачи, а гасим мы здесь ровно ОДНОГО — своего. Остальные шестеро
|
||
// в эту минуту о конце задачи не узнают ничем. Обходить список отсюда
|
||
// я НЕ стал: выбор способа — вопрос владельца 8, и пока он не отвечен,
|
||
// пункт (а) правила 25 закрытым не считать.
|
||
await killWorker({ wait_for_step: false });
|
||
return finish({ final_status: 'extinguished', reason: `Пункты задачи кончились и все засчитаны. ${konets.note}` });
|
||
}
|
||
} else {
|
||
// НЕ ЗАСЧИТАНО. Работник ОСТАЁТСЯ работать (решение диспетчера): у него есть
|
||
// ночь, чтобы дочинить свой же пункт. Нового пункта ему не дают — иначе к утру
|
||
// было бы семь начатых и ни одного доведённого.
|
||
const prichina = prigovor.closed ? pravilo.reason : prigovor.reason;
|
||
const popytka = (popytkiPoPunktu.get(nomerPunkta) ?? 0) + 1;
|
||
popytkiPoPunktu.set(nomerPunkta, popytka);
|
||
|
||
appendLine(join(runDirPath, SUMMARY_LINES_FILE), {
|
||
at: t,
|
||
worker_id: workerId,
|
||
line: `${workerId}: пункт ${nomerPunkta} НЕ засчитан (попытка ${popytka}) — ${prichina}`,
|
||
});
|
||
logEvent(runDirPath, { kind: 'item_rejected', worker: workerId, item: nomerPunkta, attempt: popytka }, t);
|
||
|
||
// 🔴 Работник узнаёт об этом из СВОЕГО ящика, отдельным файлом. С `claims.json`
|
||
// куска 3 не сливаем: тот про чужие файлы, этот про непризнанный пункт, и слей
|
||
// их — владелец читал бы про пересечение там, где его нет.
|
||
mkdirSync(yashchik, { recursive: true });
|
||
writeJsonAtomic(join(yashchik, 'item-verdict.json'), {
|
||
item: nomerPunkta, closed: false, reason: prichina, attempt: popytka, at: t,
|
||
});
|
||
|
||
if (popytka >= 2) {
|
||
// Вторая неудача по одному пункту — пункт откладывается, работник берёт другой.
|
||
otlozhennyePunkty.add(nomerPunkta);
|
||
const sdelano = [...closedItemsOfRun(runDirPath), ...otlozhennyePunkty];
|
||
let svobodnyy = nextFreeItem(runDirPath, workerId, punktyProgona, { done: sdelano, exceptItem: nomerPunkta });
|
||
let zaperto = false;
|
||
for (let p = 0; svobodnyy.item && !zaperto && p <= punktyProgona.length; p++) {
|
||
zaperto = zapertLiFaktom(svobodnyy.item.files);
|
||
if (!zaperto) svobodnyy = nextFreeItem(runDirPath, workerId, punktyProgona, { done: sdelano, exceptItem: nomerPunkta });
|
||
}
|
||
if (svobodnyy.item && zaperto) {
|
||
writeJsonAtomic(join(yashchik, 'next-item.json'), {
|
||
item: svobodnyy.item,
|
||
at: t,
|
||
reason: `Пункт ${nomerPunkta} дважды не сошёлся и отложен. Берите другой — к отложенному вернётся владелец.`,
|
||
});
|
||
} else {
|
||
await killWorker({ wait_for_step: false });
|
||
return finish({
|
||
final_status: 'extinguished',
|
||
reason: `Пункт ${nomerPunkta} дважды не сошёлся и отложен, а брать больше нечего — погашен с пометкой, чтобы не крутиться впустую.`,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 6. Встал ли он. Тридцать минут без единого признака жизни — назван вставшим.
|
||
// 🔴 Посчитано ОДИН раз и положено в переменную: то же число идёт и в условие,
|
||
// и в текст для владельца. Двумя отдельными `?? 30` — поправят одно, работник гаснет
|
||
// по одному числу, а в письме владелец читает другое.
|
||
// 🔴 Правило «встал» касается не всякого: ждущего чужую службу, стоящего в чистом
|
||
// простое и стоящего на паузе оно не трогает — список спрошен у правила отсечки
|
||
// (`stallExempt` выше), а не переписан здесь.
|
||
const stallMinutes = cfg?.stall_minutes ?? 30;
|
||
if (!stallExempt(status) && isStalled(readLife(workerDirPath), t, stallMinutes)) {
|
||
await killWorker({ wait_for_step: false });
|
||
return finish({
|
||
final_status: 'stalled',
|
||
reason: `Работник ${stallMinutes} минут не подавал признаков жизни — назван вставшим и погашен. Продолжать его — с последнего закрытого пункта.`,
|
||
});
|
||
}
|
||
|
||
// 7. Отметка «я его вижу» в карточке: по ней утром видно, до какого часа он был жив.
|
||
const prev = readJson(join(workerDirPath, 'worker.json')) ?? {};
|
||
writeJsonAtomic(join(workerDirPath, 'worker.json'), {
|
||
...prev, status, at_item: currentItem(worktreeRoot), seen_at: t,
|
||
});
|
||
|
||
await sleep(tickMs);
|
||
}
|
||
|
||
// Работник кончился сам.
|
||
// 🪤 Последняя строка могла остаться в хвосте без перевода строки — программы часто
|
||
// так и заканчивают. Разбираем хвост ПРЕЖДЕ, чем судить: иначе работник, честно отдавший
|
||
// итог, был бы записан «кончился, не отдав итога».
|
||
if (tail.trim()) { eatLine(tail); tail = ''; }
|
||
const at = now();
|
||
if (!sawStream || !result) {
|
||
return finish({
|
||
final_status: 'stalled',
|
||
reason: `Работник кончился, не отдав итога. Что он сказал напоследок: ${(stderr || 'ничего').slice(0, 400)}`,
|
||
at,
|
||
});
|
||
}
|
||
// 🪤 «Успех» с пустым ответом успехом не считается: 334 прогона с мая возвращали
|
||
// is_error false при пустом ответе и полном провале.
|
||
const verdict = isRealSuccess(result);
|
||
return finish({
|
||
final_status: 'extinguished',
|
||
reason: verdict.ok ? 'Работник закончил своё дело сам.' : verdict.reason,
|
||
at,
|
||
});
|
||
}
|
||
|
||
// --- запуск самого надзирателя ------------------------------------------------
|
||
|
||
export function spawnSupervisor({ runId, workerId, node = process.execPath, spawnFn = spawn }) {
|
||
const wdir = workerDir(runId, workerId);
|
||
mkdirSync(wdir, { recursive: true });
|
||
// Свой журнал обязателен: молча падающий надзиратель — ровно тот класс беды,
|
||
// из-за которого 334 прогона умирали три месяца незамеченными.
|
||
const log = openSync(join(wdir, 'supervisor.log'), 'a');
|
||
const child = spawnFn(node, [supervisorPath(), '--run', runId, '--worker', workerId], {
|
||
cwd: REPO_ROOT,
|
||
detached: true, // вот эта строка и есть «отдельная жизнь», ради которой всё затевалось
|
||
windowsHide: true,
|
||
stdio: ['ignore', log, log],
|
||
});
|
||
child.unref?.();
|
||
return { pid: child.pid };
|
||
}
|
||
|
||
function argValue(argv, flag) {
|
||
const i = argv.indexOf(flag);
|
||
if (i === -1 || i + 1 >= argv.length) {
|
||
throw new Error(`Надзирателю не сказали ${flag}. Запуск: supervisor.mjs --run <прогон> --worker <работник>`);
|
||
}
|
||
return argv[i + 1];
|
||
}
|
||
|
||
// 🔴 Три подмены в подписи — ради ОДНОЙ проверки: что живой вход правда кладёт в доводы
|
||
// путь к журналу замеров. Проверки на `supervise()` этого не видят никогда — они подают
|
||
// путь руками и потому слепы к тому, что его не подали. Настройки и карточка читаются
|
||
// путями снаружи, чтобы проверка могла подсунуть свои временные файлы и дойти до зова
|
||
// `supervise`, а не упасть на «Настроек нет». Умолчания — настоящие пути хозяйства,
|
||
// то есть боевое поведение не изменилось ни на строку.
|
||
export async function main(argv = process.argv, {
|
||
superviseFn = supervise,
|
||
cfgFile = configPath(),
|
||
workerDirFn = workerDir,
|
||
} = {}) {
|
||
const runId = argValue(argv, '--run');
|
||
const workerId = argValue(argv, '--worker');
|
||
const cfg = readJson(cfgFile);
|
||
if (!cfg) throw new Error(`Настроек нет: ${cfgFile}. Скопируйте night.config.example.json и заполните.`);
|
||
const wdir = workerDirFn(runId, workerId);
|
||
const card = readJson(join(wdir, 'worker.json'));
|
||
if (!card) throw new Error(`Карточки работника нет: ${join(wdir, 'worker.json')}. Её пишет оркестратор ДО старта.`);
|
||
|
||
return superviseFn({
|
||
runId,
|
||
workerId,
|
||
worktreeRoot: card.workspace,
|
||
workerDirPath: wdir,
|
||
runDirPath: runDir(runId),
|
||
// 🔴🔴 СТРОКА НИЖЕ И ЕСТЬ ВСЯ ЗАДАЧА 7 В ЖИВОЙ НОЧИ. Без неё круг получает `undefined`,
|
||
// и команды владельца не читаются ВООБЩЕ: «стоп всё» не останавливает ночь, «пауза»
|
||
// не приостанавливает, непонятое слово не называется. Каталог один на всё хозяйство —
|
||
// `.night/commands`, его имя даёт `commandsDir()` из `paths.mjs` (кусок 1).
|
||
commandsDirPath: commandsDir(),
|
||
// 🔴🔴 ВОТ ЭТА СТРОКА И ЕСТЬ ЖИВАЯ ПОЛОВИНА ЗАМКА «ПУНКТ ЗАКРЫТ». Имя своей базы знает
|
||
// карточка работника: `db_name` кладёт туда `startAll`. Не дойди оно до круга —
|
||
// прогон проверок каждого пункта отвечал бы «замерить не удалось» (у замка своих
|
||
// умолчаний нет), ни один пункт не засчитывался бы за всю ночь, и владелец узнал бы
|
||
// об этом только к утру по пустой сводке. А была бы у имени подмена — проверки
|
||
// пошли бы в общую `liderra_testing`, и семеро затёрли бы друг другу ответ.
|
||
// 🔴 Команды проверок здесь НЕТ намеренно: её считает круг из судимого пункта.
|
||
// Возьми он её из карточки — со второго пункта мерил бы проверки первого.
|
||
dbName: card.db_name ?? null,
|
||
cfg,
|
||
// 🔴 Без этой строки весь предел недельного запаса мёртв, и молча: `recordMeasure`
|
||
// падает внутрь ленты одной строкой «budget_measure_failed» (ни один замер за ночь
|
||
// в журнал не попадает), а `budgetState` на пустом пути не падает вовсе — журнал
|
||
// «пуст», ответ «слепой», и по запасу не гасит НИКОГДА. Ночь жгла бы деньги без предела,
|
||
// включая неприкосновенные две седьмых. Имя журнала знает одно место — paths.mjs.
|
||
measuresPath: budgetMeasuresPath(),
|
||
lastRunFile: lastRunPath(),
|
||
spawnWorker: () => startWorker({
|
||
cwd: card.workspace,
|
||
prompt: card.prompt,
|
||
settingsPath: card.settings_path,
|
||
sessionId: card.session_id,
|
||
// 🪤 Имя СВОЕЙ базы обязано уйти работнику окружением, иначе прогон проверок пойдёт
|
||
// в общую `liderra_testing` (app/phpunit.xml:33) и семеро затрут друг другу проверки
|
||
// при зелёном результате. Без имени `workerEnv` намеренно падает.
|
||
dbName: card.db_name,
|
||
// 🔴 Голова работника — из настроек владельца (`worker_model`), своего слова здесь нет.
|
||
// Оркестратор уже положил её в карточку; если её там нет — берём прямо из настроек,
|
||
// а не подставляем что-нибудь от себя. Нет нигде — `buildArgs` падает громко.
|
||
model: card.model ?? cfg?.worker_model,
|
||
}),
|
||
});
|
||
}
|
||
|
||
// Позвали файл напрямую (так его зовёт оркестратор) — работаем. Позвали ввозом
|
||
// (так его зовут проверки) — молчим.
|
||
if (process.argv[1] && process.argv[1].endsWith('supervisor.mjs')) {
|
||
await main();
|
||
}
|