Files
portal/tools/night/cli.mjs
T

662 lines
50 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 } 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,
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 } from './cli-parts.mjs';
// 🔴 ВОРОТА ПЛАНА И ПРАВИЛО «ЧЕМ МЕРИТЬ ПУНКТ» — оба из `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}`,
};
}
}
// 🔴 Принудительная уборка старых углов случается ЗДЕСЬ — вместе с утренней сводкой,
// потому что это единственное, что просыпается каждый день само. Про каждый убранный угол
// в сводку идёт отдельная строка: тихо уносить чужую работу нельзя.
//
// 🆕 Долг перед куском 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 };
}
}
// 🆕 Разбор команды вынесен в `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;
}
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));
}