6689e110d8
Задачи 4 и 8 куска 5. Вместе с прежним коммитом (задачи 2 и 3) мост работает по кругу: работник спросил → вопрос вышел в общий список → владелец ответил командой → ответ лёг работнику в ящик, туда, куда ему велено смотреть его же поручением. ЗАДАЧА 4 — ОТВЕТ ДОЕЗЖАЕТ (`deliverAnswers`): · чужой ответ не берётся НИ ПРИ КАКОМ СЛУЧАЕ — «да» владельца про деньги, ушедшее не тому работнику, самое дорогое, что тут может случиться; · уже доставленный второй раз не кладётся, но сверяется САМ ОТВЕТ, а не номер: владелец может ответить второй раз, и это новость. Иначе работник бросал бы работу каждый круг; · прежние ответы остаются на месте — работник читает ящик целиком; · 🪤 `repliesPath` из договора о стыках НЕ принят: правило хозяйства прямее договора — «параметр принимается только вместе с зовом», журнал ведёт задача 8. Названо вслух. ЗАДАЧА 8 — ВЛАДЕЛЬЦУ ЕСТЬ ЧЕМ ОТВЕТИТЬ (`cli.mjs answer`): · без доводов ПОКАЗЫВАЕТ ждущие вопросы с номерами, работником, пунктом и временем. 🔑 Владелец не программист: без этого списка ему пришлось бы искать номер в файле руками, а номер обязателен — иначе ответ уедет не туда; · ответ правит строку НА МЕСТЕ и под тем же замком, что подъём вопросов: сквозной номер считается по числу строк, лишняя строка увела бы следующий вопрос на чужой номер; · повторный ответ на тот же вопрос не запрещён, но назван вслух словом «ПЕРЕПИСАН»; · текст берётся всем хвостом доводов — человек пишет «нет, не сноси», оболочка разложит это на три слова. 🔴 ЧТО ЧЕСТНО ОСТАЛОСЬ НЕДОДЕЛАННЫМ В ЗАДАЧЕ 8: команды `ack` нет, и журнал `owner-replies.jsonl` этой командой не ведётся — `recordOwnerAck` по-прежнему «зовут только проверки». Записано прямо в шапке `mailer.mjs`, чтобы следующая смена не сочла сделанным. 🟢 ТРИ ЧУЖИХ СТОРОЖА ПОКРАСНЕЛИ ПО ДЕЛУ И БЫЛИ ПРАВЫ: 1. `primechanie-ne-rashoditsya-s-pravdoy` дважды: шапки `questions.mjs` («ЭТОТ ФАЙЛ НЕ ПОДКЛЮЧЁН НИ К ЧЕМУ») и `mailer.mjs` («ПИСАТЬ ИХ НЕЧЕМ») два месяца говорили правду, а сегодня стали ложью. Обе переписаны — с честным перечнем, что подключено, а что нет. ⇒ Ровно правило 263: у изменившегося утверждения спроси, ГДЕ ОНО ЕЩЁ ЗАПИСАНО. 2. `cli.test.mjs` пересчитал команды: было семь, стало восемь. Обновлён состав и названо, что `answer` НЕ платная — работника не заводит, денег не тратит. 🪤 Красное по занятой машине встретилось снова: `paths.test.mjs` упал в полном прогоне и дал 118 зелёных при одиночном перемере. Сначала перемерь, потом чини. Прогон: 3009 проверок в 114 файлах, код 0 — ровно предсказанное (было 2998, +11 моих). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2076 lines
186 KiB
JavaScript
2076 lines
186 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, dirname } from 'node:path';
|
||
import { existsSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
|
||
import { createHash } from 'node:crypto';
|
||
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,
|
||
NIGHT_ROOT, samePath, klyuchSravneniyaPuti, questionsPath,
|
||
} from './paths.mjs';
|
||
// 🔴 Кусок 5, задача 8 — вход владельца. Правило правки строки живёт в `questions.mjs`,
|
||
// здесь только зов и разговор с человеком: своей второй копии счёта номеров тут нет.
|
||
import { answerQuestion } from './questions.mjs';
|
||
import { podZamkom } from './file-claims.mjs';
|
||
import { buildSummary } from './summary.mjs';
|
||
// 🔴 Утренняя сводка ВЛАДЕЛЬЦУ — второй слой поверх `summary.mjs`, а не замена ему.
|
||
// `buildSummary` строит текст МАСТЕРУ (улики, пути, машинные отказы), `utro.mjs` — текст
|
||
// человеку, который не знает ни одного программистского слова. Почта отменена, значит файл
|
||
// остался единственной дорогой до владельца, и класть его должен тот, кто просыпается сам.
|
||
// 🪤 Ввозится ОДНОЙ строкой и ОДИН раз: три дня назад второй ввоз того же имени валил `node`
|
||
// ещё до первой строки работы, и ночь не запускалась трое суток при зелёных проверках.
|
||
import { sobratDovody, zapisatUtro, utroPath, zhivyePutiPoter } from './utro.mjs';
|
||
import { makeTransport as makeTransportDefault, sendSummary as sendSummaryDefault } from './mailer.mjs';
|
||
import { planRun, startAll, smokeLongRun } from './run.mjs';
|
||
import { sweepStaleWorkspaces } from './workspace.mjs';
|
||
// 🔴 `SVETOFOR_RU` — единственное место, где три цвета названы по-русски. Ввозится, а не
|
||
// переписывается: свой второй перевод разъехался бы с первым молча.
|
||
// 🔴 `chuzhoeOknoZelenoe`, `imyaOkna`, `vidPredela` и имя разового ключа ввозятся В ТУ ЖЕ
|
||
// строку: правило «когда разовый ключ владельца действует» живёт ОДНИМ куском в budget.mjs
|
||
// и здесь только зовётся. Своей копии условия в точке входа нет намеренно — вторая такая же
|
||
// проверка разъехалась бы с первой молча, и одна денежная дверь начала бы открываться там,
|
||
// где вторая закрыта.
|
||
import {
|
||
budgetState, recordMeasure, zapisatOplachennoe, SVETOFOR_RU, potolokIzNastroek,
|
||
chuzhoeOknoZelenoe, imyaOkna, vidPredela, KLYUCH_RAZRESHENIYA_VLADELTSA,
|
||
} from './budget.mjs';
|
||
// 🔴 К платному замеру остатка точка входа ходит ТОЛЬКО через эти ворота: они считают
|
||
// сделанное за смену (владелец разрешил до 30) и отказывают на пределе. Прямой зов
|
||
// `probeUtilization` отсюда убран намеренно — сторож `platnyy-zamer-tolko-cherez-schyot`
|
||
// покраснеет, если его вернут.
|
||
import { zamerSoSchyotom } from './schyot-zamerov.mjs';
|
||
import { freeGb, diskState, porogMesta } from './disk.mjs';
|
||
import { sealPlan, checkSeal, assertPlanEditable } from './plan-fingerprint.mjs';
|
||
// 🔴 Разбор каталога прогонов «пустота против порванного» в затее ОДИН, и живёт он у замка
|
||
// на правку плана (`readRuns`, plan-lock-hook.mjs) — там этот класс беды закрыли первым.
|
||
// Своего второго обхода каталога здесь не заводим: две копии одного правила расходятся молча,
|
||
// а расходятся они ровно в ту ночь, когда запись порвалась.
|
||
import { readRuns } from './plan-lock-hook.mjs';
|
||
// 🔴 Слово `"status": "running"` в карточке — это НАМЕРЕНИЕ, а не жизнь. Кто вправду идёт,
|
||
// спрашивается у прибора: он ищет надзирателя прогона и спрашивает о нём саму систему.
|
||
// Допущение прибора и три случая, где он ошибётся, названы в шапке его файла.
|
||
import { sostoyanieProgona, schitatZanyatym, zhivLiNomer } from './zhiv-li-progon.mjs';
|
||
import { specApproved, papersCommitted, paperPathsFor } from './paper-gates.mjs';
|
||
// 🔴 Три тихие части точки входа живут отдельным файлом и проверены двадцатью семью
|
||
// проверками: разбор доводов, раскладка ответов правил по кормушкам ворот и строки отказа.
|
||
// Здесь их только зовут — иначе проверки сторожили бы код, которого программа не исполняет.
|
||
// 🪤 `zamerDovody` отсюда убран: доводы замера собирают теперь ворота счёта
|
||
// (`schyot-zamerov.mjs`) — там же, где зовётся сам замер. Оставленный тут мёртвый ввоз
|
||
// выглядел бы как «точка входа всё ещё умеет ходить к деньгам сама».
|
||
import {
|
||
razborDovodov, vhodVorot, strokiOtkaza, vyborProdolzhaemogo, strokiPriborov,
|
||
} from './cli-parts.mjs';
|
||
// ━━━ КНОПКА «ПРОДОЛЖИТЬ»: десять правил, у которых до этой минуты не было ни одного зовущего.
|
||
// 🔴 Ни одного из них здесь не переписано и не продублировано — только позвано.
|
||
import { resumePoint, resumePlan, rabotaPosleZachtennogo, sledOtkata } from './resume.mjs';
|
||
import { resumeChecks } from './limits.mjs';
|
||
// 🔴 `zavestiSudyuNaPustuyuNoch` ввозится, а не пишется здесь: живое рождение головы
|
||
// разрешено поимённо трём файлам, и `cli.mjs` среди них нет. Заведи мы дорогу тут —
|
||
// ворота счёта рождений владельца считали бы не все рождения за ночь.
|
||
import { otkatDoKommita, closedItemsOfRun, zavestiSudyuNaPustuyuNoch } 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';
|
||
// 🔴 `startGates` и `GATE_ORDER` — ЕДИНСТВЕННОЕ место, где записан состав, порядок и правила
|
||
// девяти ворот. Ввозятся сюда ради бесплатного предпрохода ПЕРЕД деньгами (см. ниже
|
||
// `besplatnyeVorotaDoDeneg`): второго списка ворот в этом файле не заводится ни в каком виде —
|
||
// две копии одного состава разъезжаются молча, и разъедутся ровно в ту ночь, когда добавят
|
||
// девятые ворота и забудут дописать их сюда.
|
||
import { filesDisjoint, startGates, GATE_ORDER } from './gates.mjs';
|
||
// 🔴🔴🔴 ЗДЕСЬ СТОЯЛ ВТОРОЙ ВВОЗ `mkdirSync`, И ИЗ-ЗА НЕГО ТОЧКА ВХОДА НОЧИ НЕ ЗАПУСКАЛАСЬ
|
||
// ВООБЩЕ — с 03.08.2026 10:34 (коммит `215c2258a`) по 04.08.2026. `mkdirSync` уже ввезён
|
||
// строкой 13, а два ввоза одного имени в одном файле — это отказ разбора: `node` падает
|
||
// на нём ещё до первой строки работы.
|
||
//
|
||
// 🔴 ПОЧЕМУ ЭТОГО НЕ ЗАМЕТИЛИ ЧЕТЫРЕ СМЕНЫ. Прибор проверок разбирает файлы СВОИМ
|
||
// разборщиком, и тот двойной ввоз проглатывает молча. Поэтому 2139 проверок были зелёными,
|
||
// а `node tools/night/cli.mjs` отвечал `SyntaxError: Identifier 'mkdirSync' has already
|
||
// been declared`. Зелёные проверки и живая программа расходятся ровно на этом: они читают
|
||
// один и тот же файл РАЗНЫМИ разборщиками.
|
||
//
|
||
// ⇒ Сторож на это — `tochka-vhoda-zapuskaetsya.test.mjs`: он зовёт точку входа НАСТОЯЩИМ
|
||
// `node`, тем же способом, каким её позовёт ночь. Ни одна проверка внутри прибора этого
|
||
// класса поймать не может — только живой запуск.
|
||
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';
|
||
// 🔴🔴 СТОРОЖ, У КОТОРОГО ДО ЭТОЙ СТРОКИ НЕ БЫЛО НИ ОДНОГО ЗОВУЩЕГО (найдено датчиком
|
||
// `schyot-zovushchih.mjs`, задача О-3, 03.08.2026). Сверяет `night.config.json` с решениями
|
||
// владельца из протокола `docs/grilling/2026-08-02-nastroyki-nochi-i-ramka-deneg.md`. Он готов,
|
||
// проверен 23 своими проверками и живёт нетронутым — здесь его только зовут, самого не чиним.
|
||
import { sverkaIliBrosok } from './sverka-nastroek-s-resheniyami.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 = [
|
||
'отклик владельца',
|
||
'от прошлого прогона никого',
|
||
];
|
||
|
||
// 🔴🔴 ДОВЕДЕНО ДО ПОСЛЕДНЕЙ МИЛИ (задача О-5, 03.08.2026). Раньше эта строка печаталась
|
||
// ТОЛЬКО на удаче («Прогон заведён…») — ниже по файлу, у команды `run`. На отказе (ворот,
|
||
// плана, отложенной задачи и так далее) владелец её не видел НИ РАЗУ, а отказов на пути
|
||
// к рабочему плану куда больше, чем удач. Значит про недостроенные двери он узнавал
|
||
// только в ту ночь, когда всё наконец завелось, — и ни разу раньше. Экспортирую функцию,
|
||
// чтобы её слова проверялись напрямую, а не только чтением исходника.
|
||
export function stroka_pro_nestorozhashchie() {
|
||
return `Эти ворота пока НЕ сторожат (их правило пишет кусок 7, сейчас они пропускают всё): ${VOROTA_POKA_NE_STOROZHAT.join(' · ')}.`;
|
||
}
|
||
|
||
// ━━━ 🔴🔴 ВОРОТА СВЕРКИ НАСТРОЕК С РЕШЕНИЯМИ ВЛАДЕЛЬЦА — ЗОВУЩИЙ ДЛЯ
|
||
// `sverka-nastroek-s-resheniyami.mjs` (задача О-3, 03.08.2026).
|
||
//
|
||
// 🔴 ДО ЭТОЙ СТРОКИ У СТОРОЖА НЕ БЫЛО НИ ОДНОГО ЗОВУЩЕГО: построен, проверен 23 своими
|
||
// проверками, зелёный — и ночь его не спрашивала НИ РАЗУ (датчик `schyot-zovushchih.mjs`).
|
||
// Правило метода 2: защита, которую никто не зовёт, не бывает проверенной; затея заплатила
|
||
// за это шесть раз.
|
||
//
|
||
// 🔴 ЧИТАЕТ НАСТОЯЩИЙ ФАЙЛ ЗАНОВО, а не довод `cfg`, которым живёт остальной `main()`.
|
||
// Причина не лень, а сознательный выбор: `cfg` в проверках команды `run` — подставной
|
||
// у ДЕСЯТКОВ проверок (испытывают другое, воротам счёта и ёмкости этого достаточно),
|
||
// и требовать спавн- и проб-бюджет владельца от каждой из них означало бы менять правку,
|
||
// которая испытывает совсем другое. В бою разницы нет: `readConfig()` по умолчанию читает
|
||
// тот же `configPath()`, что и здесь.
|
||
export function proverkaNastroekVladeltsa({
|
||
sverka = sverkaIliBrosok,
|
||
chitatNastoyashchiyFayl = () => readJson(configPath()),
|
||
// ━━━ 🔴🔴 «СЕЙЧАС РОДЯТСЯ ПЛАТНЫЕ РАБОТНИКИ» — ДОВОД, КОТОРОГО ЗДЕСЬ НЕ БЫЛО ВОВСЕ ━━━
|
||
//
|
||
// Правило разбора ветки (`razborVetkiNochi`) разводит два случая нарочно: лежащий долг
|
||
// говорится СЛОВАМИ (держать смену в вечном красном за долг, который ей не закрыть, —
|
||
// наказание не по адресу), а вот-вот заводимые платные работники — КРАСНЫМ (решение
|
||
// владельца 25: отставшая ветка означает, что семеро получат код, который не запускается).
|
||
//
|
||
// 🔴 ЗАМЕРЕНО НАДЗИРАТЕЛЕМ 05.08.2026, И ЭТО БЫЛА ДЫРА ЦЕЛИКОМ. Обёртка звала сверку
|
||
// БЕЗ доводов, а обе боевые дороги звали обёртку тоже без доводов — значит признак живого
|
||
// запуска не ставился НИКОГДА, и красная половина правила не срабатывала ни разу. Сторож
|
||
// умел останавливать ночь и не останавливал её ни при каком отставании.
|
||
//
|
||
// 🪤 И слова, в которые он падал взамен, уходили в никуда: их печатали в окно помощника,
|
||
// а ночь приходит из задания Планировщика, где окна нет. Тот же класс, что и три беды
|
||
// утренней бумаги этой же смены, — правило работает, а до человека не доезжает.
|
||
//
|
||
// 🔴 УМОЛЧАНИЕ `false` — НАРОЧНО. Сверку зовут и те, кто живых работников не заводит
|
||
// (разбор настроек глазами, проверки). Поставь умолчанием `true` — и всякий, кто просто
|
||
// посмотрел настройки, вставал бы в красное за чужой лежащий долг. Признак ставит тот,
|
||
// кто вправду сейчас платит.
|
||
zhivoyZapusk = false,
|
||
} = {}) {
|
||
// Файла нет или он порван — `readJson` отдаёт `null` в обоих случаях (state.mjs).
|
||
// Пустой объект здесь — это НЕ «пропускаем»: сверка сама увидит все решения владельца
|
||
// отсутствующими и упадёт вслух по каждому. «Сторож сам сломался» тут не молчит,
|
||
// а становится громким и понятным отказом тем же приёмом, каким сверка уже умеет
|
||
// отвечать на пропавшую настройку.
|
||
return sverka(chitatNastoyashchiyFayl() ?? {}, { zhivoyZapusk });
|
||
}
|
||
|
||
// ━━━ 🔴🔴 ВОРОТА ПРИБОРА ЗАМЕРА — ИСПРАВЕН ЛИ ПРИБОР. ПРО ДЕНЬГИ ЗДЕСЬ НИ СЛОВА.
|
||
//
|
||
// 🔴🔴 ПЕРЕИМЕНОВАНО 03.08.2026, И ЭТО НЕ КОСМЕТИКА. Дверь звалась «ворота денег»
|
||
// (`vorotaDeneg`, довод `dengiNaZapusk`) — а спрашивает она ровно две вещи: удался ли замер
|
||
// и пришло ли в нём число. ХВАТАЕТ ЛИ ЗАПАСА она не спрашивает и никогда не спрашивала.
|
||
// Из-за имени прошлая смена врезала её пробе, записала в передачу «денежные ворота у пробы
|
||
// стоят» и закрыла задачу — а проба запустилась при израсходованных 0,76 против рамки
|
||
// владельца 0,714 и завела платного работника. Имя обещало деньги, делало прибор, и разницы
|
||
// не видел никто, включая сторожа. Теперь дверей ДВЕ и зовутся они по-разному: эта — про
|
||
// прибор, `vorotaZapasa` ниже — про деньги владельца. Одинаково назвать их больше нельзя.
|
||
//
|
||
// ━━━ ОДНА ДВЕРЬ ДЛЯ ВСЕХ, КТО ЗАВОДИТ ЖИВОГО ПЛАТНОГО РАБОТНИКА
|
||
// (задача О-4, 03.08.2026, находка третьего помощника со стороны).
|
||
//
|
||
// ДО ЭТОЙ ПРАВКИ разбор ответа замера — что считать отказом, что успехом, какими словами
|
||
// это сказать владельцу — лежал ГОТОВЫМ КУСКОМ КОДА ТОЛЬКО внутри `run`. У `smoke-longrun`
|
||
// (проба «работник живёт и через три часа», того же класса трат, что и обычный прогон) такой
|
||
// двери не было вовсе: единственная команда затеи БЕЗ денежных ворот оказалась ровно той,
|
||
// которой собирались делать первое живое рождение работника. Завести `smoke-longrun` своей
|
||
// копией этого куска значило бы построить ВТОРОЕ правило одного смысла — а два правила
|
||
// одного смысла расходятся молча, тем же классом беды, что уже нашёлся у `readRuns`
|
||
// и у замка на файлы (см. заметки в шапке файла, «две копии одного правила»). Поэтому кусок
|
||
// вынесен СЮДА, и `run` теперь тоже зовёт его вместо своей прежней копии — дверь одна
|
||
// на обоих зовущих, а не по одной на каждого.
|
||
//
|
||
// 🪤 `otkaz` — только слова владельцу («Прогон НЕ запущен» / «Проба НЕ запущена»): само
|
||
// правило (что считать отказом, что — успехом) от этих слов не зависит и не разветвляется.
|
||
// ━━━ 🔴🔴 ЗАПИСЬ ЗАМЕРА ПЕРЕЕХАЛА СЮДА (круг Б, 04.08.2026). ЗАКРЫТ КЛАСС, А НЕ СЛУЧАЙ.
|
||
//
|
||
// 🪤 ПРЕЖДЕ ЗДЕСЬ БЫЛО НАПИСАНО ОБРАТНОЕ: «запись успешного замера в общий журнал сюда
|
||
// не входит намеренно — у каждого зовущего свой `phase`». Довод был верный, а последствие
|
||
// дорогое: между тем мигом, когда деньги уже потрачены, и тем, когда зовущий доберётся
|
||
// до `recordMeasure`, лежало окно. Любой обрыв в нём (падение, убитый процесс, брошенное
|
||
// исключение по дороге) терял ОПЛАЧЕННЫЙ замер навсегда — и терял молча.
|
||
//
|
||
// Замерено в оплаченных уликах: следов в `.night/probe-raw/` — 16, строк попыток
|
||
// в `~/.night-shared/probe-attempts.jsonl` — 19, строк в журнале замеров — 23. Три журнала,
|
||
// и ни один не сходится с другими. Двум оплаченным следам (0,14828 $ и 0,03422 $) строки
|
||
// в журнале замеров нет вовсе.
|
||
//
|
||
// 🔴🔴 ЭТО НЕ ПОЧИНКА СЛУЧАЯ 15:39. Механизм той конкретной потери НЕ УСТАНОВЛЕН: попытка
|
||
// записана, след записан, замер в следе целый, код того часа записывал безусловно, а соседний
|
||
// замер через две минуты при тех же данных записался. Здесь закрыт КЛАСС «обрыв между оплатой
|
||
// и записью» — не более того. Кто будет читать это следующим: 15:39 всё ещё ищут, не считайте
|
||
// вопрос закрытым.
|
||
//
|
||
// 🔴 `phase` по-прежнему СПРАШИВАЕТСЯ У ЗОВУЩЕГО, а не угадывается — прежний довод в силе
|
||
// и здесь: под каким именем прирост ляжет в общую историю, решает зовущий. Не назвал —
|
||
// падаем вслух, до всякой траты.
|
||
// 🪤 РЕШЕНИЕ Р2 (наследуется от прежнего места в `run`, не переизобретается заново): причина
|
||
// неудачи называется ТА САМАЯ, что пришла от прибора (`z.problem`), а не одна общая строка
|
||
// на все случаи — здесь уже однажды платили десятью днями работы за придуманную причину.
|
||
export async function vorotaPribora({
|
||
cfg, komanda, phase, otkaz = 'Прогон НЕ запущен',
|
||
zamer = zamerSoSchyotom,
|
||
attemptsPath = probeAttemptsPath(),
|
||
// 🪤 Точки впрыска для проверок: без них проверка дороги писала бы в НАСТОЯЩИЙ общий
|
||
// журнал замеров `.night/budget-measures.jsonl`, а он один на всех — своя строка там
|
||
// сдвинула бы чужой прирост и погасила бы соседний прогон ни за что.
|
||
zapisatZamer = recordMeasure,
|
||
zhurnalZamerov = budgetMeasuresPath,
|
||
// 🔴🔴 РАЗОВЫЙ КЛЮЧ ВЛАДЕЛЬЦА (`KLYUCH_RAZRESHENIYA_VLADELTSA`). Умолчание — ВЫКЛЮЧЕН,
|
||
// и это главное его свойство: без ключа эта дверь ведёт себя ровно как прежде, до последнего
|
||
// слова отказа. Подаётся он только из `run` и только когда владелец набрал ключ рукой.
|
||
// 🪤 Проба (`smoke-longrun`) ключа НЕ получает вовсе: разрешение владельца дано на ночь,
|
||
// а не на диагностический инструмент. Расширять его молча нельзя.
|
||
resheniyeVladeltsa = false,
|
||
err = console.error,
|
||
log = console.log,
|
||
} = {}) {
|
||
// 🔴 Падаем ДО траты: замер, которому некуда лечь под своим именем, платить не должен.
|
||
if (!phase) throw new Error('Платный замер зовут, не назвав `phase` («before-run» / «before-smoke» / …). Записать оплаченный замер будет некуда — а платить вслепую нельзя.');
|
||
let z = null;
|
||
try {
|
||
z = await zamer({ cfg, attemptsPath, komanda });
|
||
} catch (e) {
|
||
err(`${otkaz}: замер остатка не сделан. ${e?.message ?? e}`);
|
||
err('Это настройка в вашем night.config.json. Пример строки есть в night.config.example.json.');
|
||
return { ok: false };
|
||
}
|
||
if (!z.ok) {
|
||
err(`${otkaz}: замеров остатка за смену больше не осталось.`);
|
||
err(z.reason);
|
||
return { ok: false };
|
||
}
|
||
// 🔴🔴 ДЕНЬГИ УЖЕ ПОТРАЧЕНЫ — ЗНАЧИТ ПРИГОВОРЫ ДАТЧИКОВ ГОВОРЯТСЯ ВСЛУХ. Место выбрано
|
||
// ровно здесь, ПОСЛЕ отказа по счёту и ДО разбора удачи замера, и оба края намеренны:
|
||
// • выше (`!z.ok`) прибор не запускался вовсе — денег не потрачено, приговаривать нечего;
|
||
// • ниже замер мог не удаться (`!m`) — но платный запуск ВСЁ РАВНО был, и именно
|
||
// неудачный замер интереснее всех: за него заплачено, а показать было нечего.
|
||
// Печатай мы только на удаче — датчик молчал бы ровно там, где дороже всего.
|
||
for (const line of strokiPriborov(z)) log(line);
|
||
// 🔴🔴 ЗАПИСЬ СТОИТ ВЫШЕ ЛЮБОГО РАЗБОРА УДАЧИ (круг B, 05.08.2026). Прежде она стояла НИЖЕ
|
||
// и под условием «показание своего вида есть» — и оплаченное показание чужого окна
|
||
// выбрасывалось целиком вместе с деньгами. Замерено по оплаченной улике: 03.08.2026 в 15:39
|
||
// так сгорели 0,14828025 $, самый дорогой замер тех суток; показание в потоке было целым.
|
||
// Правило «заплачено — значит записано» живёт одним куском в `zapisatOplachennoe`
|
||
// (budget.mjs); здесь оно только зовётся, второй копии правила тут нет.
|
||
// 🪤 Ворота от этого НЕ смягчаются: отказ по несовпавшему виду ниже остался слово в слово.
|
||
zapisatOplachennoe({
|
||
zamery: z.zamery, zakazan: z.zakazan_vid, path: zhurnalZamerov(), at: Date.now(), phase, zapisat: zapisatZamer,
|
||
});
|
||
const m = z.measure;
|
||
if (!m) {
|
||
// ━━━ 🔴🔴 РАЗОВЫЙ КЛЮЧ ВЛАДЕЛЬЦА — ЕДИНСТВЕННАЯ ЩЕЛЬ В ЭТОЙ ДВЕРИ (05.08.2026) ━━━
|
||
//
|
||
// Правило «когда ключ вообще действует» живёт ОДНИМ куском в `chuzhoeOknoZelenoe`
|
||
// (budget.mjs) и зовётся отсюда, а не переписывается здесь заново: тот же кусок судит
|
||
// вторую денежную дверь (`budgetState`), и две копии одного условия разъехались бы молча —
|
||
// одна дверь начала бы открываться там, где вторая закрыта.
|
||
//
|
||
// 🔴 ЧТО КЛЮЧ ЗДЕСЬ НЕ СНИМАЕТ. Оба отказа ВЫШЕ (замер не сделан вовсе; замеров за смену
|
||
// не осталось) до этой строки не доходят — они уже вернулись. А из того, что доходит,
|
||
// ключ берёт ровно один случай: система прислала ДРУГОЕ окно знакомого вида с ЗЕЛЁНЫМ
|
||
// светом. Прибор промолчал совсем (`z.zamery` пуст) — дверь закрыта, как и была: пропусти
|
||
// мы это, ночь пошла бы при сломанном приборе, не зная ни про одно окно вообще.
|
||
//
|
||
// 🪤 Показание заказанного вида отсюда НЕ ВЫДУМЫВАЕТСЯ: наверх уходит `measure: null`.
|
||
// Подставь мы сюда чужое показание вместо своего — все, кто ниже читает `measure`,
|
||
// приняли бы чужой запас за свой, и вранья стало бы больше, а не меньше.
|
||
const chuzhoyZelenyy = resheniyeVladeltsa
|
||
? (z.zamery ?? []).find(r => chuzhoeOknoZelenoe(r, vidPredela(cfg))) ?? null
|
||
: null;
|
||
if (chuzhoyZelenyy) {
|
||
// 🪤 Слова НЕ собираются из `otkaz`: он про отказ («Прогон НЕ запущен»), а здесь отказа
|
||
// нет. Вывернутый наизнанку чужой текст читался бы как отказ ровно там, где дверь открыли.
|
||
log(`🔴 Показания заказанного окна нет, и дверь прибора пропущена РАЗОВЫМ КЛЮЧОМ ВЛАДЕЛЬЦА (${KLYUCH_RAZRESHENIYA_VLADELTSA}).`);
|
||
log(` · Система прислала ${imyaOkna(chuzhoyZelenyy.kind)} окно, светофор «${chuzhoyZelenyy.status}». Про заказанное окно оно не говорит ничего.`);
|
||
log(' · Полные слова о том, чем это грозит, скажет счёт запаса ниже — он же положит их в утреннюю бумагу.');
|
||
return { ok: true, measure: null, po_resheniyu_vladeltsa: true, prishlo: chuzhoyZelenyy };
|
||
}
|
||
err(`${otkaz}: остаток запаса посчитать не удалось.`);
|
||
err(` · ${z.problem ?? 'Причина не названа.'}`);
|
||
err('Решать о деньгах вслепую нельзя, поэтому запуск не начинается вовсе. Прошлые замеры в журнале запуск не разрешают: они могли устареть за один день.');
|
||
err('Что делать: повторить `npm run night:budget` и посмотреть, отвечает ли прибор.');
|
||
// 🔴 Ключ набрали, а он не подошёл — говорим об этом прямо. Молчание здесь читалось бы
|
||
// как «ключ сломан», и владелец набирал бы его снова и снова, платя за каждый замер.
|
||
if (resheniyeVladeltsa) {
|
||
err(` · Разовый ключ ${KLYUCH_RAZRESHENIYA_VLADELTSA} здесь НЕ подошёл: он снимает ровно один случай — когда система прислала ДРУГОЕ окно знакомого вида с ЗЕЛЁНЫМ светом. Сейчас это не тот случай.`);
|
||
}
|
||
return { ok: false };
|
||
}
|
||
// 🔴 ПЕРВОЕ, ЧТО ДЕЛАЕТСЯ С ОПЛАЧЕННЫМ ПОКАЗАНИЕМ, — ОНО ЗАПИСЫВАЕТСЯ. Ни одной строки кода
|
||
// между «деньги потрачены, число в руках» и «строка в журнале» быть не должно: каждая
|
||
// такая строка — это ещё один способ потерять оплаченное. 🔴 Сама запись переехала ВЫШЕ
|
||
// (см. `zapisatOplachennoe` перед разбором удачи): условие «показание своего вида есть»
|
||
// держало её, и на нём сгорели 0,14828025 $. Второй записи здесь нет нарочно — удавшееся
|
||
// показание уже легло тем же вызовом, что и любое другое.
|
||
return { ok: true, measure: m };
|
||
}
|
||
|
||
// ━━━ 🔴🔴 ВОРОТА ЗАПАСА — ВТОРАЯ ДВЕРЬ, И ОНА ПРО ДЕНЬГИ ВЛАДЕЛЬЦА, А НЕ ПРО ПРИБОР
|
||
// (найдено 03.08.2026 живым замером: доля израсходованного 0,76 при светофоре
|
||
// `allowed_warning` — правило владельца «запускаем, пока израсходовано меньше порога»
|
||
// перейдено, а проба ЗАПУСТИЛАСЬ и завела платного работника).
|
||
//
|
||
// 🔴 ПОЧЕМУ ОТДЕЛЬНАЯ ДВЕРЬ, А НЕ ДОПИСКА В `vorotaPribora`. `vorotaPribora` спрашивает прибор:
|
||
// «замер сделан? число пришло?» — это исправность ПРИБОРА. Хватает ли запаса — вопрос
|
||
// к ЖУРНАЛУ замеров, и ответить на него можно только ПОСЛЕ того, как свежий замер в журнал
|
||
// лёг (`recordMeasure`). Слить их в одну дверь значило бы считать запас по вчерашней строке
|
||
// журнала: `budgetState` читает последний ЗАПИСАННЫЙ замер, а не тот, что держит в руках
|
||
// зовущий. Порядок «прибор → запись → запас» у `run` уже такой, здесь он тот же.
|
||
//
|
||
// 🔴 ПРАВИЛО НЕ ПЕРЕПИСЫВАЕТСЯ. Порог (настройка `start_floor_share`) и весь разбор светофоров живут
|
||
// в ОДНОМ месте — `budgetState` в budget.mjs; здесь только читается его приговор
|
||
// (`may_start`) и его же словами говорится отказ (`reason`). Второго правила одного смысла
|
||
// не заводится — тем же приёмом, каким `vorotaPribora` не копировалась вниз.
|
||
//
|
||
// 🪤 Р5, СУЖЕНИЕ ЗАЩИТЫ. Когда система прислала светофор без доли, `budgetState` пускает
|
||
// (`may_start: true`) и говорит владельцу вслух, что его рамка сейчас не меряется ничем
|
||
// (`slova_vladeltsu`). Эти слова печатаются и здесь: у `run` они печатаются, и молчать
|
||
// о том же сужении у пробы значило бы, что одно и то же место сторожит по-разному.
|
||
export function vorotaZapasa({
|
||
budget, otkaz = 'Прогон НЕ запущен',
|
||
err = console.error, log = console.log,
|
||
} = {}) {
|
||
// 🪤 Ответа может не быть вовсе (некому было спросить, зовущий ошибся) — это «неизвестно»,
|
||
// а не «можно». Неизвестный ответ зелёным не считается, тем же приёмом, каким незнакомый
|
||
// светофор не считается зелёным внутри самого `budgetState`.
|
||
if (!budget) {
|
||
err(`${otkaz}: запас владельца не посчитан вовсе — ответа от счёта запаса не пришло.`);
|
||
err('Решать о деньгах вслепую нельзя, поэтому запуск не начинается.');
|
||
return { ok: false };
|
||
}
|
||
if (!budget.may_start) {
|
||
err(`${otkaz}: ${budget.reason ?? 'запаса не хватает, а причина не названа — это тоже отказ.'}`);
|
||
return { ok: false };
|
||
}
|
||
if (budget.dolya_ne_prishla && budget.slova_vladeltsu) log(budget.slova_vladeltsu);
|
||
return { ok: true };
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 БЕСПЛАТНЫЙ ПРЕДПРОХОД ВОРОТ — ГЛАВНАЯ ДЕНЕЖНАЯ ТРАТА ЗАТЕИ (круг Б, 04.08.2026).
|
||
//
|
||
// ━━━ ЗАМЕРЕНО ПО СЛЕДУ ОПЛАТЫ, А НЕ ПОСЧИТАНО «СОБЫТИЙ × СРЕДНЮЮ ЦЕНУ» ━━━
|
||
// В `.night/probe-raw/` лежит по файлу на КАЖДЫЙ платный запуск прибора, и в каждом —
|
||
// строка `total_cost_usd`. Три замера «перед прогоном», после которых старта не случилось:
|
||
// 19:31:36 → 0,03505 $ · 21:54:07 → 0,03409 $ · 22:53:59 → 0,03768 $ = 0,10682 $
|
||
// Против 0,00767 $ на удвоении запуска, которое чинили кругом Б до этого (два дешёвых
|
||
// замера в тёплом кэше: 0,00408 и 0,00360). То есть эта беда дороже той в ЧЕТЫРНАДЦАТЬ раз.
|
||
// 🪤 Цена замера НЕ постоянна: те же 15 запусков стоили от 0,0033 до 0,1483 $ за штуку —
|
||
// умножать среднюю на число событий здесь запрещено, счёт только по следу оплаты.
|
||
//
|
||
// ━━━ ЧТО ИМЕННО СЛОМАНО ━━━
|
||
// Ворот девять, и ТОЛЬКО ОДНИ из них («запас») вообще нуждаются в платном замере. Восемь
|
||
// остальных — место на диске, замысел, бумаги, отпечаток плана, потолки задач и работников,
|
||
// пересечение по файлам и двое пока не сторожащих — стоят НОЛЬ и отвечают из файлов.
|
||
// А в `run` они все девять судились ПОСЛЕ платного шага. Отказ восьмерых бесплатных при
|
||
// таком порядке всегда стоил владельцу денег: заплатили и только потом узнали, что нельзя.
|
||
//
|
||
// ━━━ ПОЧЕМУ ЭТО ОТДЕЛЬНАЯ ДВЕРЬ, А НЕ ПЕРЕПИСАННЫЕ ВОРОТА ━━━
|
||
// Правила ворот не переписываются и не копируются: зовётся ТОТ ЖЕ `startGates`, тем же
|
||
// входом, только без ответа про деньги — его в эту минуту ещё физически нет. Из приговора
|
||
// выкидывается ровно один пункт — денежные ворота: их отказ здесь означал бы не «нельзя»,
|
||
// а «ещё не мерили». Все остальные отказы — настоящие, и стоят они ноль.
|
||
//
|
||
// 🔴 ПОЧЕМУ НЕ ПОДСТАВИТЬ ВОРОТАМ ЗАПАСА ЗЕЛЁНЫЙ ОТВЕТ. Это был бы выдуманный замер —
|
||
// ровно тот приём, который затея запрещает себе везде («не спросили значит не знаем»).
|
||
// Мы не говорим воротам запаса «всё хорошо»; мы НЕ СПРАШИВАЕМ их вовсе и честно называем
|
||
// их единственными, чей приговор здесь не считается.
|
||
export const VOROTA_DENEG = 'запас';
|
||
|
||
export function besplatnyeVorotaDoDeneg({ vhod = {}, vorota = startGates } = {}) {
|
||
// 🔴 Довод `budget` затирается в `null` НАРОЧНО, даже если зовущий что-то положил:
|
||
// до платного шага никакого ответа про деньги быть не может, а положенный сюда старый
|
||
// ответ из вчерашнего журнала — это решение о деньгах по протухшему числу.
|
||
const otvet = vorota({ ...vhod, budget: null });
|
||
const failed = (otvet.failed ?? []).filter(f => f?.gate !== VOROTA_DENEG);
|
||
return {
|
||
ok: failed.length === 0,
|
||
failed,
|
||
unguarded: otvet.unguarded ?? [],
|
||
reason: failed.length ? failed[0].reason : null,
|
||
};
|
||
}
|
||
|
||
// ━━━ 🔴🔴 ПРАВДА ПРО ЦВЕТ СВЕТОФОРА ЗАПАСА (решение владельца 04.08.2026, дословно:
|
||
// «тормозить только на красный, жёлтый — со словами в сводке»).
|
||
//
|
||
// ━━━ ЧТО БЫЛО СЛОМАНО, ЗАМЕРЕНО В ЖУРНАЛЕ ━━━
|
||
// Все замеры недельного запаса приходили ЖЁЛТЫМИ («тревога по запасу»); зелёного затея
|
||
// не видела ни разу. А решение о старте, когда число пришло, смотрит ТОЛЬКО на число
|
||
// (`utilization < floor`) и цвета не касается вовсе. Значит про тревогу системы владельцу
|
||
// не говорил НИКТО. Тормозить на жёлтом он запретил — значит взамен остаётся отчёт,
|
||
// и молчать нельзя: молчаливое сужение защиты — тот же самообман, что и молча пропускающие
|
||
// ворота.
|
||
//
|
||
// 🔴 ОТДЕЛЬНОЙ ФУНКЦИЕЙ, А НЕ КУСКОМ ВНУТРИ `run`, — чтобы её слова проверялись прямо,
|
||
// а не через живой запуск, который писал бы в настоящее хозяйство владельца.
|
||
//
|
||
// 🔴 СЛОВА ЦВЕТА БЕРУТСЯ У ЕДИНСТВЕННОГО ИХ ХОЗЯИНА (`SVETOFOR_RU` в budget.mjs), а не
|
||
// набираются здесь заново: свой второй перевод трёх цветов разъехался бы с первым молча.
|
||
//
|
||
// 🪤 Незнакомый цвет — НЕ повод промолчать. Молчание читается владельцем как «всё зелено»,
|
||
// а это ровно тот случай, когда система изменилась и об этом надо знать первым делом.
|
||
export function strokaProSvet(status) {
|
||
// Зелёный — единственный, о котором говорить нечего: он и есть «всё в порядке».
|
||
if (!status || status === 'allowed') return null;
|
||
const slovami = SVETOFOR_RU[status] ?? `система прислала цвет «${status}», которого затея не знает`;
|
||
return `Светофор запаса был НЕ зелёный: ${slovami}. Прогон всё равно пошёл — так решили вы: тормозим только на красном.`;
|
||
}
|
||
|
||
// Что владелец увидит при отказе бесплатных ворот. Отдельные слова, а не `strokiOtkaza`:
|
||
// там последней строкой говорится, когда обновится недельный запас, — а здесь про запас
|
||
// не спрашивали вовсе, и такая строка была бы враньём о причине отказа.
|
||
export function strokiOtkazaDoDeneg(prigovor) {
|
||
const out = ['Прогон НЕ запущен. Не пройдено (проверено бесплатно, ДО замера остатка):'];
|
||
for (const g of prigovor?.failed ?? []) out.push(` · ${g?.gate ?? 'ворота'}: ${g?.reason ?? 'Причина не названа.'}`);
|
||
out.push('Денег владельца это не стоило ни копейки: платный замер остатка не запускался.');
|
||
return out;
|
||
}
|
||
|
||
// 🔴 Место мерится по каталогу ХОЗЯЙСТВА, а не по текущему каталогу: у задачи планировщика
|
||
// рабочий каталог системный, и она мерила бы не тот диск.
|
||
async function diskNow(cfg = {}) {
|
||
// 🔴 Порог больше не плоское число: он считается от числа работников (`porogMesta`).
|
||
// Прежнее `cfg.disk_free_min_gb ?? 5` было слепо к тому, что углов собирается семь.
|
||
return diskState(await freeGb(REPO_ROOT), porogMesta({
|
||
rabotnikov: cfg.max_workers_per_task,
|
||
zapasGb: cfg.disk_free_min_gb,
|
||
}));
|
||
}
|
||
|
||
// Какие прогоны идут прямо сейчас. Нужно дважды: чтобы не дать перепечатать план идущего
|
||
// прогона (проверка 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` — точка впрыска для проверок, живые зовущие зовут без него.
|
||
// 🔴🔴 ЗДЕСЬ ЖЕ — ВТОРАЯ ПОЛОВИНА ОТВЕТА, ДОБЫТАЯ ЖИВЫМ ЗАМЕРОМ 03.08.2026. Карточка говорит
|
||
// `"status": "running"` — и до этой минуты ей верили на слово. А слово это ставится один раз,
|
||
// при заведении прогона, и снимать его некому, если работа оборвалась: убитый процесс своей
|
||
// карточки не правит. Замерено: два прогона висели `running` восемнадцать часов, их процессов
|
||
// давно не было, — и затея отказывала владельцу словами «сейчас идёт задач: 2, а потолок — 2»
|
||
// и «новая задача задевает те же файлы, что уже идущая». Обе строки — правда про БУМАГИ и
|
||
// ложь про хозяйство: не шло ни одной. Любой обрыв (за прошлую смену их было три) занимал
|
||
// место в потолке и держал замок на своих файлах НАВСЕГДА.
|
||
//
|
||
// Поэтому ответ теперь РАЗЛОЖЕН, а не слит в одно число:
|
||
// running — те, кем место вправду занято (идут, только заведены, либо не прочлись);
|
||
// mertvye — помечены идущими, а никого нет: места не занимают и файлов не держат;
|
||
// neyasnye — прочесть не удалось: считаются занятыми, но названы отдельно.
|
||
// 🔴 Сваливать «мёртв» и «не прочесть» в одну строку нельзя: прошлым сменам такой слив стоил
|
||
// двух дней поиска не в ту сторону. Мёртвого лечат уборкой карточки, непрочитанного — глазами.
|
||
//
|
||
// 🔴 ОДНА ДВЕРЬ НА ОБОИХ ЗОВУЩИХ. Счёт идущих прогонов спрашивают двое — `seal` и `run`, —
|
||
// и обоим нужен ОДИН ответ на порванную запись: сказать владельцу строку и не запускаться,
|
||
// а не обвалиться следом стека. Два `try` по месту разъехались бы молча: поправят один,
|
||
// забудут другой. Довод `chitat` — точка впрыска для проверок, живые зовущие зовут без него.
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 ДВЕ ДВЕРИ ПЕРЕД ДЕНЬГАМИ (04.08.2026). ВТОРОЙ ЗАПУСК ПЛАТИЛ.
|
||
//
|
||
// ━━━ ЗАМЕРЕНО В ЖИВОМ ЖУРНАЛЕ, А НЕ ПРЕДПОЛОЖЕНО ━━━
|
||
// `.night/budget-measures.jsonl`, время московское: дважды замер «перед прогоном» лёг
|
||
// ПОСЛЕ старта — на 8 секунд (22:55:37 при старте 22:55:29) и на 5 (00:29:40 при 00:29:35).
|
||
// Одному процессу это недоступно: он этот замер уже сделал и ушёл дальше. Значит запусков
|
||
// было несколько, и опоздавший ДОХОДИЛ ДО ПЛАТНОГО ЗАМЕРА, а до старта не доходил.
|
||
// Цена этих двоих по следам `.night/probe-raw/`: 0,0041 $ и 0,0036 $.
|
||
//
|
||
// ━━━ 🔴 ГЛАВНОЕ ЗДЕСЬ — ПОРЯДОК, А НЕ САМ ЗАМОК ━━━
|
||
// Всё, что могло бы узнать про идущий прогон (потолок задач, пересечение по файлам), стоит
|
||
// в `run` ПОСЛЕ платного замера. Защита, стоящая после платного шага, — не защита, а отчёт
|
||
// о потере. Поэтому обе двери ниже зовутся ДО `priborZamera`, и ничего платного они не делают.
|
||
//
|
||
// ━━━ ПОЧЕМУ ДВЕРЕЙ ДВЕ, А НЕ ОДНА ━━━
|
||
// Первая (`progonPoTomuZhePlanu`) читает карточки прогонов — но в самый опасный миг карточки
|
||
// ещё НЕТ НИ У КОГО: двое запустились в одну секунду, оба видят пустое хозяйство, оба платят.
|
||
// Этот миг закрывает вторая — неделимый замок на файле.
|
||
|
||
// Живой прогон по ТОМУ ЖЕ плану. Не «любой второй запуск»: владелец разрешил две задачи
|
||
// разом (`max_tasks_at_once`), и дверь, отбивающая всё подряд, отняла бы у него половину ночи.
|
||
//
|
||
// 🔴 Пути сравниваются `samePath` — тем же прибором, что и везде в затее. Своего сравнения
|
||
// строк здесь нет намеренно: `docs\плана.md` и `docs/Плана.md` — один файл, а строки разные,
|
||
// и замок обходился бы опечаткой.
|
||
export function progonPoTomuZhePlanu({ running = [], planPath = null } = {}) {
|
||
if (!planPath) return null;
|
||
for (const r of running ?? []) {
|
||
if (samePath(r?.plan_path, planPath)) return r;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ━━━ 🔴🔴 СРОК ЗАМКА — ЭТО НЕ ОСТОРОЖНОСТЬ, А ЛЕЧЕНИЕ ХУДШЕГО ИСХОДА ━━━
|
||
// Замок, переживший падение хозяина, ХУЖЕ отсутствия замка: ночь просто не начнётся, и
|
||
// владелец узнает об этом утром. Признака жизни номера одного мало — номера система выдаёт
|
||
// по кругу (та же оговорка, что названа в шапке `zhiv-li-progon.mjs`), и достанься номер
|
||
// чему-нибудь долгоживущему, хозяйство заперлось бы навсегда. Поэтому у замка есть ещё и срок.
|
||
//
|
||
// Пятнадцать минут взяты не наугад: замок держится РОВНО на пути от замера до старта (замер
|
||
// живьём — секунды, ворота — мгновения), и четверти часа хватает с большим запасом. Всё, что
|
||
// дольше, — это не работа, а брошенный след.
|
||
export const PREDEL_ZAMKA_MS = 15 * 60_000;
|
||
|
||
// Путь замка — СВОЙ на каждый план: два разных плана запускать разом законно.
|
||
// 🪤 Имя собрано из читаемого хвоста И отпечатка. Только хвост (после вычистки знаков) дал бы
|
||
// двум разным планам одно имя — и один молча запирал бы другой; только отпечаток нельзя
|
||
// прочесть глазом, разбирая хозяйство руками.
|
||
// 🔴 ПОЙМАНО ЧУЖИМ СТОРОЖЕМ (`paths.test.mjs`, «приведение пути к одному виду написано ровно
|
||
// в одном файле»), и поймано СПРАВЕДЛИВО: сперва я привёл путь к одному виду прямо здесь,
|
||
// своей строкой. Это второй текст про одно правило — класс, на котором в этой затее уже
|
||
// обжигались дважды (`samePlanPath` у печати и `samePath` у замка). Приведение берётся
|
||
// у единственного прибора — `klyuchSravneniyaPuti` в paths.mjs, того же, каким затея
|
||
// сравнивает файлы пунктов.
|
||
export function putZamkaZapuska(planPath, koren = NIGHT_ROOT) {
|
||
const sveden = klyuchSravneniyaPuti(String(planPath ?? ''));
|
||
const otpechatok = createHash('sha1').update(sveden).digest('hex').slice(0, 8);
|
||
const hvost = sveden.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(-60).toLowerCase() || 'bez-imeni';
|
||
return join(koren, 'zamki-zapuska', `${hvost}-${otpechatok}.json`);
|
||
}
|
||
|
||
// Взять замок. Отдаёт `{ vzyal, perehvachen, reason }` и НЕ бросает: упасть здесь значило бы
|
||
// не пустить ночь из-за самой защиты.
|
||
//
|
||
// 🔴 ЧТО ЭТА ДВЕРЬ СМОТРИТ — И ЧЕГО ОНА НАРОЧНО НЕ СМОТРИТ. Решение принимается по двум
|
||
// вещам, и ни одну не подаёт тот, кого дверь отбивает: (1) существование файла — его создаёт
|
||
// САМА СИСТЕМА неделимо (флаг `wx` — «создать, а если такой уже есть, отказаться»), двое
|
||
// разом получить его не могут физически; (2) номер хозяина — его записал ПОБЕДИТЕЛЬ.
|
||
// Отбитый до отказа не пишет ни байта.
|
||
export function vzyatZamokZapuska({
|
||
path, pid = process.pid, now = Date.now(), zhiv = zhivLiNomer, predelMs = PREDEL_ZAMKA_MS,
|
||
} = {}) {
|
||
const zapisat = () => {
|
||
// 🪤 ЗАВЕДЕНИЕ КАТАЛОГА — В СВОЁМ КОШЕЛЬКЕ, И ЭТО НЕ ПРИДИРКА, А ПОЙМАННАЯ ВЫРЕЗАНИЕМ
|
||
// ОШИБКА. Сперва оба действия стояли под одним `try`, и обе беды опознавались по одному
|
||
// слову `EEXIST`. А `mkdirSync` отвечает тем же словом, когда на месте будущего каталога
|
||
// лежит ФАЙЛ, — то есть «замок завести физически негде» читалось как «замок уже занят»,
|
||
// и запуск отбивался наглухо, навсегда, без единого живого соперника. Ровно тот исход,
|
||
// от которого предостерегает шапка выше: ночь не начнётся, а владелец узнает утром.
|
||
try {
|
||
mkdirSync(dirname(path), { recursive: true });
|
||
} catch (err) {
|
||
return { beda: `каталог для замка не заводится: ${err?.message ?? err}` };
|
||
}
|
||
try {
|
||
writeFileSync(path, `${JSON.stringify({ pid, at: now })}\n`, { encoding: 'utf8', flag: 'wx' });
|
||
return true;
|
||
} catch (err) {
|
||
// 🔴 И ЗДЕСЬ `EEXIST` означает РОВНО ОДНО: файл замка уже есть, то есть занято.
|
||
if (err?.code === 'EEXIST') return false;
|
||
// Замок не заводится вовсе (нет места, нет прав). Пускаем и говорим вслух: остановить
|
||
// ночь из-за неработающего замка — это ровно тот вред, ради которого он и не заводится.
|
||
return { beda: err?.message ?? String(err) };
|
||
}
|
||
};
|
||
|
||
const pervaya = zapisat();
|
||
if (pervaya === true) return { vzyal: true, perehvachen: false, reason: null };
|
||
if (pervaya !== false) {
|
||
return { vzyal: true, perehvachen: false, reason: `Замок запуска завести не вышло (${path}): ${pervaya.beda}. Запуск идёт БЕЗ него — второй запуск сейчас ничем не отбит.` };
|
||
}
|
||
|
||
// Занято. Разбираем, кем именно.
|
||
const ustarel = prichinaUstareniyaZamka({ path, pid, now, zhiv, predelMs });
|
||
if (!ustarel) {
|
||
let chuzhoy = null;
|
||
try { chuzhoy = JSON.parse(readFileSync(path, 'utf8')); } catch { /* уже разобрано выше */ }
|
||
return {
|
||
vzyal: false,
|
||
perehvachen: false,
|
||
reason: `Прогон по этому плану уже запускают прямо сейчас (процесс ${chuzhoy?.pid ?? '?'}, замок ${path}). `
|
||
+ 'Второй запуск остановлен ДО платного замера остатка — денег владельца он не тратит. '
|
||
+ 'Дождитесь первого; если он вправду брошен, замок сам протухнет через четверть часа.',
|
||
};
|
||
}
|
||
|
||
// Замок протух — перехватываем. 🪤 Перехват сделан НЕ «записать поверх», а «снять и взять
|
||
// заново тем же неделимым способом»: реши двое разом, что замок протух, поверх записали бы
|
||
// оба и оба пошли бы платить. Так же, как и в первый раз, победит ровно один.
|
||
try { unlinkSync(path); } catch { /* кто-то успел снять раньше — тем лучше */ }
|
||
const vtoraya = zapisat();
|
||
if (vtoraya === true) return { vzyal: true, perehvachen: true, reason: ustarel };
|
||
if (vtoraya !== false) {
|
||
return { vzyal: true, perehvachen: true, reason: `${ustarel} Перезавести замок при этом не вышло: ${vtoraya.beda}.` };
|
||
}
|
||
return {
|
||
vzyal: false,
|
||
perehvachen: false,
|
||
reason: `Замок запуска был протухшим (${ustarel}), но его перехватил кто-то другой в ту же секунду. `
|
||
+ 'Второй запуск остановлен ДО платного замера — денег владельца он не тратит.',
|
||
};
|
||
}
|
||
|
||
// Почему замок считается протухшим. Отдаёт СЛОВА (их читает владелец) либо `null` — «не протух».
|
||
// 🔴 Четыре дороги, и каждая поодиночке заперла бы хозяйство навсегда, промолчи мы о ней.
|
||
function prichinaUstareniyaZamka({ path, pid, now, zhiv, predelMs }) {
|
||
let syroe;
|
||
try {
|
||
syroe = readFileSync(path, 'utf8');
|
||
} catch (err) {
|
||
return `замок есть, а прочесть его не вышло (${err?.message ?? err}) — верить такому нельзя, забираем.`;
|
||
}
|
||
let zamok;
|
||
try {
|
||
zamok = JSON.parse(syroe);
|
||
} catch {
|
||
return 'замок порван и не разбирается — забираем: неразбираемый замок иначе запер бы ночь навсегда.';
|
||
}
|
||
const chuzhoyPid = zamok?.pid;
|
||
// Мой же номер в замке — это не «я сам себя запер», а старый след: номер достался мне после
|
||
// смерти прежнего хозяина. Без этой ветки запуск отказал бы САМ СЕБЕ, и навсегда.
|
||
if (chuzhoyPid === pid) return `в замке стоит мой же номер процесса (${pid}) — это старый след, а не живой хозяин.`;
|
||
if (typeof chuzhoyPid !== 'number' || !Number.isInteger(chuzhoyPid) || chuzhoyPid <= 0) {
|
||
return `в замке не записан годный номер процесса (${JSON.stringify(chuzhoyPid)}) — спросить у системы не о ком, забираем.`;
|
||
}
|
||
if (!zhiv(chuzhoyPid)) return `хозяин замка (процесс ${chuzhoyPid}) уже НЕ ЖИВ — забираем.`;
|
||
const vzyat = zamok?.at;
|
||
if (!Number.isFinite(vzyat)) return 'в замке не записано, когда его взяли, — судить о сроке нечем, забираем.';
|
||
const vozrast = now - vzyat;
|
||
if (vozrast > predelMs) {
|
||
return `замку ${Math.round(vozrast / 60_000)} мин, а срок его — ${Math.round(predelMs / 60_000)}: он давно просрочен, забираем `
|
||
+ '(номер процесса система выдаёт по кругу, и один только признак жизни запер бы ночь навсегда).';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Отдать замок. 🔴 ЧУЖОЙ НЕ ТРОГАЕМ: открыть дверь тому, кто её прямо сейчас держит, —
|
||
// это ровно та беда, что дверь и лечит.
|
||
//
|
||
// 🪤 Пропущенная отдача здесь НЕ смертельна, и это решение названо вслух: команда `run`
|
||
// кончается вместе со своим процессом, а дверь выше спрашивает у системы, жив ли хозяин, —
|
||
// то есть брошенный замок мёртвого запуска перехватывается СЛЕДУЮЩИМ ЖЕ запуском сразу,
|
||
// не дожидаясь срока. Отдача нужна для случая, когда `run` зовут дважды в ОДНОМ процессе.
|
||
export function otdatZamokZapuska({ path, pid = process.pid } = {}) {
|
||
try {
|
||
const zamok = JSON.parse(readFileSync(path, 'utf8'));
|
||
if (zamok?.pid !== pid) return false;
|
||
unlinkSync(path);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export function idushchieProgony(chitat = runningRuns, { sudit = sostoyanieProgona, koren = null, now = Date.now() } = {}) {
|
||
let vse;
|
||
try {
|
||
vse = chitat();
|
||
} catch (err) {
|
||
return {
|
||
ok: false,
|
||
running: [],
|
||
mertvye: [],
|
||
neyasnye: [],
|
||
reason: `Сколько прогонов идёт сейчас — сосчитать не удалось. ${err?.message ?? err}`,
|
||
};
|
||
}
|
||
// Каталог тот же, из которого читались карточки. Проверки подают свой; живые зовущие — нет.
|
||
const papkaProgonov = koren ?? runsDir();
|
||
const running = [];
|
||
const mertvye = [];
|
||
const neyasnye = [];
|
||
for (const r of vse) {
|
||
const sud = sudit(r, { dir: join(papkaProgonov, String(r?.run_id ?? '')), now });
|
||
if (sud.vid === 'mertv') { mertvye.push(sud); continue; }
|
||
if (sud.vid === 'ne_prochest') neyasnye.push(sud);
|
||
// 🔴 Занятым считается ВСЁ, кроме доказанно мёртвого, — правило одно и живёт в приборе.
|
||
// Своей второй копии этого решения здесь нет намеренно.
|
||
if (schitatZanyatym(sud.vid)) running.push(r);
|
||
}
|
||
return { ok: true, running, mertvye, neyasnye, reason: null };
|
||
}
|
||
|
||
// Что сказать владельцу про хозяйство — простым русским и тремя РАЗНЫМИ строками.
|
||
// 🔴 Строки нужны не только при отказе: мёртвые карточки владелец обязан увидеть и тогда,
|
||
// когда запуск прошёл, — иначе мусор копится молча до ночи, в которую он всё перекроет.
|
||
const bolshoyBukvoy = s => (typeof s === 'string' && s.length ? s[0].toUpperCase() + s.slice(1) : String(s ?? ''));
|
||
|
||
export function strokiProProgony(schyot) {
|
||
const out = [];
|
||
if (!schyot) return out;
|
||
const idut = (schyot.running ?? []).length - (schyot.neyasnye ?? []).length;
|
||
if (idut > 0) out.push(`Вправду идёт прогонов: ${idut}.`);
|
||
// 🪤 Причину прибор уже написал целой фразой и с номером прогона внутри. Не приписывай
|
||
// сюда своей второй половины той же мысли: владелец получал строку с заиканием
|
||
// («Прогон X помечен идущим, но НЕ ИДЁТ: прогон X помечен идущим, но…») — замерено глазами
|
||
// на живом хозяйстве 03.08.2026. Здесь добавляется ТОЛЬКО последствие, которого прибор
|
||
// знать не может: занимает место или нет.
|
||
for (const s of schyot.mertvye ?? []) {
|
||
out.push(`${bolshoyBukvoy(s.pochemu)}. Место в потолке задач он больше не занимает и файлы не держит.`);
|
||
}
|
||
if ((schyot.mertvye ?? []).length) {
|
||
out.push(`Карточки таких прогонов стоит убрать рукой (${runsDir()}) — сама затея чужие улики не трогает.`);
|
||
}
|
||
for (const s of schyot.neyasnye ?? []) {
|
||
out.push(`Про прогон ${s.run_id} не удалось узнать, идёт он или нет. ${bolshoyBukvoy(s.pochemu)}. Пока не ясно — считаем его идущим и место за ним держим.`);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ━━━ 🆕 СБОРЩИК КАРТОЧЕК РАБОТНИКОВ ПОДНИМАЕМОГО ПРОГОНА ━━━
|
||
//
|
||
// 🟡 ПОСТРОЕН СВЕРХ ЗАДАЧИ, называю вслух. `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`, а не в этой обёртке, и обходить её отсюда нельзя.
|
||
// 🔴 УТРЕННЯЯ СВОДКА ВЛАДЕЛЬЦУ ЛОЖИТСЯ ФАЙЛОМ. Зовётся из ОБОИХ утренних дел (`summary`
|
||
// и `mail`): по расписанию просыпается то одно, то другое, а владелец должен получить
|
||
// бумагу в любом случае. Класть дважды не страшно — файл один и тот же, он переписывается.
|
||
//
|
||
// 🪤 НИКОГДА НЕ БРОСАЕТ. Утренняя бумага не имеет права уронить утреннее дело: упади она —
|
||
// не ушло бы и письмо, и владелец остался бы вообще без всего. `zapisatUtro` своё падение
|
||
// уже съедает сам; здесь накрыт и сбор доводов из улик.
|
||
export function polozhitUtroFaylom({ disk_free_gb = null } = {}) {
|
||
try {
|
||
// 🔴 Потолок израсходованного — у ЕДИНСТВЕННОГО ХОЗЯИНА (`potolokIzNastroek` в budget.mjs),
|
||
// того же, по которому решают ворота. Своего числа у утренней бумаги нет намеренно:
|
||
// оно там однажды стояло и молча разъехалось с настройкой владельца (круг C, 05.08.2026).
|
||
// 🔴 Пути к уликам о деньгах подаёт точка входа: без них прибор потерь молчит,
|
||
// и владелец про сгоревшие деньги не узнаёт (так и было с 03.08 по 06.08.2026).
|
||
const dovody = sobratDovody({
|
||
koren: NIGHT_ROOT,
|
||
predel: potolokIzNastroek(),
|
||
poteriPuti: zhivyePutiPoter(NIGHT_ROOT),
|
||
});
|
||
return zapisatUtro({ ...dovody, disk_free_gb }, { put: utroPath(NIGHT_ROOT) });
|
||
} catch (err) {
|
||
const error = String(err?.message ?? err);
|
||
console.error(`Утреннюю сводку владельцу собрать не удалось: ${error}`);
|
||
return { ok: false, error };
|
||
}
|
||
}
|
||
|
||
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`), а он один на всех: своя строка там сдвинула бы чужой
|
||
// прирост и погасила бы соседний прогон ни за что. Поймано живьём при первом же прогоне
|
||
// этих проверок — они вправду написали в общий журнал.
|
||
//
|
||
// 🪤 Живёт отдельным именем, а не громоздким умолчанием прямо в подписи: сторож «точка входа
|
||
// не зовёт имён, которых у неё нет» разбирает список доводов приметой по тексту, и вложенные
|
||
// скобки внутри умолчания сбивают его — он назвал невиновными пятерых. Сторож при этом прав
|
||
// по сути: длинное тело в списке доводов не читается человеком.
|
||
// 🔴 `phase` СПРАШИВАЕТСЯ У ЗОВУЩЕГО, А НЕ УГАДЫВАЕТСЯ. Дверь одна, зовущих двое
|
||
// (продолжение и проба), и под каким именем прирост ляжет в общую историю — решает зовущий:
|
||
// у продолжения это «before-resume», у пробы «before-smoke». Умолчания нет нарочно: молчаливое
|
||
// «наверное, продолжение» подписало бы чужую строку чужим именем, а разбирают эту историю
|
||
// потом глазами. Не назвали — падаем вслух.
|
||
//
|
||
// 🪤 Номер прогона и работника есть только у продолжения (у пробы своего прогона нет вовсе).
|
||
// Личная доля работника без них не считается — `workersCount` подаётся только когда есть
|
||
// кому её отмерить. Подставить единицу «на всякий случай» значило бы придумать работника.
|
||
// 🔴🔴 ПРИНИМАЕТ ТЕПЕРЬ ВСЕ ОПЛАЧЕННЫЕ ПОКАЗАНИЯ, А НЕ ОДНО СВОЁ (круг B, 05.08.2026).
|
||
// Прежде сюда подавалось `measure` — показание ЗАКАЗАННОГО вида, — и потому эта дверь
|
||
// зовётся ПОСЛЕ проверки «показание своего вида есть». То есть на несовпавшем виде она
|
||
// не звалась вовсе, и оплаченное показание пропадало молча. Правило записи одно на все
|
||
// три платные дороги и живёт в `zapisatOplachennoe` (budget.mjs).
|
||
function zapisatZamerIVzyatOstatok({ zamery = [], zakazan = null, runId = null, workerId = null, cfg, at, phase }) {
|
||
if (!phase) throw new Error('Замер записывать некуда: зовущий не назвал `phase` («before-resume» / «before-smoke»).');
|
||
zapisatOplachennoe({ zamery, zakazan, path: budgetMeasuresPath(), at, runId, phase });
|
||
return budgetState(budgetMeasuresPath(), cfg, at, { runId, workerId, workersCount: runId ? 1 : null });
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 КНОПКА «ПРОДОЛЖИТЬ» — ЖИВАЯ ДОРОГА ИЗ ДВЕНАДЦАТИ ШАГОВ (решение 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);
|
||
// 🔴 Те же два приговора, что и у `vorotaPribora`, и той же дверью (`strokiPriborov`):
|
||
// копии слов здесь нет. Идут через `skazat` — то есть и в окно владельцу, и в сводку
|
||
// поднимаемого прогона; иначе разбирать ночью было бы нечем.
|
||
for (const line of strokiPriborov(z)) skazat(line, { workerId: wid });
|
||
// 🔴🔴 ЗАПИСЬ ОПЛАЧЕННОГО — ВЫШЕ ОТКАЗА (круг B, 05.08.2026). Прежде эта строка стояла
|
||
// НИЖЕ отказа по несовпавшему виду, и на нём оплаченное показание пропадало молча
|
||
// (замерено по оплаченной улике: 03.08.2026 в 15:39 так сгорели 0,14828025 $).
|
||
// 🔴 Номер прогона — НОВЫЙ и выданный ЗДЕСЬ, до заведения карточки: доля прогона
|
||
// у продолжения своя, чистая (проверка 6). Работник один, потому `workersCount: 1`.
|
||
const zapas = ostatok({
|
||
zamery: z.zamery, zakazan: z.zakazan_vid,
|
||
runId: novyjProgon, workerId: wid, cfg, at: now(), phase: 'before-resume',
|
||
});
|
||
// 🪤 Ворота от этого не смягчаются: своего показания нет — продолжения нет, слово в слово
|
||
// как было. Записана только правда о том, за что уже заплачено.
|
||
if (!z.measure) {
|
||
return otkaz(`Продолжение не начато: остаток запаса посчитать не удалось. ${z.problem ?? 'причина не названа.'}`, wid);
|
||
}
|
||
budget = zapas;
|
||
}
|
||
|
||
// ━━━ ШАГ 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) {
|
||
// 🔴 ТРЕТИЙ ЗОВУЩИЙ СЧЁТА ИДУЩИХ, НАЙДЕННЫЙ ГЛАЗАМИ, А НЕ КОМАНДОЙ. Поиск по тексту
|
||
// `idushchieProgony(` его не видит: сюда он приходит ПЕРЕИМЕНОВАННЫМ на входе
|
||
// (`idushchie = idushchieProgony`) и зовётся как `idushchie()`. А кнопка «Продолжить» —
|
||
// ровно то место, куда владелец жмёт после оборванной ночи, то есть когда покойники
|
||
// в хозяйстве наиболее вероятны. Без этих строк он читал бы «файл занят другим прогоном»
|
||
// и не знал бы, что «другой прогон» — покойник, которого некому хоронить.
|
||
for (const s of strokiProProgony(schyot)) skazat(s, { plohо: true, workerId: wid });
|
||
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,
|
||
// 🔴 Ворота сверки настроек с решениями владельца (задача О-3) — умолчание НАСТОЯЩЕЕ,
|
||
// подставка подаётся явно и только в проверках.
|
||
proverkaVladeltsa = proverkaNastroekVladeltsa,
|
||
// 🔴 Общий список вопросов владельцу (кусок 5, задача 8). Умолчание настоящее;
|
||
// подставка подаётся явно и только в проверках — иначе они правили бы живой список,
|
||
// за которым стоят настоящие вопросы настоящих работников.
|
||
qPath = questionsPath(),
|
||
// 🔴 Судья на ПУСТУЮ ночь (заведено 07.08.2026 по слову владельца). Умолчание
|
||
// НАСТОЯЩЕЕ — живое рождение судьи; подставка подаётся явно и только в проверках,
|
||
// иначе проверять было бы нечем без настоящих денег владельца.
|
||
// 🔴 Сама дорога живёт в `supervisor.mjs`: живое рождение головы разрешено поимённо
|
||
// трём файлам, и `cli.mjs` среди них нет. Здесь только зов, и это не мелочь —
|
||
// впусти мы сюда рождение, ворота счёта рождений перестали бы быть короткими.
|
||
sudyaPustoyNochiFn = zavestiSudyuNaPustuyuNoch,
|
||
// 🔴 Ворота ПРИБОРА замера (задача О-4) — та же дверь у `run` и у `smoke-longrun`.
|
||
// 🪤 Это НЕ ворота денег: хватает ли запаса, спрашивает `vorotaZapasa`, отдельная дверь
|
||
// и отдельное имя. Слить их обратно в одно имя — вернуть беду 03.08.2026. Умолчание
|
||
// НАСТОЯЩЕЕ (`vorotaPribora`, платный зов); подставка подаётся явно и только в проверках —
|
||
// иначе проверить дорогу нечем без живого платного замера.
|
||
priborZamera = vorotaPribora,
|
||
// 🔴 Утренняя бумага владельцу. Умолчание НАСТОЯЩЕЕ — иначе она не легла бы сама
|
||
// ни разу; подставка подаётся только в проверках, чтобы не писать на диск зря.
|
||
polozhitUtro = polozhitUtroFaylom,
|
||
// 🔴 «Записать замер и спросить остаток» — ОДНА точка впрыска, а не две (тот же довод,
|
||
// что у продолжения: без неё проверка дороги писала бы в НАСТОЯЩИЙ общий журнал замеров
|
||
// `.night/budget-measures.jsonl` и сдвигала бы чужой прирост). Умолчание НАСТОЯЩЕЕ —
|
||
// та же самая дверь, что зовёт продолжение, а не вторая её копия.
|
||
zapisatIZapas = zapisatZamerIVzyatOstatok,
|
||
// 🔴 Счёт запаса у пробы — точкой впрыска по той же причине, что и всё остальное:
|
||
// живой `budgetState` читал бы НАСТОЯЩИЙ общий журнал замеров, а он один на всех.
|
||
// Умолчание НАСТОЯЩЕЕ — та же самая машинка, что у `run`, а не вторая её копия.
|
||
sostoyanieZapasa = budgetState,
|
||
// 🔴 САМО ЗАВЕДЕНИЕ ПРОБЫ — тоже точка впрыска, и не ради удобства. Без неё проверку
|
||
// «ворота отказали — проба НЕ заведена» пришлось бы доказывать одним кодом возврата,
|
||
// а код возврата этого НЕ доказывает: сломанные ворота могли бы завести живого платного
|
||
// работника И вернуть единицу. Со спаем доказывается то самое: зовущего не было ни разу.
|
||
// 🪤 И проверка при этом физически не может завести платного работника, даже когда
|
||
// ворота сломаны нарочно (вырезание) — а вырезать их обязаны, иначе они не сторожат.
|
||
probaProgon = smokeLongRun,
|
||
// 🔴 ДВЕ ДВЕРИ ПРОТИВ ВТОРОГО ЗАПУСКА — тоже точками впрыска, и не ради удобства.
|
||
// Без них проверка дороги трогала бы НАСТОЯЩЕЕ хозяйство: `idushchie` читала бы живые
|
||
// карточки прогонов (ответ непредсказуем — проверка врала бы через раз), а `zamokPut`
|
||
// указывал бы в живой `.night/`, куда проверкам писать запрещено. Умолчания НАСТОЯЩИЕ.
|
||
idushchie = idushchieProgony,
|
||
zamokPut = putZamkaZapuska,
|
||
// 🔴🔴 БЕСПЛАТНЫЙ ПРЕДПРОХОД ВОРОТ — точка впрыска, и она нужна ровно для одного:
|
||
// доказать проверкой, что при отказе бесплатных ворот платного зова НЕ БЫЛО НИ РАЗУ.
|
||
// Кодом возврата это не доказывается (единица приходит и оттуда, и отсюда), а живой
|
||
// платный зов в проверке недопустим. Умолчание НАСТОЯЩЕЕ.
|
||
besplatnyeVorota = besplatnyeVorotaDoDeneg,
|
||
} = deps;
|
||
|
||
const cmd = argv[2];
|
||
const cfg = readConfig();
|
||
|
||
if (!cfg) {
|
||
console.error(`Настроек нет: ${configPath()}. Скопируйте night.config.example.json и заполните.`);
|
||
return 2;
|
||
}
|
||
|
||
if (cmd === 'summary') {
|
||
// Та же сводка, что уходит письмом, но напечатанная в окно: помощнику — посмотреть глазами.
|
||
// 🔴 Строка про несторожащие ворота — в оба зова, и в окно, и в письмо.
|
||
const disk = await measureDisk(cfg);
|
||
const summary = summaryOf(cfg, [...sweep(cfg), stroka_pro_nestorozhashchie()], { disk });
|
||
console.log(summary.text);
|
||
// 🔴 Владельцу — своя бумага, и она ложится ФАЙЛОМ, а не печатается в это окно.
|
||
// Окно видит помощник; владелец ночью спит, а утром открывает файл.
|
||
const utro = polozhitUtro({ disk_free_gb: typeof disk?.free_gb === 'number' ? Number(disk.free_gb.toFixed(1)) : null });
|
||
console.log(utro.ok ? `Сводка владельцу записана: ${utro.put}` : `Сводку владельцу записать НЕ удалось: ${utro.error}`);
|
||
return 0;
|
||
}
|
||
|
||
// ━━━ ОТВЕТ ВЛАДЕЛЬЦА РАБОТНИКУ (кусок 5, задача 8) ━━━
|
||
//
|
||
// 🔴 До 07.08.2026 этой команды не было вовсе, и мост был односторонним: работник мог
|
||
// спросить (а точнее — думал, что может), а положить ответ владельцу было НЕЧЕМ.
|
||
// 🔴 Владелец не программист. Поэтому без доводов команда не ругается, а ПОКАЗЫВАЕТ,
|
||
// на что он ещё не ответил, вместе с номерами: номер обязателен, иначе ответ уедет
|
||
// не туда, а искать его в файле руками — не работа для человека.
|
||
if (cmd === 'answer') {
|
||
const nomer = argv[3];
|
||
// Текст берётся ВСЕМ ХВОСТОМ, а не одним словом: человек пишет «нет, не сноси»,
|
||
// и оболочка разложит это на три довода.
|
||
const tekst = argv.slice(4).join(' ').trim();
|
||
|
||
if (nomer === undefined) {
|
||
const zhdut = readLines(qPath).filter(v => v && (v.answer === null || v.answer === undefined));
|
||
if (zhdut.length === 0) {
|
||
console.log('Ждущих вопросов нет — никто из работников ни о чём не спрашивал.');
|
||
return 0;
|
||
}
|
||
console.log(`Вопросов ждёт ответа: ${zhdut.length}.\n`);
|
||
for (const v of zhdut) {
|
||
const kogda = Number.isFinite(v.asked_at) ? new Date(v.asked_at).toLocaleString('ru-RU') : 'время не записано';
|
||
console.log(` № ${v.number} · работник ${v.worker_id ?? '—'} · пункт ${v.item ?? '—'} · спросил ${kogda}`);
|
||
console.log(` ${v.text ?? '(текст вопроса не записан)'}\n`);
|
||
}
|
||
console.log('Ответить так: node tools/night/cli.mjs answer <номер> <ваш ответ словами>');
|
||
return 0;
|
||
}
|
||
|
||
const nomerChislom = Number(nomer);
|
||
if (!Number.isInteger(nomerChislom) || nomerChislom < 1) {
|
||
console.error(`Номер вопроса — целое число, а названо «${nomer}». Посмотреть ждущие: node tools/night/cli.mjs answer`);
|
||
return 1;
|
||
}
|
||
if (!tekst) {
|
||
console.error('Ответ пуст. Напишите его словами: node tools/night/cli.mjs answer <номер> <ваш ответ>');
|
||
return 1;
|
||
}
|
||
|
||
// 🔴 ПОД ЗАМКОМ, и это не осторожность впрок: правку строки на месте нельзя вести
|
||
// одновременно с записью нового вопроса семью надзирателями — один из них пропал бы,
|
||
// и пропал бы молча. Замок берётся тот же, что у подъёма вопросов (кусок 3).
|
||
const itog = podZamkom(dirname(qPath), () => answerQuestion(qPath, nomerChislom, tekst, Date.now()));
|
||
if (!itog?.ok) {
|
||
console.error(`${itog?.reason ?? `вопроса № ${nomerChislom} записать не удалось`}. Посмотреть ждущие: node tools/night/cli.mjs answer`);
|
||
return 1;
|
||
}
|
||
const komu = itog.question?.worker_id ?? '—';
|
||
console.log(itog.replaced
|
||
? `Ответ на вопрос № ${nomerChislom} ПЕРЕПИСАН (прежний ответ там уже был). Работник ${komu} получит новый на ближайшем круге надзирателя.`
|
||
: `Ответ на вопрос № ${nomerChislom} записан. Работник ${komu} получит его на ближайшем круге надзирателя.`);
|
||
// 🪤 Названо вслух: если ночь уже кончилась, ответ ляжет в список и будет ждать —
|
||
// его подхватит продолженный работник (кусок 6), а не сегодняшний.
|
||
return 0;
|
||
}
|
||
|
||
if (cmd === 'mail') {
|
||
const r = await runMail({ cfg });
|
||
console.log(r.ok ? 'Сводка отправлена.' : `Сводка НЕ отправлена: ${r.error}`);
|
||
// 🔴 Кладётся ДАЖЕ КОГДА ПИСЬМО НЕ УШЛО, и это главное. Почта отменена решением
|
||
// владельца, и если бумага зависела бы от успеха письма, дороги до него не было бы вовсе.
|
||
// 🪤 Замер места делается ОДИН раз: он ходит к железу, и второй зов — лишняя работа
|
||
// на ровном месте (написал было дважды — поймал на перечитывании).
|
||
const diskM = await measureDisk(cfg);
|
||
const utro = polozhitUtro({ disk_free_gb: typeof diskM?.free_gb === 'number' ? Number(diskM.free_gb.toFixed(1)) : null });
|
||
console.log(utro.ok ? `Сводка владельцу записана: ${utro.put}` : `Сводку владельцу записать НЕ удалось: ${utro.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;
|
||
}
|
||
// 🔴 Третья и последняя дорога платного замера. Приговоры печатаются ДО всего прочего:
|
||
// владелец жмёт эту команду рукой и смотрит в окно, а числа остатка ниже длинные —
|
||
// строку про чужую голову в их хвосте он бы попросту не увидел.
|
||
for (const line of strokiPriborov(z)) console.log(line);
|
||
// 🔴🔴 ЗАПИСЬ — ДО РАЗБОРА УДАЧИ И БЕЗ ВСЯКОГО УСЛОВИЯ (круг B, 05.08.2026). Прежде она
|
||
// стояла внутри `if (m)`, то есть оплаченное показание чужого окна выбрасывалось молча.
|
||
// Правило одно на все три платные дороги и живёт в `zapisatOplachennoe` (budget.mjs).
|
||
zapisatOplachennoe({
|
||
zamery: z.zamery, zakazan: z.zakazan_vid, path: budgetMeasuresPath(), at: Date.now(), phase: 'probe',
|
||
});
|
||
const m = z.measure;
|
||
if (m) {
|
||
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);
|
||
// 🔴 Отказ обязан сказать, ЧЕМ именно занято. Раньше здесь хватило бы слова из карточки,
|
||
// и владелец печатал бы план заново, упираясь в покойника снова и снова.
|
||
for (const s of strokiProProgony(schyot)) console.error(s);
|
||
return 1;
|
||
}
|
||
// Запрет не сработал — но про мёртвые карточки владельцу сказать всё равно надо.
|
||
for (const s of strokiProProgony(schyot)) if (!s.startsWith('Вправду идёт')) console.log(s);
|
||
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`, ДО РАЗБОРА ДОВОДОВ.
|
||
// Здесь дорога ЕЩЁ ОДНА: обычный запуск и продолжение (`--resume`) расходятся НИЖЕ,
|
||
// у проверки `razbor.resumeRunId`. Сверка стоит ДО развилки нарочно — так, чтобы разойтись
|
||
// эти дороги ни разу не смогли МИМО неё. Проверь я только один рукав — второй утёк бы
|
||
// мимо сторожа, и это была бы ровно та беда, что чинится этой правкой (датчик зовущих
|
||
// нашёл у сторожа ноль зовущих; см. задачу О-3).
|
||
try {
|
||
// 🔴 `zhivoyZapusk: true` — НЕ украшение зова. Через несколько шагов отсюда родятся
|
||
// платные работники, и ровно этот признак включает красную половину разбора ветки
|
||
// (решение владельца 25). Без него сторож отставшей ветки печатал слова в окно —
|
||
// а ночь приходит из задания Планировщика, где окна нет, и слова уходили в никуда.
|
||
const slova = proverkaVladeltsa({ zhivoyZapusk: true });
|
||
for (const s of slova) console.log(s.tekst);
|
||
} catch (err) {
|
||
console.error('Прогон НЕ запущен: настройка ночи разошлась с решением владельца дороже обещанного (либо сверку сделать не вышло — это тоже не повод молча пускать ночь).');
|
||
console.error(err?.message ?? String(err));
|
||
return 1;
|
||
}
|
||
|
||
// 🔴 Разбор доводов — один на все три команды, и он проверен двенадцатью проверками.
|
||
// Разбирай `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} возвращается в работу по слову владельца. План перепроверен заново. Делать её будет НОВЫЙ работник.`);
|
||
}
|
||
|
||
// ━━━ 🔴🔴 ДВЕ ДВЕРИ ПРОТИВ ВТОРОГО ЗАПУСКА — ПОСЛЕДНЕЕ, ЧТО СТОИТ ПЕРЕД ДЕНЬГАМИ ━━━
|
||
// Замерено живьём: дважды замер «перед прогоном» лёг ПОСЛЕ старта (на 8 и на 5 секунд),
|
||
// то есть опоздавший запуск ДОХОДИЛ СЮДА и платил. Все ворота, которые могли бы его
|
||
// отбить (потолок задач, пересечение по файлам), стоят НИЖЕ платного замера — то есть
|
||
// были не защитой, а отчётом о потере. Обе двери здесь бесплатны: одна читает карточки,
|
||
// вторая трогает один файл.
|
||
//
|
||
// 🪤 Порядок дверей не случаен: дешёвая и точная — первой. Она же единственная умеет
|
||
// отличить законную вторую задачу (другой план) от удвоения одной и той же.
|
||
const kartochki = idushchie();
|
||
if (kartochki.ok) {
|
||
const dvoynik = progonPoTomuZhePlanu({ running: kartochki.running, planPath });
|
||
if (dvoynik) {
|
||
console.error(`Прогон НЕ запущен: по этому же плану уже идёт прогон ${dvoynik.run_id}.`);
|
||
console.error(` · план: ${planPath}`);
|
||
console.error(' · это второй запуск одного и того же — остановлен ДО платного замера остатка, денег владельца он не потратил.');
|
||
console.error(' · вправду брошен? Тогда его карточку увидит прибор живости, и следующий запуск пройдёт сам.');
|
||
return 1;
|
||
}
|
||
}
|
||
// 🪤 Сосчитать идущих не вышло — это НЕ повод пускать: но и не повод отказывать здесь.
|
||
// Ниже стоит своя, полная проверка того же (`schyotIdushchih`), она и откажет словами.
|
||
// Второго правила об одном я тут не завожу — оно молча разъехалось бы с первым.
|
||
|
||
const putZamka = zamokPut(planPath);
|
||
const zamok = vzyatZamokZapuska({ path: putZamka });
|
||
if (!zamok.vzyal) {
|
||
console.error('Прогон НЕ запущен: этот же план прямо сейчас запускает кто-то ещё.');
|
||
console.error(` · ${zamok.reason}`);
|
||
return 1;
|
||
}
|
||
// Перехват брошенного замка — не пустяк и молчать о нём нельзя: владелец обязан знать,
|
||
// что здесь остался след оборванного запуска.
|
||
if (zamok.reason) console.log(`Замок запуска: ${zamok.reason}`);
|
||
|
||
// 🔴 ОДНА дверь наружу с этой минуты, а не пять `return` вразнобой. Забудь я отдать замок
|
||
// хоть на одном из выходов — следующий запуск того же плана уткнулся бы в него. Смертельным
|
||
// это не было бы (дверь выше спрашивает систему, жив ли хозяин, а этот процесс к тому
|
||
// времени мёртв), но в ОДНОМ процессе — а так зовут проверки — заперло бы наглухо.
|
||
const konec = kod => { otdatZamokZapuska({ path: putZamka }); return kod; };
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// 🔴🔴 ВСЁ БЕСПЛАТНОЕ — ВЫШЕ ПЛАТНОГО. ГЛАВНАЯ ДЕНЕЖНАЯ ТРАТА ЗАТЕИ (круг Б, 04.08.2026).
|
||
//
|
||
// Раньше эти пять замеров (место, отпечаток, хозяйство, бумаги, замысел) стояли НИЖЕ
|
||
// платного замера, и ворота судили по ним уже после того, как деньги ушли. По следу
|
||
// оплаты в `.night/probe-raw/`: 0,10682 $ сожжено тремя замерами, после которых старта
|
||
// не случилось, — против 0,00767 $ на удвоении запуска, которое чинили до этого.
|
||
// Всё, что стоит ниже платного шага, — не защита, а отчёт о потере.
|
||
//
|
||
// 🔴 Каждая строка ниже стоит НОЛЬ: чтение файлов, вызов `git` и замер свободного места.
|
||
// Платного здесь нет ничего, и появиться ему тут нельзя.
|
||
const disk = await measureDisk(cfg);
|
||
const seal = checkSeal(planSealPath(planPath), planPath);
|
||
// 🔴 Тот же счёт и та же одна дверь, что у `seal`. Порванная карточка — это «неизвестно,
|
||
// идёт прогон или нет»: с таким ответом ворота потолков и ворота пересечения по файлам
|
||
// пропустили бы что угодно, и оба отказа исчезли бы молча. Значит прогона нет, и сказано
|
||
// почему.
|
||
// 🔴 СЧЁТ ИДУЩИХ БЕРЁТСЯ ТЕМ ЖЕ ЗОВОМ, ЧТО И У ДВЕРИ ДВОЙНИКА ВЫШЕ (`idushchie`), а не
|
||
// вторым прямым походом в каталог. Прежде здесь стоял прямой `idushchieProgony()`: один
|
||
// запуск читал карточки ДВАЖДЫ, и два чтения могли разойтись между собой (за секунды
|
||
// между ними прогон успевает завестись или умереть) — а проверки, подставлявшие первый
|
||
// зов, второго не видели вовсе и сторожили не то, что работает.
|
||
const schyotIdushchih = kartochki;
|
||
if (!schyotIdushchih.ok) {
|
||
console.error(`Прогон НЕ запущен. ${schyotIdushchih.reason}`);
|
||
return konec(1);
|
||
}
|
||
const running = schyotIdushchih.running;
|
||
// 🔴 РАЗБОР ХОЗЯЙСТВА ГОВОРИТСЯ ВСЛУХ ДО ВОРОТ. Ворота дальше скажут сухое «идёт задач: N,
|
||
// а потолок — M», и это число само по себе владельца обманывает: он читает его как
|
||
// «хозяйство занято работой», а бывало, что все N — покойники с прошлой ночи. Три
|
||
// состояния разложены тремя РАЗНЫМИ строками; сваливать их в одну запрещено (§ отказа).
|
||
for (const s of strokiProProgony(schyotIdushchih)) console.log(s);
|
||
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 vhod = vhodVorot({
|
||
// 🔴 Пункты уже разобраны выше и сюда подаются готовыми. `planPath` здесь НЕ подаётся
|
||
// намеренно: второе чтение файла плана — это второй план в одном запуске.
|
||
cfg, items: punkty, budget: null, disk, planSeal: seal, papers, spec, running,
|
||
// Номер задачи доходит и до ворот: продолжение начатого третьей задачей не считается,
|
||
// и своими же файлами задача сама с собой не пересекается.
|
||
taskId: razbor.taskId ?? null,
|
||
});
|
||
|
||
// 🔴🔴 БЕСПЛАТНЫЙ ПРЕДПРОХОД — ПОСЛЕДНЕЕ, ЧТО СТОИТ ПЕРЕД ДЕНЬГАМИ. Восемь ворот из
|
||
// девяти судят прямо здесь и стоят ноль. Отказал хоть один — платного замера не будет
|
||
// вовсе, и владельцу сказано это прямым текстом.
|
||
const doDeneg = besplatnyeVorota({ vhod });
|
||
if (!doDeneg.ok) {
|
||
for (const s of strokiOtkazaDoDeneg(doDeneg)) console.error(s);
|
||
console.error(stroka_pro_nestorozhashchie());
|
||
return konec(1);
|
||
}
|
||
|
||
// 💳 Один крошечный замер остатка ПЕРЕД воротами запаса. Без него воротам «запас» нечем
|
||
// питаться, и они пропустили бы что угодно. Замер идёт программой `claude` из подписки —
|
||
// ANTHROPIC_API_KEY не выставляется никогда, оплата ушла бы мимо подписки.
|
||
//
|
||
// 🔴 РЕШЕНИЕ ВЛАДЕЛЬЦА, дословно: замер делается СВЕЖИЙ перед КАЖДЫМ запуском, и срока
|
||
// годности у него нет вовсе. Не вышел замер — прогона нет, и владельцу сказано почему.
|
||
// Никакой «ещё не протухшей» строки из журнала: между тем замером и этой минутой могла
|
||
// пройти дневная работа и съесть весь запас, а решать о деньгах вслепую нельзя (проверка 17).
|
||
//
|
||
// 🪤 ПРЕЖНЯЯ ОГОВОРКА СНЯТА ЭТОЙ ЖЕ ПРАВКОЙ. Здесь стояло: «замер делается ДО ворот,
|
||
// значит КАЖДЫЙ отказ ворот тоже стоит замера» — и это была правда, стоившая 0,10682 $.
|
||
// Теперь до замера доходит только тот запуск, которому все восемь бесплатных ворот
|
||
// уже сказали «можно», и платит он ровно за то, чего иначе узнать нельзя, — за деньги.
|
||
//
|
||
// 🔴 РАЗБОР ОТВЕТА — ЧЕРЕЗ ОБЩУЮ ДВЕРЬ `vorotaPribora` (задача О-4, 03.08.2026), НЕ СВОИМ
|
||
// КУСКОМ КОДА. Раньше здесь стоял готовый код (try/catch, `z.ok`, `z.measure`) — и когда
|
||
// `smoke-longrun` понадобилась та же дверь, копия вниз завела бы ВТОРОЕ правило одного
|
||
// смысла. Дверь вынесена выше по файлу, `run` и `smoke-longrun` зовут ОДНУ и ту же.
|
||
// 🔴 Запись замера теперь делает САМА дверь, сразу как деньги потрачены (круг Б). Своей
|
||
// второй записи здесь нет намеренно: две записи одного замера дали бы двойную строку
|
||
// в журнале, а по нему считаются оба денежных предела ночи.
|
||
// 🔴 РАЗОВЫЙ КЛЮЧ ВЛАДЕЛЬЦА подаётся ОБЕИМ денежным дверям одним и тем же значением,
|
||
// взятым из разбора доводов. Собери его тут заново (вторым `argv.includes`) — и однажды
|
||
// одна дверь считала бы ключ набранным, а вторая нет.
|
||
const klyuchVladeltsa = Boolean(razbor.resheniyeVladeltsa);
|
||
const dengi = await priborZamera({ cfg, komanda: 'run', phase: 'before-run', resheniyeVladeltsa: klyuchVladeltsa });
|
||
if (!dengi.ok) return konec(1);
|
||
const m = dengi.measure;
|
||
|
||
const budget = budgetState(budgetMeasuresPath(), cfg, Date.now(), { resheniyeVladeltsa: klyuchVladeltsa });
|
||
|
||
// 🔴 ПОЛНЫЙ ПРОХОД ВСЕХ ДЕВЯТИ ВОРОТ — тем же входом, что судился бесплатно, плюс ответ
|
||
// про деньги. Восемь из них здесь заведомо зелены (их только что спросили даром), но
|
||
// спрашиваются они заново намеренно: правило «прогон заводит только полный проход ворот»
|
||
// одно, и обходить его коротким путём «раз бесплатные прошли, судим один запас» нельзя —
|
||
// так в затее уже однажды появилась дверь, которая обещала деньги, а проверяла прибор.
|
||
const plan = planRun({ ...vhod, budget });
|
||
if (!plan.ok) {
|
||
for (const s of strokiOtkaza(plan, budget)) console.error(s);
|
||
// 🔴🔴 БЫЛО МОЛЧАНИЕ ИМЕННО ЗДЕСЬ (задача О-5). Строка про недостроенные ворота печаталась
|
||
// ниже, только на удаче — а на отказе владелец её не видел вовсе. Читал «не пройдено:
|
||
// такие-то ворота» и мог решить, что всё остальное (включая двоих незапертых) держит.
|
||
// Правда должна звучать в обоих исходах, а не только в том, что случается реже.
|
||
console.error(stroka_pro_nestorozhashchie());
|
||
return konec(1);
|
||
}
|
||
|
||
// 🔴🔴 РЕШЕНИЕ Р5, ВИДИМАЯ ЧАСТЬ. Ворота запаса пропустили ПО СВЕТОФОРУ СИСТЕМЫ, а не
|
||
// по числу, — то есть прежняя рамка владельца (остаток над порогом неприкосновенен) сейчас
|
||
// не меряется ничем. Это СУЖЕНИЕ ЕГО ЗАЩИТЫ, и оно обязано быть видно ему прямым текстом,
|
||
// а не жить примечанием к коду. Печатается ДО слов «прогон заведён»: строка, спрятанная
|
||
// под списком работников, до глаз не доходит.
|
||
if (budget?.dolya_ne_prishla && budget?.slova_vladeltsu) {
|
||
console.log(budget.slova_vladeltsu);
|
||
}
|
||
|
||
// 🔴 ТОЧКА ОТСЧЁТА ПРОГОНА. Крошечный замер выше лёг в журнал БЕЗ номера прогона —
|
||
// номера в ту минуту ещё не существовало, его выдал `planRun`. А прирост прогона
|
||
// считается от первой строки ЭТОГО прогона (задача 2, `utilizationDelta`).
|
||
// Не дописать её — точкой отсчёта станет первый тик первого работника, всё съеденное
|
||
// до него в прирост не войдёт, и предел одной седьмой сработает позже обещанного.
|
||
// Число берём то же самое, что и в замере перед воротами: второй раз прибор не спрашиваем.
|
||
// 🔴🔴 ПОКАЗАНИЯ СВОЕГО ВИДА МОЖЕТ НЕ БЫТЬ ВОВСЕ — и только по разовому ключу владельца
|
||
// (без ключа дверь прибора сюда не пустила бы). Записывать тогда НЕЧЕГО, и выдумывать
|
||
// точку отсчёта нельзя: подставленное чужое показание сделало бы прирост прогона разницей
|
||
// чисел из разных запасов, то есть числом, которого не бывает. Молчать об этом тоже
|
||
// нельзя — говорим прямо, чего именно у ночи не будет.
|
||
// 🪤 Оплаченное показание при этом НЕ ПОТЕРЯНО: его уже положила в журнал сама дверь
|
||
// прибора (`zapisatOplachennoe`), с пометкой, какого вида ждала настройка.
|
||
// 🔴🔴 УСЛОВИЕ НАПИСАНО «ОТ ОБРАТНОГО» — СНАЧАЛА СЛУЧАЙ «ПОКАЗАНИЯ НЕТ». ЭТО НЕ ПРИЧУДА,
|
||
// И «ПРИЧЁСЫВАТЬ» ЕГО НЕЛЬЗЯ. На прямую связку «показание есть, значит пишем замер»
|
||
// в этом файле стоит сторож (`oplachennoe-ne-propadaet.test.mjs`, правило «старого условия
|
||
// „пиши, только если вид совпал“ в cli.mjs больше нет»): ровно в такой форме здесь однажды
|
||
// сгорели 0,14828025 $ — оплаченное показание чужого окна выбрасывалось вместе с деньгами.
|
||
// 🪤 Сторож ищет ФОРМУ по всему файлу, а не место, — значит его будит ЛЮБАЯ запись под
|
||
// условием по показанию, даже безобидная вроде этой (она пишет уже записанное второй раз,
|
||
// ради точки отсчёта прогона, и денег стоить не может). Разверни условие обратно — прогон
|
||
// проверок покраснеет, и покраснеет ПРАВИЛЬНО: две записи одной формы текстом не различить.
|
||
// 🔴 Само правило при этом не ослаблено ни на волос: платную запись по-прежнему делает
|
||
// дверь прибора БЕЗУСЛОВНО (`zapisatOplachennoe`), и её сторожат четыре правила того файла.
|
||
if (!m) {
|
||
console.log('🔴 Точки отсчёта расхода у этой ночи НЕТ: показания заказанного окна система не прислала. Значит и прирост за ночь посчитать будет не из чего.');
|
||
} else {
|
||
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',
|
||
// 🔴 Второй светофор («дадут ли работать сверх запаса») — из того же показания.
|
||
sverh_zapasa: m.sverh_zapasa ?? null,
|
||
sverh_pochemu: m.sverh_pochemu ?? null,
|
||
});
|
||
}
|
||
|
||
// ━━━ 🔴🔴 «ЗАПУСКАЛИСЬ ВСЛЕПУЮ» ИДЁТ В УТРЕННЮЮ СВОДКУ, А НЕ ТОЛЬКО В ОКНО
|
||
// (решения владельца 04.08.2026: порог `start_floor_share` 0,99 и тумблер
|
||
// `bez_doli_ne_zapuskat` ВЫКЛ — оба оставлены сознательно).
|
||
//
|
||
// 🔴 ПОЧЕМУ ЭТОГО МАЛО — ПЕЧАТАТЬ В ОКНО. Строка выше (`console.log`) видна тому, кто
|
||
// жмёт запуск и в эту минуту смотрит в экран. А ночь запускает планировщик, и владелец
|
||
// узнаёт о ней утром из письма. Значит про сужение своей защиты он не узнавал НИ РАЗУ
|
||
// именно в том случае, ради которого затея и построена, — в автоматическом ночном пуске.
|
||
//
|
||
// 🔴 Раз тормоза по решению владельца практически нет, остаётся ОТЧЁТ. Молчаливое
|
||
// сужение защиты — это самообман того же рода, что молчаливо пропускающие ворота.
|
||
// Слова берутся у запаса (`slova_vladeltsu`), своих здесь не сочиняется: два текста
|
||
// об одном разъезжаются молча.
|
||
// 🔴🔴 ПОПРАВЛЕНО 05.08.2026: «доли нет» и «запускались ВСЛЕПУЮ» — РАЗНЫЕ вещи. Система
|
||
// зажигает тревогу на 75 % и только тогда прикладывает число; значит зелёный светофор
|
||
// без доли ДОКАЗЫВАЕТ расход ниже 75 %, и при пороге старта 0,99 старт доказан. Писать
|
||
// владельцу «запускались вслепую» в этом случае — враньё в его же сводке. Решает не эта
|
||
// строка, а сам запас (`vslepuyu` из `budgetState`): двух правил об одном тут не заводим.
|
||
if (budget?.dolya_ne_prishla && budget?.slova_vladeltsu) {
|
||
// 🔴🔴 У НОЧИ ПО РАЗОВОМУ КЛЮЧУ — СВОЙ ЗАГОЛОВОК, И ЭТО НЕ УКРАШЕНИЕ, А ЕДИНСТВЕННЫЙ
|
||
// СПОСОБ ДОВЕЗТИ ЭТИ СЛОВА ДО ВЛАДЕЛЬЦА. Утренняя бумага выбрасывает строки, начинающиеся
|
||
// словами «Запускались ВСЛЕПУЮ», когда про слепоту она уже сказала своими словами
|
||
// (`CHUZHIE_FAKTY` в utro.mjs — свёртка «один факт дважды»). Начнись строка про ключ
|
||
// теми же словами — весь громкий текст о том, на каком основании пошла ночь, пропал бы
|
||
// из бумаги МОЛЧА. Замерено проверкой «строка про ключ доезжает до бумаги».
|
||
// 🪤 `vslepuyu` при этом остаётся ИСТИННЫМ и не подкручивается: про заказанное окно мы
|
||
// вправду ничего не знаем. Меняется заголовок строки, а не правда о ночи.
|
||
const golova = budget.po_resheniyu_vladeltsa
|
||
? 'Ночь запущена РАЗОВЫМ КЛЮЧОМ ВЛАДЕЛЬЦА, без числа недельного остатка. '
|
||
: (budget.vslepuyu
|
||
? 'Запускались ВСЛЕПУЮ: доли израсходованного система не прислала. '
|
||
: 'Старт был ДОКАЗАН, а не слеп: доли система не прислала, но и тревоги не зажигала. ');
|
||
appendLine(summaryLinesPathOf(plan.run_id), {
|
||
at: Date.now(),
|
||
line: `${golova}${budget.slova_vladeltsu}`,
|
||
});
|
||
}
|
||
|
||
// ━━━ 🔴🔴 ЖЁЛТЫЙ СВЕТ ГОВОРИТСЯ ВСЛУХ (решение владельца 04.08.2026, дословно:
|
||
// «тормозить только на красный, жёлтый — со словами в сводке»).
|
||
//
|
||
// 🔴 ЧТО БЫЛО СЛОМАНО. Все замеры недельного запаса приходили ЖЁЛТЫМИ («тревога по
|
||
// запасу»), зелёного затея не видела ни разу — а решение о старте, когда число пришло,
|
||
// смотрит ТОЛЬКО на число (`utilization < floor`) и цвета не касается вовсе. Значит про
|
||
// тревогу системы владельцу не говорил никто. Останавливать на жёлтом он запретил —
|
||
// значит взамен остаётся отчёт, и молчать нельзя.
|
||
//
|
||
// 🔴 СЛОВА ЦВЕТА БЕРУТСЯ У ЕДИНСТВЕННОГО ИХ ХОЗЯИНА (`SVETOFOR_RU`), а не набираются
|
||
// здесь заново. Свой второй перевод трёх цветов разъехался бы с первым молча.
|
||
const svetStroka = strokaProSvet(budget?.status ?? null);
|
||
if (svetStroka) {
|
||
console.log(svetStroka);
|
||
appendLine(summaryLinesPathOf(plan.run_id), { at: Date.now(), line: svetStroka });
|
||
}
|
||
|
||
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,
|
||
// 🔴 След решения в карточке прогона: на каком основании эта ночь вообще пошла.
|
||
klyuchVladeltsa: klyuchVladeltsa ? KLYUCH_RAZRESHENIYA_VLADELTSA : null,
|
||
// 🔴 Команда проверок — своя у каждого пункта, из его же `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('Прогон закрыт сразу — места в потолке задач он не занимает, и следующая попытка ничем не заперта.');
|
||
// 🔴 БУМАГА ВЛАДЕЛЬЦУ ЗДЕСЬ НЕ КЛАДЁТСЯ, И ЭТО НЕ ЗАБЫВЧИВОСТЬ. Ночь тут вправду
|
||
// кончилась, но закрывает её `writeRunOutcome` внутри `startAll` — то же самое место,
|
||
// что закрывает ночь после последнего погасшего работника. Бумагу кладёт ОНО.
|
||
// Положи её ещё и здесь — в затее завелось бы второе правило «прогон кончился»,
|
||
// ровно то, чего `run.mjs` избегает вслух. Одна дверь, один зов.
|
||
//
|
||
// 🔴🔴 А ВОТ СУДЬЮ ЗОВЁМ — И ИМЕННО ЗДЕСЬ (заведено 07.08.2026 по слову владельца).
|
||
// Такую ночь закрывает `startAll`, минуя надзирателя, а судью зовёт только надзиратель —
|
||
// значит на самую тревожную ночь из всех взгляда со стороны не было ВООБЩЕ.
|
||
// 🪤 Зов стоит ЗДЕСЬ, а не в `startAll`, по замеренной причине: рождение судьи
|
||
// возвращает обещание, а `startAll` синхронная — ждать там было бы нечем, и вышли бы
|
||
// бумаги о судье без судьи. Здесь `await` возможен.
|
||
// 🔴 Порядок: судья идёт ПОСЛЕ того, как `startAll` закрыл прогон. Позови раньше —
|
||
// он читал бы незакрытую ночь и судил недописанное.
|
||
// 🔴 Сама дорога живёт в `supervisor.mjs`: живое рождение разрешено поимённо трём
|
||
// файлам, и этот среди них, а `cli.mjs` — нет. Здесь только зов.
|
||
await sudyaPustoyNochiFn({ runDirPath: runDir(plan.run_id), runId: plan.run_id, cfg });
|
||
return konec(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 konec(0);
|
||
}
|
||
|
||
if (cmd === 'smoke-longrun') {
|
||
// ━━━ 🔴🔴 ВОРОТА, НАЙДЕННЫЕ ПРОПУЩЕННЫМИ 03.08.2026 (задача О-4, находка третьего
|
||
// помощника со стороны). Проба заводит ТОГО ЖЕ платного работника, что и `run`, а ворот
|
||
// у неё не было НИ ОДНИХ: сверка настроек и денежные ворота стояли только в куске `run`,
|
||
// ДО развилки на `--resume`, — а `smoke-longrun` сравнивает `cmd` ЗАНОВО, отдельной веткой,
|
||
// и внутрь ветки `run` не заходит вовсе. Единственная команда затеи без денежных ворот
|
||
// оказалась ровно той, которой собирались делать первое живое рождение работника.
|
||
//
|
||
// 🔴 ОБЕ ДВЕРИ — ТЕ ЖЕ САМЫЕ, а не вторые копии: `proverkaVladeltsa` — тот же довод главной
|
||
// программы, что уже стоит у `run` чуть выше по файлу; `priborZamera` — дверь, вынесенную
|
||
// ИЗ `run` специально ради этого (раньше разбор ответа замера был готовым куском кода
|
||
// только внутри `run`, и своя копия здесь завела бы второе правило одного смысла).
|
||
//
|
||
// 🟡 РЕШЕНО НЕ ВЕСТИ ПРОБУ ЧЕРЕЗ ВСЕ ДЕВЯТЬ ВОРОТ обычного прогона (план, приёмка, замысел,
|
||
// отпечаток, место на диске, потолки задач/работников, пересечение файлов, отложенная
|
||
// задача) — только через эти ТРИ (сверка настроек · прибор замера · запас владельца;
|
||
// третья врезана 03.08.2026, до неё их было две и денег не сторожила ни одна).
|
||
// Причина сужения: проба не план и не задачу заводит, а один
|
||
// работник на N минут вручную, руками, обычно когда что-то чинят; своего плана/приёмки/
|
||
// замысла у неё нет и не будет, и требовать их означало бы придумывать бумаги под
|
||
// диагностический инструмент. Сужение названо, а не спрятано: см. отчёт задачи.
|
||
try {
|
||
// 🔴 Тот же признак, что и у обычного запуска: проба заводит ОДНОГО живого платного
|
||
// работника, и отставшая ветка стоит ей ровно столько же — он получит код, который
|
||
// не запускается. Разной строгости у двух дорог к одним и тем же деньгам быть не должно.
|
||
const slova = proverkaVladeltsa({ zhivoyZapusk: true });
|
||
for (const s of slova) console.log(s.tekst);
|
||
} catch (err) {
|
||
console.error('Проба НЕ запущена: настройка ночи разошлась с решением владельца дороже обещанного (либо сверку сделать не вышло — это тоже не повод молча пускать платного работника).');
|
||
console.error(err?.message ?? String(err));
|
||
return 1;
|
||
}
|
||
|
||
const dengi = await priborZamera({ cfg, komanda: 'smoke-longrun', phase: 'before-smoke', otkaz: 'Проба НЕ запущена' });
|
||
if (!dengi.ok) return 1;
|
||
|
||
// 🔴🔴 ВОРОТА ЗАПАСА — ровно то, чего тут не было. `priborZamera` выше спрашивает прибор
|
||
// («замер сделан? число пришло?»), и на этом останавливалась. Хватает ли запаса
|
||
// по правилу владельца — спрашивается здесь, ПОСЛЕ записи свежего замера в журнал:
|
||
// счёт запаса читает последний ЗАПИСАННЫЙ замер, а не тот, что держит в руках зовущий.
|
||
// 🔄 ПОПРАВЛЕНО КРУГОМ Б: запись свежего замера сделала САМА дверь прибора, сразу как
|
||
// деньги ушли. Здесь остался только вопрос о запасе — он читает последний ЗАПИСАННЫЙ
|
||
// замер, и записан он уже строкой выше. Прежняя пара «записать и спросить» (`zapisatIZapas`)
|
||
// отсюда убрана: писала бы второй раз, а по этому журналу считаются денежные пределы ночи.
|
||
//
|
||
// 🟡 Из девяти ворот обычного прогона проба по-прежнему не идёт ни через одни (план,
|
||
// приёмка, замысел, отпечаток, диск, потолки, пересечение файлов, отложенная задача) —
|
||
// довод прежний и не меняется: это бумаги под план, а у пробы плана нет. Но ворота
|
||
// запаса НЕ ИЗ ТОГО РЯДА: они не про бумаги, а про деньги владельца, и трата у пробы
|
||
// ровно того же класса, что у прогона (тот же платный работник, те же часы).
|
||
const zapas = vorotaZapasa({
|
||
budget: sostoyanieZapasa(budgetMeasuresPath(), cfg, Date.now()),
|
||
otkaz: 'Проба НЕ запущена',
|
||
});
|
||
if (!zapas.ok) return 1;
|
||
|
||
// Доказательство проверки 1: движение должно быть и через три часа после конца сессии
|
||
// помощника. Проверками кода это не доказывается — только живым прогоном.
|
||
const minutes = Number(flag(argv, '--minutes') ?? 200);
|
||
// 🔴 Имя прогона можно назвать своё (`--run`). Без этого вторая попытка невозможна:
|
||
// ветка и угол от первой уже заняты, а каталог прошлого прогона сносить нельзя —
|
||
// из него живёт сводка. Короткая проба перед ночью зовётся как раз со своим именем.
|
||
const runId = flag(argv, '--run') ?? 'smoke';
|
||
const r = await probaProgon({ 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, answer.`);
|
||
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));
|
||
}
|