Files
portal/tools/night/limits.mjs
T

310 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Пределы прогона. Сторожа-снаружи в этом куске нет вовсе, поэтому здесь не «второй рубеж»,
// а единственный. Зовёт эти правила НАДЗИРАТЕЛЬ (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 };
}
// ── кусок 6, задача 5: трое ворот кнопки «продолжить» ────────────────────────────────
//
// 🔴 Это ОТДЕЛЬНОЕ правило, а не ветка `checkSelfLimits`: у той свой закрытый список причин
// и свои проверки, и внутри неё я ничего не переставляю и не дописываю. Кнопка «продолжить»
// вообще не ходит через девять ворот обычного запуска — дословный текст проверки 9 бумаги:
// «больше кнопка не проверяет ничего». Ни отклик владельца, ни живой работник прошлого
// прогона её не держат: поставь их сюда — и кнопка не сработала бы никогда.
//
// 🔴 ПОРЯДОК ПРИЧИН — НЕ УКРАШЕНИЕ: отпечаток → место → запас. Владелец читает ОДНУ строку
// и по ней решает, что чинить. Назови ему «мало запаса», когда на деле сменился план, —
// и он пойдёт покупать запас вместо того, чтобы посмотреть на план.
const VOROTA_PRODOLZHENIYA = [
['seal', 'отпечаток плана не сошёлся: план правили после того, как работник по нему пошёл'],
['disk', 'на сервере нет места'],
['budget', 'остаток запаса не позволяет продолжать'],
];
// Ответ ворот бывает трёх видов: настоящее «да», настоящее «нет» и «не мерили».
// 🪤 Годны только НАСТОЯЩИЕ `true` / `false`. Ловушка истинности здесь стоит подъёма
// работника: строка «нет» в `if (otvet)` отдаёт ИСТИНУ, и ворота пропустили бы отказ
// как разрешение. Поэтому всё, что не `true` и не `false`, — это «не мерили», то есть
// «нельзя», а не «наверное можно».
function otvetVorot(znachenie) {
if (znachenie === true) return { proshli: true, pochemu: null };
if (znachenie === false) return { proshli: false, pochemu: null };
if (znachenie && typeof znachenie === 'object' && !Array.isArray(znachenie)) {
if (znachenie.ok === true) return { proshli: true, pochemu: null };
if (znachenie.ok === false) return { proshli: false, pochemu: znachenie.reason ?? null };
}
return { proshli: false, pochemu: 'не мерили — остаток неизвестен' };
}
export function resumeChecks({ seal, budget, disk } = {}) {
// 🔴 Список ворот ЗАКРЫТ — трое. Чужие поля в доводах правило не замечает намеренно:
// иначе список тихо разрастётся, и кнопка перестанет срабатывать вовсе.
const podano = { seal, disk, budget };
for (const [imya, slova] of VOROTA_PRODOLZHENIYA) {
const otvet = otvetVorot(podano[imya]);
if (otvet.proshli) continue;
const prichina = otvet.pochemu ? `${slova} (${otvet.pochemu})` : slova;
return {
ok: false,
counts_as_new_run: false,
reason: prichina,
summary_line: `продолжить не вышло: ${prichina}`,
};
}
return {
ok: true,
counts_as_new_run: true,
reason: null,
// 🔴 Владелец обязан УЗНАТЬ СЛОВАМИ, что это считается новым прогоном: восемь часов
// пойдут заново, а запас будет тратиться дальше. Молчаливое продолжение «как будто
// той же ночи» — обман по часам и по деньгам.
summary_line: 'продолжение разрешено; оно считается НОВЫМ прогоном: восемь часов пойдут заново, запас тратится дальше',
};
}