Files
portal/tools/night/limits.mjs
T
Дмитрий 6d095d582c fix: шесть дыр из ворот К2 в правилах ночи — замок, сравнение путей, потерянные пороги, единый час, враньё про полночь, день обновления
1. Битая карточка прогона больше не считается отсутствующей: readRuns различает файл-нет и файл-есть-но-не-читается через readJsonOrBroken, и на нечитаемой карточке замок ЗАКРЫТ. Раньше порванный run.json молча выпадал из списка — замок открывался ровно в ту ночь, когда кончилось место.

2. samePath у замка сведён к поведению samePlanPath у печати через resolve: полный путь владельца и путь от корня хранилища от сторожа теперь один файл. Замерено: lefthook 2.1.8 подставляет staged_files путём ОТ КОРНЯ хранилища.

3. Потерянная настройка больше не читается как можно: нет disk_free_min_gb — отказ; нет hours_limit — hoursLimitHit падает громко, а checkSelfLimits гасит работника с названной причиной.

4. Дорога к московскому часу правда одна: limits берёт час у budget.moskovskoeVremya, своего Intl там больше нет.

5. Запись про полночь-как-24 переписана по замеру: этот Node отдаёт 00. Подчистку сторожит прямая проверка normalizeMskHour, а не случайная зелень.

6. Отказ по запасу без дня обновления и с бессмысленной датой от невиданного прибора теперь виден как сбой, а не как нормальный ответ: renewalDay плюс поле renews_on_problem.

Каждая починка накрыта проверкой, краснеющей при её вырезании. Проверок в четырёх файлах 155, было 108. Полный набор tools: 619 зелёных в 31 файле.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:29:40 +03:00

252 lines
18 KiB
JavaScript

// Пределы прогона. Сторожа-снаружи в этом куске нет вовсе, поэтому здесь не «второй рубеж»,
// а единственный. Зовёт эти правила НАДЗИРАТЕЛЬ (supervisor.mjs, задача 12) — раз в тридцать
// секунд, пока работник жив. Сам работник их позвать не может: он другой процесс
// в другом каталоге и этих модулей не видит.
import { moskovskoeVremya } from './budget.mjs';
const H = 3600_000;
// 🆕 Долг перед куском 5 (решение Р9 / проверка П24): час утренней отсечки — постоянная,
// не спрятанное число внутри morningCutoff. Восьмёрка стоит ЕЩЁ и в TIHIE_CHASY.to куска 5
// (тихие часы, questions.mjs) — правила разные НАРОЧНО (там про срок ответа владельца, здесь
// про гашение работающих), сводить их в одну постоянную нельзя. Но раз обе теперь названы,
// а не спрятаны текстом внутри функций, расхождение между ними можно хотя бы УВИДЕТЬ —
// прежде обе восьмёрки было негде даже сравнить.
export const MORNING_CUTOFF_HOUR = 8;
// Что съедает восемь часов: работа, доделывание шага перед остановкой и ожидание ответа
// С РАБОТОЙ. Бесплатны чистый простой, ожидание чужой службы и пауза.
// Список закрыт и обязан совпадать с договором о стыках.
const BILLABLE = new Set(['working', 'stopping', 'waiting_answer_busy']);
export function billableMs(spans) {
return (spans ?? []).reduce((sum, s) => (BILLABLE.has(s.kind) ? sum + s.ms : sum), 0);
}
// 🔴 ПРЕДЕЛА НЕ ДАЛИ — ЭТО НЕ «ЕЩЁ НЕ ВЫБРАЛ». Прежде здесь стояла одна строка `ms >= hours * H`,
// и на потерянной настройке она отвечала `ms >= NaN`, то есть ВСЕГДА «нет»: предел восьми часов
// исчезал молча, работник жёг запас до самого утра. Ответа «наверное можно» у этого правила
// нет — падаем громко, чтобы это увидели, а не выясняли по счёту наутро.
// Вырежи любую из двух проверок ниже — покраснеет раздел «предела не дали».
export function hoursLimitHit(ms, hours) {
if (!Number.isFinite(hours)) {
throw new Error(
'Предел часов работнику не задан (потеряна настройка hours_limit) — сравнивать не с чем. '
+ 'Молчаливое «предел не выбран» здесь запрещено: оно снимает восемь часов целиком.',
);
}
if (!Number.isFinite(ms)) {
throw new Error(
'Отработанное время не посчитано (billable_ms не число) — сравнивать нечего. '
+ 'Молчаливое «предел не выбран» здесь запрещено по той же причине.',
);
}
return ms >= hours * H;
}
// --- «служба не ответила» -----------------------------------------------------
//
// 🔴 Работник может честно объявить `waiting_service` и простоять так всю ночь: часы
// его не съедают, а значит по восьми часам он не погаснет НИКОГДА. Без этого правила
// он доживёт до 8:00 впустую и займёт собой место в прогоне. Порог — два часа
// непрерывного ожидания, причина — дословно из бумаги.
export const SERVICE_WAIT_LIMIT_HOURS = 2;
// Учётчик отрезков (spansNow, задача 11) кладёт ИДУЩИЙ СЕЙЧАС отрезок последним.
// Порог считается по нему одному: восемь коротких ожиданий по десять минут —
// это восемь полученных ответов, а не «прождал два часа».
export function serviceWaitHit(spans, hours = SERVICE_WAIT_LIMIT_HOURS) {
const list = spans ?? [];
const current = list.length ? list[list.length - 1] : null;
if (!current || current.kind !== 'waiting_service') {
return { extinguish: false, reason: null, waited_ms: 0 };
}
const waited = current.ms ?? 0;
if (waited < hours * H) {
return { extinguish: false, reason: null, waited_ms: waited };
}
return {
extinguish: true,
reason: `Погашен: служба не ответила. Работник ждал чужую службу ${Math.round(waited / H * 10) / 10} ч подряд (порог — ${hours} ч). Это ожидание его восемь часов не съело.`,
waited_ms: waited,
};
}
// --- отсечка 8:00 -------------------------------------------------------------
//
// Местное время сервера — Москва. Тихие часы 23:00–8:00.
//
// 🔴 ДОРОГА К МОСКОВСКОМУ ЧАСУ ОДНА, И ОНА В budget.mjs. Прежде здесь стоял свой
// `Intl.DateTimeFormat` на другой локали — вторая дорога к тому же самому, при том что
// budget.mjs своими словами утверждал «единый прибор московского времени на всю затею,
// и день и час берутся ОТСЮДА… теперь дорога одна». Ответы у них тогда совпадали, и беды
// не было — беда была в том, что это записано неправдой: следующая смена поправила бы
// «единый прибор» и была бы уверена, что поправила всё.
// Заведи здесь второй прибор — покраснеет проверка «час берётся у ЕДИНОГО прибора».
function mskHour(ms) {
return moskovskoeVremya(ms).hour;
}
function isQuiet(h) {
return h >= 23 || h < MORNING_CUTOFF_HOUR;
}
function touchesQuietHours(runStartedAt, now) {
// Прогон захватил тихие часы, если между стартом и сейчас была хоть одна минута 23:00–8:00.
//
// 🪤 Обход идёт получасовыми шагами, и последний шаг почти всегда перелетает за `now` —
// а значит сам конец промежутка остался бы неосмотренным. Прогон, начатый в 22:45
// и осмотренный в 23:10, дал бы шаги 22:45 и 23:15: второй уже за `now`, тихие часы
// не засчитались бы, хотя они наступили. Поэтому оба КОНЦА промежутка смотрятся отдельно
// и всегда: тихий кусок длиной девять часов мимо получасового шага не проскочит,
// а проскочить могли только края.
if (isQuiet(mskHour(runStartedAt))) return true;
if (isQuiet(mskHour(now))) return true;
for (let t = runStartedAt; t <= now; t += 30 * 60_000) {
if (isQuiet(mskHour(t))) return true;
}
return false;
}
export function morningCutoff(worker, now) {
// 🪤 НАЗВАНО ВСЛУХ: слова, которые разбираются ниже, приходят из ДВУХ разных списков,
// и это не описка.
// — `waiting_answer_idle`, `waiting_service` — из `WORKER_STATUSES` (задача 11):
// это пять слов, которыми работник называет себя сам;
// — `extinguished`, `paused` — из `FINAL_STATUSES` (задача 11): их ставит НАДЗИРАТЕЛЬ
// в карточку работника, сам работник таких слов о себе не говорит никогда.
// Отсюда два следствия, которые надо знать заранее:
// 1. живой круг куска 1 (`supervise`, задача 12) кладёт сюда только слово работника,
// поэтому ветки `extinguished` и `paused` в живом круге ЭТОГО куска не сработают;
// 2. они всё равно написаны и закреплены проверками, потому что зовётся `morningCutoff`
// не только кругом: `paused` придёт из команд владельца (кусок 3), `extinguished` —
// из карточки уже погашенного при повторном осмотре. Убрать их «как мёртвый код»
// значило бы отдать эти два случая на самотёк ровно тогда, когда они появятся.
//
// 🔴 Погашенного второй раз не гасим. Гашение — не «состояние на минуту», а конец жизни
// работника: объявить его снова значит написать владельцу вторую строку про то же самое
// и на второе утро подряд.
if (worker.status === 'extinguished') {
return { extinguish: false, reason: 'Уже погашен — второй раз не гасим.' };
}
if (worker.status === 'waiting_answer_idle') {
return { extinguish: false, reason: 'Стоит в чистом простое — ничего не тратит, доживает до ответа.' };
}
// 🔴 Решение владельца: отсечка 8:00 не трогает ещё двоих.
//
// Пауза — команда самого владельца. Погасить по отсечке того, кого владелец сам поставил
// на паузу, значит отменить его же команду его же сторожем. Он вернётся и продолжит.
if (worker.status === 'paused') {
return { extinguish: false, reason: 'Стоит на паузе по команде владельца — отсечка 8:00 его не трогает.' };
}
// У ждущего чужую службу уже есть СВОЙ предел — два часа подряд (serviceWaitHit выше).
// Второй предел ему не нужен: он погаснет по своему, с честной причиной «служба
// не ответила», а не безымянно по отсечке.
if (worker.status === 'waiting_service') {
return { extinguish: false, reason: 'Ждёт чужую службу — у него свой предел в два часа, отсечка 8:00 его не трогает.' };
}
if (!touchesQuietHours(worker.runStartedAt, now)) {
return { extinguish: false, reason: 'Прогон идёт целиком вне тихих часов — отсечка 8:00 к нему не применяется.' };
}
const h = mskHour(now);
if (h >= MORNING_CUTOFF_HOUR && h < 23) {
return { extinguish: true, reason: 'Настало 8:00 — работающие гаснут, чтобы не жечь запас вместе с владельцем днём.' };
}
return { extinguish: false, reason: null };
}
// --- пределы, видные ВО ВРЕМЯ прогона -----------------------------------------
//
// Зовёт это надзиратель (supervisor.mjs) каждый круг, раз в тридцать секунд, пока работник
// жив. Сам работник позвать не может: он другой процесс в другом каталоге и этих модулей
// не видит.
//
// 🔴 ПОРЯДОК НЕ УКРАШЕНИЕ (договор о стыках, дословно):
// место на сервере → общий предел прогона (гасит ВСЕХ) → своя доля работника (гасит одного)
// → восемь часов → отсечка 8:00.
//
// Почему запас раньше часов: бумага (проверка 14) требует, чтобы в сводке было сказано,
// что упёрлись В ЗАПАС, а не во время. Скажешь «восемь часов» — владелец решит, что дело
// во времени, и запустит следующую ночь по пустому запасу.
//
// Почему общий предел раньше личного: по общему гаснут ВСЕ, и назвать при этом личную долю
// значило бы соврать соседу — он гаснет не за свой расход.
export function checkSelfLimits({
billable_ms,
hours_limit,
budget,
disk,
worker,
now,
current_item = null,
}) {
// Место чинит только человек и только руками — поэтому единственное, за что будят
// владельца ночью.
//
// 🔴 Отдельно назван вход «ответа про место не дали ВОВСЕ» (`disk` пустой): гасим и будим
// так же, как при нехватке места, — работать вслепую нельзя. Но человека, поднятого ночью,
// обязаны встретить причиной, а не словами «Причина не названа».
if (!disk?.ok) {
const why = disk?.reason
?? 'Ответа про место не было вовсе — надзиратель его не подал. Работать вслепую нельзя.';
return {
extinguish: true,
wake_owner: true,
reason: `Погашен: на сервере беда с местом. ${why}`,
at_item: current_item,
};
}
// 🔴 Общий предел прогона. Гаснут ВСЕ работники прогона, даже тот, кто не выбрал
// ни своих часов, ни своей доли.
if (budget?.run_over_limit) {
return {
extinguish: true,
wake_owner: false,
reason: 'Погашен: прогон выбрал свою седьмую часть недельного запаса. Гаснут все работники прогона, независимо от отработанных часов. Упёрлись в запас, а не во время.',
at_item: current_item,
};
}
// Своя доля работника: доля прогона, делённая на число его работников. Гаснет он ОДИН.
if (budget?.worker_over_limit) {
return {
extinguish: true,
wake_owner: false,
reason: 'Погашен: работник выбрал свою долю недельного запаса (доля прогона, делённая на число его работников). Гаснет он один, соседи работают дальше.',
at_item: current_item,
};
}
// 🔴 Замера запаса нет вовсе (`budget` пустой) — посреди ночи по запасу НЕ гасим:
// прибор мог промолчать один круг. Слепой СТАРТ запрещён отдельно, воротами перед
// запуском. Это решение названо вслух в плане куска 2.
//
// 🔴 А вот потерянный ПРЕДЕЛ ЧАСОВ — не «прибор промолчал», а дыра в настройках: без него
// работник не погаснет НИКОГДА. Гасим и говорим прямо, чего не хватает. Владельца ночью
// не будим — будят только за место, его чинит человек руками. Само правило `hoursLimitHit`
// на таком входе падает громко; здесь этот вход разобран отдельно, чтобы надзиратель
// не умер посреди круга, а честно погасил работника с названной причиной.
if (!Number.isFinite(hours_limit)) {
return {
extinguish: true,
wake_owner: false,
reason: 'Погашен: не сказано, сколько часов отводится работнику (потеряна настройка hours_limit). '
+ 'Без предела он не погаснет никогда и будет жечь запас до утра, поэтому гасим сейчас. '
+ 'Сделанное осталось на месте.',
at_item: current_item,
};
}
if (hoursLimitHit(billable_ms, hours_limit)) {
return {
extinguish: true,
wake_owner: false,
reason: `Погашен: работник упёрся в восемь часов. Сделанное осталось на месте.`,
at_item: current_item,
};
}
const cut = morningCutoff(worker, now);
if (cut.extinguish) {
return { extinguish: true, wake_owner: false, reason: cut.reason, at_item: current_item };
}
return { extinguish: false, wake_owner: false, reason: null, at_item: current_item };
}