diff --git a/cspell-words.txt b/cspell-words.txt index 15a9ca86..481f48f0 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -2589,3 +2589,31 @@ stenogramma vorota deneg zovushchih +utro +zavesti +Sudyu +sudya +nochi +zaveden +platnyy +tolko +cherez +rodit +Rebyonka +minut +Konchilas +Priborom +Punkty +SUDI +Sudye +chego +itog +nakaz +nemye +otlozhennye +prigovor +prigovory +slova +tekst +uliki +vladeltsu diff --git a/tools/night/sudya-nochi-zamer-do.md b/tools/night/sudya-nochi-zamer-do.md new file mode 100644 index 00000000..7b6315c3 --- /dev/null +++ b/tools/night/sudya-nochi-zamer-do.md @@ -0,0 +1,392 @@ +# Судья ночи — что построено, чем замерено и чего он НЕ умеет + +**Смена от 06.08.2026, угол `night1`, ветка `feat/night-dispatcher-part1`.** +Исполнено решение владельца 12 (протокол `docs/grilling/2026-08-02-nastroyki-nochi-i-ramka-deneg.md`, +раздел «🔴🔴 У НОЧИ БУДЕТ СВОЙ СУДЬЯ») и решения 134/135, откладывавшие третью ночь до тех пор, +пока судья не построен. Двенадцать смен решение не исполнялось. + +Живого судью НЕ заводил ни разу. Ни одной копейки владельца не потрачено. + +--- + +## 1. Что построено, простыми словами + +Когда гаснет **последний** работник прогона и задача доделана целиком, ночь сама заводит +**одного** помощника, который не писал ни задания, ни строчки работы. Ему дают пять вопросов +и пути ко всем уликам этой ночи. Он читает, проверяет числа командами, ничего не чинит — +и пишет приговор. Приговор ложится файлом `prigovor-sudi.md` рядом с уликами прогона, а в +утренней бумаге владельца появляется строка: «Судья ночи прочёл эту ночь целиком и сказал +вот что: …». + +Три новых файла и две правки: + +| файл | что в нём | +|---|---| +| `tools/night/sudya-nochi.mjs` | наказ судье, сбор улик, замок «не больше одного раза», рождение, запись приговора | +| `tools/night/sudya-nochi.test.mjs` | 31 проверка | +| `tools/night/sudya-nochi-zamer-do.md` | вот эта бумага | +| `tools/night/supervisor.mjs` | зов судьи в `closeRunIfLast` + довод `zavestiSudyuFn` | +| `tools/night/supervisor.test.mjs` | 6 проверок на зов | + +Больше не тронуто **ничего**. + +### Пять вопросов — не черновик + +Взяты **дословно** из настоящего приговора настоящего судьи +(`docs/superpowers/priyomka/4/sudya-tretiy-pribor-2026-08-03.md`): + +1. Где надзиратель и помощник согласились, а правда другая +2. Числа, которые не сходятся +3. Правила, которые не следуют из своих находок +4. Чего в журнале нет вовсе +5. Похвала как порча + +### Четыре правила, каждое куплено болью + +- **Довод принят ВМЕСТЕ с зовом.** Правило хозяйства самого `supervisor.mjs`: шесть доводов + «на вырост» когда-то приняли и не звали — вышла кнопка «стоп», которая никого не слышит. + Довод `zavestiSudyuFn` зовётся в том же куске, и на зов стоят проверки, краснеющие без него. +- **Не больше одного раза за прогон.** Место занимается файлом `sudya.json` с запретом на + перезапись (`flag: 'wx'`) — одно неделимое действие. Двое, дошедшие в одну секунду, не разойдутся. +- **Смерть судьи не роняет ночь.** Итог прогона (`writeRunOutcome`, `last-run.json`, утренняя + бумага) кладётся **строкой выше** зова судьи. На это стоит отдельная проверка: она смотрит, + что в минуту зова итог **уже лежал**. +- **Но молча его смерть не глотается.** Всякая неудача — строка владельцу в + `summary-lines.jsonl` и запись `sudya_ne_zaveden` в ленту прогона. + +### Судья — не работник + +Карточки в `workers/` ему не заводится, приговоров о пунктах он не пишет, в состав прогона +не вписывается. Значит: потолка задач не съедает, счёт закрытых пунктов не портит, +`amILast` его не видит. Проверено двумя проверками. + +### Строка в сводке — без единой правки чужого файла + +Судья пишет свою строку в `summary-lines.jsonl` — ту же улику, куда пишет надзиратель. +`utro.mjs` читает её сам и печатает в графе «ЧТО НОЧЬ РАССКАЗАЛА О СЕБЕ». `summary.mjs` +и `utro.mjs` не тронуты ни знаком. Замерено живьём: + +``` +ЧТО НОЧЬ РАССКАЗАЛА О СЕБЕ + · Судья ночи прочёл эту ночь целиком и сказал вот что: двое сошлись на числе, + которого никто не мерил. Весь его приговор лежит отдельным файлом в каталоге + этого прогона — имя файла ниже, в части для мастера: (слово мастера). +``` + +Путь заменён на «(слово мастера)» — так утренняя бумага поступает со **всякой** латиницей. +Строка написана так, чтобы **читаться и без хвоста**; полный путь стоит ниже, в части для +мастера, и там он целый. Чинить это без правки `utro.mjs` нечем — а её мне не разрешали. + +--- + +## 2. 🔴 ПРИЁМКА ВЫРЕЗАНИЕМ — с числами + +Каждая правка вносилась и **возвращалась** обычной правкой файла. + +| # | что вырезал | что должно было покраснеть | покраснело | +|---|---|---|---| +| 1 | зов `zavestiSudyuFn` в `closeRunIfLast` | проверки на зов | **3 красных из 248** | +| 2 | вернул зов | всё зелено | **248 из 248** | +| 3 | запрет на перезапись отметки (`flag: 'wx'`) | защита «не больше одного раза» | **2 красных из 31** | +| 4 | пояс `try/catch` вокруг зова судьи | прогон падает вместе с судьёй | **1 красная из 217** | + +**Поимённо, чтобы можно было повторить:** + +**Вырез 1** — `supervisor.mjs`, вместо `await zavestiSudyuFn({…})` поставлено `void zavestiSudyuFn;`. +Покраснели: + +- «погас ПОСЛЕДНИЙ работник и задача доделана — судья позван РОВНО ОДИН раз» +- «🔴 итог прогона лёг ДО судьи — упади судья, утренняя бумага уже на месте» +- «🔴🔴 судья УПАЛ — ночь всё равно закрыта, а его смерть НЕ проглочена молча» + +**Вырез 3** — `sudya-nochi.mjs`, у `writeFileSync` убран `{ flag: 'wx' }`. Покраснели: + +- «второй заход не занимает место и говорит почему» +- «🔴 два зова `zavestiSudyu` подряд рождают судью РОВНО ОДИН раз» (счётчик рождений стал 2) + +**Вырез 4** — `supervisor.mjs`, пояс заменён на `throw err`. Покраснела проверка «судья УПАЛ» +с настоящим обвалом: `Error: судья взорвался на ровном месте` — то есть без пояса падение +судьи вправду уносит надзирателя, а с ним и закрытие ночи. + +### Живой замер вне прибора проверок + +Отдельно прогнан настоящий `zavestiSudyu` **вне** vitest (дорога к рождению — подставная, +денег ноль). Замерено: + +- судья «упал» → `zaveden: false`, файла приговора **нет**, в `summary-lines.jsonl` легла + строка «Судью завести не удалось: …», в ленту — `sudya_ne_zaveden`; +- второй зов после падения → **0 рождений**, ответ «судью по этому прогону уже заводили». + +--- + +## 3. Полный прогон + +``` +npm run test:tools +Test Files 104 passed (104) + Tests 2824 passed (2824) +код возврата 0 +``` + +Было до смены: **2787 проверок в 103 файлах**. Стало: **2824 в 104**. Прибавилось 37 (31 + 6). + +### 🔴 По дороге сработали три чужих сторожа, и они были правы + +Это стоит записать: новый файл **сам по себе** нарушил три живых правила хозяйства, и все три +пришлось лечить **со своей стороны** — списки живут в чужих файлах, которые смене трогать нельзя. + +1. **`platnyy-zamer-tolko-cherez-schyot.test.mjs`** — «к деньгам владельца подходят ровно три + файла поимённо». Первый заход ввозил `startWorker` прямо в судью. ⇒ **Ввоз убран.** Дорогу + к рождению подаёт надзиратель доводом `roditRebyonka` — он и есть «единственный зовущий + рождения» по формулировке самого сторожа. Так ворота счёта рождений (решение владельца 9) + продолжают считать **все** рождения за ночь, а не часть. +2. **`paths.test.mjs`** — «всякая настройка, которую читает код, стоит в образце + `night.config.example.json`». Первый заход читал `sudya_model` и `sudya_minut`. Образец + править нельзя ⇒ **обе настройки сняты**: срок судьи — число в коде (`PREDEL_SUDI_MINUT = 20`), + голова — та же, что у работников (`worker_model`). Кто будет заводить настройки — заводит + их **обе разом**, и в образце, и в коде. +3. **`nemye-prigovory.test.mjs`** — «приговор владельцу без читателя — немой». Он узнаёт цели + по слову `tekst:` в теле функции; у меня в этом поле лежал текст приговора судьи. ⇒ **Поле + переименовано в `prigovor`.** Говорю это вслух, а не прячу: сторож здесь ошибался — приговор + судьи уходит к владельцу **другой** дорогой (файлом и строкой в сводке), которую он видеть + не умеет. Имя `prigovor` вернее по смыслу; что приговор вправду доезжает, сторожат две + собственные проверки этого файла. + +--- + +## 4. 🔴 ЧЕГО ЭТОТ СУДЬЯ НЕ УМЕЕТ + +Пишу поимённо, потому что это ценнее похвалы. + +1. **🔴🔴 Главное: судья приходит только на УДАЧНУЮ ночь, а решение 12 говорит «не реже раза + за ночь».** Зов стоит на флаге `reviewer_starts`, а этот флаг блок 5а ставит **только** + когда все пункты задачи засчитаны. Отложи ночь хоть один пункт — флага нет, судьи нет. + То есть ночь, которую **больше всего** стоило бы судить (половина не вышла, работники + гасли), останется **без единого взгляда со стороны**. Так велел промт задания, и я так + сделал; но с решением владельца это расходится, и решать тут не мне. Развилка, оставшаяся + открытой в самом протоколе допроса («когда именно ночь зовёт судью»), этим **не закрыта**. +2. **Судью никто не судит.** Он ровно такой же прибор, как надзиратель и работники, только + не участвовавший в стройке. Ошибётся — узнать об этом нечем: сторожа на его приговор нет. + Это записал о себе и настоящий судья 03.08.2026, и с тех пор не изменилось ничего. +3. **Судья не видит прошлых ночей.** Улики собираются по **одному** прогону. «Числа, которые + не сходятся» между сменами (а именно там их и находили — счётчик возражений дал пять разных + чисел) он не поймает: сравнивать ему не с чем. +4. **Живого судью не заводили ни разу.** Всё замерено на подставном рождении. Значит + не проверено: успевает ли он за 20 минут; влезает ли наказ с уликами семерых работников + в его голову; понимает ли он наказ вообще. Первый живой заход — это отдельный платный + замер, и делать его без печати владельца нельзя. +5. **Приговор судьи никуда не действует.** Он ложится файлом и строкой в сводку — и всё. + Ни одна находка судьи ночью ничего не меняет: работники уже погасли. Вторая половина + развилки из протокола («что делать с его находками ночью, пока человека нет») **открыта**. +6. **Своей головы и своего срока у судьи нет** — см. §3 пункт 2. Судья читает много и думает + вширь; вполне возможно, ему нужна другая голова, чем работникам. Сегодня выбора нет. +7. **Прогон, в котором не завёлся НИ ОДИН работник, судьи не получает.** Такой прогон + закрывает `run.mjs` своим зовом `writeRunOutcome`, минуя надзирателя. Это, на мой взгляд, + правильно (судить нечего), но сказать вслух обязан. +8. **Наказ судье может устареть молча.** Он написан один раз рукой, из головы, и сторожа + «наказ не разошёлся с образцом приговора» нет. Ровно тот класс, который настоящий судья + и нашёл: «правила теряются между передачами». +9. **Второй зов молчит для владельца** (пишет только в ленту). Сделано нарочно — чтобы + не задваивать строку в сводке, — но если защита однажды сработает не по делу, владелец + об этом из бумаги не узнает. + +--- + +## 5. Что осталось надзирателю + +1. **Развилка «когда звать судью»** — см. §4 пункт 1. Это решение владельца, не моё. +2. **Строка в сводке отдельной графой.** Сегодня приговор судьи стоит наравне с прочими + строками ночи в графе «ЧТО НОЧЬ РАССКАЗАЛА О СЕБЕ», и путь к файлу владелец видит как + «(слово мастера)». Сделать судье **свою** графу («ЧТО СКАЗАЛ СУДЬЯ») можно только правкой + `utro.mjs` — чужого файла, которого мне трогать нельзя. Не делал. +3. **Настройки владельца `sudya_model` и `sudya_minut`** — заводить вместе с образцом + `night.config.example.json`. +4. **Первый живой заход судьи** — платный, только с печати владельца. +5. **Открыть ночи её собственные файлы?** `sudya-nochi.mjs` лежит в `tools/night/` и в список + «кроме» (`claims-audit.mjs`, 13 путей, открытых работникам поимённо) **не внесён** — я его + не трогал и трогать не вправе. Считаю, что так и надо: судья — мерный прибор, и работник, + правящий своего судью, судим быть не может. Но решение это не моё. + +--- + +# ДОПИСКА ОТ 06.08.2026 — ПОЧИНКА ГЛАВНОГО ДОЛГА (§4 пункт 1 ЗАКРЫТ) + +**Тот же угол `night1`, та же ветка. Живого судью снова НЕ заводил ни разу. Ни копейки +владельца не потрачено.** + +Строитель судьи назвал свой главный долг сам и оказался прав: **ошибка была не в судье, +а в надзирателе.** Эта смена её чинит. + +## 6. Что было не так, простыми словами + +Судья приходил **только на удачную ночь**. Зов стоял на признаке `reviewer_starts`, а этот +признак блок 5а поднимает ровно тогда, когда **все** пункты задачи засчитаны: + +```js +const vseZaschitany = otlozhennyePunkty.size === 0; +if (!vseZaschitany) { + pravitKartochkuProgona(runDirPath, { reviewer_starts: false, … }) +``` + +Отложи ночь хоть один пункт — признака нет, судьи нет. Получалось наоборот замыслу: +**ночь, которую больше всего стоило бы судить (половина не вышла, работники гасли), +оставалась без единого взгляда со стороны**, а взгляд получала та, где и так всё сошлось. +Решение владельца 12 говорит дословно: судья заводится **«не реже раза за ночь»**. + +Ломалось это **тихо**: и сводка, и приёмщик вели себя как положено — просто судьи не было, +и об этом никто не узнавал. + +## 7. Что изменено + +Тронуто **пять** файлов и ни одного больше. + +| файл | что сделано | +|---|---| +| `tools/night/supervisor.mjs` | из зова судьи убрана опора на `reviewer_starts`; заведён счёт итога ночи `chemKonchilasNoch`, и он передаётся судье доводом `itog` | +| `tools/night/supervisor.test.mjs` | одна проверка заменена (она закрепляла ошибку), одна добавлена, одна усилена | +| `tools/night/sudya-nochi.mjs` | наказ судье получил раздел «ЧЕМ КОНЧИЛАСЬ ЭТА НОЧЬ»; довод `itog` доносится до наказа | +| `tools/night/sudya-nochi.test.mjs` | 6 новых проверок | +| `tools/night/sudya-nochi-zamer-do.md` | эта дописка | + +### 7.1. Судья приходит на каждую ночь + +Дошло дело до закрытия прогона — судья заводится. Удачная ночь, наполовину удачная, +провальная — одинаково. **Признак `reviewer_starts` не тронут ни знаком**: он про сборщик +экранов продукта и нужен затее сам по себе. Зов на него просто больше не смотрит. + +### 7.2. 🔴 Судье говорят, ЧЕМ НОЧЬ КОНЧИЛАСЬ — и это меняет его работу целиком + +Раньше он этого не получал вовсе. Теперь в наказе стоит: + +``` +━━━ ЧЕМ КОНЧИЛАСЬ ЭТА НОЧЬ ━━━ + +🔴 Это СЛОВО НАДЗИРАТЕЛЯ о самом себе, а не замер. Он — один из тех двоих, кого ты +судишь. Пересчитай эти числа по уликам сам; разойдись они с уликами — это находка, +и находка дорогая. + +· Пунктов в задании: 2 +· Засчитано надзирателем: 1 — а именно: 2 +· НЕ закрыто: 1 — а именно: 1 +· Из них отложено надзирателем (пункт дважды не сошёлся): 1 +``` + +и дальше — **разный главный вопрос**: + +- **ночь не доделана** → «ПОЧЕМУ НЕ ВЫШЛО. Ответь на это ПЕРВЫМ, прежде пяти вопросов… + скажи прямо: помешало ли делу устройство самой ночи (правила надзирателя, разводка + работников по файлам, сроки, пределы) или дело было только в самой работе»; +- **ночь доделана** → «Тем внимательнее смотри, ЧЕМ ЗА ЭТО ЗАПЛАЧЕНО. Слово „засчитано" + сказал надзиратель о работе, которую сам же и раздавал»; +- **пунктов в задании нет вовсе** → сказано и это; +- **итога не подали** → «Чем кончилась эта ночь, мне не сказали — сочти сам и скажи вслух, + что считал сам». Молчание тут было бы неотличимо от «ночь удалась». + +Три решения внутри, каждое названо вслух: + +1. **Числа считаются ПО ФАЙЛАМ, а не по памяти надзирателя.** Источник закрытого — тот же + `closedItemsOfRun`, каким считают «сделано» очередь пунктов и след отложенной задачи. + Второго счёта не заведено: разойдись они — владелец получил бы утром два разных числа + об одной ночи. +2. **Числа поданы судье как СЛОВО НАДЗИРАТЕЛЯ, а не как замер.** Их даёт тот самый, кого + судья судит. Не скажи мы этого — судья принял бы их за проверенное, а «поверил числу + подсудимого» и есть та самая болезнь, ради которой он заводится. +3. **Список отложенного честно неполон, и это написано прямо в наказе.** Отложенное помнит + только тот надзиратель, который откладывал (дыра, названная ещё в самом + `otlozhennyePunkty`). Отложенное соседями попадает в «НЕ закрыто», но не в «отложено» — + судья видит оба списка и потому не примет часть за целое. + +## 8. 🔴 ПРИЁМКА ВЫРЕЗАНИЕМ — с числами + +Каждая правка вносилась и **возвращалась** обычной правкой файла. + +| # | что вырезал | что должно было покраснеть | покраснело | +|---|---|---|---| +| 0 | ничего (замер ДО починки: новые проверки против сегодняшнего кода) | все новые | **9 красных из 255** | +| 1 | вернул зависимость от `reviewer_starts` | судья не приходит на провальную ночь | **2 красных из 218** | +| 2 | снял передачу `itog` в наказ | судья не знает, чем кончилась ночь | **1 красная из 37** | +| 3 | снял запрет на перезапись отметки (`flag: 'wx'`) | защита «не больше одного судьи за прогон» | **2 красных из 255** | + +**Поимённо, чтобы можно было повторить:** + +**Замер 0** — новые проверки прогнаны против **непочиненного** кода. Покраснели все девять: +шесть в `sudya-nochi.test.mjs` (весь новый раздел про итог) и три в `supervisor.test.mjs` +(«погас последний… судья позван ровно один раз» — на новом требовании об итоге; +«🔴🔴 ПУНКТ ОТЛОЖЕН… судья ВСЁ РАВНО приходит»; «флага приёмщика нет… судья ВСЁ РАВНО зван»). + +**Вырез 1** — `supervisor.mjs`, перед зовом возвращена строка +`if (readJson(...)?.reviewer_starts !== true) return;`. Покраснели ровно те две, ради +которых всё и делалось: + +- «🔴🔴 ПУНКТ ОТЛОЖЕН, ночь НЕ доделана — судья ВСЁ РАВНО приходит, и знает, что не вышло» + (`ночь, которую больше всего стоило судить, осталась без судьи`); +- «🔴 флага приёмщика нет и до пунктов дело не дошло — судья ВСЁ РАВНО зван». + +**Вырез 2** — `sudya-nochi.mjs`, `nakazSudye({ runId, uliki, itog })` заменено на +`nakazSudye({ runId, uliki })`. Покраснела сквозная: «итог доезжает СКВОЗЬ `zavestiSudyu` +до самого наказа, а не теряется по дороге». + +**Вырез 3** — `sudya-nochi.mjs`, у `writeFileSync` убран `{ flag: 'wx' }`. Покраснели обе +проверки защиты: «второй заход не занимает место и говорит почему» и «два зова +`zavestiSudyu` подряд рождают судью РОВНО ОДИН раз» (счётчик рождений стал 2). **Защита +жива.** + +### Пояс «смерть судьи не роняет ночь» не тронут + +Проверка «🔴🔴 судья УПАЛ — ночь всё равно закрыта, а его смерть НЕ проглочена молча» +осталась на месте и зелена: итог прогона по-прежнему кладётся **строкой выше** зова, а +неудача уходит строкой владельцу и записью `sudya_ne_zaveden` в ленту. Вырез этого пояса +делала прошлая смена (1 красная из 217) — повторно не резал. + +## 9. Полный прогон + +``` +npm run test:tools +Test Files 104 passed (104) + Tests 2831 passed (2831) +код возврата 0 +``` + +Было до правки: **2824 в 104**. Стало: **2831 в 104**. Прибавилось 7 (6 новых в +`sudya-nochi.test.mjs`, 1 новая в `supervisor.test.mjs`; ещё одна там же **заменена** — +прежняя «флага приёмки нет — судью НЕ зовём» закрепляла как раз ту ошибку, которую чиним, +и оставить её значило бы охранять баг). + +### 🪤 Чего я боялся и чего не случилось + +Снятие признака открывает зов судьи для **всякой** ночи, а значит и для тех проверок в +чужих файлах, где подставки судьи нет (`slova-vladeltsu.test.mjs` зовёт круг напрямую). +Там теперь работает **настоящий** `zavestiSudyu` — и упирается в собственный пол +`podPriboromProverok`: живого рождения нет, денег ноль, в сводку ложится честное «судью +завести не удалось: идёт прибор проверок». Ни одна чужая проверка от этого не покраснела — +замерено полным прогоном выше, а не выведено рассуждением. + +## 10. 🔴 ЧЕГО ЭТА ПРАВКА НЕ ЗАКРЫВАЕТ + +Честно и поимённо. **§4 пункт 1 закрыт, остальные восемь пунктов §4 живы полностью:** + +1. **Судью по-прежнему никто не судит** (§4 п. 2). Ошибётся — узнать нечем. +2. **Судья по-прежнему не видит прошлых ночей** (§4 п. 3). Улики — по одному прогону. +3. **Живого судью так и не заводили ни разу** (§4 п. 4). Всё замерено на подставном + рождении. Не проверено: успевает ли он за 20 минут, влезает ли наказ (а он стал + **длиннее** на раздел об итоге) в его голову, понимает ли он наказ вообще. Первый + живой заход — платный, только с печати владельца. +4. **Приговор судьи по-прежнему никуда не действует** (§4 п. 5). Он ложится файлом и + строкой в сводку — и всё. Вторая половина развилки из протокола («что делать с его + находками ночью, пока человека нет») **осталась открытой**: эта правка закрыла только + вопрос «когда звать», и то наполовину. +5. **Своей головы и своего срока у судьи по-прежнему нет** (§4 п. 6, §3 п. 2). +6. **Прогон, в котором не завёлся НИ ОДИН работник, судьи по-прежнему не получает** + (§4 п. 7). Такой прогон закрывает `run.mjs` своим зовом `writeRunOutcome`, минуя + надзирателя, а `run.mjs` мне трогать нельзя. **Говорю прямо: это единственная дыра + в требовании «на каждую ночь», которая осталась.** Ночь, где всё погибло на рождении, + судьи не увидит. +7. **Наказ судье может устареть молча** (§4 п. 8) — и теперь его стало **больше**, значит + расхождению есть где спрятаться. +8. **Второй зов по-прежнему молчит для владельца** (§4 п. 9). +9. **Число «отложено» неполно между надзирателями.** Оно верно для того, кто откладывал, + и пусто для соседей. В наказе это сказано вслух, но починить это нечем без пятого файла + состояния, которого затея не заводила. +10. **Порядок «сколько попыток дали пункту» судья узнаёт не из итога, а из улик.** Я передаю + номера и счёт, но не историю попыток: она есть в ленте прогона, и наказ велит её читать. + Если ленты не будет — судья об этом узнает из списка `chego_net`, но не из итога. diff --git a/tools/night/sudya-nochi.mjs b/tools/night/sudya-nochi.mjs new file mode 100644 index 00000000..10cd51a1 --- /dev/null +++ b/tools/night/sudya-nochi.mjs @@ -0,0 +1,718 @@ +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// СУДЬЯ НОЧИ — третий прибор, смотрящий на работу ЦЕЛИКОМ +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// +// 🔴🔴 ЗАЧЕМ ОН ЕСТЬ. Решение владельца 12 (протокол `docs/grilling/2026-08-02-nastroyki-nochi- +// i-ramka-deneg.md`): «ночь заводит своего судью». Довод владельца замерен числом, а не выведен: +// за целую смену помощники не поймали НИ ОДНОЙ ошибки надзирателя — все пять он нашёл сам. +// Причина не в помощниках: правило «ничего другого не трогай» разводит их по файлам и +// ОБРЕЗАЕТ ИМ ОБЗОР ДО СВОЕГО ФАЙЛА. Ночь разводит работников так же ⇒ перекос неизбежен +// по устройству. Судья — тот, кто не писал ни задания, ни работы, и потому видит целое. +// +// 🔴 ЭТО НЕ `reviewer.mjs`. Тот файл собирает экраны продукта к сводке и к судье отношения +// не имеет. Два прибора, два имени, ни одной общей строки. +// +// 🔴 ЦЕНА, НАЗВАННАЯ ВЛАДЕЛЬЦУ И ПРИНЯТАЯ: одно-два лишних рождения за ночь. +// +// ━━━ ПЯТЬ ПРАВИЛ ХОЗЯЙСТВА, КАЖДОЕ КУПЛЕНО БОЛЬЮ ━━━ +// +// 0. 🔴🔴 СУДЬЯ ПРИХОДИТ НА КАЖДУЮ НОЧЬ, дошедшую до закрытия прогона: удачную, +// наполовину удачную и провальную — одинаково. Так велит решение владельца 12 +// дословно: «не реже раза за ночь». Первый заход строился иначе — зов стоял на флаге +// приёмщика `reviewer_starts`, а тот поднимается ТОЛЬКО когда все пункты засчитаны, — +// и выходило ровно наоборот замыслу: ночь, которую больше всего стоило судить, +// оставалась без единого взгляда со стороны. Починено 06.08.2026. +// И вместе с зовом судье говорят, ЧЕМ НОЧЬ КОНЧИЛАСЬ (довод `itog`): на провальной +// ночи главный вопрос другой, и не зная итога, судья судил бы провал как удачу. +// 1. Судья заводится НЕ БОЛЬШЕ ОДНОГО РАЗА за прогон. Дважды — это лишние деньги владельца +// и ДВЕ РАЗНЫЕ ПРАВДЫ утром, между которыми ему пришлось бы выбирать. Место занимается +// файлом-отметкой с запретом на перезапись (`flag: 'wx'`) — это одно неделимое действие +// файловой системы, и двое, дошедшие сюда в одну секунду, не разойдутся. +// 2. Смерть судьи НЕ РОНЯЕТ ночь. Судья — оценка, а не работа: не завёлся, упал, кончилось +// время — прогон закрывается как обычно. Итог прогона (`writeRunOutcome`) кладётся +// ДО зова судьи, а не после, и это не вкусовщина: случись наоборот, упавший судья унёс +// бы с собой утреннюю бумагу владельца. +// 3. НО МОЛЧА ГЛОТАТЬ ЕГО СМЕРТЬ НЕЛЬЗЯ. «Никогда не роняем» уже однажды превращало ошибку +// в тишину (память: `feedback-best-effort-glotaet-moyu-oshibku.md`). Всякая неудача судьи +// уходит СТРОКОЙ В СВОДКУ ВЛАДЕЛЬЦУ и записью в ленту прогона. +// 4. Судья НЕ РАБОТНИК. Карточки в `workers/` ему не заводится, приговоров о пунктах он +// не пишет, в состав прогона не вписывается — значит ни потолка задач не съедает, +// ни счёта закрытых пунктов не портит, и `amILast` его не видит вовсе. +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { readJson, appendLine } from './state.mjs'; +import { logEvent } from './heartbeat.mjs'; +// 🔴 Имя файла сводки знает ОДНО место на всю затею. Своей строки-копии здесь нет намеренно: +// переименуют файл там — судья писал бы в старое имя, а утренняя бумага читала бы новое +// и видела пустоту (та же беда уже ловилась в надзирателе). +import { SUMMARY_LINES_FILE, REPO_ROOT, priyomkaDir, podPriboromProverok } from './paths.mjs'; +// 🔴🔴 РОЖДЕНИЯ ЗДЕСЬ НЕТ И НЕ БУДЕТ — ЕГО ПОДАЁТ НАДЗИРАТЕЛЬ ДОВОДОМ. Это не удобство, +// а требование живого сторожа `platnyy-zamer-tolko-cherez-schyot.test.mjs`: подходить +// к деньгам владельца разрешено ровно трём файлам поимённо (`spawn.mjs` — дом рождения, +// `budget.mjs` — крошечный замер, `supervisor.mjs` — единственный зовущий). Судья в этот +// список не входит и входить не должен: заведи он свою дорогу к запуску — ворота счёта +// рождений (решение владельца 9) считали бы не все рождения за ночь, а часть, и предел +// владельца молча перестал бы быть пределом. Поэтому здесь ввозится только разбор потока. +import { parseStreamLine } from './spawn.mjs'; + +// --- имена и числа ----------------------------------------------------------- + +// Как судья зовётся. Не `w-…`: он не работник, и путать их именами нельзя. +export const SUDYA_ID = 'sudya'; + +// Куда ложится его приговор — файлом, в каталоге прогона, рядом с уликами. +export const PRIGOVOR_FILE = 'prigovor-sudi.md'; + +// Отметка «судью по этому прогону уже заводили». Она же — замок от второго рождения. +export const OTMETKA_FILE = 'sudya.json'; + +// 🔴 Метка, с которой судья ОБЯЗАН начать приговор. Из неё берётся строка в утреннюю сводку: +// без неё владелец получил бы только путь к файлу и не узнал бы, что судья вообще сказал. +export const METKA_GLAVNOGO = 'ГЛАВНОЕ ОДНОЙ СТРОКОЙ:'; + +// 🔴 Сколько ждём судью. Число не с потолка и не от щедрости: настоящий судья 03.08.2026 +// (`docs/superpowers/priyomka/4/sudya-tretiy-pribor-2026-08-03.md`) читал бумаги и проверял +// утверждения командами — это работа на десятки минут, а не на минуту. Но и вечно ждать +// нельзя: судья, зациклившийся в четыре утра, жёг бы запас владельца до самого утра, а ночь +// уже закрыта и его никто не сторожит. +// +// 🟡 СВОЕЙ НАСТРОЙКИ У ВЛАДЕЛЬЦА ПОКА НЕТ, И Я ГОВОРЮ ЭТО ВСЛУХ, А НЕ ПРЯЧУ. Число и голова +// судьи просились в настройки (`sudya_minut`, `sudya_model`) — но всякая настройка, которую +// читает код, обязана стоять в образце `night.config.example.json`, а этот файл смене +// заводить не разрешено. Читать настройку, которой нет в образце, — значит завести молчаливое +// расхождение образца с живым кодом, и на это в затее стоит свой сторож (`paths.test.mjs`). +// ⇒ Пока: срок — вот это число, голова — та же, что у работников (`worker_model`). +// Кто будет заводить настройки, заводит их ОБЕ разом: и в образце, и здесь. +export const PREDEL_SUDI_MINUT = 20; + +// ━━━ ПЯТЬ ВОПРОСОВ. ЭТО НЕ ЧЕРНОВИК — ЭТО ПРОВЕРЕННЫЙ ДЕЛОМ НАКАЗ ━━━ +// +// 🔴 Взяты дословно из настоящего приговора настоящего судьи (тот же файл приёмки куска 4). +// Менять их можно только с замером: каждый из пяти на живой работе что-то нашёл, и вопрос 4 +// («чего в журнале нет вовсе») вскрыл ровно ту беду, из-за которой судья и заводится. +export const PYAT_VOPROSOV = Object.freeze([ + 'Ответ 1. Где надзиратель и помощник согласились, а правда другая', + 'Ответ 2. Числа, которые не сходятся', + 'Ответ 3. Правила, которые не следуют из своих находок', + 'Ответ 4. Чего в журнале нет вовсе', + 'Ответ 5. Похвала как порча', +]); + +// --- улики ------------------------------------------------------------------- + +function estLiFayl(put) { + try { return Boolean(put) && existsSync(put); } catch { return false; } +} + +// Бумаги работников `*-zamer-do.md`. Живут в ХРАНИЛИЩЕ (`tools/night/`), а не под `.night`: +// это единственная дорога от работника к владельцу, и работник кладёт их коммитом. +// 🪤 Собираются КОМАНДОЙ (обход каталога), а не перечнем имён: перечень устарел бы к первой же +// новой бумаге и молча увёл бы у судьи улику. Правило самого надзирателя — «всякий перечень +// собирать командой, а не памятью» — и нарушать его в приборе, который за такое и судит, +// было бы смешно. +function bumagiRabotnikov(koren) { + const dir = join(koren, 'tools', 'night'); + try { + return readdirSync(dir) + .filter(f => f.endsWith('-zamer-do.md')) + .sort() + .map(f => join(dir, f)); + } catch { + return []; + } +} + +/** + * Что судье читать. Собирается КОМАНДОЙ по каталогу прогона и хранилищу. + * + * 🔴 Чего НЕ нашлось — называется вслух, отдельным списком (`chego_net`). Молча пропущенная + * улика страшнее отсутствующей: судья решил бы, что прочёл всё, и вынес бы приговор по + * половине дела, не зная об этом. Тот же класс, что «зелёный сторож, смотревший не туда». + */ +export function ulikiProgona(runDirPath, { koren = REPO_ROOT } = {}) { + const chego_net = []; + + const kartochka_progona = join(runDirPath, 'run.json'); + const run = readJson(kartochka_progona) ?? null; + if (!run) chego_net.push('карточки прогона (run.json) нет или она не читается — состав ночи и задание неизвестны'); + + // 🪤 Нет файла — отдаём `null`, а не путь. Иначе наказ звал бы судью читать то, чего нет, + // и «улика пуста» стало бы неотличимо от «улики не существует». + const slovaPut = join(runDirPath, SUMMARY_LINES_FILE); + const slova_nochi = estLiFayl(slovaPut) ? slovaPut : null; + if (!slova_nochi) chego_net.push('слов, которые ночь написала о себе, нет — сводки строк не осталось'); + + const lentaPut = join(runDirPath, 'events.jsonl'); + const lenta = estLiFayl(lentaPut) ? lentaPut : null; + if (!lenta) chego_net.push('ленты событий прогона нет — по минутам ночь восстановить нечем'); + + const rabotniki = []; + const workersDir = join(runDirPath, 'workers'); + let imena = []; + try { imena = readdirSync(workersDir).sort(); } catch { imena = []; } + for (const wid of imena) { + const kartochka = join(workersDir, wid, 'worker.json'); + if (!estLiFayl(kartochka)) continue; + const card = readJson(kartochka) ?? {}; + const konecPut = join(workersDir, wid, 'exit.json'); + const prigovoryPut = join(workersDir, wid, 'items-verified.jsonl'); + rabotniki.push({ + worker_id: card.worker_id ?? wid, + kartochka, + konec: estLiFayl(konecPut) ? konecPut : null, + prigovory: estLiFayl(prigovoryPut) ? prigovoryPut : null, + ugol: card.workspace && estLiFayl(card.workspace) ? card.workspace : null, + }); + } + if (!rabotniki.length) chego_net.push('карточек работников не нашлось ни одной — судить некого и не по чему'); + + // 🪤 Угол работника к этой минуте мог быть уже снесён уборкой. Это НЕ поломка, но и молчать + // нельзя: судья, не найдя в углу кода, обязан знать, что кода там нет вовсе, а не решить, + // что работник ничего не написал. + const bezUgla = rabotniki.filter(r => !r.ugol).map(r => r.worker_id); + if (bezUgla.length) { + chego_net.push(`рабочих углов больше нет у: ${bezUgla.join(', ')} — их код смотреть негде, только по коммитам`); + } + + let zadanie = null; + if (run?.plan_path) { + const put = join(koren, String(run.plan_path)); + if (estLiFayl(put)) zadanie = put; + else if (estLiFayl(String(run.plan_path))) zadanie = String(run.plan_path); + else chego_net.push(`задания (плана) по пути «${run.plan_path}» на месте нет — что именно заказывали, проверить нечем`); + } else { + chego_net.push('в карточке прогона не назван план — задание ночи неизвестно'); + } + + const listPut = priyomkaDir(); + const list = estLiFayl(listPut) ? listPut : null; + if (!list) chego_net.push('приёмочных бумаг нет — сверить работу с тем, как её обещали принимать, нечем'); + + const bumagi = bumagiRabotnikov(koren); + if (!bumagi.length) chego_net.push('бумаг работников «-zamer-do.md» не нашлось ни одной'); + + return { + kartochka_progona, + slova_nochi, + lenta, + rabotniki, + bumagi, + zadanie, + list, + chego_net, + }; +} + +// --- наказ ------------------------------------------------------------------- + +/** + * Чем кончилась ночь — словами, которые прочтёт судья. + * + * 🔴🔴 ЗАЧЕМ ЭТО ВООБЩЕ ЕСТЬ. Судья приходит на КАЖДУЮ ночь, дошедшую до закрытия прогона + * (решение владельца 12: «не реже раза за ночь»). Но провальная ночь и удачная — это два + * РАЗНЫХ дела, и главный вопрос у них разный: на удачной спрашивают «чем заплатили за + * успех», на провальной — «почему не вышло». Приди судья на провал, не зная о нём, он + * судил бы его как удачу, и весь смысл его прихода пропал бы. + * + * 🔴 ЧИСЛА НАЗЫВАЮТСЯ СЛОВОМ НАДЗИРАТЕЛЯ, А НЕ ЗАМЕРОМ. Их даёт тот самый, кого судья + * судит. Не скажи мы этого вслух — судья принял бы их за проверенное, а «поверил числу + * подсудимого» и есть ровно та болезнь, ради которой он заводится. + */ +function slovaOItoge(itog) { + const s = []; + s.push('━━━ ЧЕМ КОНЧИЛАСЬ ЭТА НОЧЬ ━━━'); + s.push(''); + + if (!itog || typeof itog !== 'object') { + s.push('🔴 Чем кончилась эта ночь, мне не сказали — зовущий итога не подал. Сочти сам, по'); + s.push('уликам, и в приговоре скажи вслух, что считал сам: иначе выйдет, будто числа тебе'); + s.push('дали, а их не давал никто.'); + s.push(''); + return s; + } + + const spisok = a => (Array.isArray(a) && a.length ? a.join(', ') : 'ни одного'); + const vsego = Number(itog.vsego ?? 0); + const zakryto = Array.isArray(itog.zakryto) ? itog.zakryto : []; + const neZakryto = Array.isArray(itog.ne_zakryto) ? itog.ne_zakryto : []; + const otlozheno = Array.isArray(itog.otlozheno) ? itog.otlozheno : []; + + s.push('🔴 Это СЛОВО НАДЗИРАТЕЛЯ о самом себе, а не замер. Он — один из тех двоих, кого ты'); + s.push('судишь. Пересчитай эти числа по уликам сам; разойдись они с уликами — это находка,'); + s.push('и находка дорогая.'); + s.push(''); + s.push(`· Пунктов в задании: ${vsego}`); + s.push(`· Засчитано надзирателем: ${zakryto.length} — а именно: ${spisok(zakryto)}`); + s.push(`· НЕ закрыто: ${neZakryto.length} — а именно: ${spisok(neZakryto)}`); + s.push(`· Из них отложено надзирателем (пункт дважды не сошёлся): ${spisok(otlozheno)}`); + // 🪤 Дыра названа вслух: отложенное помнит только тот надзиратель, который откладывал. + s.push(' 🪤 Этот список неполон по устройству: отложенное помнит только тот надзиратель,'); + s.push(' который откладывал. Отложенное соседями стоит выше, в «НЕ закрыто».'); + s.push(''); + + if (vsego === 0) { + s.push('🟡 Пунктов в задании не названо ни одного — судить по счёту закрытого тут нечего.'); + s.push('Тем важнее ответить, дошло ли до работы вообще и что этой ночью вправду произошло.'); + } else if (itog.dodelana === true) { + s.push('🟢 Все пункты задания засчитаны — ночь дошла до конца.'); + s.push('🔴 Тем внимательнее смотри, ЧЕМ ЗА ЭТО ЗАПЛАЧЕНО. Слово «засчитано» сказал'); + s.push('надзиратель о работе, которую сам же и раздавал: выдавал пункты и проверял их'); + s.push('один и тот же. Ночь, где всё зелено, — самый удобный случай для дорогой ошибки'); + s.push('проехать незамеченной.'); + } else { + s.push('🔴🔴 ЭТА НОЧЬ НЕ ДОДЕЛАНА — И ЭТО МЕНЯЕТ ТВОЙ ГЛАВНЫЙ ВОПРОС.'); + s.push('На удачной ночи спрашивают «чем заплатили за успех». Здесь спрашивают другое:'); + s.push('ПОЧЕМУ НЕ ВЫШЛО. Ответь на это ПЕРВЫМ, прежде пяти вопросов, и ответь по уликам,'); + s.push('а не по чувству: возьми каждый незакрытый пункт поимённо, найди, на чём он встал'); + s.push('и сколько попыток ему дали, и скажи прямо: помешало ли делу устройство самой ночи'); + s.push('(правила надзирателя, разводка работников по файлам, сроки, пределы) или дело было'); + s.push('только в самой работе. Ночь, которая не вышла, — это ровно то, ради чего тебя'); + s.push('и заводят.'); + } + s.push(''); + return s; +} + +/** + * Наказ судье. ПРОСТЫМ РУССКИМ: его слова владелец читает в приговоре, а не разбирает. + * + * 🔴 Наказ говорит судье ЧЕТЫРЕ вещи и ни одной лишней: кто он, что читать, чем ночь + * кончилась и о чём отвечать. Ни одного намёка на то, какой ответ мы ждём, здесь нет + * намеренно — судья, которому подсказали вывод, меряет наше согласие, а не правду. Ровно + * этой болезнью и болеет пара «надзиратель + помощник», ради которой судья заводится. + * + * 🔴 `itog` — чем кончилась ночь. Без него судья судил бы всякую ночь как удачную; не дали — + * говорим об этом вслух, а не молчим (то же правило, что у списка `chego_net`). + */ +export function nakazSudye({ runId = null, uliki, itog = null, kudaPisat = PRIGOVOR_FILE } = {}) { + const u = uliki ?? { rabotniki: [], bumagi: [], chego_net: [] }; + const s = []; + + s.push('Ты судья этой ночи. Ты не писал ни задания, ни строчки работы — тебя завели именно'); + s.push('затем, чтобы посмотреть на сделанное со стороны.'); + s.push(''); + s.push('🔴 Почему ты нужен, честно и без прикрас. Ночью работают несколько помощников, и каждому'); + s.push('велено не трогать ничего, кроме своего файла. Это разумно, но у этого есть цена: обзор'); + s.push('каждого обрезан до его файла. Замерено на живой смене: за целый круг помощники не поймали'); + s.push('ни одной ошибки надзирателя — все нашёл он сам. Двое, которые смотрят друг на друга,'); + s.push('строят согласие, а не правду. Ты — третий, и ты не участвовал.'); + s.push(''); + s.push('━━━ ЧТО ТЕБЕ МОЖНО И ЧЕГО НЕЛЬЗЯ ━━━'); + s.push(''); + s.push('· Читай что угодно и проверяй утверждения командами — считай, меряй, открывай файлы.'); + s.push('· 🔴 Ничего не чини и ничего не правь. Ты — оценка, а не работа. Ни одного файла ты'); + s.push(' не трогаешь: свой приговор просто напиши ответом, его положат на место без тебя.'); + s.push('· Не верь ни одному числу на слово — ни моему, ни чужому. Не смог проверить — так'); + s.push(' и напиши: «не проверял и потому не знаю». Это ценнее любой похвалы.'); + s.push('· Не рассуждай там, где можно замерить. Рассуждение о коде — не замена вопросу к коду.'); + s.push(''); + s.push('━━━ УЛИКИ ЭТОЙ НОЧИ ━━━'); + if (runId) s.push(`Прогон: ${runId}`); + s.push(''); + s.push(`· Карточка прогона (кто был, что заказывали, чем кончилось): ${u.kartochka_progona}`); + if (u.slova_nochi) s.push(`· Что ночь написала о себе строка за строкой: ${u.slova_nochi}`); + if (u.lenta) s.push(`· Лента событий по минутам: ${u.lenta}`); + if (u.zadanie) s.push(`· Задание (план), по которому шла ночь: ${u.zadanie}`); + if (u.list) s.push(`· Приёмочные бумаги — как работу обещали принимать: ${u.list}`); + for (const r of u.rabotniki) { + const chasti = [`карточка ${r.kartochka}`]; + if (r.konec) chasti.push(`конец ${r.konec}`); + if (r.prigovory) chasti.push(`приговоры по пунктам ${r.prigovory}`); + if (r.ugol) chasti.push(`рабочий угол ${r.ugol}`); + s.push(`· Работник ${r.worker_id}: ${chasti.join('; ')}`); + } + for (const b of u.bumagi) s.push(`· Бумага работника: ${b}`); + s.push(''); + if (u.chego_net?.length) { + s.push('🔴 ЧЕГО В УЛИКАХ НЕТ (сказано вслух, чтобы ты не принял половину дела за целое):'); + for (const n of u.chego_net) s.push(`· ${n}`); + s.push(''); + } + for (const stroka of slovaOItoge(itog)) s.push(stroka); + s.push('━━━ НА ЧТО ОТВЕТИТЬ. ПЯТЬ ВОПРОСОВ, И ВСЕ ПЯТЬ ОБЯЗАТЕЛЬНЫ ━━━'); + s.push(''); + s.push(`${PYAT_VOPROSOV[0]}.`); + s.push(' Ищи места, где двое сошлись во мнении, а замера под этим мнением нет. Согласие —'); + s.push(' это не доказательство. Самое дорогое такое согласие называй первым.'); + s.push(''); + s.push(`${PYAT_VOPROSOV[1]}.`); + s.push(' Возьми числа из бумаг и пересчитай их сам, командой, а не глазами. Одно число,'); + s.push(' написанное в двух местах по-разному, — находка.'); + s.push(''); + s.push(`${PYAT_VOPROSOV[2]}.`); + s.push(' Каждое новое правило сверь с находкой, из которой оно выросло. Правило шире своей'); + s.push(' находки разрешает то, чего автор не имел в виду. Скажи прямо — что именно.'); + s.push(''); + s.push(`${PYAT_VOPROSOV[3]}.`); + s.push(' Найди вопрос, которого не задал никто. Это самое трудное и самое ценное.'); + s.push(''); + s.push(`${PYAT_VOPROSOV[4]}.`); + s.push(' Посмотри, не измельчали ли возражения после похвалы и не сменился ли их предмет.'); + s.push(' Если нет — так и скажи; ответ «не измельчали» тоже ответ.'); + s.push(''); + s.push('━━━ КАК ПИСАТЬ ПРИГОВОР ━━━'); + s.push(''); + s.push('· Простым русским. Твой приговор читает хозяин, а он не программист.'); + s.push(`· 🔴 ПЕРВОЙ СТРОКОЙ напиши ровно так: «${METKA_GLAVNOGO} …» и одним предложением`); + s.push(' назови самое дорогое, что ты нашёл. Эту строку хозяин прочтёт утром в сводке —'); + s.push(' всё остальное он откроет, только если она его зацепит.'); + s.push('· Дальше — пять разделов по пяти вопросам, ровно в этом порядке.'); + s.push('· В конце — раздел «Чего я сам не проверил и потому не знаю», поимённо. Ты заведён'); + s.push(' против выдавания непроверенного за замеренное — начни с себя.'); + s.push(`· Твой ответ целиком ляжет в файл «${kudaPisat}» рядом с уликами. Писать его руками`); + s.push(' не нужно, и создавать никаких файлов тоже.'); + + return s.join('\n'); +} + +// --- приговор ---------------------------------------------------------------- + +/** + * Главное одной строкой — то, что доедет до владельца в утренней сводке. + * + * 🪤 Метки нет — отвечаем «нет», а не берём первую попавшуюся строку. Первая строка чужого + * текста — это заголовок, вступление или пустота, и выдать её за вывод судьи значило бы + * положить владельцу в сводку случайные слова под видом приговора. + */ +export function glavnoeIzPrigovora(tekst) { + const s = String(tekst ?? ''); + const i = s.indexOf(METKA_GLAVNOGO); + if (i === -1) return null; + const hvost = s.slice(i + METKA_GLAVNOGO.length); + const stroka = hvost.split('\n')[0].trim(); + return stroka ? stroka : null; +} + +/** + * Слово судьи из его потока. + * + * 🔴 Пустой ответ приговором НЕ считается — тот же урок, что у работника (`isRealSuccess`): + * 334 прогона возвращали «ошибки нет» при полном провале. Судья, сказавший пустоту, — это + * судья, который не судил. + */ +export function itogIzPotoka(potok) { + let itog = null; + for (const line of String(potok ?? '').split('\n')) { + const event = parseStreamLine(line); + if (!event || event.type !== 'result') continue; + if (event.is_error) { itog = null; continue; } + const t = typeof event.result === 'string' ? event.result.trim() : ''; + itog = t ? event.result : null; + } + return itog; +} + +// --- замок «не больше одного раза» ------------------------------------------- + +/** + * Занять место судьи. Одно неделимое действие файловой системы. + * + * 🔴 Почему не «прочитать, посмотреть, записать»: прочтение и запись — это два действия, + * и двое, дошедшие сюда в одну секунду, оба прочли бы «судьи нет» и оба родили бы своего. + * Ровно эту беду затея уже доказывала живьём на четырёх процессах для `file-claims.json`. + * Запрет на перезапись (`flag: 'wx'`) не даёт второму даже начать. + */ +export function zanyatMestoSudi(runDirPath, { at = Date.now() } = {}) { + const put = join(runDirPath, OTMETKA_FILE); + try { + mkdirSync(runDirPath, { recursive: true }); + writeFileSync(put, JSON.stringify({ zavodili_v: at, kto: SUDYA_ID }, null, 2), { flag: 'wx' }); + return { ok: true, prichina: null, put }; + } catch (err) { + if (err?.code === 'EEXIST') { + return { + ok: false, + put, + prichina: 'судью по этому прогону уже заводили — второй раз не зовём: это лишние деньги ' + + 'владельца и две разные правды утром', + }; + } + return { ok: false, put, prichina: `отметку судьи положить не удалось: ${err?.message ?? err}` }; + } +} + +// --- рождение ---------------------------------------------------------------- + +// Настройки, с которыми шли работники этого прогона. Судья идёт с ТЕМИ ЖЕ: в них лежат +// замки владельца (запреты, предупреждение про боевой), и пустить судью мимо них значило бы +// выпустить в ночь прибор без предохранителей. +function nastroykiProgona(runDirPath) { + const workersDir = join(runDirPath, 'workers'); + let imena = []; + try { imena = readdirSync(workersDir).sort(); } catch { return null; } + for (const wid of imena) { + const card = readJson(join(workersDir, wid, 'worker.json')); + const put = card?.settings_path; + if (put && existsSync(put)) return put; + } + return null; +} + +/** + * Живое рождение судьи. Отдельным именем — чтобы его можно было подменить в проверках + * и чтобы `zavestiSudyu` осталась читаемой. + * + * 🔴🔴 ПОЛ, А НЕ ПОТОЛОК. Под прибором проверок живого судью не рождаем НИКОГДА — ни с + * подставкой, ни без неё, ни с забытой. Это ровно то же правило и тот же прибор + * (`podPriboromProverok`), каким `paths.mjs` не пускает проверки в оплаченные улики + * владельца. Причина та же: подставку однажды забудут, и забытая подставка тратила бы + * деньги владельца молча, прогон за прогоном. + * 🔴 `env` — ДОВОД, а не `process.env` изнутри: иначе этот пол нечем показать красным, + * а сторож, которого не видели красным, не сторожит ничего. + * + * 🔴 `startFn` — ДОВОД БЕЗ УМОЛЧАНИЯ, и это не забывчивость. Рождение подаёт надзиратель + * (единственный, кому оно разрешено поимённо, — см. ввозы в шапке). Подставь мы умолчание — + * судья завёл бы свою дорогу к деньгам владельца мимо счёта рождений. + * + * Отвечает ВСЕГДА и никогда не бросает: `{ ok, prigovor, prichina }`. + * 🪤 Поле зовётся `prigovor`, а не `tekst`, НАРОЧНО и с оговоркой вслух: по слову `tekst:` + * узнаёт свои цели сторож немых приговоров (`nemye-prigovory.test.mjs`), а он умеет искать + * читателя только внутри кода — приговор же судьи уходит к владельцу ДРУГОЙ дорогой, + * файлом и строкой в сводке, и сторож её не видит. Имя `prigovor` вернее по смыслу и снимает + * ложную тревогу; что приговор вправду доезжает до владельца, сторожат проверки этого файла + * («файл приговора на месте», «строка в сводке есть»). + */ +export function rozhdenieSudi({ + nakaz, + runDirPath, + runId = null, + cfg = {}, + predelMs = null, + env = process.env, + startFn = null, + cwd = REPO_ROOT, +} = {}) { + return new Promise(resolve => { + let konchili = false; + let chasy = null; + const konec = otvet => { + if (konchili) return; + konchili = true; + if (chasy) clearTimeout(chasy); + resolve(otvet); + }; + + if (podPriboromProverok(env)) { + konec({ + ok: false, + prigovor: null, + prichina: 'идёт прибор проверок — живого судью здесь не рождаем никогда: это были бы ' + + 'настоящие деньги владельца, потраченные проверкой', + }); + return; + } + + if (typeof startFn !== 'function') { + konec({ + ok: false, + prigovor: null, + prichina: 'судью рождать нечем: зовущий не подал дорогу к рождению. Своей здесь нет ' + + 'намеренно — она одна на затею и живёт у надзирателя, вместе с воротами счёта', + }); + return; + } + + // 🔴 Голова судьи — из настроек владельца, своего слова здесь нет. Причина денежная: + // разные головы едят запас по-разному, а запас у владельца один на всё. + // 🟡 Своей настройки у судьи пока нет (см. оговорку у `PREDEL_SUDI_MINUT`) — идёт той же + // головой, что и работники. + const model = cfg?.worker_model ?? null; + if (!model) { + konec({ + ok: false, + prigovor: null, + prichina: 'не сказано, какой головой заводить судью: в настройках нет worker_model. ' + + 'Подставлять голову молча нельзя — это чужой запас', + }); + return; + } + + const settingsPath = nastroykiProgona(runDirPath); + if (!settingsPath) { + konec({ + ok: false, + prigovor: null, + prichina: 'настроек прогона не нашлось (ни у одного работника нет settings_path на диске) — ' + + 'судья пошёл бы в ночь без замков владельца, а этого делать нельзя', + }); + return; + } + + let child; + try { + child = startFn({ + cwd, + prompt: nakaz, + settingsPath, + sessionId: randomUUID(), + // 🪤 Имя базы обязательное (без него `workerEnv` намеренно падает), и оно у судьи + // СВОЁ и заведомо несуществующее. Судья проверок не гоняет вовсе; полезет он вдруг + // их гонять — упадёт громко на своей базе, а не затрёт чужую при зелёном ответе. + dbName: `night_sudya_${String(runId ?? 'bez-imeni').replace(/[^\w]+/g, '_')}`, + model, + // Каталог прогона лежит вне хранилища — без этого судья не прочёл бы ни одной улики. + addDirs: [runDirPath], + }); + } catch (err) { + konec({ ok: false, prigovor: null, prichina: `судью не удалось запустить: ${err?.message ?? err}` }); + return; + } + + const minut = PREDEL_SUDI_MINUT; + const predel = Number.isFinite(predelMs) && predelMs > 0 ? predelMs : minut * 60_000; + + let syro = ''; + let oshibki = ''; + child.stdout?.on?.('data', chunk => { syro += String(chunk); }); + child.stderr?.on?.('data', chunk => { oshibki += String(chunk); }); + child.on?.('error', err => { + konec({ ok: false, prigovor: null, prichina: `судья сорвался на запуске: ${err?.message ?? err}` }); + }); + child.on?.('exit', code => { + const slovo = itogIzPotoka(syro); + if (slovo) { konec({ ok: true, prigovor: slovo, prichina: null }); return; } + const hvost = oshibki.trim() ? ` Последние слова: ${oshibki.trim().slice(0, 400)}` : ''; + konec({ + ok: false, + prigovor: null, + prichina: `судья кончился (код ${code}), а приговора не отдал — говорить о ночи ему было нечем.${hvost}`, + }); + }); + + chasy = setTimeout(() => { + // 🔴 Гасим. Ночь уже закрыта, сторожить судью больше некому, и зациклившийся судья жёг + // бы запас владельца до самого утра — ровно тот «пустой расход», от которого затея + // сторожит работников. + try { child.kill?.(); } catch { /* уже мёртв — и хорошо */ } + konec({ + ok: false, + prigovor: null, + prichina: `судья не уложился в ${minut} минут и погашен — приговора он не отдал`, + }); + }, predel); + chasy.unref?.(); + }); +} + +// --- сам зов ----------------------------------------------------------------- + +function skazatVladeltsu(runDirPath, at, line) { + // 🔴 Строка ложится в ту же улику, что и слова надзирателя (`summary-lines.jsonl`), и + // потому доезжает до утренней бумаги БЕЗ единой правки чужих файлов: `utro.mjs` читает + // эту улику сам и печатает её в графе «ЧТО НОЧЬ РАССКАЗАЛА О СЕБЕ», а сырую строку + // целиком — в части для мастера. + // 🪤 `worker_id: null` — судья не работник, и подписывать его строку именем работника + // значило бы соврать про состав ночи. + try { + appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at, worker_id: null, line }); + } catch { /* сказать не вышло — но ронять из-за этого нечего: ниже есть лента */ } +} + +/** + * Завести судью на этот прогон. Зовётся ОДИН раз — из `closeRunIfLast`, когда погас + * последний работник и прогон закрывается. + * + * 🔴🔴 НА КАЖДУЮ НОЧЬ, А НЕ ТОЛЬКО НА УДАЧНУЮ. Решение владельца 12: «не реже раза за + * ночь». Зов НЕ смотрит на флаг `reviewer_starts` (тот про сборщик экранов продукта): + * стой он на этом флаге — судью получала бы только ночь, где и так всё сошлось, а ночь, + * которую больше всего стоило судить, оставалась бы без единого взгляда со стороны. + * + * 🔴 `itog` — чем ночь кончилась (сколько пунктов закрыто, сколько нет и каких). Считает + * его зовущий, по файлам; здесь он только доносится до наказа. Не дали — наказ скажет + * судье об этом вслух, а не промолчит. + * + * 🔴 НИКОГДА НЕ БРОСАЕТ. Её зовёт надзиратель в ту минуту, когда закрывает ночь; умри она + * здесь — и ночь кончилась бы молча, ровно тем классом беды, из-за которого 334 прогона + * умирали три месяца незамеченными. + * + * 🔴 НО И МОЛЧА НЕ ГЛОТАЕТ. Всякая неудача — строка владельцу и запись в ленту. + */ +export async function zavestiSudyu({ + runDirPath, + runId = null, + cfg = {}, + // 🔴 Чем кончилась ночь. Умолчание `null` — «не сказали», и наказ произнесёт это вслух. + itog = null, + now = () => Date.now(), + koren = REPO_ROOT, + // 🔴 Умолчание НАСТОЯЩЕЕ — живое рождение судьи. Подставка подаётся ЯВНО и только + // в проверках: пустышка, отвечающая «всё хорошо», в этом хозяйстве за три куска нашлась + // пять раз, и умолчанием здесь она означала бы, что судьи нет вовсе, а бумаги о нём есть. + rodit = rozhdenieSudi, + // 🔴 Дорога к рождению. Своей здесь нет намеренно (см. ввозы в шапке): её подаёт + // надзиратель — единственный, кому подходить к деньгам владельца разрешено поимённо. + // Не подали — судья не родится и скажет об этом человеческой строкой, а не молча. + roditRebyonka = null, + predelMs = null, +} = {}) { + const at = now(); + + const mesto = zanyatMestoSudi(runDirPath, { at }); + if (!mesto.ok) { + // 🪤 Второй зов — это НЕ новая беда для владельца, а защита, которая отработала. + // Строки в сводку не пишем: первый зов о судье уже сказал, и вторая строка об одном + // и том же — то самое дублирование, от которого сводку берегут. В ленту — пишем. + try { + logEvent(runDirPath, { kind: 'sudya_povtor', prichina: mesto.prichina }, at); + } catch { /* лента не открылась — дальше некуда, ронять ночь из-за этого нельзя */ } + return { zaveden: false, put: null, prichina: mesto.prichina, glavnoe: null, stroka: null }; + } + + let otvet; + try { + const uliki = ulikiProgona(runDirPath, { koren }); + const nakaz = nakazSudye({ runId, uliki, itog }); + otvet = await rodit({ nakaz, runDirPath, runId, cfg, predelMs, startFn: roditRebyonka }); + } catch (err) { + otvet = { ok: false, prigovor: null, prichina: String(err?.message ?? err) }; + } + + if (!otvet?.ok || !String(otvet?.prigovor ?? '').trim()) { + const prichina = otvet?.prichina ?? 'причина не названа'; + const line = `Судью завести не удалось: ${prichina}. Ночь закрыта как обычно, но взгляда со ` + + 'стороны на эту работу нет — принимать её придётся вам самим.'; + skazatVladeltsu(runDirPath, at, line); + try { + logEvent(runDirPath, { kind: 'sudya_ne_zaveden', prichina }, at); + } catch { /* см. выше */ } + return { zaveden: false, put: null, prichina, glavnoe: null, stroka: line }; + } + + const glavnoe = glavnoeIzPrigovora(otvet.prigovor); + const put = join(runDirPath, PRIGOVOR_FILE); + try { + const shapka = [ + '# Приговор судьи ночи', + '', + `Прогон: ${runId ?? 'без имени'}. Судья не писал ни задания, ни работы — его завели, чтобы`, + 'посмотреть на сделанное целиком и со стороны. Ничего он не чинил и не правил.', + '', + '---', + '', + ].join('\n'); + writeFileSync(put, `${shapka}${String(otvet.prigovor).trim()}\n`, 'utf8'); + } catch (err) { + const prichina = `приговор судьи получен, но положить его файлом не удалось: ${err?.message ?? err}`; + const line = `Судью завести не удалось: ${prichina}.`; + skazatVladeltsu(runDirPath, at, line); + try { logEvent(runDirPath, { kind: 'sudya_ne_zaveden', prichina }, at); } catch { /* см. выше */ } + return { zaveden: false, put: null, prichina, glavnoe, stroka: line }; + } + + // 🔴 Строка владельцу читается ГЛАЗАМИ и обязана читаться даже тогда, когда путь к файлу + // утренняя бумага заменит пометкой «слово мастера» (она так делает со всякой латиницей). + // Поэтому имя файла стоит В КОНЦЕ, а смысл — в начале: пропади хвост, строка всё равно + // говорит владельцу, что судья был и что он сказал. + const line = glavnoe + ? `Судья ночи прочёл эту ночь целиком и сказал вот что: ${glavnoe} Весь его приговор лежит ` + + `отдельным файлом в каталоге этого прогона — имя файла ниже, в части для мастера: ${put}` + : 'Судья ночи прочёл эту ночь целиком, но главного одной строкой не назвал — читать его ' + + `приговор придётся целиком. Он лежит отдельным файлом в каталоге прогона: ${put}`; + skazatVladeltsu(runDirPath, at, line); + try { + logEvent(runDirPath, { kind: 'sudya_prigovor', put, glavnoe: glavnoe ?? null }, at); + } catch { /* см. выше */ } + + return { zaveden: true, put, prichina: null, glavnoe, stroka: line }; +} diff --git a/tools/night/sudya-nochi.test.mjs b/tools/night/sudya-nochi.test.mjs new file mode 100644 index 00000000..1ffe7276 --- /dev/null +++ b/tools/night/sudya-nochi.test.mjs @@ -0,0 +1,455 @@ +// Проверки СУДЬИ НОЧИ. Писаны ДО кода — так велит договор смены. +// +// 🔴 Чего здесь нет и быть не должно: живого рождения судьи. Ни одна проверка в этом файле +// не тратит ни копейки владельца. Рождение подставляется доводом `rodit`/`startFn`, а на +// случай, если кто-то однажды подставку забудет, в самом приборе стоит ПОЛ (`podPriboromProverok`) — +// и на него здесь отдельная проверка. +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { EventEmitter } from 'node:events'; +import { readJson, writeJsonAtomic, readLines } from './state.mjs'; +import { SUMMARY_LINES_FILE } from './paths.mjs'; +import { + SUDYA_ID, PRIGOVOR_FILE, OTMETKA_FILE, METKA_GLAVNOGO, PREDEL_SUDI_MINUT, + PYAT_VOPROSOV, + ulikiProgona, nakazSudye, glavnoeIzPrigovora, itogIzPotoka, + zanyatMestoSudi, rozhdenieSudi, zavestiSudyu, +} from './sudya-nochi.mjs'; + +let dir, rdir, koren; + +// Подставной ребёнок: тот же вид, что настоящий запуск отдаёт надзирателю. +function podstavnoyRebyonok() { + const c = new EventEmitter(); + c.stdout = new EventEmitter(); + c.stderr = new EventEmitter(); + c.killed = false; + c.kill = () => { c.killed = true; }; + c.skazat = obj => c.stdout.emit('data', JSON.stringify(obj) + '\n'); + c.umeret = (code = 0) => c.emit('exit', code); + return c; +} + +function sobratProgon({ reviewer_starts = true } = {}) { + mkdirSync(join(rdir, 'workers', 'w-1'), { recursive: true }); + // 🔴 Настройки прогона обязаны ЛЕЖАТЬ НА ДИСКЕ, а не быть просто именем в карточке: + // судья идёт с теми же замками владельца, что и работники, и прибор это проверяет. + writeFileSync(join(dir, 'settings.json'), '{}'); + writeJsonAtomic(join(rdir, 'run.json'), { + run_id: 'r-1', task_id: 't-1', status: 'finished', workers: ['w-1'], + reviewer_starts, + plan_path: 'docs/superpowers/plans/пример.md', + items: [{ id: '1' }], + }); + writeJsonAtomic(join(rdir, 'workers', 'w-1', 'worker.json'), { + worker_id: 'w-1', status: 'extinguished', workspace: join(dir, 'ws-1'), + settings_path: join(dir, 'settings.json'), + }); + writeJsonAtomic(join(rdir, 'workers', 'w-1', 'exit.json'), { status: 'extinguished' }); + writeFileSync(join(rdir, SUMMARY_LINES_FILE), ''); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'night-sudya-')); + rdir = join(dir, 'runs', 'r-1'); + koren = join(dir, 'hranilishche'); + mkdirSync(join(koren, 'docs', 'superpowers', 'plans'), { recursive: true }); + writeFileSync(join(koren, 'docs', 'superpowers', 'plans', 'пример.md'), '# план\n'); + mkdirSync(join(koren, 'tools', 'night'), { recursive: true }); + writeFileSync(join(koren, 'tools', 'night', 'proba-zamer-do.md'), '# бумага работника\n'); + sobratProgon(); +}); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('наказ судье — пять вопросов, и владелец их прочтёт', () => { + it('в наказе стоят ВСЕ пять вопросов, дословно', () => { + expect(PYAT_VOPROSOV).toHaveLength(5); + const nakaz = nakazSudye({ runId: 'r-1', uliki: ulikiProgona(rdir, { koren }) }); + for (const v of PYAT_VOPROSOV) { + expect(nakaz, `в наказе нет вопроса «${v}»`).toContain(v); + } + }); + + it('🔴 наказ прямо запрещает судье чинить и править — он оценка, а не работа', () => { + const nakaz = nakazSudye({ runId: 'r-1', uliki: ulikiProgona(rdir, { koren }) }); + expect(nakaz).toMatch(/ничего не чини/i); + expect(nakaz).toMatch(/не прав/i); // «в чём я не прав» / «правда другая» + }); + + it('🔴 наказ велит начать с одной строки главного — её потом читает владелец в сводке', () => { + const nakaz = nakazSudye({ runId: 'r-1', uliki: ulikiProgona(rdir, { koren }) }); + expect(nakaz).toContain(METKA_GLAVNOGO); + }); + + it('наказ называет улики ЭТОГО прогона по путям, а не «посмотри где-нибудь»', () => { + const uliki = ulikiProgona(rdir, { koren }); + const nakaz = nakazSudye({ runId: 'r-1', uliki }); + expect(nakaz).toContain(uliki.kartochka_progona); + expect(nakaz).toContain(uliki.slova_nochi); + expect(nakaz).toContain(uliki.rabotniki[0].kartochka); + expect(nakaz).toContain(uliki.zadanie); + expect(nakaz).toContain(uliki.bumagi[0]); + }); + + it('🔴 чего среди улик НЕТ — названо вслух, а не молча пропущено', () => { + rmSync(join(koren, 'docs', 'superpowers', 'plans', 'пример.md')); + const uliki = ulikiProgona(rdir, { koren }); + expect(uliki.zadanie).toBe(null); + expect(uliki.chego_net.join(' ')).toMatch(/задание|план/i); + const nakaz = nakazSudye({ runId: 'r-1', uliki }); + expect(nakaz).toMatch(/чего в уликах нет/i); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// 🔴🔴 СУДЬЯ ОБЯЗАН ЗНАТЬ, ЧЕМ КОНЧИЛАСЬ НОЧЬ +// +// Решение владельца 12: судья заводится «не реже раза за ночь» — значит приходит и на +// ПРОВАЛЬНУЮ ночь тоже. А на провальной ночи главный вопрос ДРУГОЙ: не «чем заплатили +// за успех», а «почему не вышло». Не скажи мы судье итог — он судил бы всякую ночь как +// удачную и половину дела не увидел бы вовсе. +describe('🔴🔴 наказ говорит судье, ЧЕМ КОНЧИЛАСЬ НОЧЬ', () => { + const uliki = () => ulikiProgona(rdir, { koren }); + + it('числа итога стоят в наказе поимённо: сколько закрыто, сколько нет и какие именно', () => { + const nakaz = nakazSudye({ + runId: 'r-1', + uliki: uliki(), + itog: { vsego: 3, zakryto: ['2'], ne_zakryto: ['1', '3'], otlozheno: ['1'], dodelana: false }, + }); + expect(nakaz).toContain('Пунктов в задании: 3'); + expect(nakaz).toContain('Засчитано надзирателем: 1'); + expect(nakaz).toContain('НЕ закрыто: 2'); + // 🔴 Номера, а не только счёт: «два не закрыто» без имён судье некуда приложить. + expect(nakaz).toContain('1, 3'); + expect(nakaz).toMatch(/отложено[^\n]*1/i); + }); + + it('🔴🔴 ночь НЕ доделана — наказ ставит судье ДРУГОЙ главный вопрос: почему не вышло', () => { + const nakaz = nakazSudye({ + runId: 'r-1', + uliki: uliki(), + itog: { vsego: 2, zakryto: ['2'], ne_zakryto: ['1'], otlozheno: ['1'], dodelana: false }, + }); + expect(nakaz, 'судье не сказали, что ночь не доделана').toMatch(/не доделана/i); + expect(nakaz, 'главный вопрос провальной ночи не поставлен').toMatch(/почему не вышло/i); + // И вопрос этот не про работу одну: устройство самой ночи судья обязан взвесить тоже. + expect(nakaz).toMatch(/устройств[оа] самой ночи/i); + }); + + it('ночь доделана — сказано и это, и сказано, что зелёное проверяют строже', () => { + const nakaz = nakazSudye({ + runId: 'r-1', + uliki: uliki(), + itog: { vsego: 2, zakryto: ['1', '2'], ne_zakryto: [], otlozheno: [], dodelana: true }, + }); + expect(nakaz).toMatch(/все пункты задания засчитаны/i); + expect(nakaz).toMatch(/чем за это заплачено/i); + // 🪤 И НЕ ставит вопрос провальной ночи: на удачной он увёл бы судью не туда. + expect(nakaz).not.toMatch(/почему не вышло/i); + }); + + it('🔴 итога судье не подали вовсе — наказ говорит это ВСЛУХ, а не молчит', () => { + const nakaz = nakazSudye({ runId: 'r-1', uliki: uliki() }); + expect(nakaz, 'молчание тут неотличимо от «ночь удалась»').toMatch(/чем кончилась эта ночь, мне не сказали/i); + }); + + it('🔴 числа названы СЛОВОМ НАДЗИРАТЕЛЯ, а не замером — судья судит и того, кто их дал', () => { + const nakaz = nakazSudye({ + runId: 'r-1', + uliki: uliki(), + itog: { vsego: 1, zakryto: ['1'], ne_zakryto: [], otlozheno: [], dodelana: true }, + }); + expect(nakaz).toMatch(/слово надзирателя/i); + }); + + it('🔴 итог доезжает СКВОЗЬ `zavestiSudyu` до самого наказа, а не теряется по дороге', async () => { + let nakaz = null; + const rodit = async d => { nakaz = d.nakaz; return { ok: true, prigovor: 'приговор', prichina: null }; }; + await zavestiSudyu({ + runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 1, + itog: { vsego: 2, zakryto: [], ne_zakryto: ['1', '2'], otlozheno: ['1'], dodelana: false }, + }); + expect(nakaz, 'судью завели, а наказа ему не собрали').toBeTruthy(); + expect(nakaz).toContain('Пунктов в задании: 2'); + expect(nakaz).toMatch(/почему не вышло/i); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('улики прогона собираются КОМАНДОЙ, а не памятью', () => { + it('находит карточку прогона, слова ночи, карточки и концы работников', () => { + const u = ulikiProgona(rdir, { koren }); + expect(u.kartochka_progona).toBe(join(rdir, 'run.json')); + expect(u.slova_nochi).toBe(join(rdir, SUMMARY_LINES_FILE)); + expect(u.rabotniki).toHaveLength(1); + expect(u.rabotniki[0].worker_id).toBe('w-1'); + expect(u.rabotniki[0].konec).toBe(join(rdir, 'workers', 'w-1', 'exit.json')); + }); + + it('бумаги работников `*-zamer-do.md` находятся в хранилище', () => { + const u = ulikiProgona(rdir, { koren }); + expect(u.bumagi).toContain(join(koren, 'tools', 'night', 'proba-zamer-do.md')); + }); + + it('🪤 каталога работников нет вовсе — не падаем, а честно отдаём пусто', () => { + rmSync(join(rdir, 'workers'), { recursive: true, force: true }); + const u = ulikiProgona(rdir, { koren }); + expect(u.rabotniki).toEqual([]); + expect(u.chego_net.join(' ')).toMatch(/работник/i); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('главное одной строкой — то, что доедет до владельца в сводке', () => { + it('вытаскивает строку после метки', () => { + const t = `${METKA_GLAVNOGO} двое сошлись на числе, которого никто не мерил.\n\nдальше подробности`; + expect(glavnoeIzPrigovora(t)).toBe('двое сошлись на числе, которого никто не мерил.'); + }); + + it('🪤 метки нет — отвечаем «нет», а не выдумываем первую попавшуюся строку', () => { + expect(glavnoeIzPrigovora('просто текст без метки')).toBe(null); + expect(glavnoeIzPrigovora('')).toBe(null); + expect(glavnoeIzPrigovora(null)).toBe(null); + }); + + it('🪤 метка есть, а строка за ней пустая — это тоже «нет»', () => { + expect(glavnoeIzPrigovora(`${METKA_GLAVNOGO} \n\nтело`)).toBe(null); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('итог из потока судьи', () => { + it('берёт слово судьи из итогового события', () => { + const potok = [ + JSON.stringify({ type: 'system', subtype: 'init' }), + JSON.stringify({ type: 'assistant', message: {} }), + JSON.stringify({ type: 'result', is_error: false, result: 'мой приговор' }), + ].join('\n'); + expect(itogIzPotoka(potok)).toBe('мой приговор'); + }); + + it('🔴 пустой ответ приговором НЕ считается', () => { + expect(itogIzPotoka(JSON.stringify({ type: 'result', is_error: false, result: ' ' }))).toBe(null); + expect(itogIzPotoka(JSON.stringify({ type: 'result', is_error: true, result: 'беда' }))).toBe(null); + expect(itogIzPotoka('')).toBe(null); + expect(itogIzPotoka('мусор не по-json')).toBe(null); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('🔴🔴 судья заводится НЕ БОЛЬШЕ ОДНОГО РАЗА за прогон', () => { + it('второй заход не занимает место и говорит почему', () => { + expect(zanyatMestoSudi(rdir, { at: 1 }).ok).toBe(true); + const vtoroy = zanyatMestoSudi(rdir, { at: 2 }); + expect(vtoroy.ok).toBe(false); + expect(vtoroy.prichina).toMatch(/уже/i); + expect(existsSync(join(rdir, OTMETKA_FILE))).toBe(true); + }); + + it('🔴 два зова `zavestiSudyu` подряд рождают судью РОВНО ОДИН раз', async () => { + let rozhdeniy = 0; + const rodit = async () => { rozhdeniy += 1; return { ok: true, prigovor: `${METKA_GLAVNOGO} раз.`, prichina: null }; }; + const a = await zavestiSudyu({ runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 1 }); + const b = await zavestiSudyu({ runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 2 }); + expect(rozhdeniy, 'судья родился больше одного раза — это лишние деньги владельца').toBe(1); + expect(a.zaveden).toBe(true); + expect(b.zaveden).toBe(false); + expect(b.prichina).toMatch(/уже/i); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('приговор ложится файлом, и сводка о нём говорит', () => { + it('удачный судья: файл приговора на месте, строка в сводке есть, событие в ленте есть', async () => { + const rodit = async () => ({ ok: true, prigovor: `${METKA_GLAVNOGO} числа не сходятся.\n\nОтвет 1. …`, prichina: null }); + const r = await zavestiSudyu({ runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 111 }); + + expect(r.zaveden).toBe(true); + expect(r.put).toBe(join(rdir, PRIGOVOR_FILE)); + const tekst = readFileSync(r.put, 'utf8'); + expect(tekst).toContain('числа не сходятся'); + expect(tekst).toMatch(/судья/i); + + const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(s => s.line).join('\n'); + expect(stroki).toMatch(/судья/i); + expect(stroki).toContain('числа не сходятся'); + + const lenta = readLines(join(rdir, 'events.jsonl')).map(e => e.kind); + expect(lenta).toContain('sudya_prigovor'); + }); + + it('🪤 судья не назвал главного одной строкой — говорим это честно, а не молчим', async () => { + const rodit = async () => ({ ok: true, prigovor: 'приговор без метки', prichina: null }); + const r = await zavestiSudyu({ runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 1 }); + expect(r.zaveden).toBe(true); + const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(s => s.line).join('\n'); + expect(stroki).toMatch(/судья/i); + expect(stroki).toMatch(/главного одной строкой (он )?не назвал/i); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('🔴🔴 смерть судьи НЕ роняет ночь и НЕ проглатывается молча', () => { + it('рождение бросило — `zavestiSudyu` не бросает, а честно рассказывает', async () => { + const rodit = async () => { throw new Error('нутро программы не найдено'); }; + const r = await zavestiSudyu({ runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 1 }); + expect(r.zaveden).toBe(false); + expect(r.prichina).toMatch(/нутро программы не найдено/); + const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(s => s.line).join('\n'); + expect(stroki, 'смерть судьи проглочена молча — так нельзя').toMatch(/судью завести не удалось/i); + expect(stroki).toMatch(/нутро программы не найдено/); + const lenta = readLines(join(rdir, 'events.jsonl')).map(e => e.kind); + expect(lenta).toContain('sudya_ne_zaveden'); + }); + + it('судья родился, но приговора не отдал — это неудача, и файла приговора нет', async () => { + const rodit = async () => ({ ok: false, prigovor: null, prichina: 'судья кончился, не сказав ни слова' }); + const r = await zavestiSudyu({ runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 1 }); + expect(r.zaveden).toBe(false); + expect(existsSync(join(rdir, PRIGOVOR_FILE)), 'пустой приговор класть нельзя — его прочтут как настоящий').toBe(false); + const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(s => s.line).join('\n'); + expect(stroki).toMatch(/судью завести не удалось/i); + expect(stroki).toContain('не сказав ни слова'); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('🔴 судья — не работник: места в прогоне он не занимает', () => { + it('карточки работника ему не заводится, состав прогона не растёт', async () => { + const rodit = async () => ({ ok: true, prigovor: 'приговор', prichina: null }); + await zavestiSudyu({ runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 1 }); + expect(existsSync(join(rdir, 'workers', SUDYA_ID))).toBe(false); + const run = readJson(join(rdir, 'run.json')); + expect(run.workers).toEqual(['w-1']); + }); + + it('🔴 приговоров о пунктах он не пишет — в счёт закрытого не попадает', async () => { + const rodit = async () => ({ ok: true, prigovor: 'приговор', prichina: null }); + await zavestiSudyu({ runDirPath: rdir, runId: 'r-1', rodit, koren, now: () => 1 }); + expect(existsSync(join(rdir, 'workers', SUDYA_ID, 'items-verified.jsonl'))).toBe(false); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('🔴🔴 ПОЛ: под прибором проверок живого судью не рождаем НИКОГДА', () => { + it('настоящее окружение проверок — рождения нет, и сказано почему', async () => { + let zvali = 0; + const r = await rozhdenieSudi({ + nakaz: 'наказ', runDirPath: rdir, runId: 'r-1', + startFn: () => { zvali += 1; return podstavnoyRebyonok(); }, + }); + expect(zvali, 'под прибором проверок родили ЖИВОГО судью — это деньги владельца').toBe(0); + expect(r.ok).toBe(false); + expect(r.prichina).toMatch(/прибор/i); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('рождение судьи: поток, предел времени и отказы', () => { + const boy = { env: {} }; // «как будто не под прибором» — довод, а не подмена process.env + + it('судья сказал итог — берём его слово', async () => { + const c = podstavnoyRebyonok(); + const p = rozhdenieSudi({ + nakaz: 'наказ', runDirPath: rdir, runId: 'r-1', ...boy, + cfg: { worker_model: 'голова' }, + startFn: () => c, + }); + c.skazat({ type: 'result', is_error: false, result: 'мой приговор' }); + c.umeret(0); + await expect(p).resolves.toMatchObject({ ok: true, prigovor: 'мой приговор' }); + }); + + it('🔴 судья молчит дольше предела — гасим и говорим вслух', async () => { + const c = podstavnoyRebyonok(); + const r = await rozhdenieSudi({ + nakaz: 'наказ', runDirPath: rdir, runId: 'r-1', ...boy, + cfg: { worker_model: 'голова' }, + predelMs: 20, + startFn: () => c, + }); + expect(c.killed, 'судью не погасили — он жёг бы деньги до утра').toBe(true); + expect(r.ok).toBe(false); + expect(r.prichina).toMatch(/не уложился|предел|минут/i); + }); + + it('🔴 запуск бросил — это ответ, а не обвал', async () => { + const r = await rozhdenieSudi({ + nakaz: 'наказ', runDirPath: rdir, runId: 'r-1', ...boy, + cfg: { worker_model: 'голова' }, + startFn: () => { throw new Error('claude не найден'); }, + }); + expect(r.ok).toBe(false); + expect(r.prichina).toContain('claude не найден'); + }); + + it('🔴 головы судье не назвали — не рождаем и говорим прямо', async () => { + let zvali = 0; + const r = await rozhdenieSudi({ + nakaz: 'наказ', runDirPath: rdir, runId: 'r-1', ...boy, + cfg: {}, + startFn: () => { zvali += 1; return podstavnoyRebyonok(); }, + }); + expect(zvali).toBe(0); + expect(r.ok).toBe(false); + expect(r.prichina).toMatch(/голов/i); + }); + + it('🔴 голову судьи берём из настроек владельца, своего слова не подставляем', async () => { + let dano = null; + const c = podstavnoyRebyonok(); + const p = rozhdenieSudi({ + nakaz: 'наказ', runDirPath: rdir, runId: 'r-1', ...boy, + cfg: { worker_model: 'голова-работника' }, + startFn: dovody => { dano = dovody; return c; }, + }); + c.skazat({ type: 'result', is_error: false, result: 'п' }); + c.umeret(0); + await p; + expect(dano.model).toBe('голова-работника'); + expect(dano.prompt).toBe('наказ'); + // 🔴 Свою базу судье дают ЗАВЕДОМО отдельную: общая база на всех даёт зелёные врущие + // проверки, и это худший вид беды. + expect(dano.dbName).toMatch(/sudya/); + }); + + it('🔴 дорогу к рождению не подали — не рождаем и говорим прямо', async () => { + const r = await rozhdenieSudi({ + nakaz: 'наказ', runDirPath: rdir, runId: 'r-1', ...boy, + cfg: { worker_model: 'голова' }, + }); + expect(r.ok).toBe(false); + expect(r.prichina).toMatch(/рождать нечем|не подал/i); + }); + + it('🔴 настроек прогона нет — не рождаем: судья пошёл бы без замков владельца', async () => { + rmSync(join(rdir, 'workers'), { recursive: true, force: true }); + let zvali = 0; + const r = await rozhdenieSudi({ + nakaz: 'наказ', runDirPath: rdir, runId: 'r-1', ...boy, + cfg: { worker_model: 'голова' }, + startFn: () => { zvali += 1; return podstavnoyRebyonok(); }, + }); + expect(zvali).toBe(0); + expect(r.ok).toBe(false); + expect(r.prichina).toMatch(/настро/i); + }); +}); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +describe('числа, которые нельзя менять молча', () => { + it('предел судьи назван и разумен', () => { + expect(Number.isFinite(PREDEL_SUDI_MINUT)).toBe(true); + expect(PREDEL_SUDI_MINUT).toBeGreaterThan(0); + }); + + it('имена файлов судьи — по-русски латиницей, но постоянные', () => { + expect(PRIGOVOR_FILE).toBe('prigovor-sudi.md'); + expect(OTMETKA_FILE).toBe('sudya.json'); + expect(SUDYA_ID).toBe('sudya'); + }); +}); diff --git a/tools/night/supervisor.mjs b/tools/night/supervisor.mjs index 37220eed..6b831775 100644 --- a/tools/night/supervisor.mjs +++ b/tools/night/supervisor.mjs @@ -43,6 +43,11 @@ import { pustoyRashod, slepokZapisi, schyotOdinakovyh } from './pustoy-rashod.mj // здесь руками: на это стоит сторож (`resume.test.mjs`, П-06), и он прав. import { statePath } from './resume.mjs'; import { startWorker, parseStreamLine, isStepBoundary, isRealSuccess, rateLimitFromEvent } from './spawn.mjs'; +// 🔴🔴 СУДЬЯ НОЧИ (решение владельца 12 от 03.08.2026, подтверждено решениями 134 и 135). +// Правило «кто и как судит ночь целиком» живёт отдельным файлом, а ЗОВЁТ его этот — иначе +// оно зеленело бы на проверках и не сработало ни разу. Ввоз односторонний: `sudya-nochi.mjs` +// не ввозит надзирателя ни одной строкой, кольца нет. +import { zavestiSudyu } from './sudya-nochi.mjs'; // 🔴 «Сделал ли он дело» — вопрос о СЛЕДЕ, а не о том, как работник завершился. Правило // живёт отдельным файлом и ввозится, а не переписывается здесь: `isRealSuccess` (spawn.mjs) // отвечает только на «отдал ли он годный ответ», и на живой оплаченной пробе 03.08.2026 @@ -1085,6 +1090,43 @@ export function closedItemsOfRun(runDirPath) { return out; } +/** + * 🔴🔴 ЧЕМ КОНЧИЛАСЬ НОЧЬ — то, без чего судья судит не ту ночь. + * + * Решение владельца 12 велит заводить судью «не реже раза за ночь» — значит и на ту ночь, + * где половина не вышла. Но провальная ночь и удачная — это два РАЗНЫХ дела: на одной + * спрашивают «чем заплатили за успех», на другой «почему не вышло». Не скажи мы судье, + * чем ночь кончилась, — он всякую судил бы как удачную, и главного вопроса провальной + * ночи не задал бы никто. + * + * 🔴 СЧИТАЕТСЯ ПО ФАЙЛАМ, А НЕ ПО ПАМЯТИ НАДЗИРАТЕЛЯ. Источник закрытого — тот же + * `closedItemsOfRun` (приговоры надзирателей в каталоге прогона), каким считает «сделано» + * очередь пунктов и след отложенной задачи в `writeRunOutcome`. Второго счёта затея + * не заводит: разойдись они — владелец получил бы утром два разных числа об одной ночи. + * + * 🪤 `otlozheno` — ЧЕСТНО НЕПОЛНОЕ, и это сказано вслух, а не спрятано. Отложенное помнит + * только ТОТ надзиратель, который откладывал (та же названная дыра, что у самого + * `otlozhennyePunkty`): пункты, отложенные соседями, попадут в `ne_zakryto`, но не в + * `otlozheno`. Судья видит оба списка и потому не примет часть за целое. + */ +export function chemKonchilasNoch(runDirPath, otlozhennye = []) { + const run = readJson(join(runDirPath, 'run.json')) ?? {}; + const vse = (run.items ?? []).map(i => String(i?.id)).filter(id => id && id !== 'undefined'); + const zaschitany = new Set(closedItemsOfRun(runDirPath)); + const zakryto = vse.filter(id => zaschitany.has(id)); + const ne_zakryto = vse.filter(id => !zaschitany.has(id)); + const otlozheno = [...otlozhennye].map(String).filter(id => ne_zakryto.includes(id)); + return { + vsego: vse.length, + zakryto, + ne_zakryto, + otlozheno, + // 🪤 Ночь без единого пункта в задании доделанной НЕ зовём: там судить по счёту закрытого + // нечего вовсе, и «доделана» прозвучало бы похвалой за пустоту. + dodelana: vse.length > 0 && ne_zakryto.length === 0, + }; +} + // Правка карточки прогона ПОД ЗАМКОМ и с перечитыванием внутри него. // // 🔴🔴 Карточку прогона правят все семеро надзирателей, и правят её чтением-и-записью: @@ -1187,6 +1229,15 @@ export async function supervise({ // 🔴 Довод принят ВМЕСТЕ с зовом (правило хозяйства этого файла: принимать параметр можно // только вместе с зовом в том же куске). Зовётся ниже, в `finish`. naytiStenogrammu = stenogrammaPoKartochke, + // 🔴🔴 СУДЬЯ НОЧИ. Довод принят ВМЕСТЕ с зовом — зовётся ниже, в `closeRunIfLast`, и на зов + // стоят проверки, которые краснеют без него. Шестерых предшественников этого довода + // («на вырост», принятых и не звавшихся) файл уже выгонял: они выглядели работающей + // кнопкой «стоп», которая на деле никого не слышит. + // 🪤 Умолчание НАСТОЯЩЕЕ — живое заведение судьи. Подставка подаётся ЯВНО и только + // в проверках: судья стоит денег владельца, и пустышка умолчанием означала бы, что судьи + // нет вовсе, а бумаги о нём есть. Второй заслон от живого рождения под прибором проверок + // стоит внутри самого судьи (`podPriboromProverok`), и он не отменяет этого. + zavestiSudyuFn = zavestiSudyu, }) { const startedAt = now(); const seen = {}; @@ -1251,16 +1302,68 @@ export async function supervise({ // Последнее удавшееся число свободного места — для утренней бумаги (заполняется кругом ниже). let posledniyZamerMesta = null; - function closeRunIfLast(at) { + async function closeRunIfLast(at) { // 🔴 Прогон закрывает надзиратель ПОСЛЕДНЕГО погасшего работника — больше некому: // оркестратор к этому времени давно умер вместе с сессией помощника. - // Заведение приёмщика по флагу `reviewer_starts` дописывает кусок 7 — ровно сюда. if (!amILast(runDirPath)) return; // 🔴 Каталог следов передаётся ЯВНО: иначе итог прогона взял бы своё умолчание — // боевой `.night/deferred/`, — и проверки круга оставляли бы там настоящие отказы. // Поймано живьём 02.08.2026: вырез этой строки положил в хранилище восемь настоящих // отказов по задачам, которых никто не откладывал. writeRunOutcome({ runDirPath, runId, lastRunFile, now: at, deferredPath, diskFreeGb: posledniyZamerMesta }); + + // ━━━ 🔴🔴 СУДЬЯ НОЧИ. РЕШЕНИЕ ВЛАДЕЛЬЦА 12, ИСПОЛНЕНО 06.08.2026 ━━━ + // + // 🔴 ПОРЯДОК СТРОК ЗДЕСЬ — НЕ ВКУСОВЩИНА. Итог прогона лёг СТРОКОЙ ВЫШЕ: и карточка + // прогона, и `last-run.json`, и утренняя бумага владельцу — всё уже на месте. Судья + // зовётся ПОСЛЕ, и только так: он оценка, а не работа, и его падение не имеет права + // унести бумагу, ради которой вся ночь и затевалась. На этот порядок стоит отдельная + // проверка — она смотрит, что в минуту зова судьи итог УЖЕ лежал. + // + // 🔴🔴 СУДЬЯ ПРИХОДИТ НА КАЖДУЮ НОЧЬ, ДОШЕДШУЮ ДО ЗАКРЫТИЯ ПРОГОНА. Удачную, + // наполовину удачную и провальную — одинаково. Решение владельца 12 дословно: + // судья заводится «НЕ РЕЖЕ РАЗА ЗА НОЧЬ». + // + // 🔴 ЧТО ЗДЕСЬ БЫЛО НЕ ТАК И ПОЧЕМУ ЭТО ОШИБКА НАДЗИРАТЕЛЯ, А НЕ СТРОИТЕЛЯ СУДЬИ. + // Зов стоял на флаге `reviewer_starts`, а тот флаг блок 5а поднимает ТОЛЬКО когда + // все пункты засчитаны (`vseZaschitany`). Отложи ночь хоть один пункт — флага нет, + // судьи нет. Выходило ровно наоборот замыслу: ночь, которую больше всего стоило бы + // судить (половина не вышла, работники гасли), оставалась без единого взгляда со + // стороны, а взгляд получала только та, где и так всё сошлось. Ломалось ТИХО: + // и сводка, и приёмщик вели себя как положено. + // + // 🔴 ФЛАГ `reviewer_starts` НЕ ТРОНУТ И ТРОГАТЬ ЕГО НЕЛЬЗЯ: он про сборщик экранов + // продукта (кусок 7) и нужен затее сам по себе. Мы просто на него не опираемся. + // + // 🔴🔴 И ГЛАВНОЕ: СУДЬЕ ГОВОРИМ, ЧЕМ НОЧЬ КОНЧИЛАСЬ. Провальная ночь и удачная — два + // разных дела: на одной главный вопрос «чем заплатили за успех», на другой «почему + // не вышло». Приди судья на провал, не зная о нём, — он судил бы её как удачную, + // и весь смысл прихода пропал бы. Итог считается ПО ФАЙЛАМ (`chemKonchilasNoch`), + // тем же счётом, каким считает след отложенной задачи строкой выше. + // + // 🔴 ПАДЕНИЕ СУДЬИ НЕ ИМЕЕТ ПРАВА УБИТЬ НАДЗИРАТЕЛЯ. Сам судья не бросает по устройству, + // но верить в это на слово нельзя: тот же класс беды («никогда не роняем» превратило + // ошибку в тишину) уже стоил этому хозяйству смены. Поэтому здесь свой пояс — и он + // НЕ МОЛЧАЛИВЫЙ: сбой уходит строкой владельцу и записью в ленту, ровно теми же словами, + // какими о своей неудаче говорит сам судья. + const itogNochi = chemKonchilasNoch(runDirPath, otlozhennyePunkty); + try { + // 🔴 ДОРОГУ К РОЖДЕНИЮ ПОДАЁМ МЫ, А НЕ СУДЬЯ. Живой запуск головы разрешён поимённо + // ровно трём файлам (сторож `platnyy-zamer-tolko-cherez-schyot.test.mjs`), и надзиратель + // среди них — «единственный зовущий рождения». Заведи судья свою дорогу к `startWorker` — + // ворота счёта рождений (решение владельца 9) считали бы не все рождения за ночь, и + // предел владельца молча перестал бы быть пределом. Поэтому зов остаётся здесь. + await zavestiSudyuFn({ runDirPath, runId, cfg, itog: itogNochi, now: () => at, roditRebyonka: startWorker }); + } catch (err) { + const prichina = String(err?.message ?? err); + appendLine(join(runDirPath, SUMMARY_LINES_FILE), { + at, + worker_id: null, + line: `Судью завести не удалось: ${prichina}. Ночь закрыта как обычно, но взгляда со ` + + 'стороны на эту работу нет — принимать её придётся вам самим.', + }); + logEvent(runDirPath, { kind: 'sudya_ne_zaveden', prichina }, at); + } } // 🔴🔴 ЗАКРЫТИЕ ДЫРЫ 03.08.2026, ВТОРОЙ КРУГ, ВРЕЗАНО В ГОРЯЧЕЕ МЕСТО. `claimFiles` @@ -1300,7 +1403,12 @@ export async function supervise({ // 🪤 Признак приходит ДОВОДОМ, а не вычитывается из текста причины. Разбирай мы причину // по словам вроде «закончил своё дело» — сторож со списком слов сам стал бы дверью: // первая же правка формулировки прошла бы насквозь молча. - function finish({ final_status, reason, kak_konchil = null, ulika = false, delo_zayavleno = false, at = now() }) { + // 🔴 `async` — ради ОДНОЙ строки в самом конце: закрытие прогона теперь может завести судью, + // и дождаться его обязан ТОТ ЖЕ процесс. Не дождись — надзиратель вышел бы, а судья остался + // бы сиротой: его поток некому читать, его приговор некому положить, а сам он продолжал бы + // жечь запас владельца. Все зовущие `finish` — это `return finish(…)` внутри самого круга, + // который и так асинхронный, поэтому наружу это не видно ни одной строкой. + async function finish({ final_status, reason, kak_konchil = null, ulika = false, delo_zayavleno = false, at = now() }) { const prev = readJson(join(workerDirPath, 'worker.json')) ?? {}; const baseBranch = prev.base_branch ?? cfg?.base_branch ?? null; // 🔴 Список изменённых файлов считается ОДИН раз и расходится по двум потребителям: @@ -1463,7 +1571,7 @@ export async function supervise({ // и назавтра ночь началась бы с замков, которые держат мертвецы. releaseClaims(runDirPath, workerId); - closeRunIfLast(at); + await closeRunIfLast(at); // 🪤 Строка про стенограмму отдаётся и НАВЕРХ, не только в файл сводки: тот, кто зовёт // круг напрямую (проверки, а завтра и кусок 7), читает ответ, а не лезет в файл. Отдай diff --git a/tools/night/supervisor.test.mjs b/tools/night/supervisor.test.mjs index 6e8f6831..c4669c00 100644 --- a/tools/night/supervisor.test.mjs +++ b/tools/night/supervisor.test.mjs @@ -485,6 +485,12 @@ async function runSupervisor({ // 🔴 Свой каталог следов — обязателен. Умолчание круга настоящее (`.night/deferred`), // и без подмены проверки оставляли бы в хранилище настоящие отказы по задачам. deferredPath = null, + // 🔴🔴 СУДЬЯ НОЧИ — ПОДСТАВКА, И ЗДЕСЬ ЭТО ЕДИНСТВЕННО ВЕРНО. Умолчание самого круга + // настоящее (живое рождение за деньги владельца), и без этой строки всякая проверка, + // доходящая до конца задачи, пыталась бы завести ЖИВОГО судью. Второй заслон на тот же + // случай стоит в самом приборе (`podPriboromProverok` в `sudya-nochi.mjs`) — но заслон + // без подставки означал бы, что проверки каждый раз бьются в него, а не проверяют дело. + zavestiSudyuFn = async () => ({ zaveden: false, put: null, prichina: 'подставка проверок' }), } = {}) { putRunStartedAt(runStartedAt); const clock = { t: startAt }; @@ -520,6 +526,7 @@ async function runSupervisor({ zarabotano, dbName, deferredPath: deferredPath ?? join(dir, 'deferred'), + zavestiSudyuFn, }); return { out, child, clock }; } @@ -3729,3 +3736,150 @@ describe('🔴🔴 датчик пустого расхода в ЖИВОМ кр expect(readLines(join(rdir, 'events.jsonl')).filter(e => e.kind === 'pustoy_rashod')).toEqual([]); }); }); + +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// 🔴🔴 СУДЬЯ НОЧИ ЗОВЁТСЯ ИЗ КРУГА (решение владельца 12, решения 134/135) +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// +// 🔴 Довод `zavestiSudyuFn` принят В ТОМ ЖЕ КУСКЕ, что и зов, — это правило хозяйства +// самого `supervisor.mjs`, и куплено оно болью: шесть доводов «на вырост» когда-то приняли +// и не звали, вышла «кнопка стоп, которая никого не слышит». Проверки ниже краснеют без +// зова — вырежи строку в `closeRunIfLast`, и первые две падают. +// +// 🔴🔴 ПОЧИНКА 06.08.2026, ОШИБКА НАДЗИРАТЕЛЯ, А НЕ СТРОИТЕЛЯ. Зов судьи стоял на флаге +// `reviewer_starts`, а тот флаг блок 5а поднимает ТОЛЬКО когда все пункты засчитаны. +// Выходило ровно наоборот замыслу: ночь, которую больше всего стоило судить (половина +// не вышла, работники гасли), оставалась без единого взгляда со стороны. Решение +// владельца 12 говорит дословно: судья заводится «не реже раза за ночь». Теперь зов +// от удачи ночи НЕ зависит, а вместо флага судье передаётся ИТОГ — чем ночь кончилась. +describe('🔴🔴 судья ночи: зов, единственность и то, что его смерть не роняет ночь', () => { + it('погас ПОСЛЕДНИЙ работник и задача доделана — судья позван РОВНО ОДИН раз', async () => { + zayavit('1'); + planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]); + const zovy = []; + await runSupervisor({ + tickMs: 1000, script: [() => {}], head: () => 'sha-1', runTests: ZELENO, + zavestiSudyuFn: async dovody => { zovy.push(dovody); return { zaveden: true, put: 'п', prichina: null }; }, + }); + expect(readJson(join(rdir, 'run.json')).reviewer_starts).toBe(true); + expect(zovy.length, 'судью позвали не один раз — это лишние деньги владельца').toBe(1); + // Судье дают ровно то, без чего он слеп: где улики и какой это прогон. + expect(zovy[0].runDirPath).toBe(rdir); + expect(zovy[0].runId).toBe('r-1'); + // И чем ночь кончилась: на удачной ночи главный вопрос один, на провальной другой. + expect(zovy[0].itog, 'судье не сказали, чем кончилась ночь').toBeTruthy(); + expect(zovy[0].itog.dodelana).toBe(true); + expect(zovy[0].itog.zakryto).toEqual(['1']); + expect(zovy[0].itog.ne_zakryto).toEqual([]); + }); + + it('🔴🔴 ПУНКТ ОТЛОЖЕН, ночь НЕ доделана — судья ВСЁ РАВНО приходит, и знает, что не вышло', async () => { + // 🔴🔴 СЕРДЦЕ ПОЧИНКИ. Раньше зов стоял на флаге приёмщика, а его на отложенном пункте + // не поднимают — и ночь, которую больше всего стоило судить, оставалась без судьи вовсе. + // Ломалось ТИХО: и сводка, и приёмщик вели себя как положено, просто взгляда со стороны + // не было ни одного. Решение владельца 12: «не реже раза за ночь». + // 🪤 Рецепт провальной ночи взят у соседней проверки («пункт отложен, а свободных больше + // нет»): пункт 1 краснеет дважды и откладывается, пункт 2 зелёный и засчитан. + sozdatKatalog(inbox, { recursive: true }); + zapisatFayl(join(inbox, 'closed-items.jsonl'), + JSON.stringify({ item: '1', at: T0 }) + '\n' + JSON.stringify({ item: '2', at: T0 }) + '\n'); + planPunktov4([ + { id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }, + { id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false }, + ]); + let kommit = 'sha-1'; + let shag = 0; + const zovy = []; + await runSupervisor({ + tickMs: 1000, script: [() => {}, () => {}, () => {}], + head: () => kommit, + runTests: d => { shag += 1; kommit = `sha-${shag + 1}`; return d.item.id === '1' ? KRASNO() : ZELENO(); }, + zavestiSudyuFn: async dovody => { zovy.push(dovody); return { zaveden: true, put: 'п', prichina: null }; }, + }); + // Флага приёмщика нет — и это правильно, он про другое. Судья от него не зависит. + expect(readJson(join(rdir, 'run.json')).reviewer_starts).toBe(false); + expect(zovy.length, 'ночь, которую больше всего стоило судить, осталась без судьи').toBe(1); + // 🔴 И судье сказано, ЧЕМ она кончилась: без этого он судил бы провал как удачу. + expect(zovy[0].itog.dodelana, 'судье выдали недоделанную ночь за доделанную').toBe(false); + expect(zovy[0].itog.zakryto).toEqual(['2']); + expect(zovy[0].itog.ne_zakryto).toEqual(['1']); + expect(zovy[0].itog.otlozheno).toEqual(['1']); + expect(zovy[0].itog.vsego).toBe(2); + }); + + it('🔴 итог прогона лёг ДО судьи — упади судья, утренняя бумага уже на месте', async () => { + zayavit('1'); + planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]); + let itogBylNaMeste = null; + await runSupervisor({ + tickMs: 1000, script: [() => {}], head: () => 'sha-1', runTests: ZELENO, + zavestiSudyuFn: async () => { + itogBylNaMeste = readJson(lastRunFile) !== null; + return { zaveden: true, put: 'п', prichina: null }; + }, + }); + expect(itogBylNaMeste, 'судью позвали ДО того, как ночь закрыли, — так его падение уносило бы бумагу').toBe(true); + }); + + it('🔴 флага приёмщика нет и до пунктов дело не дошло — судья ВСЁ РАВНО зван', async () => { + // 🔴 Признак `reviewer_starts` — про сборщик экранов продукта, а не про судью. Зов судьи + // на него больше не опирается: дошло дело до закрытия прогона — судья заводится. + const zovy = []; + await runSupervisor({ + script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }], + zavestiSudyuFn: async d => { zovy.push(d); return { zaveden: true }; }, + }); + expect(readJson(lastRunFile), 'прогон закрыт — значит до места зова дошли').not.toBeNull(); + // Флаг не тронут и остался чужим делом. + expect(readJson(join(rdir, 'run.json')).reviewer_starts ?? false).toBe(false); + expect(zovy.length, 'судья не пришёл на ночь без флага приёмщика').toBe(1); + expect(zovy[0].itog.dodelana).toBe(false); + expect(zovy[0].itog.ne_zakryto).toEqual(['1', '2']); + }); + + it('погас НЕ последний — судью не зовём, ночь ещё идёт', async () => { + writeJsonAtomic(join(rdir, 'run.json'), { + ...readJson(join(rdir, 'run.json')), workers: ['w-1', 'w-2'], reviewer_starts: true, + }); + mkdirSync(join(rdir, 'workers', 'w-2'), { recursive: true }); + writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), { worker_id: 'w-2', status: 'working' }); + let zvali = 0; + await runSupervisor({ + script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }], + zavestiSudyuFn: async () => { zvali += 1; return { zaveden: true }; }, + }); + expect(readJson(lastRunFile)).toBeNull(); + expect(zvali).toBe(0); + }); + + it('🔴🔴 судья УПАЛ — ночь всё равно закрыта, а его смерть НЕ проглочена молча', async () => { + zayavit('1'); + planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]); + const { out } = await runSupervisor({ + tickMs: 1000, script: [() => {}], head: () => 'sha-1', runTests: ZELENO, + zavestiSudyuFn: async () => { throw new Error('судья взорвался на ровном месте'); }, + }); + // Ночь закрыта как обычно — это главное. + expect(out.status).toBe('extinguished'); + expect(readJson(lastRunFile), 'падение судьи унесло итог прогона — так нельзя').not.toBeNull(); + expect(readJson(join(rdir, 'run.json')).status).toBe('finished'); + // И его смерть слышно: строкой владельцу и записью в ленту. + const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join('\n'); + expect(stroki, 'смерть судьи проглочена молча').toMatch(/судью завести не удалось/i); + expect(stroki).toContain('судья взорвался на ровном месте'); + expect(readLines(join(rdir, 'events.jsonl')).map(e => e.kind)).toContain('sudya_ne_zaveden'); + }); + + it('🔴 судья не считается работником: состав прогона и счёт закрытых пунктов не меняются', async () => { + zayavit('1'); + planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]); + await runSupervisor({ + tickMs: 1000, script: [() => {}], head: () => 'sha-1', runTests: ZELENO, + zavestiSudyuFn: async () => ({ zaveden: true, put: 'п', prichina: null }), + }); + const last = readJson(lastRunFile); + expect(last.workers.map(w => w.worker_id)).toEqual(['w-1']); + expect(readJson(join(rdir, 'run.json')).workers).toEqual(['w-1']); + expect(closedItemsOfRun(rdir)).toEqual(['1']); + }); +});