feat(ночь): у ночи появился свой судья — приходит на КАЖДУЮ ночь, знает чем она кончилась

This commit is contained in:
Дмитрий
2026-08-06 09:27:46 +03:00
parent 66a86fe290
commit f9e2616ca1
6 changed files with 1859 additions and 4 deletions
+28
View File
@@ -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
+392
View File
@@ -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`, но не из итога.
+718
View File
@@ -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 };
}
+455
View File
@@ -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');
});
});
+112 -4
View File
@@ -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), читает ответ, а не лезет в файл. Отдай
+154
View File
@@ -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']);
});
});