Files
portal/tools/night/cli.mjs
T

1009 lines
79 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.
#!/usr/bin/env node
// Точки входа. `mail` зовёт планировщик Windows в 8:00, остальное — помощник по слову
// владельца. Кусок 1 знал четыре команды из одиннадцати; кусок 2 добавил ещё три
// (`budget`, `seal`, `approve-spec`) — итого семь. Остальные заводят куски 3–7.
//
// 🆕 Правка К1 (О-2): логика вынесена в экспортируемые функции (`main`, `runMailCommand`,
// `collectSummary`, `sweepNow`) с внедряемыми зависимостями — иначе проверить кусок 1
// нечем: раньше это был голый скрипт, который на одном чтении настроек уже дёргал
// `process.exit`. Точка входа (низ файла) зовёт `main()` только когда файл запущен
// САМ, а не подключён проверкой как модуль — `import.meta.url` совпал бы с путём
// запуска только в первом случае.
import { join, resolve } from 'node:path';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { readJson, readLines, appendLine, writeJsonAtomic } from './state.mjs';
import {
REPO_ROOT, configPath, mailFailuresPath, summaryLinesPath as summaryLinesPathOf,
lastRunPath, summarySentPath, priyomkaDir, runsDir, runDir,
budgetMeasuresPath, probeAttemptsPath, planSealPath, specApprovalPath,
} from './paths.mjs';
import { buildSummary } from './summary.mjs';
import { makeTransport as makeTransportDefault, sendSummary as sendSummaryDefault } from './mailer.mjs';
import { planRun, startAll, smokeLongRun } from './run.mjs';
import { sweepStaleWorkspaces } from './workspace.mjs';
import { budgetState, recordMeasure } from './budget.mjs';
// 🔴 К платному замеру остатка точка входа ходит ТОЛЬКО через эти ворота: они считают
// сделанное за смену (владелец разрешил до 30) и отказывают на пределе. Прямой зов
// `probeUtilization` отсюда убран намеренно — сторож `platnyy-zamer-tolko-cherez-schyot`
// покраснеет, если его вернут.
import { zamerSoSchyotom } from './schyot-zamerov.mjs';
import { freeGb, diskState } from './disk.mjs';
import { sealPlan, checkSeal, assertPlanEditable } from './plan-fingerprint.mjs';
// 🔴 Разбор каталога прогонов «пустота против порванного» в затее ОДИН, и живёт он у замка
// на правку плана (`readRuns`, plan-lock-hook.mjs) — там этот класс беды закрыли первым.
// Своего второго обхода каталога здесь не заводим: две копии одного правила расходятся молча,
// а расходятся они ровно в ту ночь, когда запись порвалась.
import { readRuns } from './plan-lock-hook.mjs';
import { specApproved, papersCommitted, paperPathsFor } from './paper-gates.mjs';
// 🔴 Три тихие части точки входа живут отдельным файлом и проверены двадцатью семью
// проверками: разбор доводов, раскладка ответов правил по кормушкам ворот и строки отказа.
// Здесь их только зовут — иначе проверки сторожили бы код, которого программа не исполняет.
// 🪤 `zamerDovody` отсюда убран: доводы замера собирают теперь ворота счёта
// (`schyot-zamerov.mjs`) — там же, где зовётся сам замер. Оставленный тут мёртвый ввоз
// выглядел бы как «точка входа всё ещё умеет ходить к деньгам сама».
import { razborDovodov, vhodVorot, strokiOtkaza, vyborProdolzhaemogo } from './cli-parts.mjs';
// ━━━ КНОПКА «ПРОДОЛЖИТЬ»: десять правил, у которых до этой минуты не было ни одного зовущего.
// 🔴 Ни одного из них здесь не переписано и не продублировано — только позвано.
import { resumePoint, resumePlan, rabotaPosleZachtennogo, sledOtkata } from './resume.mjs';
import { resumeChecks } from './limits.mjs';
import { otkatDoKommita, closedItemsOfRun } from './supervisor.mjs';
import { gryazenLiUgol } from './item-audit.mjs';
import { zavestiProdolzhenie } from './run.mjs';
import { readJsonOrBroken } from './state.mjs';
// 🔴 Замки на файлы — ТЕ ЖЕ, что у обычного прогона. Своего второго способа занимать файлы
// кусок 6 не заводит: две дороги к одному замку разъезжаются молча, и за смену это находили
// трижды. `claimFiles`/`releaseClaims` — внутри прогона, `filesDisjoint` — МЕЖДУ прогонами.
import { claimFiles, releaseClaims } from './file-claims.mjs';
import { filesDisjoint } from './gates.mjs';
import { mkdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
// 🔴 ВОРОТА ПЛАНА И ПРАВИЛО «ЧЕМ МЕРИТЬ ПУНКТ» — оба из `plan-model.mjs`, и оба нужны ДВОИМ:
// командной строке (здесь) и надзирателю (задача 5). Второй дороги к ним не заводить.
// 🔴🔴 Долг, закрытый здесь: до этой строки `validatePlan` не звал в рабочем коде НИКТО —
// правило было написано, проверено 44 проверками и не подключено к делу ни одним зовом.
// План с повторяющимися номерами пунктов, план без единого пункта, план с мерным прибором
// в `files=` — все уходили в ночь молча.
import { validatePlan, komandaProverokPunkta } from './plan-model.mjs';
// Ворота возврата отложенной задачи (проверки 28, 29, 30).
import { deferredTaskGate } from './items.mjs';
// 🔴 Путь к следу знает ОДИН файл на всю затею. Своего второго каталога следов здесь
// не заводить ни в каком виде: разойдись они хоть на знак — след лёг бы в одно место,
// а искался в другом, и отложенная задача подхватилась бы назавтра САМА.
import { readDeferred, clearDeferred, deferredDir } from './deferred.mjs';
function flag(argv, name) {
const i = argv.indexOf(name);
return i > -1 && i + 1 < argv.length ? argv[i + 1] : null;
}
// 🔴 ДВОЕ ВОРОТ ПОКА НЕ СТОРОЖАТ. Было четверо; двоих (потолки задач и работников,
// пересечение задач по файлам) кусок 3 дописал, и они сторожат по-настоящему.
// Оставшимся двоим правило пишет кусок 7; до тех пор они пропускают всё — иначе живую ночь
// нельзя было бы запустить, и владелец не увидел бы работающую затею до самого конца.
// Решение владельца: пропускать — но говорить об этом вслух, каждый раз, поимённо.
// Молчаливо пропускающие ворота — это самообман: человек видит «все ворота пройдены»
// и думает, что его сторожат девятеро.
const VOROTA_POKA_NE_STOROZHAT = [
'отклик владельца',
'от прошлого прогона никого',
];
function stroka_pro_nestorozhashchie() {
return `Эти ворота пока НЕ сторожат (их правило пишет кусок 7, сейчас они пропускают всё): ${VOROTA_POKA_NE_STOROZHAT.join(' · ')}.`;
}
// 🔴 Место мерится по каталогу ХОЗЯЙСТВА, а не по текущему каталогу: у задачи планировщика
// рабочий каталог системный, и она мерила бы не тот диск.
async function diskNow(cfg = {}) {
return diskState(await freeGb(REPO_ROOT), cfg.disk_free_min_gb ?? 5);
}
// Какие прогоны идут прямо сейчас. Нужно дважды: чтобы не дать перепечатать план идущего
// прогона (проверка 43) и чтобы честно сказать воротам ёмкости, сколько задач в работе.
//
// 🔴🔴 ПОПРАВЛЕНО ПО ЖИВОЙ БЕДЕ. Прежде здесь стоял плоский `readJson`, а он на порванном
// файле отдаёт `null` — и `.filter(Boolean)` убирал такой прогон из счёта МОЛЧА. Ворота
// порвали одну запись и тут же завели третью задачу при потолке в две, а следом задачу
// на файл, который порванный прогон держит: оба отказа исчезли без единого слова.
// Порванная запись — это «неизвестно, идёт он или кончился», а не «такого прогона нет».
//
// 🔴 Второго способа читать карточки прогонов НЕ ЗАВОДИМ. Прибор «пустота против порванного»
// в затее один — `readJsonOrBroken` в state.mjs, — и разбор каталога прогонов этим прибором
// уже написан: `readRuns` в plan-lock-hook.mjs (замок на правку плана, тот же класс беды
// и то же решение — «не смогли прочесть, значит замок ЗАКРЫТ»). Зовём его. Свой третий
// обход `readdirSync` рядом разъехался бы с ним молча.
//
// Каталог берётся доводом только ради проверок: живые зовущие зовут без довода и работают
// по настоящему `runsDir()`.
export function runningRuns(dir = runsDir()) {
const vse = readRuns(dir);
const porvannye = vse.filter(r => r?.broken);
if (porvannye.length) {
throw new Error(
`Карточку прогона прочесть не удалось: ${porvannye.map(r => r.card_path ?? r.run_id).join(', ')}. `
+ 'Файл на месте, но разобрать его нельзя. Считать это «такого прогона нет» нельзя: так потолок '
+ 'задач и замок на файлы откроются всем именно в ту ночь, когда запись порвалась (а рвётся она '
+ 'чаще всего оттого, что на сервере кончилось место посреди записи). Посмотрите этот файл глазами: '
+ 'прогон кончился — поправьте или удалите карточку, прогон идёт — остановите его командой «стоп всё».',
);
}
return vse.filter(r => r && r.status === 'running');
}
// 🔴 ОДНА ДВЕРЬ НА ОБОИХ ЗОВУЩИХ. Счёт идущих прогонов спрашивают двое — `seal` и `run`, —
// и обоим нужен ОДИН ответ на порванную запись: сказать владельцу строку и не запускаться,
// а не обвалиться следом стека. Два `try` по месту разъехались бы молча: поправят один,
// забудут другой. Довод `chitat` — точка впрыска для проверок, живые зовущие зовут без него.
export function idushchieProgony(chitat = runningRuns) {
try {
return { ok: true, running: chitat(), reason: null };
} catch (err) {
return {
ok: false,
running: [],
reason: `Сколько прогонов идёт сейчас — сосчитать не удалось. ${err?.message ?? err}`,
};
}
}
// ━━━ 🆕 СБОРЩИК КАРТОЧЕК РАБОТНИКОВ ПОДНИМАЕМОГО ПРОГОНА ━━━
//
// 🟡 ПОСТРОЕН СВЕРХ ЗАДАЧИ, называю вслух. `vyborProdolzhaemogo` принимает карточки готовым
// списком, а собрать их не поручено ни одному куску (долг М-181, я же его и назвал).
// Без сборщика дорога продолжения не заводится вовсе, поэтому строю здесь.
//
// 🔴🔴 ТРЕТИЙ ОТВЕТ, А НЕ ПУСТОТА. Плоский `readJson` + `.filter(Boolean)` выбросил бы
// порванную карточку МОЛЧА — и работник, которого нельзя поднимать (скажем, уже продолженный,
// у которого порвалось поле `resumed_into`), просто не попал бы в список. Продолжаемым
// оказался бы другой, а отказы «его уже продолжили» и «продолжаемых несколько» тихо
// перестали бы срабатывать, оставаясь зелёными. Это тот же класс и то же лечение, что
// у `readRuns` для карточек прогонов: «не смогли прочесть» — отдельный ответ.
export function kartochkiRabotnikov(runId, dir = runDir(runId)) {
const papka = join(dir, 'workers');
if (!existsSync(papka)) {
return { ok: false, cards: [], reason: `У прогона ${runId} нет каталога работников (${papka}). Проверьте номер прогона.` };
}
const cards = [];
const porvannye = [];
for (const wid of readdirSync(papka)) {
const put = join(papka, wid, 'worker.json');
const otvet = readJsonOrBroken(put);
if (!otvet.found) continue; // карточки нет — работника не заводили
if (otvet.broken) { porvannye.push(wid); continue; }
cards.push(otvet.value);
}
if (porvannye.length) {
return {
ok: false,
cards,
reason: `Карточки работников ${porvannye.join(', ')} прочесть не удалось — файлы на месте, но разобрать их нельзя. `
+ 'Считать это «таких работников нет» нельзя: тогда продолжаемым окажется кто-то другой, а отказы «его уже '
+ `продолжили» и «продолжаемых несколько» промолчат. Посмотрите эти файлы глазами: ${papka}`,
};
}
return { ok: true, cards, reason: null };
}
// Коммит УЖЕ ЗАСЧИТАННОГО пункта. Нужен откату: до какого места возвращать угол.
//
// 🔴 ПРАВИЛО «ЧТО СЧИТАЕТСЯ ЗАСЧИТАННЫМ» ЗДЕСЬ НЕ ПОВТОРЯЕТСЯ. Оно живёт в `closedItemsOfRun`
// (кусок 4: засчитано только то, у чего проверки вправду зелены), и зовущий спрашивает
// СНАЧАЛА его. Сюда приходит уже благословлённый номер пункта, и это место отвечает ровно
// на один вопрос — «каким коммитом он закрыт». Своей проверки `tests_green` тут нет намеренно:
// две копии одного правила разъезжаются молча, и тогда откат поехал бы до пункта, который
// на самом деле не засчитан.
export function kommitZachtennogoPunkta(runDirPath, itemId) {
if (!itemId) return null;
const dir = join(runDirPath, 'workers');
if (!existsSync(dir)) return null;
let nayden = null;
for (const wid of readdirSync(dir)) {
for (const row of readLines(join(dir, wid, 'items-verified.jsonl'))) {
if (String(row?.item) !== String(itemId)) continue;
// Самая поздняя запись про этот пункт: пункт могли пересдавать.
if (!nayden || (row?.at ?? 0) > (nayden.at ?? 0)) nayden = row;
}
}
return nayden?.commit_sha ?? null;
}
// 🔴 Принудительная уборка старых углов случается ЗДЕСЬ — вместе с утренней сводкой,
// потому что это единственное, что просыпается каждый день само. Про каждый убранный угол
// в сводку идёт отдельная строка: тихо уносить чужую работу нельзя.
//
// 🆕 Долг перед куском 6 (решение владельца 126 от 01.08.2026): угол работника со статусом
// `stalled` или `extinguished` и БЕЗ отметки `resumed_at` эта уборка не тронет, сколько бы
// дней ни прошло, — правило и его причина названы у `isWorkspaceStale` (задача 7). Здесь,
// в `night:mail`/`night:summary`, ничего звать по-другому не нужно: защита стоит внутри
// `sweepStaleWorkspaces`, а не в этой обёртке, и обходить её отсюда нельзя.
export function sweepNow(cfg) {
return sweepStaleWorkspaces({
repoRoot: REPO_ROOT,
maxAgeDays: cfg.workspace_max_age_days ?? 7,
}).map(s => s.line);
}
// 🆕 Чтение последнего прогона и строк сводки вынесено во внедряемые зависимости
// (`readLastRun`/`readSummaryLines`) — иначе проверить «сводка на пустом месте не молчит»
// нечем без похода в НАСТОЯЩИЙ `.night/` этого рабочего угла, а там уже живут файлы
// второго правщика (виден `.night/mail-failures.jsonl`, `.night/runs/`) — трогать их нельзя.
export function collectSummary(cfg, extraLines = [], {
readLastRun = () => readJson(lastRunPath()),
readSummaryLines = runId => readLines(summaryLinesPathOf(runId)).map(r => r.line).filter(Boolean),
// Замер места делает зовущий и передаёт сюда готовым: сама сводка никуда не ходит.
disk = null,
} = {}) {
const last = readLastRun();
const lines = last?.run_id ? readSummaryLines(last.run_id) : [];
return buildSummary({
run_id: last?.run_id ?? null,
workers: last?.workers ?? [],
lines: [...lines, ...extraLines],
// Кусок 2 умеет мерить место — выдуманного числа здесь по-прежнему нет: не замерили,
// значит `null`, и сводка скажет об этом словами.
disk_free_gb: typeof disk?.free_gb === 'number' ? Number(disk.free_gb.toFixed(1)) : null,
server_rebooted_at: last?.server_rebooted_at ?? null,
});
}
// О-1: самое дорогое во всей затее. Раньше между «собрать сводку» и «отдать отправлялке»
// не было НИКАКОЙ защиты — сбой сборки сводки или транспорта (до самого sendMail) вылетал
// наружу необработанным исключением, `mail-failures.jsonl` не получал строку, и владелец
// утром видел ТИШИНУ — а тишину он не отличит от «всё хорошо». Отправлялка (`mailer.mjs`
// `sendSummary`) уже умеет записывать СВОЮ неудачу — тем же способом (`appendLine` в тот
// же файл) здесь записывается неудача на подступах к ней, не только внутри неё.
// Настоящих писем эта функция никогда не шлёт САМА — она только собирает и передаёт дальше;
// шлёт (или не шлёт) внедрённый `transport`.
export async function runMailCommand({
cfg,
failPath = mailFailuresPath(),
sentPath = summarySentPath(),
sweep = () => sweepNow(cfg),
measureDisk = diskNow,
buildSummaryLines = async extra => collectSummary(cfg, extra, { disk: await measureDisk(cfg) }),
makeTransport = makeTransportDefault,
sendSummary = sendSummaryDefault,
now = () => Date.now(),
} = {}) {
try {
// 🔴 Строка про несторожащие ворота идёт и в письмо тоже, а не только в окно помощника.
// Убрать её из письма значило бы прятать правду именно от того, кому она нужна.
const summary = await buildSummaryLines([...sweep(), stroka_pro_nestorozhashchie()]);
const transport = await makeTransport(cfg);
return await sendSummary({
...summary,
failPath,
sentPath,
transport,
from: cfg.mail.from,
to: cfg.mail.to,
});
} catch (err) {
// Тем же способом, что уже умеет отправлялка: пишем СВОЮ неудачу в тот же файл.
// Не бросаем дальше — обещание «никогда не бросает» держится и на этом шаге тоже.
const error = String(err?.message ?? err);
const at = now();
try {
appendLine(failPath, { at, subject: null, error, stage: 'before-send' });
} catch (writeErr) {
// 🔴 Запись собственной неудачи — тоже запись на диск, и она тоже умеет упасть.
// Последнее, что можно сделать, — крикнуть в свой журнал и всё равно вернуть отказ,
// а не сделать вид, что письмо ушло.
console.error(`Сводка не ушла (${error}), и записать эту неудачу тоже не вышло: ${writeErr?.message ?? writeErr}`);
return { ok: false, error, at, unrecorded: `не записано: ${writeErr?.message ?? writeErr}` };
}
return { ok: false, error, at };
}
}
// 🔴 «Записать замер и спросить остаток» — ОДНО дело и ОДНА точка впрыска, а не две.
// Иначе проверке дороги пришлось бы писать в НАСТОЯЩИЙ журнал замеров общего хозяйства
// (`.night/budget-measures.jsonl`), а он один на всех: своя строка там сдвинула бы чужой
// прирост и погасила бы соседний прогон ни за что. Поймано живьём при первом же прогоне
// этих проверок — они вправду написали в общий журнал.
//
// 🪤 Живёт отдельным именем, а не громоздким умолчанием прямо в подписи: сторож «точка входа
// не зовёт имён, которых у неё нет» разбирает список доводов приметой по тексту, и вложенные
// скобки внутри умолчания сбивают его — он назвал невиновными пятерых. Сторож при этом прав
// по сути: длинное тело в списке доводов не читается человеком.
function zapisatZamerIVzyatOstatok({ measure, runId, workerId, cfg, at }) {
recordMeasure(budgetMeasuresPath(), {
utilization: measure.utilization, status: measure.status, kind: measure.kind,
resets_at: measure.resets_at, at, run_id: runId, phase: 'before-resume',
});
return budgetState(budgetMeasuresPath(), cfg, at, { runId, workerId, workersCount: 1 });
}
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 КНОПКА «ПРОДОЛЖИТЬ» — ЖИВАЯ ДОРОГА ИЗ ДВЕНАДЦАТИ ШАГОВ (решение 10).
//
// ЗАЧЕМ ЭТО ГЛАВНОЕ. Датчик зовущих показывал у ДЕСЯТИ правил куска 6 ноль: построены,
// проверены, зелены — и не позваны ни разу. Правило метода 2: защита, которую никто
// не зовёт, не бывает проверенной; затея заплатила за это шесть раз. Вот зовущий.
//
// 🔴 ДОРОГА ИДЁТ МИМО ДЕВЯТИ ВОРОТ обычного запуска (решение 8). Ни `planRun`, ни `vhodVorot`
// здесь не зовутся НИ РАЗУ, и это не небрежность: проверка 9 говорит дословно «больше кнопка
// не проверяет ничего: живой работник того же прогона и молчание владельца её НЕ
// останавливают — иначе она не сработала бы никогда». Через `planRun` её держали бы ещё семь
// ворот. Сторож на это — счёт зовов подставного `planRun` (П-24).
//
// 🔴 ВЛАДЕЛЕЦ УЗНАЁТ ОБО ВСЁМ ИЗ ОКНА (решение 125). Отдельного письма нет и не строится:
// кнопку он жмёт рукой и в эту минуту смотрит в окно. Поэтому КАЖДАЯ причина отказа
// и каждая из четырёх строк печатается немедленно, а не только ложится в сводку.
export async function komandaProdolzheniya({
cfg = {},
planPath,
resumeRunId,
resumeWorkerId = null,
// Точки впрыска — только для проверок; живой зовущий подаёт лишь первые четыре довода.
readCards = kartochkiRabotnikov,
// 🪤 Отпечаток подаётся точкой впрыска по той же причине, что и место с замером: живая
// печать лежит в общем `.night/seals/`, и проверке пришлось бы либо печатать туда
// по-настоящему (портя чужое хозяйство), либо не проверять дорогу дальше первых ворот.
// Умолчание — НАСТОЯЩИЙ `checkSeal`; подставка подаётся явно и только в проверках.
pechat = () => checkSeal(planSealPath(planPath), planPath),
measureDisk = diskNow,
zamer = zamerSoSchyotom,
ostatok = zapisatZamerIVzyatOstatok,
podnyat = zavestiProdolzhenie,
otkatchik = otkatDoKommita,
gryaz = gryazenLiUgol,
// Кто идёт прямо сейчас — нужно, чтобы развести файлы МЕЖДУ прогонами. Отдельная точка
// впрыска: живой зов ходит в настоящий `.night/runs/`, а проверке нужен свой мир.
idushchie = idushchieProgony,
now = () => Date.now(),
out = console.log,
err = console.error,
} = {}) {
// 🔴 Строка идёт СРАЗУ В ДВА МЕСТА: в окно владельцу (единственная дорога до него)
// и в сводку ПОДНИМАЕМОГО прогона (для разбора командой `cli.mjs summary <номер>`).
// Порознь их писать нельзя: забудешь одно — либо владелец не узнает, либо разбирать нечем.
const skazat = (line, { plohо = false, workerId = null } = {}) => {
(plohо ? err : out)(line);
try {
appendLine(summaryLinesPathOf(resumeRunId), { at: now(), worker_id: workerId, line });
} catch { /* сводка не должна мешать владельцу увидеть строку в окне */ }
};
const otkaz = (line, workerId = null) => { skazat(line, { plohо: true, workerId }); return 1; };
// ━━━ ШАГ 2. КОГО ПОДНИМАЕМ ━━━ (шаг 1 — разбор доводов — сделан зовущим)
const sobrano = readCards(resumeRunId);
if (!sobrano.ok) return otkaz(`Продолжение не начато: ${sobrano.reason}`);
const vybor = vyborProdolzhaemogo({ workers: sobrano.cards, workerId: resumeWorkerId });
if (!vybor.worker) return otkaz(`Продолжение не начато: ${vybor.reason}`);
const worker = vybor.worker;
const wid = worker.worker_id;
// 🔴 ПРИЧИНА 10 (решение 13): УГЛА РАБОТНИКА НЕТ ВОВСЕ. После решения владельца 126 уборка
// углы вставших и погашенных не трогает, так что случай краевой — угол может пропасть только
// рукой владельца или бедой на диске. Но отказ обязан быть ПОНЯТНЫМ: без этой проверки
// дорога упала бы глубже, в чужом правиле, строкой про несуществующий путь.
const ugol = worker.workspace;
if (!ugol || !existsSync(ugol)) {
return otkaz(`Продолжение не начато: угла работника ${wid} нет вовсе (${ugol ?? 'путь не записан'}). `
+ 'Продолжать нечего и негде: вся наработка жила в нём. Уборка углы вставших и погашенных не трогает — '
+ 'значит его унесли рукой или случилась беда с диском.', wid);
}
// ━━━ ШАГИ 3 и 4: ДВА ДЕШЁВЫХ ОТВЕТА ━━━
const seal = pechat();
const disk = await measureDisk(cfg);
// ━━━ ШАГ 5: ПЛАТНЫЙ ЗАМЕР — ТОЛЬКО ЕСЛИ ОБА ДЕШЁВЫХ СОШЛИСЬ ━━━
// 🔴 Замер идёт платной программой. Жечь его на попытку, которая заведомо провалится
// по отпечатку плана или по месту на сервере, нельзя — это деньги владельца (П-41).
// Не сошлись — остаток подаётся ответом «не мерили», и ворота назовут ПЕРВУЮ по порядку
// причину, а не невнятное «запас неизвестен».
const novyjProgon = `resume-${randomUUID().slice(0, 8)}`;
let budget = null;
if (seal?.ok && disk?.ok) {
let z = null;
try {
z = await zamer({ cfg, attemptsPath: probeAttemptsPath(), komanda: 'resume' });
} catch (e) {
return otkaz(`Продолжение не начато: замер остатка не сделан. ${e?.message ?? e}`, wid);
}
if (!z.ok) return otkaz(`Продолжение не начато: ${z.reason}`, wid);
if (!z.measure) {
return otkaz(`Продолжение не начато: остаток запаса посчитать не удалось. ${z.problem ?? 'причина не названа.'}`, wid);
}
// 🔴 Номер прогона — НОВЫЙ и выданный ЗДЕСЬ, до заведения карточки: доля прогона
// у продолжения своя, чистая (проверка 6). Работник один, потому `workersCount: 1`.
budget = ostatok({ measure: z.measure, runId: novyjProgon, workerId: wid, cfg, at: now() });
}
// ━━━ ШАГ 6: ТРИ ВОРОТ. Список ЗАКРЫТ ━━━
// 🔴🔴 ШОВ МЕЖДУ КУСКАМИ 2 И 6 — НАЙДЕН ЖИВЬЁМ 03.08.2026 И ПЕРЕВОДИТСЯ ЗДЕСЬ.
// `budgetState` (кусок 2) отвечает полем `may_start`; `otvetVorot` внутри `resumeChecks`
// (кусок 6) смотрит ТОЛЬКО на `ok` и на голые `true`/`false`, а всё прочее считает
// «не мерили», то есть «нельзя». ЗАМЕРЕНО: на заведомо хорошем запасе (израсходовано 5%)
// `resumeChecks` отвечает `ok: false` со словами «остаток запаса не позволяет продолжать
// (не мерили — остаток неизвестен)». То есть кнопка не сработала бы НИ РАЗУ — и оба куска
// при этом зелены, потому что ни один не видит другого.
// 🟡 Перевожу здесь, у зовущего, а не правлю `limits.mjs`: это чужой предмет, и правило
// «не мерили — значит нельзя» само по себе верное. Настоящее лечение — свести имена
// в договоре; названо вслух в отчёте.
const zapasDlyaVorot = budget ? { ok: budget.may_start, reason: budget.reason } : null;
const vorota = resumeChecks({ seal, budget: zapasDlyaVorot, disk });
if (!vorota.ok) return otkaz(`Продолжение не начато: ${vorota.summary_line}`, wid);
skazat(`${wid}: ${vorota.summary_line}`, { workerId: wid });
if (budget?.dolya_ne_prishla && budget?.slova_vladeltsu) out(budget.slova_vladeltsu);
// ━━━ ШАГ 7: ТОЧКА ПРОДОЛЖЕНИЯ. НЕ ОБРЫВАЕТ НИКОГДА ━━━
// 🔴 «Что засчитано» спрашивается у ЧУЖОГО правила (кусок 4), а не решается здесь:
// засчитано только то, у чего проверки вправду зелены. Оно отдаёт НОМЕРА пунктов.
const dirProgona = runDir(resumeRunId);
const zachteno = closedItemsOfRun(dirProgona);
const posledniyZachtennyy = zachteno.length ? zachteno[zachteno.length - 1] : null;
const kommitZachtennogo = kommitZachtennogoPunkta(dirProgona, posledniyZachtennyy);
const punkty = (readJson(join(dirProgona, 'run.json'))?.items ?? []).map(i => i?.id).filter(Boolean);
const tochka = resumePoint(ugol, { last_closed_item: posledniyZachtennyy, known_items: punkty });
// 🔴 ВТОРАЯ из четырёх строк: запись состояния порвана или неполна. Дорога идёт ДАЛЬШЕ —
// это не отказ, — но молчать нельзя: владелец обязан знать, что продолжаем не с записи.
if (tochka.sboy) skazat(`${wid}: запись состояния не пригодилась — ${tochka.note}`, { workerId: wid });
// ━━━ ШАГ 8: ЧТО НАРАБОТАНО ПОСЛЕ ЗАСЧИТАННОГО ━━━
const rabotaPosle = rabotaPosleZachtennogo({ worktreeRoot: ugol, commit_sha: kommitZachtennogo });
// ━━━ ШАГ 9: ПРИГОВОР О ПРОДОЛЖЕНИИ ━━━
const prigovor = resumePlan({
status: worker.status,
last_closed_item: posledniyZachtennyy,
work_after: rabotaPosle,
paused_at_item: worker.paused_at_item ?? null,
known_items: punkty,
});
if (!prigovor.auto_resume) {
// 🔴 ЧЕТВЁРТАЯ из четырёх строк: «продолжать не с чего» — с описью отложенного,
// чтобы владелец решал сам, а не гадал, куда делась работа.
return otkaz(`Продолжение не начато: ${prigovor.reason}. Отложено: ${(prigovor.kept_aside ?? []).join(', ') || '(ничего)'}`, wid);
}
// ━━━ 🔴🔴 ШАГ 10а: ЗАНЯТЬ ФАЙЛЫ. ДО ВСЯКОГО ИЗМЕНЕНИЯ И ТОЧНО ДО ОТКАТА ━━━
//
// САМОЕ ОПАСНОЕ МЕСТО ЗАТЕИ, найдено 03.08.2026 четвёртым вопросом («а что если этого же
// самого будет ДВА одновременно?»). Продолжение не занимало файлов ВОВСЕ — а оно не просто
// пишет, оно ОТКАТЫВАЕТ: `read-tree --reset -u` переписывает рабочее дерево целиком.
// Идёт в это время обычный прогон, чей работник законно занял тот же файл (замок свободен —
// продолжение его не брало), и откат сносит ЧУЖУЮ правку в общей ветке и общей базе.
// Владелец обнаружит это не утром, а когда чего-то недосчитается.
//
// 🔴 ЗАНИМАЕМ НЕ ТОЛЬКО ФАЙЛЫ ПУНКТА. Откат трогает всё, что наработано после засчитанного
// пункта, а это шире пункта: работник мог задеть соседние файлы. Список берётся у того же
// прибора, что уже спросили на шаге 8 (`rabotaPosleZachtennogo`), — второго зова нет.
const moiFayly = [...new Set([
...(worker.item?.files ?? []),
...(rabotaPosle?.files ?? []),
])].filter(Boolean);
// 🔴🔴 ДВА РАЗНЫХ ЗАМКА, И ОДНОГО МАЛО — это и есть суть находки.
// Занятия живут в каталоге ПРОГОНА (`.night/runs/<прогон>/file-claims.json`). Продолжение
// заводит НОВЫЙ прогон, то есть новый ПУСТОЙ список занятий: занять в нём что угодно можно
// всегда, и от соседнего прогона это не защищает НИ ОТ ЧЕГО. Между прогонами файлы разводит
// другое правило — `filesDisjoint` (кусок 2), и обычный запуск зовёт его девятыми воротами.
// Продолжение идёт мимо девяти ворот (решение 8) — значит зовёт его САМО.
// 🪤 Своего способа занимать файлы не заводим: оба прибора уже есть и оба чужие.
const schyot = idushchie();
if (!schyot.ok) return otkaz(`Продолжение не начато: ${schyot.reason}`, wid);
const chuzhieFayly = schyot.running
.filter(r => r?.run_id !== resumeRunId) // поднимаемый прогон себе не соперник: он кончился
.map(r => (r?.items ?? []).flatMap(i => i?.files ?? []));
const razvedeny = filesDisjoint(moiFayly, chuzhieFayly);
if (!razvedeny.ok) {
return otkaz(`Продолжение не начато: ${razvedeny.reason} Откат переписал бы рабочее дерево целиком и снёс бы чужую правку молча.`, wid);
}
// Занятие внутри своего прогона: им держится освобождение (его делает надзиратель
// при гашении) и на него смотрит замок «двое не правят один файл» внутри задачи.
mkdirSync(runDir(novyjProgon), { recursive: true });
const zanyatie = claimFiles(runDir(novyjProgon), wid, moiFayly);
if (!zanyatie.ok) {
return otkaz(`Продолжение не начато: занять файлы за работником ${wid} не вышло — ${zanyatie.reason}`, wid);
}
// 🔴 Замок, переживший неудачную попытку, держал бы файлы за работником, которого нет.
// Поэтому КАЖДЫЙ отказ ниже освобождает занятое.
const otkazOsvobodiv = (line) => { releaseClaims(runDir(novyjProgon), wid); return otkaz(line, wid); };
// ━━━ ШАГ 10: ГРЯЗЕН ЛИ УГОЛ ━━━
// 🪤 Тот же прибор спрашивает у себя внутри и общий откатчик — это ОДИН прибор, спрошенный
// дважды, а не два прибора. Мой зов нужен, чтобы отказать ДО отката понятной строкой.
// 🪤 ПРО ГРЯЗЬ СПРАШИВАЮТ ТРИЖДЫ, И ТРЁХ НЕ ИЗБЕЖАТЬ — называю вслух, как обещал.
// Спрашивают: этот шаг, `sledOtkata` (кусок 6) и `otkatDoKommita` (кусок 5). Свести
// к одному нельзя, и вот почему: у обоих нижних есть ВТОРОЙ зовущий помимо меня
// (`sledOtkata` зовут и другие дороги куска 6, откатчик зовёт ещё и блок 2в куска 5),
// и правило «грязный угол отката не получает» обязано держаться у каждого зовущего,
// а не только у меня. Убери я свой вопрос — потеряю понятную строку владельцу ДО отката
// и оставлю без присмотра случай, когда откат не нужен вовсе (коммита засчитанного нет).
// 🔴 Щель во времени между тремя вопросами остаётся: работник может дописать файл между
// ними. Она СУЖЕНА тем, что файлы теперь заняты выше — сосед в них уже не войдёт;
// остаётся только сам работник этого угла, а он погашен. Это названо, а не закрыто.
const gryazno = gryaz(ugol);
if (gryazno === null) return otkazOsvobodiv(`Продолжение не начато: спросить про несохранённые правки в углу ${wid} не удалось — вслепую не откатываем.`);
if (gryazno === true) return otkazOsvobodiv(`Продолжение не начато: в углу работника ${wid} лежат несохранённые правки. Откат потерял бы их. Посмотрите угол глазами: ${ugol}`);
// ━━━ ШАГ 11: ОТКАТ ДО ЗАСЧИТАННОГО ━━━
if (kommitZachtennogo) {
const sled = sledOtkata({ worktreeRoot: ugol, commit_sha: kommitZachtennogo, otkat: otkatchik });
if (!sled.ok) return otkazOsvobodiv(`Продолжение не начато: откат не удался — ${sled.reason}`);
// 🔴 ТРЕТЬЯ из четырёх строк: откат прошёл — называем ветку и коммит, чтобы владелец
// мог достать откаченное.
skazat(`${wid}: ${sled.reason}${sled.otlozheno?.branch ? ` (ветка ${sled.otlozheno.branch})` : ''}`, { workerId: wid });
}
// ━━━ ШАГ 12: ПОДЪЁМ ━━━
let podnyato;
try {
podnyato = podnyat({
runId: novyjProgon,
worker,
planPath,
taskId: worker.task_id ?? null,
workerModel: cfg.worker_model,
});
} catch (e) {
return otkazOsvobodiv(`Продолжение не начато: ${e?.message ?? e}`);
}
// 🔴 Работник не поднялся — замок обязан упасть вместе с ним. Надзирателя, который освободил
// бы файлы при гашении, в этом случае нет вовсе: его как раз и не завели.
if (!podnyato?.ok) return otkazOsvobodiv(`Продолжение не начато: ${podnyato?.reason ?? 'подъём не удался, причина не названа'}`);
skazat(`Работник ${wid} продолжен прогоном ${podnyato.run_id}: начинает с пункта ${prigovor.start_from ?? '(пункт не назван)'}, надзиратель ${podnyato.supervisor_pid}.`, { workerId: wid });
out('Окно можно закрывать: надзиратель переживёт конец сессии.');
return 0;
}
// 🆕 Разбор команды вынесен в `main(argv, deps)` — точка входа внизу файла зовёт его,
// когда файл запущен как программа, а не подключён как модуль (проверки подключают
// именно так). Возвращает КОД, а не зовёт `process.exit` сам — иначе подключить
// функцию в проверку означало бы уронить процесс проверки вместе с ней.
export async function main(argv = process.argv, deps = {}) {
const {
readConfig = () => readJson(configPath()),
runMail = runMailCommand,
summaryOf = collectSummary,
sweep = sweepNow,
measureDisk = diskNow,
} = deps;
const cmd = argv[2];
const cfg = readConfig();
if (!cfg) {
console.error(`Настроек нет: ${configPath()}. Скопируйте night.config.example.json и заполните.`);
return 2;
}
if (cmd === 'summary') {
// Та же сводка, что уходит письмом, но напечатанная в окно: помощнику — посмотреть глазами.
// 🔴 Строка про несторожащие ворота — в оба зова, и в окно, и в письмо.
const summary = summaryOf(cfg, [...sweep(cfg), stroka_pro_nestorozhashchie()], {
disk: await measureDisk(cfg),
});
console.log(summary.text);
return 0;
}
if (cmd === 'mail') {
const r = await runMail({ cfg });
console.log(r.ok ? 'Сводка отправлена.' : `Сводка НЕ отправлена: ${r.error}`);
return r.ok ? 0 : 1;
}
if (cmd === 'budget') {
// 💳 Замер стоит копейки, но не ноль: прибор системы отдаёт остаток только в потоке
// живого запуска. Своего счёта в долларах у затеи нет вовсе — мы спрашиваем, а не считаем.
//
// 🔴 Обёртка обязательна. Нет настройки `probe_model` — ворота падают понятной
// строкой С ИМЕНЕМ НАСТРОЙКИ (задача 2), и эту строку надо ПОКАЗАТЬ владельцу. Без обёртки
// он получил бы обвал программы со следом стека — то есть строку не для человека.
let z = null;
try {
z = await zamerSoSchyotom({ cfg, attemptsPath: probeAttemptsPath(), komanda: 'budget' });
} catch (err) {
console.error(`Замер не сделан: ${err?.message ?? err}`);
console.error('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
return 1;
}
// Замеров за смену уже сделано столько, сколько разрешил владелец. Печатаем ЕГО слова
// и всё равно показываем, что известно из журнала: остаток по прошлым замерам он видеть
// вправе, деньги на это не тратятся.
if (!z.ok) {
console.error(z.reason);
console.log(JSON.stringify(budgetState(budgetMeasuresPath(), cfg, Date.now()), null, 2));
return 1;
}
const m = z.measure;
if (m) {
recordMeasure(budgetMeasuresPath(), {
utilization: m.utilization, status: m.status, kind: m.kind,
resets_at: m.resets_at, at: Date.now(), phase: 'probe',
});
if (m.utilization === null) {
// 🔴 Молчать об этом нельзя: владелец, глядя на `"utilization": null` в ответе, решил бы,
// что прибор сломан. Он не сломан — доля приходит только вместе с тревогой.
console.log('Доли израсходованного система не прислала — она кладёт её в событие только вместе с тревогой («allowed_warning»). Светофор системы при этом получен, и решение принимается по нему.');
}
} else {
// 🔴 РЕШЕНИЕ Р2: причина называется ТА САМАЯ, а не одна на все случаи. Прежде здесь
// стояла строка «события rate_limit_event НЕДЕЛЬНОГО вида от системы не пришло» — и она
// была враньём ровно в том случае, который случался живьём каждый раз: событие приходило,
// не совпадал ВИД предела.
console.error(`Замер не удался: ${z.problem ?? 'причина не названа.'}`);
console.error('Ноль сюда не подставляется — это был бы выдуманный ответ.');
}
console.log(JSON.stringify(budgetState(budgetMeasuresPath(), cfg, Date.now()), null, 2));
return 0;
}
if (cmd === 'seal') {
// Печать плана ставит ПОМОЩНИК по слову владельца — ни работник, ни надзиратель.
// Печать значит «этот текст владелец видел и разрешил»; поставленная автоматом,
// она значила бы только «файл существует».
const d = razborDovodov(argv);
if (d.error) {
console.error(d.error);
return 2;
}
if (!existsSync(d.planPath)) {
console.error(`Плана нет: ${d.planPath}`);
return 2;
}
// 🔴 Проверка 43, первая половина: пока идёт прогон, план не перепечатывают. Правило
// (`assertPlanEditable`) было написано задачей 3 — а звать его было НЕКОМУ. Печать нового
// текста посреди ночи означала бы, что работники доделывают одно, а разрешено уже другое.
// 🔴 Порванную карточку прогона считать за «никого нет» нельзя: печать нового текста
// посреди чужой ночи — ровно то, от чего эти ворота и стоят. Отказ идёт СТРОКОЙ.
const schyot = idushchieProgony();
if (!schyot.ok) {
console.error(`План НЕ опечатан. ${schyot.reason}`);
return 1;
}
const going = schyot.running;
const editable = assertPlanEditable(going.length ? { status: 'running', run_id: going[0].run_id } : null);
if (!editable.ok) {
console.error(editable.reason);
return 1;
}
const seal = sealPlan(planSealPath(d.planPath), d.planPath, { approved_by: 'owner', at: Date.now() });
console.log(`План опечатан: ${d.planPath}`);
console.log(`Отпечаток: ${seal.fingerprint}`);
console.log('Поменяете в нём хоть строчку — запуска по нему не будет, пока не опечатаете заново.');
return 0;
}
if (cmd === 'approve-spec') {
// Отметка «замысел проверен» — это ДВА рецензента И слово владельца. Ставит её помощник.
const d = razborDovodov(argv);
if (d.error) {
console.error(d.error);
return 2;
}
if (!existsSync(d.specPath)) {
console.error(`Бумаги замысла нет: ${d.specPath}`);
console.error('Надо так: node tools/night/cli.mjs approve-spec <путь-к-бумаге> --reviewer <имя> --reviewer <имя>');
return 2;
}
// 🪤 Путь в отметку кладём в том же виде, в каком его потом спросит команда `run`:
// приводит его к одному виду сам разбор доводов. Отметка `docs\...\замысел.md` против
// запроса `docs/.../замысел.md` — это два разных текста, и ворота замысла отказывали бы
// всегда, а причина была бы непонятной.
const reviewers = d.reviewers ?? [];
const mark = { spec_path: d.specPath, reviewed_by: reviewers, owner_said_yes_at: Date.now() };
// 🔴 Вторым доводом — та же бумага: `specApproved` без неё отказывает всегда
// («не сказано, по какой бумаге запускают»), и отметку нельзя было бы поставить вовсе.
const verdict = specApproved(mark, d.specPath);
if (!verdict.ok) {
console.error(verdict.reason);
return 1;
}
writeJsonAtomic(specApprovalPath(), mark);
console.log(`Замысел ${d.specPath} отмечен проверенным: рецензенты ${reviewers.join(', ')} и слово владельца.`);
return 0;
}
if (cmd === 'run') {
// 🔴 Разбор доводов — один на все три команды, и он проверен двенадцатью проверками.
// Разбирай `run` по-своему рядом — проверки сторожили бы не тот код, что работает.
const razbor = razborDovodov(argv);
if (razbor.error) {
console.error(razbor.error);
return 2;
}
const { planPath, priyomkaPath, specPath } = razbor;
if (!existsSync(planPath)) {
console.error(`Плана нет: ${planPath}`);
return 2;
}
// 🔴🔴 РАЗВИЛКА КНОПКИ «ПРОДОЛЖИТЬ» — В САМОМ НАЧАЛЕ КОМАНДЫ (решение 8, столкновение С-3).
// Дальше идёт СВОЯ дорога из двенадцати шагов; чужой порядок обычного запуска
// («бумаги → разбор плана → замер → девять ворот → строки отказа») не тронут ни строкой.
// 🔴 Ключи `--priyomka` и `--spec` кнопке не нужны — бумаг она не проверяет; но разбор
// доводов у команды `run` один на оба пути, и он их всё равно требует. Это разница
// названа вслух: продолжение запускают тем же `run`, просто с ключом `--resume`.
if (razbor.resumeRunId) {
return await komandaProdolzheniya({
cfg,
planPath,
resumeRunId: razbor.resumeRunId,
resumeWorkerId: razbor.resumeWorkerId,
});
}
if (!existsSync(priyomkaPath)) {
console.error(`Приёмочного листа нет: ${priyomkaPath}. Без него закрывать пункт нечем.`);
return 2;
}
// Бумагу замысла кусок 1 не читает, но её существование проверяет здесь же: названный
// и несуществующий путь — это опечатка, которая всплыла бы только в куске 2, ночью.
if (!existsSync(specPath)) {
console.error(`Бумаги замысла нет: ${specPath}. Ворота на неё заводит кусок 2, но путь должен быть настоящим уже сейчас.`);
return 2;
}
// 🔴 РАЗБОР ПЛАНА — это ВХОД ворот, а не ворота. Без разобранного плана воротам
// питаться нечем вовсе, поэтому он идёт первым, до всего.
// 🔴 Из этого зова берутся ТОЛЬКО пункты: план читается и разбирается ровно ОДИН раз
// за запуск. Приговор ворот здесь НЕ смотрится и смотреться не может — запаса
// и места на диске в эту минуту ещё не мерили, бумаги и замысел сюда не поданы.
// Судят ворота ниже, одним полным зовом, и туда идут уже разобранные пункты.
// Второй раз файл плана не читается: между двумя чтениями он мог измениться,
// и ворота судили бы не тот план, который пошёл в работу.
// 🪤 Кусок 1 лепил здесь ОДИН пункт «весь-план» из первой строки файла, а файлы к нему
// называл владелец рукой ключом `--files`. Больше одного работника за раз так завести
// было нельзя. Этот путь заменён разбором целиком.
let punkty;
try {
punkty = vhodVorot({ planPath }).items;
} catch (err) {
// 🔴 Разбор плана падает вслух и только на настоящей беде плана (незакрытая
// рамка-образец). Показываем СЛОВА, а не след стека: их писали владельцу.
console.error('Прогон НЕ запущен: план прочитать не удалось.');
console.error(` · ${err?.message ?? err}`);
return 1;
}
// 🟡 СВЕРХ ПЛАНА, называю вслух. Ключ `--files` кусок 4 обессмыслил: файлы теперь
// берутся из плана. Промолчи мы — владелец набрал бы `--files a.php`, ничего
// бы не произошло, и он искал бы беду в своём плане, а не в лишнем ключе.
if ((razbor.files ?? []).length) {
console.log('Ключ --files больше не нужен и не учитывается: файлы каждого пункта теперь берутся из самого плана (строка «<!-- night: ... files=... -->» под заголовком пункта).');
}
// 🔴 ОТКАЗ ПО ПЛАНУ (проверки 39, 41 и прочие). Печатается ДО замера остатка: замер
// идёт платной программой `claude` из подписки, и жечь его на заведомо негодный
// план нельзя — каждая ошибочная попытка стоила бы владельцу денег.
// Список девяти ворот при этом не тронут: новых ворот туда не вставляется.
const planOk = validatePlan(punkty);
if (!planOk.ok) {
console.error('Прогон НЕ запущен: план проверку не прошёл.');
console.error(` · ${planOk.reason}`);
console.error(` · пунктов в плане: ${planOk.items_count}, из них помечено длинными: ${planOk.long_count}.`);
console.error(` · план: ${planPath}`);
console.error('Это правится в самом плане — ночь по такому плану не запускается. Ни один работник не заведён, деньги не потрачены.');
return 1;
}
// 🔴 ВОРОТА ОТЛОЖЕННОЙ ЗАДАЧИ (проверки 28, 29, 30). След кладёт надзиратель последнего
// погасшего работника (задача 5) либо рука владельца. Отложенная задача сама
// НЕ подхватывается ни следующим прогоном, ни через неделю.
const zadacha = razbor.taskId ?? null;
let sled = null;
if (zadacha) {
try {
sled = readDeferred(deferredDir(), zadacha);
} catch (err) {
// 🔴 След есть, но порван. Молчать нельзя: «следа нет» пустило бы отложенную
// задачу в работу само, без слова владельца.
console.error(`Прогон НЕ запущен: запись об отложенной задаче ${zadacha} испорчена и не читается.`);
console.error(` · ${err?.message ?? err}`);
return 1;
}
}
if (sled) {
const vorota = deferredTaskGate({
reason: sled.reason,
owner_said_continue: razbor.ownerSaidContinue,
// 🔴 Перепроверка плана — из ЭТОГО запуска, а не «когда-то была» (проверка 30):
// пока задача лежала, продукт изменился, и план мог разойтись с ним.
plan_revalidated: planOk.ok,
defect_list: razbor.defectList,
// 🔴🔴 ТОТ ЖЕ ПЛАН (проверка 29). Слева — путь из следа, справа — план этого запуска.
// Не подай оба — правило внутри ворот стояло бы и молчало, а задачу можно было бы
// «доделать» по совсем другому плану.
plan_path: sled.plan_path,
plan_path_now: razbor.planPath,
});
if (!vorota.may_run) {
console.error(`Прогон НЕ запущен: задача ${zadacha} отложена (${sled.reason === 'rejected' ? 'не прошла приёмку владельца' : 'не была доделана в прошлый раз'}).`);
console.error(` · ${vorota.reason}`);
console.error(` · осталось незакрытым: ${(sled.left_items ?? []).join(', ') || '(не записано)'}`);
console.error('Продолжить: тот же запуск плюс ключ --continue. Если задача вернулась с приёмки — ещё и --defects <файл со списком брака>.');
console.error('Список брака пока подаётся файлом с руки — по строке на замечание. Ни один работник не заведён, деньги не потрачены.');
return 1;
}
console.log(`Задача ${zadacha} возвращается в работу по слову владельца. План перепроверен заново. Делать её будет НОВЫЙ работник.`);
}
// 💳 Один крошечный замер остатка ПЕРЕД воротами. Без него воротам «запас» нечем
// питаться, и они пропустили бы что угодно. Замер идёт программой `claude` из подписки —
// ANTHROPIC_API_KEY не выставляется никогда, оплата ушла бы мимо подписки.
//
// 🔴 РЕШЕНИЕ ВЛАДЕЛЬЦА, дословно: замер делается СВЕЖИЙ перед КАЖДЫМ запуском, и срока
// годности у него нет вовсе. Не вышел замер — прогона нет, и владельцу сказано почему.
// Никакой «ещё не протухшей» строки из журнала: между тем замером и этой минутой могла
// пройти дневная работа и съесть весь запас, а решать о деньгах вслепую нельзя (проверка 17).
//
// 🔴 Обёртка обязательна: нет настройки `probe_model` — ворота падают понятной
// строкой с именем настройки, и её надо ПОКАЗАТЬ, а не уронить программу со следом стека.
//
// 🪤 Замер тут делается ДО ворот прогона, значит КАЖДЫЙ отказ ворот тоже стоит замера.
// Десяток попыток «поправил — перезапустил» — это десяток замеров, и ровно от такого
// расхода счёт за смену и защищает.
let z = null;
try {
z = await zamerSoSchyotom({ cfg, attemptsPath: probeAttemptsPath(), komanda: 'run' });
} catch (err) {
console.error(`Прогон НЕ запущен: замер остатка не сделан. ${err?.message ?? err}`);
console.error('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
return 1;
}
if (!z.ok) {
console.error('Прогон НЕ запущен: замеров остатка за смену больше не осталось.');
console.error(z.reason);
return 1;
}
const m = z.measure;
if (!m) {
// 🔴🔴 РЕШЕНИЕ Р2. Здесь стояла строка «события "rate_limit_event" от системы не пришло»,
// и она была ВРАНЬЁМ: событие приходило исправно каждый раз, не совпадал ВИД предела.
// Владелец десять дней читал про поломку прибора, а поправить надо было одну строку
// в своих настройках. Теперь причина называется та самая, какая есть.
console.error('Прогон НЕ запущен: остаток запаса посчитать не удалось.');
console.error(` · ${z.problem ?? 'Причина не названа.'}`);
console.error('Решать о деньгах вслепую нельзя, поэтому прогон не начинается вовсе. Прошлые замеры в журнале запуск не разрешают: они могли устареть за один день.');
console.error('Что делать: повторить `npm run night:budget` и посмотреть, отвечает ли прибор.');
return 1;
}
recordMeasure(budgetMeasuresPath(), {
utilization: m.utilization, status: m.status, kind: m.kind,
resets_at: m.resets_at, at: Date.now(), phase: 'before-run',
});
const budget = budgetState(budgetMeasuresPath(), cfg, Date.now());
const disk = await measureDisk(cfg);
const seal = checkSeal(planSealPath(planPath), planPath);
// 🔴 Тот же счёт и та же одна дверь, что у `seal`. Порванная карточка — это «неизвестно,
// идёт прогон или нет»: с таким ответом ворота потолков и ворота пересечения по файлам
// пропустили бы что угодно, и оба отказа исчезли бы молча. Значит прогона нет, и сказано
// почему.
const schyotIdushchih = idushchieProgony();
if (!schyotIdushchih.ok) {
console.error(`Прогон НЕ запущен. ${schyotIdushchih.reason}`);
return 1;
}
const running = schyotIdushchih.running;
const papers = papersCommitted(REPO_ROOT, paperPathsFor({ planPath, priyomkaPath, specPath }));
// 🔴 Вторым доводом — путь бумаги замысла, по которой запускают. Без него отметка
// «замысел проверен» открывала бы запуск по ЛЮБОЙ другой бумаге, в том числе никем
// не читанной (задача 4 сверяет `spec_path` в отметке с этим путём).
const spec = specApproved(readJson(specApprovalPath()), specPath);
// 🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух: план предлагал разобрать доводы здесь ещё раз
// (`const dovody = razborDovodov(argv)`), описывая `cli.mjs` таким, каким тот был
// ДО куска 1. Разбор уже сделан выше и лежит в `razbor` — второй разбор рядом означал бы
// два места, знающих доводы по-своему, и проверки сторожили бы не то, что работает.
const plan = planRun(vhodVorot({
// 🔴 Пункты уже разобраны выше и сюда подаются готовыми. `planPath` здесь НЕ подаётся
// намеренно: второе чтение файла плана — это второй план в одном запуске.
cfg, items: punkty, budget, disk, planSeal: seal, papers, spec, running,
// Номер задачи доходит и до ворот: продолжение начатого третьей задачей не считается,
// и своими же файлами задача сама с собой не пересекается.
taskId: razbor.taskId ?? null,
}));
if (!plan.ok) {
for (const s of strokiOtkaza(plan, budget)) console.error(s);
return 1;
}
// 🔴🔴 РЕШЕНИЕ Р5, ВИДИМАЯ ЧАСТЬ. Ворота запаса пропустили ПО СВЕТОФОРУ СИСТЕМЫ, а не
// по числу, — то есть прежняя рамка владельца («две седьмых неприкосновенны») сейчас
// не меряется ничем. Это СУЖЕНИЕ ЕГО ЗАЩИТЫ, и оно обязано быть видно ему прямым текстом,
// а не жить примечанием к коду. Печатается ДО слов «прогон заведён»: строка, спрятанная
// под списком работников, до глаз не доходит.
if (budget?.dolya_ne_prishla && budget?.slova_vladeltsu) {
console.log(budget.slova_vladeltsu);
}
// 🔴 ТОЧКА ОТСЧЁТА ПРОГОНА. Крошечный замер выше лёг в журнал БЕЗ номера прогона —
// номера в ту минуту ещё не существовало, его выдал `planRun`. А прирост прогона
// считается от первой строки ЭТОГО прогона (задача 2, `utilizationDelta`).
// Не дописать её — точкой отсчёта станет первый тик первого работника, всё съеденное
// до него в прирост не войдёт, и предел одной седьмой сработает позже обещанного.
// Число берём то же самое, что и в замере перед воротами: второй раз прибор не спрашиваем.
recordMeasure(budgetMeasuresPath(), {
utilization: m.utilization,
status: m.status,
kind: m.kind,
resets_at: m.resets_at,
at: Date.now(),
run_id: plan.run_id,
phase: 'start',
});
const started = startAll({
runId: plan.run_id,
workers: plan.workers,
planPath,
baseBranch: plan.base_branch,
// Голова работника — из настроек владельца. Здесь она только передаётся дальше.
workerModel: cfg.worker_model,
// Продолжаем названную владельцем задачу; не назвал — прогон сам себе задача.
taskId: razbor.taskId ?? plan.run_id,
// 🔴 Команда проверок — своя у каждого пункта, из его же `tests`. Общий набор здесь
// превратил бы «зелень по этому пункту» в «зелень вообще».
testCommandFor: w => komandaProverokPunkta(w.item),
// 🔴 В карточку прогона идут ВСЕ пункты плана, а не только пункты семерых заведённых:
// это очередь, из неё надзиратель берёт работнику следующий пункт.
items: punkty,
});
// 🔴🔴 «ЗАВЕДЁН» — ЭТО ПРО ЖИВЫХ РАБОТНИКОВ, А НЕ ПРО ЗАПИСЬ НА ДИСКЕ. Найдено воротами
// живьём: при нуле заведённых владельцу печаталось «Прогон заведён. Работников: 0» и следом
// «надзиратели переживут конец сессии» — при нуле надзирателей. Это прямое враньё тому,
// кто после этого закроет окно и уйдёт спать. Сам прогон при нуле работников закрывается
// в `startAll` (иначе он навсегда держал бы место в потолке задач) — здесь только правда
// словами и код возврата, отличный от успеха: ночь не началась.
if (!started.length) {
console.error(`Прогон ${plan.run_id} НЕ начался: не завёлся НИ ОДИН работник.`);
console.error(`Почему — написано в карточках работников (поле finish_reason): ${runsDir()}`);
console.error('Чаще всего это два пункта плана на один и тот же файл либо несобравшийся composer/npm в углу.');
console.error('Прогон закрыт сразу — места в потолке задач он не занимает, и следующая попытка ничем не заперта.');
return 1;
}
// След снят: задача пошла в работу. Не сними — следующий запуск отказал бы по нему снова,
// и владельцу пришлось бы каждый раз говорить «продолжай» заново. Раньше снимать нельзя:
// до этой строки любой отказ обязан застать след на месте, иначе вторая попытка прошла бы
// мимо ворот вовсе.
if (sled && zadacha) clearDeferred(deferredDir(), zadacha);
console.log(`Прогон ${plan.run_id} заведён. Работников: ${started.length}.`);
for (const s of started) {
console.log(` · ${s.worker_id} — угол ${s.workspace}, база ${s.db_name}, надзиратель ${s.supervisor_pid}`);
}
console.log('Окно можно закрывать: надзиратели переживут конец сессии.');
console.log(`Замысел ${specPath} проверен, бумаги сохранены, отпечаток плана сошёлся.`);
// 🔴 Честность про пустые ворота. «Все ворота пройдены» при четырёх ненаписанных правилах —
// это самообман; владелец обязан знать, что его сторожат пятеро, а не девятеро.
console.log(stroka_pro_nestorozhashchie());
return 0;
}
if (cmd === 'smoke-longrun') {
// Доказательство проверки 1: движение должно быть и через три часа после конца сессии
// помощника. Проверками кода это не доказывается — только живым прогоном.
const minutes = Number(flag(argv, '--minutes') ?? 200);
// 🔴 Имя прогона можно назвать своё (`--run`). Без этого вторая попытка невозможна:
// ветка и угол от первой уже заняты, а каталог прошлого прогона сносить нельзя —
// из него живёт сводка. Короткая проба перед ночью зовётся как раз со своим именем.
const runId = flag(argv, '--run') ?? 'smoke';
const r = await smokeLongRun({ cfg, minutes, runId });
console.log(`Пробный долгий прогон заведён: прогон ${runId}, работник ${r.worker_id}, надзиратель ${r.supervisor_pid}.`);
console.log(`Признаки жизни: ${r.heartbeat_path}`);
console.log(`След работника: ${r.notes_path}`);
console.log('Закройте окно помощника целиком и вернитесь через три часа.');
console.log(`Бумагу по итогам класть сюда: ${join(priyomkaDir(), '1')}`);
return 0;
}
console.error(`Неизвестная команда: ${cmd ?? '(команду не назвали)'}. Написаны семь: run, mail, summary, smoke-longrun, budget, seal, approve-spec.`);
return 2;
}
// Точка входа звонит только когда файл запущен САМ (`node tools/night/cli.mjs …»),
// а не подключён проверкой через import — иначе проверка сама себе позвала бы
// `process.exit` при подключении файла, и ни один тест этого файла не дожил бы до конца.
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
main().then(code => process.exit(code));
}