diff --git a/tools/night/cli-parts.mjs b/tools/night/cli-parts.mjs index 0518e325..14962050 100644 --- a/tools/night/cli-parts.mjs +++ b/tools/night/cli-parts.mjs @@ -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 }; diff --git a/tools/night/slova-vladeltsu.test.mjs b/tools/night/slova-vladeltsu.test.mjs new file mode 100644 index 00000000..1cce94ae --- /dev/null +++ b/tools/night/slova-vladeltsu.test.mjs @@ -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); + }); +}); diff --git a/tools/night/summary.mjs b/tools/night/summary.mjs index 6bfaa5cb..ac8dbfe6 100644 --- a/tools/night/summary.mjs +++ b/tools/night/summary.mjs @@ -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; } diff --git a/tools/night/supervisor.mjs b/tools/night/supervisor.mjs index ec33c80a..e18a5f6e 100644 --- a/tools/night/supervisor.mjs +++ b/tools/night/supervisor.mjs @@ -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] }; } // 🔴 След сырого потока работника открывается ДО его рождения — иначе первые куски