310 lines
22 KiB
JavaScript
310 lines
22 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 };
|
||
}
|
||
|
||
// ── кусок 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: 'продолжение разрешено; оно считается НОВЫМ прогоном: восемь часов пойдут заново, запас тратится дальше',
|
||
};
|
||
}
|