1009 lines
79 KiB
JavaScript
1009 lines
79 KiB
JavaScript
#!/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));
|
||
}
|