ночь: мёртвого больше не зовут живым, путь к стенограмме доезжает до утреннего письма, живые состояния названы по-русски

This commit is contained in:
Дмитрий
2026-08-03 21:14:23 +03:00
parent 51402c0aed
commit 2e866f7e7e
4 changed files with 432 additions and 22 deletions
+67 -9
View File
@@ -20,6 +20,14 @@ import { vidPredela } from './budget.mjs';
// `resumePlan`. Своей копии здесь нет намеренно: два списка слов на один вопрос разъехались
// бы МОЛЧА, и обе стороны остались бы зелёными. Сторож на возврат копии — в проверках.
import { PRODOLZHAEMYE } from './resume.mjs';
// 🔴 Русские слова состояний берутся ОТТУДА ЖЕ, откуда их берёт утренняя сводка
// (`summary.mjs`, задача 9), — своего словаря здесь нет. Английское слово в объяснении
// владельцу равно его отсутствию: он не поймёт, погас работник или сломался.
// 🪤 Ввозится именно сводка, а не надзиратель: у надзирателя тот же словарь, но ввоз
// из него замкнул бы кольцо (`cli-parts` → `supervisor` → `spawn` → `schyot-zamerov`
// → `cli-parts`), а кольца ввозов ломаются молча и не там, где заведены. `summary.mjs`
// не ввозит ничего вовсе — из него ввозить безопасно.
import { statusRu } from './summary.mjs';
const OBRAZTSY = {
run: 'Надо так: node tools/night/cli.mjs run <план> --priyomka <приёмочный лист> --spec <бумага замысла> [--task <номер идущей задачи>] [--files a.php,b.php]',
@@ -153,15 +161,34 @@ export function zamerDovody(cfg = {}) {
// В затее уже записано, чем кончается общая база на двоих: «ЗЕЛЁНЫЕ ВРУЩИЕ проверки,
// а это худший вид беды» (`workerEnv` в spawn.mjs).
//
// 🔴 СКОЛЬКО РАЗНЫХ СОСТОЯНИЙ У ЭТОГО ВХОДА НА САМОМ ДЕЛЕ — ЗАМЕРЕНО, А НЕ ПРИКИНУТО.
// Слов состояния у работника ВОСЕМЬ (посчитано по рабочим файлам 03.08.2026):
// продолжаемые — `stalled`, `extinguished`;
// живые — `working`, `paused`, `waiting_service`, `waiting_answer_idle`,
// `waiting_answer_busy`, `stopped`.
// А состояний — ДЕВЯТЬ. Девятое: **слова нет вовсе** (запись оборвалась, карточка неполна,
// 🔴 СКОЛЬКО РАЗНЫХ СОСТОЯНИЙ У ЭТОГО ВХОДА НА САМОМ ДЕЛЕ — ПЕРЕСЧИТАНО КОМАНДОЙ 03.08.2026,
// А НЕ ГЛАЗАМИ. Прежняя запись здесь говорила «слов ВОСЕМЬ, состояний ДЕВЯТЬ» и была неверна
// дважды: `born_dead` в ней не было вовсе (его завели в тот же день), а слово `stopped`
// в ней было — хотя такого слова в затее нет ни в одном рабочем файле; настоящее — `stopping`.
// Перечень снят зовом самих списков, а не переписыванием их сюда руками:
// node -e "…import('./supervisor.mjs')… WORKER_STATUSES + FINAL_STATUSES…"
// Слов состояния у работника ДЕВЯТЬ:
// продолжаемые (`PRODOLZHAEMYE`, resume.mjs) — `stalled`, `extinguished`;
// прочие итоговые (`FINAL_STATUSES`) — `paused`, `born_dead`;
// объявляет о себе сам (`WORKER_STATUSES`) — `working`, `stopping`, `waiting_service`,
// `waiting_answer_idle`, `waiting_answer_busy`.
// А состояний — ДЕСЯТЬ. Десятое: **слова нет вовсе** (запись оборвалась, карточка неполна,
// в поле не строка). Слить его с «живым» — соврать владельцу: он прочёл бы «работник жив,
// пауза снимается командой `resume`» про работника, которого, может быть, и нет.
// Поэтому у «слова нет» свой ответ и свои слова.
//
// 🪤 ЭТОТ ПЕРЕЧЕНЬ — ПРИМЕЧАНИЕ, А ПРИМЕЧАНИЯ УСТАРЕВАЮТ МОЛЧА: он и устарел, ровно так.
// Поэтому ниже по нему НИЧЕГО не считается — счёт идёт по живым спискам, а сторож на само
// это примечание стоит в проверках (`slova-vladeltsu.test.mjs`) и краснеет, когда число
// слов в затее разойдётся с числом, написанным здесь.
// 🔴 Какие слова считаются продолжаемыми — сказать ВЛАДЕЛЬЦУ, а не показать ему список
// английских слов. Прежде здесь стояло `PRODOLZHAEMYE.join(' и ')`, то есть в письмо уходило
// «(stalled и extinguished)» — ровно та же беда, что и `born_dead` в утренней таблице,
// только вторым местом. Список остаётся ОДИН (`resume.mjs`), переводится он на месте печати.
function prodolzhaemyeSlovami() {
return PRODOLZHAEMYE.map(s => `«${statusRu(s)}»`).join(' и ');
}
export function vyborProdolzhaemogo({ workers = [], workerId = null } = {}) {
const spisok = Array.isArray(workers) ? workers.filter(Boolean) : [];
if (!spisok.length) {
@@ -205,16 +232,47 @@ export function vyborProdolzhaemogo({ workers = [], workerId = null } = {}) {
+ 'в том же углу и той же базе оказались бы двое живых разом. Смотрите прогон ' + `${w.resumed_into}.`,
};
}
// Жив — в том числе на паузе.
// ━━━ 🔴🔴 НЕ ПРОДОЛЖАЕМ — И ПРИЧИНА НАЗЫВАЕТСЯ ТА, КОТОРАЯ ЕСТЬ НА САМОМ ДЕЛЕ ━━━
//
// ЧТО БЫЛО, замерено живьём и дословно 03.08.2026:
// «Работник w-4 жив (born_dead) — продолжают только вставших и погашенных»
// Работник МЁРТВ: он не запустился вовсе. Денежный замок при этом отрабатывал ВЕРНО —
// `born_dead` в `PRODOLZHAEMYE` не входит, в платный подъём такого не берут. Врало
// только объяснение, которое читает человек.
//
// 🔴 ПОЧЕМУ ЭТО ХУЖЕ ОБЫЧНОЙ ОШИБКИ. Замок держит, а владелец читает обратное — и
// решение принимает по прочитанному. Прочтя «жив», он пошёл бы искать работающего
// работника, которого нет ни одного, вместо того чтобы разбираться, почему тот
// не запустился, — а причина (не та программа, не то окружение, нет ключа) сама
// не проходит и повторится в следующую же ночь. Правильный замок с враньём в объяснении
// стоит дороже честного отказа: беду он не пускает, но и чинить её никто не идёт.
//
// 🔴 «ЖИВ» ТЕПЕРЬ НЕ ДОГАДКА ПО СПИСКУ СЛОВ, А ОТВЕТ ЕГО СОБСТВЕННОЙ КАРТОЧКИ.
// Кончившемуся надзиратель пишет `finished_at` и `finish_reason` (`finish` в
// supervisor.mjs, одно место на все выходы), живому — не пишет никогда. Второго списка
// «живых слов» здесь нет намеренно: он разъехался бы с `FINAL_STATUSES` молча, а ввезти
// настоящий отсюда нельзя — вышло бы кольцо ввозов через надзирателя.
if (!PRODOLZHAEMYE.includes(w.status)) {
const konchilsya = Number.isFinite(w.finished_at) || typeof w.finish_reason === 'string';
const proPauzu = w.status === 'paused'
? ' Он на паузе, а пауза снимается командой `resume`, а не кнопкой «продолжить».'
: '';
// 🔴 Слова надзирателя ДОНОСЯТСЯ, а не сочиняются здесь заново: он уже написал
// полную русскую причину, и второе правило того же смысла разошлось бы с первым.
const zapisal = konchilsya && typeof w.finish_reason === 'string' && w.finish_reason
? ` Вот что записал о нём надзиратель: ${w.finish_reason}`
: '';
// 🪤 Во второй половине сказано «ПО СВОЕЙ КАРТОЧКЕ», а не просто «жив». Карточка —
// единственное, что у нас тут есть; работник мог умереть секунду назад, а надзиратель
// ещё не успел дописать. Голое «жив» было бы обещанием, которого не на чем держать, —
// ровно тем же приёмом, каким сегодня «жив» говорили про мертворождённого.
const golova = konchilsya
? `Работник ${workerId} кончился и продолжению не подлежит — состояние «${statusRu(w.status)}».`
: `Работник ${workerId} по своей карточке ещё жив и работает — состояние «${statusRu(w.status)}».`;
return {
worker: null,
candidates: kandidaty.map(x => x.worker_id),
reason: `Работник ${workerId} жив (${w.status}) — продолжают только вставших и погашенных `
+ `(${PRODOLZHAEMYE.join(' и ')}).${proPauzu}`,
reason: `${golova} Продолжают только вставших и погашенных (${prodolzhaemyeSlovami()}).${proPauzu}${zapisal}`,
};
}
return { worker: w, candidates: kandidaty.map(x => x.worker_id), reason: null };
+253
View File
@@ -0,0 +1,253 @@
// ━━━ СТОРОЖА ОДНОГО КЛАССА: «СКАЗАНО ВЛАДЕЛЬЦУ» ПРОТИВ «СДЕЛАНО НА САМОМ ДЕЛЕ» ━━━
//
// Все три беды 03.08.2026 — одна и та же беда: работа сделана, а до владельца не дошла
// или дошла искажённой.
// 1. `born_dead` печатался в утренней таблице английским словом;
// 2. `cli-parts.mjs` звал мертворождённого ЖИВЫМ — замок держал верно, врало объяснение;
// 3. приговор прибора поиска стенограммы не шёл никуда вовсе.
//
// 🔴 ПОЧЕМУ ОТДЕЛЬНЫМ ФАЙЛОМ. Он стережёт не какой-то один кусок затеи, а СТЫК между тем,
// что затея решила, и тем, что она про это сказала человеку. Стык этот ничей — и оттого
// рвётся: каждая из трёх правок сегодня остановилась ровно на пороге чужого файла.
//
// 🔴 КАЖДЫЙ СТОРОЖ ЗДЕСЬ БЫЛ ПРИНЯТ ВЫРЕЗАНИЕМ: защиту ломали и смотрели, что он краснеет.
// Зелёный сторож, которого ни разу не видели красным, не доказывает ничего.
import { describe, it, expect } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { STATUS_WORD, statusRu, buildSummary } from './summary.mjs';
import { FINAL_STATUSES, WORKER_STATUSES } from './supervisor.mjs';
import { PRODOLZHAEMYE } from './resume.mjs';
import { vyborProdolzhaemogo } from './cli-parts.mjs';
const KATALOG = dirname(fileURLToPath(import.meta.url));
const ishodnik = imya => readFileSync(join(KATALOG, imya), 'utf8');
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
describe('слова состояний: ни одно не доходит до владельца по-английски', () => {
// Сторож КЛАССА, а не одного забытого слова: список берётся у самой затеи.
it('каждое слово, каким затея зовёт работника, переводится на русский', () => {
const vse = [...new Set([...FINAL_STATUSES, ...WORKER_STATUSES, ...PRODOLZHAEMYE])];
// 🪤 Сам перечень тоже сторожим: опустей он по недосмотру — цикл ниже прошёл бы
// вхолостую и остался зелёным, ничего не проверив. Пустой обход — любимый вид
// мнимого сторожа.
expect(vse.length).toBeGreaterThanOrEqual(9);
for (const status of vse) {
const slovo = statusRu(status);
expect(slovo, `состояние «${status}» завели, а русского слова к нему не дали`).toBeTruthy();
expect(slovo, `слово состояния «${status}» осталось латиницей`).not.toMatch(/[A-Za-z]/);
}
});
it('словарь в затее ОДИН: у надзирателя своей копии слов больше нет', async () => {
// 🔴 Копия слова у надзирателя побеждала общую при слиянии. Разойдись копии на знак —
// владелец читал бы про одного работника два разных слова, а обе стороны были бы зелёными.
const sup = await import('./supervisor.mjs');
expect(Object.keys(sup.SLOVA_NOVYH_SOSTOYANIY)).toEqual([]);
for (const [slovo, russkoe] of Object.entries(STATUS_WORD)) {
expect(sup.SLOVA_SOSTOYANIY[slovo]).toBe(russkoe);
}
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
describe('отказ кнопки «продолжить»: слово человеку не расходится с решением замка', () => {
const kartochka = (o = {}) => ({ worker_id: 'w-1', status: 'stalled', ...o });
it('🔴 КЛАСС: ни одно НЕпродолжаемое итоговое состояние не зовётся живым', () => {
// Замерено живьём: «Работник w-4 жив (born_dead) — продолжают только вставших…».
// Работник был мёртв. Сторож обходит ВСЕ итоговые слова, а не одно сегодняшнее:
// заведут завтра десятое — он покраснеет сам.
const mertvye = FINAL_STATUSES.filter(s => !PRODOLZHAEMYE.includes(s));
expect(mertvye.length).toBeGreaterThan(0);
for (const status of mertvye) {
const r = vyborProdolzhaemogo({
workers: [kartochka({ status, finished_at: 1, finish_reason: 'Запустить не удалось.' })],
workerId: 'w-1',
});
expect(r.worker).toBeNull();
expect(r.reason, `состояние «${status}» подано владельцу как живое`).not.toMatch(/жив/i);
expect(r.reason).toMatch(/кончился/i);
}
});
it('в отказе стоит русское слово состояния, а не английское', () => {
const r = vyborProdolzhaemogo({
workers: [kartochka({ status: 'born_dead', finished_at: 1, finish_reason: 'Запустить не удалось.' })],
workerId: 'w-1',
});
expect(r.reason).toContain(STATUS_WORD.born_dead);
expect(r.reason).not.toMatch(/born_dead/);
});
it('🔴 список продолжаемых называется владельцу по-русски, а не слитыми английскими словами', () => {
// Прежде здесь печаталось «(stalled и extinguished)» — то же самое английское слово
// в лицо владельцу, только вторым местом.
const r = vyborProdolzhaemogo({
workers: [kartochka({ status: 'born_dead', finished_at: 1, finish_reason: 'Запустить не удалось.' })],
workerId: 'w-1',
});
for (const s of PRODOLZHAEMYE) {
expect(r.reason, `слово «${s}» ушло владельцу английским`).not.toContain(s);
expect(r.reason).toContain(STATUS_WORD[s]);
}
});
it('слова надзирателя доносятся, а не сочиняются заново', () => {
const r = vyborProdolzhaemogo({
workers: [kartochka({ status: 'born_dead', finished_at: 1, finish_reason: 'Работника не удалось запустить: нет ключа.' })],
workerId: 'w-1',
});
expect(r.reason).toContain('нет ключа');
});
it('работник по карточке ещё не кончился — он и назван живым, но со ссылкой на карточку', () => {
// 🪤 «Жив» — не обещание, а пересказ карточки: он мог умереть секунду назад.
const r = vyborProdolzhaemogo({ workers: [kartochka({ status: 'working' })], workerId: 'w-1' });
expect(r.reason).toMatch(/жив/i);
expect(r.reason).toMatch(/карточке/i);
});
it('🪤 примечание о числе состояний сверено с самой затеей, а не с памятью писавшего', () => {
// Примечание в `cli-parts.mjs` УЖЕ устаревало молча: говорило «слов восемь» и называло
// слово `stopped`, которого в затее нет вовсе. Теперь число в примечании сторожится.
const vse = [...new Set([...FINAL_STATUSES, ...WORKER_STATUSES])];
expect(vse.length).toBe(9); // измерено командой 03.08.2026
const src = ishodnik('cli-parts.mjs');
expect(src).toMatch(/Слов состояния у работника ДЕВЯТЬ/);
expect(src).toMatch(/состояний — ДЕСЯТЬ/);
// 🔴 И каждое слово, перечисленное в самом примечании, обязано быть НАСТОЯЩИМ. Прежнее
// примечание называло `stopped`, которого в затее нет ни в одном файле, — выдуманное
// слово в примечании страшнее устаревшего числа: по нему свежая смена ищет и не находит.
const perechen = src.slice(src.indexOf('Слов состояния у работника ДЕВЯТЬ'), src.indexOf('А состояний — ДЕСЯТЬ'));
const nazvannye = [...perechen.matchAll(/`([a-z_]+)`/g)].map(m => m[1]);
expect(nazvannye.length).toBeGreaterThanOrEqual(9);
for (const slovo of nazvannye) {
if (slovo === 'PRODOLZHAEMYE') continue;
expect(vse, `в примечании названо слово «${slovo}», которого в затее нет`).toContain(slovo);
}
});
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
describe('стенограмма работника доезжает до владельца, а не кончается в приборе', () => {
// Живое хозяйство прогона: каталог прогона + каталог работника.
function hozyaystvo() {
const koren = mkdtempSync(join(tmpdir(), 'noch-stenogramma-'));
const runDirPath = join(koren, 'run');
const workerDirPath = join(runDirPath, 'workers', 'w-1');
mkdirSync(workerDirPath, { recursive: true });
writeFileSync(join(runDirPath, 'run.json'), JSON.stringify({ started_at: 1, workers: ['w-1'] }), 'utf8');
return { koren, runDirPath, workerDirPath };
}
// Круг зовём НЕ целиком — целиком он живёт часами и ходит в git. Зовём `supervise`
// с подставками на всё живое, и работник у нас кончается сразу: `spawnWorker` бросает,
// то есть попадаем ровно в тот выход, ради которого всё и затевалось (`born_dead`).
async function progonPogibshego(naytiStenogrammuFn) {
const { runDirPath, workerDirPath } = hozyaystvo();
const { supervise } = await import('./supervisor.mjs');
const itog = await supervise({
runId: 'r-1',
workerId: 'w-1',
worktreeRoot: workerDirPath,
workerDirPath,
runDirPath,
cfg: { base_branch: 'main', supervisor_tick_seconds: 0 },
measuresPath: join(runDirPath, 'measures.jsonl'),
commandsDirPath: join(runDirPath, 'commands'),
lastRunFile: join(runDirPath, 'last-run.json'),
deferredPath: join(runDirPath, 'deferred'),
spawnWorker: () => { throw new Error('программа не найдена'); },
now: () => 1000,
sleep: async () => {},
probeDisk: async () => ({ ok: true }),
head: () => 'sha-0',
changedFiles: () => [],
rollbackFn: () => ({ ok: true }),
runTests: () => ({ ok: true }),
dirty: () => false,
naytiStenogrammu: naytiStenogrammuFn,
});
return { itog, runDirPath, workerDirPath };
}
const nakhodka = {
naydeno: 1,
put: 'C:/дом/.claude/projects/ugol/abc.jsonl',
kandidaty: [],
katalog: 'C:/дом/.claude/projects/ugol',
katalogi_probovali: [],
sposob: 'единственная в каталоге работника',
stroka: 'Стенограмма работника: C:/дом/.claude/projects/ugol/abc.jsonl',
};
function strokiSvodki(runDirPath) {
const put = join(runDirPath, 'summary-lines.jsonl');
if (!existsSync(put)) return [];
return readFileSync(put, 'utf8').split('\n').filter(Boolean).map(s => JSON.parse(s).line);
}
it('🔴 путь к стенограмме попадает в СТРОКИ СВОДКИ — то, что владелец читает утром', async () => {
const { runDirPath } = await progonPogibshego(() => nakhodka);
const stroki = strokiSvodki(runDirPath);
expect(stroki.some(l => l.includes(nakhodka.put)), 'путь к улике до сводки не дошёл').toBe(true);
});
it('🔴 и до самой утренней сводки: строка видна в письме владельцу', async () => {
// Сквозной сторож: строки сводки → `buildSummary` → письмо. Он краснеет, даже если
// строку положить в файл, который письмо не читает.
const { runDirPath } = await progonPogibshego(() => nakhodka);
const pismo = buildSummary({ run_id: 'r-1', lines: strokiSvodki(runDirPath), disk_free_gb: 10 });
expect(pismo.text).toContain(nakhodka.put);
});
it('прибор зовут ГОТОВЫЙ, по готовой карточке — своего второго поиска нет', async () => {
let dali = null;
await progonPogibshego(put => { dali = put; return nakhodka; });
expect(dali, 'прибор поиска стенограммы не позвали вовсе').toBeTruthy();
expect(String(dali)).toMatch(/worker\.json$/);
// 🪤 И в исходнике надзирателя нет своей копии правила поиска: имя каталога стенограмм
// считает один файл на всю затею.
const src = ishodnik('supervisor.mjs');
expect(src).toMatch(/from '\.\/stenogramma\.mjs'/);
expect(src, 'у надзирателя завелась своя копия правила имени каталога').not.toMatch(/\.claude', 'projects'/);
});
it('улика остаётся и в exit.json, а не только в письме', async () => {
const { workerDirPath } = await progonPogibshego(() => nakhodka);
const exit = JSON.parse(readFileSync(join(workerDirPath, 'exit.json'), 'utf8'));
expect(exit.stenogramma?.put).toBe(nakhodka.put);
});
it('🔴 стенограммы НЕ нашлось — владельцу говорят и это, а не молчат', async () => {
// Молчание тут неотличимо от «печать опять отвалилась» — ровно та беда, которую чиним.
const pusto = { ...nakhodka, naydeno: 0, put: null, stroka: 'Стенограммы работника нет: каталога под неё не заведено вовсе.' };
const { runDirPath } = await progonPogibshego(() => pusto);
expect(strokiSvodki(runDirPath).some(l => l.includes('Стенограммы работника нет'))).toBe(true);
});
it('🔴 ПАДЕНИЕ ПРИБОРА НЕ УБИВАЕТ НАДЗИРАТЕЛЯ — работник закрыт, а сбой назван вслух', async () => {
// Надзиратель в этот миг закрывает работника. Умри он здесь — не будет ни карточки итога,
// ни строки сводки, ни освобождения замков, и ночь кончится молча.
const { itog, runDirPath, workerDirPath } = await progonPogibshego(() => { throw new Error('диск отвалился'); });
expect(itog.status).toBe('born_dead');
expect(JSON.parse(readFileSync(join(workerDirPath, 'worker.json'), 'utf8')).status).toBe('born_dead');
const stroki = strokiSvodki(runDirPath);
expect(stroki.some(l => /поиск упал|диск отвалился/.test(l))).toBe(true);
});
it('🪤 у честно погашенного стенограмму не ищут — сводку не забивают исправностью', async () => {
// У него `kak_konchil` нет: он кончился по названной причине, разбирать нечего.
// Строка про стенограмму на каждого из семерых утопила бы единственную важную.
let zvali = 0;
const { runDirPath } = await progonPogibshego(() => { zvali += 1; return nakhodka; });
expect(zvali).toBe(1); // погибший — искали
const stroki = strokiSvodki(runDirPath);
expect(stroki.filter(l => l.includes('Стенограмма работника:')).length).toBe(1);
});
});
+23 -1
View File
@@ -19,10 +19,32 @@ export const STATUS_WORD = {
// был чужим (`SLOVA_NOVYH_SOSTOYANIY`): два разных слова про одного работника — та же
// ложь, только с двух сторон.
born_dead: 'не начал работу вовсе',
// 🔴 НАЙДЕНО СОБСТВЕННЫМ СТОРОЖЕМ 03.08.2026, СВЕРХ ТРЁХ НАЗВАННЫХ БЕД. Здесь были только
// ИТОГОВЫЕ состояния — те, что ставит надзиратель. А есть ещё пять, которые работник
// объявляет о себе САМ (`WORKER_STATUSES` в supervisor.mjs), и до владельца они доходят
// тоже: отказ кнопки «продолжить» называет ему состояние живого работника. То есть
// английских слов в лицо владельцу было не одно, а шесть — просто про пять из них никто
// не спрашивал. Сторож обходит теперь ОБА списка, а не один.
// 🪤 Слова описательные, а не однословные: «ждёт ответа» без «и стоит без дела» / «и
// работу не бросил» — это два РАЗНЫХ состояния, и по ним по-разному идут деньги
// (`billableMs` в limits.mjs). Слей их в одно слово — владелец не отличил бы работника,
// который жжёт деньги в ожидании, от работника, который просто стоит.
working: 'работает',
stopping: 'доделывает начатый шаг и останавливается',
waiting_answer_idle: 'ждёт ответа владельца и стоит без дела',
waiting_answer_busy: 'ждёт ответа владельца, но работу не бросил',
waiting_service: 'ждёт чужую службу',
};
// Незнакомое слово печатается как есть: молча съесть его — соврать владельцу.
function statusRu(status) {
// 🔴 Вывезено наружу 03.08.2026: слова состояний владельцу печатает не только сводка
// (`cli-parts.mjs` объясняет ими отказ кнопки «продолжить»). Пусть зовут ЭТО правило,
// а не переписывают у себя `STATUS_WORD[x] ?? x`: третья копия одного правила разошлась бы
// с первыми двумя молча — ровно тем классом, каким сегодня разъехалась подпись ночи.
// 🪤 Этот файл НИЧЕГО не ввозит, и так и должно остаться: его ввозят все, а кольцо ввозов
// (`cli-parts` → `supervisor` → `spawn` → `schyot-zamerov` → `cli-parts`) ломается молча.
export function statusRu(status) {
if (!status) return 'работал';
return STATUS_WORD[status] ?? status;
}
+89 -12
View File
@@ -66,6 +66,12 @@ import { markDeferred, deferredDir } from './deferred.mjs';
// 🔴 Команда проверок считается ЗДЕСЬ, на каждом судимом пункте. `plan-model.mjs` не ввозит
// из затеи ничего, поэтому кольца ввозов не будет.
import { komandaProverokPunkta } from './plan-model.mjs';
// 🔴 ПОСЛЕДНЯЯ МИЛЯ УЛИКИ (03.08.2026). Прибор поиска стенограммы построен и проверен
// (`stenogramma.mjs`), но его приговор никуда не шёл: владелец получал «кончился, не отдав
// итога» и перебирал каталоги руками — прошлая смена так потеряла целый день. Прибор зовётся
// ГОТОВЫЙ, второго правила поиска здесь нет ни строки: две мысли об одном, написанные
// в двух местах, расходятся молча (сегодня это уже случилось с подписью ночи).
import { stenogrammaPoKartochke, strokaVladeltsu } from './stenogramma.mjs';
// Сколько ждать после вежливого «остановись» до безусловного «умри».
export const KILL_GRACE_MS = 10_000;
@@ -278,19 +284,22 @@ export const WORKER_STATUSES = [
// мертворождённого, не трогая ни resume.mjs, ни cli-parts.mjs — чужих файлов, которые правят
// прямо сейчас. Признак сбоку так не умеет: его надо было бы ещё научиться читать всем троим.
//
// 🟡 ЧЕСТНО И ВСЛУХ, ОДНА НЕЗАКРЫТАЯ ПОЛОВИНА: русское слово для `born_dead` живёт в
// `summary.mjs` (`STATUS_WORD`) — это чужой файл, и я его не тронул. Пока в него не добавят
// строку, утренняя таблица «кто что делал» напечатает по такому работнику английское слово
// `born_dead`, а сразу за ним — полную русскую причину, которую пишет этот файл. Смысл до
// владельца дойдёт, английское слово в глаза бросится. Это названная дыра, а не молчаливая.
// 🟢 ТА САМАЯ НЕЗАКРЫТАЯ ПОЛОВИНА — ЗАКРЫТА 03.08.2026. Русское слово для `born_dead`
// дописано в `STATUS_WORD` (`summary.mjs`), и утренняя таблица больше не печатает владельцу
// английского слова. Прежде здесь стояло: «пока в него не добавят строку, владелец прочтёт
// `born_dead`» — дыра была названа вслух и оттого не потерялась.
export const FINAL_STATUSES = ['extinguished', 'paused', 'stalled', 'born_dead'];
// Русские слова для состояний, которых `summary.mjs` ещё не знает. Заводится СВЕРХУ
// единственного списка (`STATUS_WORD`), а не рядом с ним: старые слова остаются в одном
// месте и разойтись не могут физически — здесь их попросту нет.
export const SLOVA_NOVYH_SOSTOYANIY = {
born_dead: 'не начал работу вовсе',
};
// Русские слова для состояний, которых `summary.mjs` ещё не знает.
// 🟢 СЕЙЧАС ЗДЕСЬ ПУСТО, И ЭТО ПРАВИЛЬНОЕ СОСТОЯНИЕ: словарь в затее ОДИН (`STATUS_WORD`),
// и `born_dead` переехал туда. Держать копию слова здесь после этого нельзя — при слиянии
// (`SLOVA_SOSTOYANIY`) местная копия побеждает общую, и разойдись они хоть на знак, владелец
// читал бы про одного работника два разных слова, а обе стороны остались бы зелёными.
// 🔴 Пустой список оставлен НАРОЧНО, а не убран: это шов на следующий раз. Заведут новое
// состояние в надзирателе раньше, чем слово в сводке, — его временное слово ляжет сюда,
// и владелец не увидит английского ни одной ночи. Сторож на «состояние без русского слова» —
// в проверках сводки, и он краснеет по КЛАССУ, а не по одному забытому слову.
export const SLOVA_NOVYH_SOSTOYANIY = {};
export const SLOVA_SOSTOYANIY = { ...STATUS_WORD, ...SLOVA_NOVYH_SOSTOYANIY };
// 🪤 Незнакомое слово печатается КАК ЕСТЬ, а не проваливается в `undefined`. Прежде строка
@@ -1061,6 +1070,13 @@ export async function supervise({
// след в БОЕВОЙ `.night/deferred/`, и следующая живая ночь упёрлась бы в отказ,
// оставленный проверкой. Умолчание настоящее, то есть живая ночь не изменилась.
deferredPath = deferredDir(),
// 🪤 Умолчание НАСТОЯЩЕЕ — живой поиск стенограммы по карточке работника, как и у прочих
// доводов этого круга. Подставка подаётся ЯВНО и только в проверках: пустышка, отвечающая
// «нашлась», за три куска нашлась пять раз, а здесь она была бы особенно вредна —
// владелец читал бы путь к улике, которой нет.
// 🔴 Довод принят ВМЕСТЕ с зовом (правило хозяйства этого файла: принимать параметр можно
// только вместе с зовом в том же куске). Зовётся ниже, в `finish`.
naytiStenogrammu = stenogrammaPoKartochke,
}) {
const startedAt = now();
const seen = {};
@@ -1191,10 +1207,48 @@ export async function supervise({
// видеть, где лежит поток и есть ли в нём хоть что-то, — а не искать по каталогам.
const sostoyanieSleda = sledPotoka ? sledPotoka.zakryt() : null;
writeJsonAtomic(join(workerDirPath, 'worker.json'), card);
// ━━━ 🔴🔴 ПОСЛЕДНЯЯ МИЛЯ: ПУТЬ К ПОЛНОЙ ЗАПИСИ РАБОТНИКА ДОХОДИТ ДО ВЛАДЕЛЬЦА ━━━
//
// Ищется ТОЛЬКО у тех, кто кончился не по-хорошему, — то есть когда есть `kak_konchil`
// (не запустился · замолчал · не сказал ни слова · оборвался без итога). У честно
// погашенного по слову владельца или по восьми часам разбирать нечего, и строка про
// стенограмму была бы шумом: семеро работников за ночь утопили бы в исправности
// единственную важную строку — тем же приёмом, каким уже отобран показ следа потока.
//
// 🔴 Зовётся ГОТОВЫЙ прибор по ГОТОВОЙ карточке — она записана строкой выше и уже несёт
// всё нужное (угол, номер сессии, время, поручение). Своего второго поиска здесь нет
// ни строки: два правила одного смысла расходятся молча.
//
// 🔴 ПАДЕНИЕ ПРИБОРА НЕ ИМЕЕТ ПРАВА УБИТЬ НАДЗИРАТЕЛЯ. Он в этот миг закрывает работника;
// умри он здесь — не будет ни карточки итога, ни строки сводки, ни освобождения замков,
// и ночь кончится молча. Это ровно тот класс беды, из-за которого 334 прогона умирали
// три месяца незамеченными. Поэтому сбой поиска — это СТРОКА ВЛАДЕЛЬЦУ, а не обвал.
let stenogramma = null;
if (kak_konchil) {
try {
stenogramma = naytiStenogrammu(join(workerDirPath, 'worker.json'));
} catch (err) {
stenogramma = {
naydeno: 0,
put: null,
kandidaty: [],
katalog: null,
katalogi_probovali: [],
sposob: 'поиск стенограммы упал',
stroka: `Стенограмму работника найти не удалось — поиск упал: ${err?.message ?? err}. `
+ `Смотреть руками: node tools/night/stenogramma.mjs ${join(workerDirPath, 'worker.json')}`,
};
}
}
writeJsonAtomic(join(workerDirPath, 'exit.json'), {
status: final_status, reason, result, touched, at,
kak_konchil,
stream_raw: sostoyanieSleda,
// 🔴 Кладём и сюда, а не только в письмо: письмо владелец прочтёт утром и закроет,
// а `exit.json` останется рядом с уликами навсегда.
stenogramma,
});
if (sostoyanieSleda) {
logEvent(runDirPath, {
@@ -1225,6 +1279,26 @@ export async function supervise({
// а не `summaryLinesPath(runId)`: та функция всегда считает от настоящего NIGHT_ROOT
// и подмену каталога в проверках не увидит.
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at, worker_id: workerId, line });
// 🔴 Строка про стенограмму идёт ОТДЕЛЬНОЙ строкой сразу за приговором, а не приклеена
// к нему хвостом: строка приговора и так длинная, а путь к улике владелец копирует
// глазами — склеенный с причиной смерти, он потеряется в ней.
// 🔴 Печатается ВСЕГДА, когда искали, — в том числе «стенограммы нет» и «их несколько».
// Молчи мы при неудаче — владелец не отличил бы «улики нет» от «печать опять отвалилась»,
// а неразличимость этих двух и есть та беда, которую здесь чинят.
const stenogrammaLine = stenogramma
? `${workerId}: ${strokaVladeltsu(stenogramma)}`
: null;
if (stenogrammaLine) {
appendLine(join(runDirPath, SUMMARY_LINES_FILE), { at, worker_id: workerId, line: stenogrammaLine });
logEvent(runDirPath, {
kind: 'worker_transcript',
worker: workerId,
naydeno: stenogramma.naydeno,
sposob: stenogramma.sposob,
put: stenogramma.put,
}, at);
}
logEvent(runDirPath, { kind: 'worker_finish', worker: workerId, status: final_status, kak_konchil, reason }, at);
// 🔴 Файлы освобождаются здесь, в одном месте на все выходы из круга. Расставь мы
@@ -1234,7 +1308,10 @@ export async function supervise({
closeRunIfLast(at);
return { status: final_status, reason, touched, lines: [line] };
// 🪤 Строка про стенограмму отдаётся и НАВЕРХ, не только в файл сводки: тот, кто зовёт
// круг напрямую (проверки, а завтра и кусок 7), читает ответ, а не лезет в файл. Отдай
// мы её только в файл — половина зовущих потеряла бы улику молча.
return { status: final_status, reason, touched, lines: stenogrammaLine ? [line, stenogrammaLine] : [line] };
}
// 🔴 След сырого потока работника открывается ДО его рождения — иначе первые куски