Files
portal/tools/night/supervisor.mjs
T

1687 lines
129 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// НАДЗИРАТЕЛЬ, часть 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();
}