6d095d582c
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>
252 lines
18 KiB
JavaScript
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 };
|
|
}
|