951b15fb85
Находка М-406, правила 244-246. Смена 27 передала список «пять доступных настоящих долгов». Правило 243 велит проверять такой список замером, каждую строку отдельно. Проверил — ДОСТУПНЫМ ОКАЗАЛСЯ ОДИН. Третья смена подряд с той же долей: 14 ложных из 33 долгов, 3 из 4 разъездов, 4 из 5. - `zadachaKonchena` — ✅ настоящий, закрыт этим коммитом; - `reshyonnyeSNevypolnimymi` — 🔒 заперт словом владельца: единственный источник невыполнимых пунктов — ответ владельца «нет» в `questions.mjs`, а он мёртв целиком; - `mailFailedSince` — 🔒 заперт им же: замысел строки 750/1073 называет его дословно («затея видит, что сводка не ушла») частью правила ворот «отклик владельца»; - `readResult` — ❌ не долг, а МЁРТВЫЙ ОСТАТОК. Дороги «запусти, дождись конца, возьми итог» в затее нет: `startWorker` отдаёт живого ребёнка. Четверо читающих потоки делают ДРУГОЕ (надзиратель — живьём по строке; `budget` — ищет замеры предела; судья — берёт последнее событие `result` и текст приговора; `dengi-bez-zapisi` — первую цену), а расход затея нарочно не считает, а спрашивает. Не снесён намеренно: это решение владельца. ЧТО ПОЧИНЕНО. В блоке 5а надзирателя стояло упрощение `otlozhennyePunkty.size === 0` — «отложенных нет, значит всё засчитано». Сюда приходят по ДВУМ причинам, и вторая — не «пунктов не осталось», а «пункт ЕСТЬ, но его файлы держит живой сосед и занять их из-под руки не вышло». Отложенных при этом нет — и на НЕДОДЕЛАННОЙ задаче поднимался флаг приёмщику, а владельцу уходило «Пункты задачи кончились и все засчитаны». Доказано вырезанием: сторож написан первым и покраснел (`items_all_verified` = true на недоделанной задаче), после починки зелёный, при возврате упрощения — снова красный, и ровно один, посчитанный ДО прогона. Прибор №6 подтвердил независимо: имя ушло из приговора. 🔴 ГЛАВНОЕ, ЧТО КУПИЛА СМЕНА, И КУПЛЕНО ЧУЖИМ КРАСНЫМ. Полный прогон после починки дал красное у чужого сторожа `primechanie-ne-rashoditsya-s-pravdoy.test.mjs`. Правило 242 велит сперва спросить, что он бережёт. Он бережёт согласие примечания с правдой и по замыслу ПРАВ. Шапку я поправил — он всё равно краснел. Причина: сторож брал `items.mjs` СЫРЫМ файлом и искал фразу-маркер «НЕТ НИ ОДНОГО», а она стоит там в ЖИВОМ КОДЕ — в тексте ошибки «в плане задачи нет ни одного пункта». Пока шапка врала тем же словом, обе половины совпадали и сторож молчал: он был зелёным ПО ДВУМ ПРИЧИНАМ РАЗОМ, и одна из них случайная. ⇒ Заведён третий рез `tolkoPrimechaniya`, обратный `bezPrimechaniy`, в тот же дом и к той же развилке «какой брать». Обратность стережётся отдельной проверкой (что вырезал один — то оставил второй). Все три места сторожа переведены на него, хотя слепота задевала одно: у двух других фразы-маркера в коде сегодня нет — но это удача, а не защита. 244. У СТОРОЖА, ЧИТАЮЩЕГО ФАЙЛ СЫРЫМ, СПРОСИ: НЕ ОТВЕЧАЕТ ЛИ ЗА ПРИМЕЧАНИЕ ТО, ЧТО ПРИМЕЧАНИЕМ НЕ ЯВЛЯЕТСЯ. Обратная сторона 234: тот про «рез срезал зерно», этот про «отсутствие реза подсунуло шелуху». 245. У КРАСНОГО ЧУЖОГО СТОРОЖА ТРИ ИСХОДА, А НЕ ДВА. Третий: прав он по замыслу, но слеп по способу — тогда чинят САМОГО СТОРОЖА. Спроси, что он измерил, и то ли это, что он берётся охранять. 246. `grep -i` НА РУССКИХ БУКВАХ РЕГИСТР НЕ ПРИВОДИТ: «НЕТ НИ ОДНОГО» → 0, «нет ни одного» → 1, node тем же выражением → нашёл. Мерь тем же телом, каким меряет проверяемый. ПОПУТНО три примечания приведены к правде (правило 235): у `zadachaKonchena` живой зовущий теперь ЕСТЬ И ОН ОДИН (второго не будет, пока нет куска 7 — писать «зовут двое» значило бы чинить враньё половиной правды); `readResult` назван мёртвым остатком; `reshyonnyeSNevypolnimymi` помечен запертым, а не «некому сделать». Проверок 2929 в 108 файлах (было 2927), ноль красных, код 0. Прибор №6: вывозится 613, точных долгов 29 — число то же, состав другой: `zadachaKonchena` ушёл, на его место встал новый рез (класс правила 231, прибор говорит это сам). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
770 lines
68 KiB
JavaScript
770 lines
68 KiB
JavaScript
// Запуск работника. Работник — ребёнок НАДЗИРАТЕЛЯ (supervisor.mjs), а не сессии помощника:
|
||
// отвязанным (detached) живёт надзиратель, он и переживает конец сессии.
|
||
// Учёта расхода через onExit родителя здесь НЕТ: родитель умирает вместе с сессией,
|
||
// и его onExit не сработает никогда. Расход пишет надзиратель.
|
||
// 🪤 334 прежних фоновых прогона в этом хозяйстве упали с "spawn claude ENOENT":
|
||
// на Windows `claude` — скрипт без .cmd, и по голому имени его не находят.
|
||
// Путь к любой внешней программе строит ОДНО место — resolveBin в paths.mjs.
|
||
import { spawn } from 'node:child_process';
|
||
import { existsSync, readFileSync } from 'node:fs';
|
||
import { dirname, join } from 'node:path';
|
||
import { resolveBin, needsShell, configPath, PODMENA_HOZYAYSTVA, PRIMETY_PRIBORA } from './paths.mjs';
|
||
import { readJson } from './state.mjs';
|
||
import { rozhdenieSoSchyotom, spawnAttemptsPath } from './schyot-zamerov.mjs';
|
||
// 🔴 Где лежит стенограмма работника — считает ОДНО место на всю затею (`stenogramma.mjs`),
|
||
// а не каждый по-своему. Второй такой счёт разошёлся бы с первым в первый же вечер.
|
||
import { naytiStenogrammu, strokaVladeltsu } from './stenogramma.mjs';
|
||
|
||
export function resolveClaudeBin({ lookIn = null, name = 'claude' } = {}) {
|
||
return resolveBin(name, lookIn ? { lookIn } : {});
|
||
}
|
||
|
||
// 🔴 Найти файл — половина дела. Вторая половина: Node с 18.20/20.12 (у нас 24) ОТКАЗЫВАЕТСЯ
|
||
// запускать `.cmd`/`.bat` без оболочки (закрытая дыра CVE-2024-27980, ответ EINVAL),
|
||
// а `claude` на этой машине — ровно `.cmd`.
|
||
//
|
||
// 🪤 И просто включить оболочку тут нельзя: оболочка разбирает командную строку ЗАНОВО,
|
||
// а мы передаём длинное русское поручение с переводами строк и кавычками — она его изуродует
|
||
// или разорвёт. Поэтому нутро программы ищется и зовётся НАПРЯМУЮ, без оболочки в цепочке.
|
||
//
|
||
// ━━━ 🔴🔴 ПЕРЕЕЗД ПРОГРАММЫ — замерено живьём 03.08.2026 ━━━
|
||
// Прежде здесь стояло ЖЁСТКОЕ имя нутра — `node_modules/@anthropic-ai/claude-code/cli.js`.
|
||
// Такого файла больше НЕТ ВОВСЕ: пакет 2.1.142 кладёт рядом `cli-wrapper.cjs` и каталог
|
||
// `bin`, а настоящий запускаемый файл — `bin/claude.exe`. Не найдя старого имени, запуск
|
||
// молча сваливался на оболочку, и там умирало ВСЁ, что не переживает склейку: многострочное
|
||
// поручение работника рвалось, пути с пробелом разваливались, ключ со значением «пустая
|
||
// строка» исчезал бесследно. Живой платный замер обеих дорог: через оболочку — код 1 и
|
||
// ни байта вывода, напрямую — код 0, поручение целиком, и вдвое дешевле.
|
||
//
|
||
// 🔑 ЧЕМ ЛЕЧИМ КЛАСС, А НЕ СЛУЧАЙ: имя нутра НЕ ЗАШИТО НИ ОДНО. Его называет сам пакет
|
||
// в своём `package.json` (поле `bin` — это ЗАКАЗ пакета: «зови вот это»). Переедет нутро
|
||
// снова — переедет и запись в `package.json`, и здесь чинить будет нечего. Жёсткое
|
||
// `bin/claude.exe` было бы тем же капканом, что `cli.js`, только с новым именем.
|
||
export function claudeNutro(target, { pkgDir = null } = {}) {
|
||
const dir = pkgDir ?? join(dirname(target), 'node_modules', '@anthropic-ai', 'claude-code');
|
||
const manifestPath = join(dir, 'package.json');
|
||
if (!existsSync(manifestPath)) return null;
|
||
let manifest;
|
||
try {
|
||
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||
} catch {
|
||
return null; // бумага пакета испорчена — это «не нашли», а не «нашли пустоту»
|
||
}
|
||
// 🪤 Поле `bin` бывает ТРЁХ видов, и третий — не «да» и не «нет»: строкой (одно имя),
|
||
// мелочью с именами (`{"claude": "bin/claude.exe"}`) и вовсе отсутствующим. Свести
|
||
// первые два в один вид обязаны мы, а не звать наугад.
|
||
const bin = manifest?.bin;
|
||
let rel = null;
|
||
if (typeof bin === 'string') rel = bin;
|
||
else if (bin && typeof bin === 'object' && !Array.isArray(bin)) {
|
||
rel = typeof bin.claude === 'string' ? bin.claude
|
||
: (Object.values(bin).find(v => typeof v === 'string') ?? null);
|
||
}
|
||
if (!rel) return null;
|
||
const file = join(dir, rel);
|
||
if (!existsSync(file)) return null;
|
||
// 🔴 Нутро, которое САМО требует оболочки (`.cmd`/`.bat`), нутром не считается: звать его
|
||
// без оболочки — EINVAL, с оболочкой — та самая склейка, от которой мы и уходим.
|
||
// «Нашли негодное» честнее слить с «не нашли», чем молча отдать заведомо битый запуск.
|
||
if (needsShell(file)) return null;
|
||
return file;
|
||
}
|
||
|
||
export function claudeLaunch(bin) {
|
||
const target = bin ?? resolveClaudeBin();
|
||
if (!needsShell(target)) return { command: target, prefixArgs: [], shell: false };
|
||
const nutro = claudeNutro(target);
|
||
if (nutro) {
|
||
// Нутро на JS зовётся через `node`, настоящий запускаемый файл — сам собой.
|
||
return /\.(c|m)?js$/i.test(nutro)
|
||
? { command: resolveBin('node'), prefixArgs: [nutro], shell: false }
|
||
: { command: nutro, prefixArgs: [], shell: false };
|
||
}
|
||
// 🔴🔴 ЗАПАСНОЙ ДОРОГИ ЧЕРЕЗ ОБОЛОЧКУ ЗДЕСЬ БОЛЬШЕ НЕТ — И ЭТО РЕШЕНИЕ, А НЕ ПРОПУСК.
|
||
// Она выглядела безобидной («ну хоть как-то запустим»), а на деле молча уродовала всё,
|
||
// что через неё шло: работник рождался и умирал через минуту, не отдав итога, а причину
|
||
// две смены искали не там. Тихая запасная дорога, которая ЛОМАЕТ, хуже громкого отказа:
|
||
// отказ виден сразу и стоит ноль денег, а поломка видна утром и стоит ночь работы.
|
||
throw new Error(
|
||
`Нутро программы claude не найдено рядом с «${target}». Ждали запись «bin» в ` +
|
||
`${join(dirname(target), 'node_modules', '@anthropic-ai', 'claude-code', 'package.json')} ` +
|
||
`и файл по ней. Через оболочку не зовём НАМЕРЕННО: она склеивает доводы в одну строку ` +
|
||
`без кавычек — многострочное поручение работника рвётся, пути с пробелом разваливаются, ` +
|
||
`ключ со значением «пустая строка» исчезает. Проверить: цел ли пакет @anthropic-ai/` +
|
||
`claude-code (переустановить npm i -g @anthropic-ai/claude-code) и не переехал ли он.`,
|
||
);
|
||
}
|
||
|
||
// ━━━ 🔴🔴 СТОРОЖ КЛАССА «ДОВОД, КОТОРЫЙ НЕ ПЕРЕЖИВЁТ СКЛЕЙКУ ОБОЛОЧКОЙ» ━━━
|
||
// Беда не в имени ключа и не в одном месте кода. Беда в СОЮЗЕ двух вещей: запуск идёт через
|
||
// оболочку И среди доводов есть такой, который склейка в одну строку без кавычек уродует.
|
||
// Порознь обе половины законны: без оболочки многострочное поручение и пути с пробелом —
|
||
// совершенно нормальные доводы, и краснеть на них было бы враньём.
|
||
//
|
||
// 🔑 ЛОВИМ ПО ФОРМЕ ДОВОДА, А НЕ ПО СПИСКУ ЗНАКОМЫХ ИМЁН. Список имён («вот эти три ключа
|
||
// опасны») устарел бы к следующей правке — ровно как жёсткое имя `cli.js` устарело к переезду.
|
||
// Смотрим на сам довод: пустой, с пробельным знаком, со знаком, который оболочка толкует
|
||
// по-своему. Затея заведомо подаёт такие доводы: поручение работника многострочное, путь
|
||
// к его настройкам лежит в каталоге с пробелом («портал crm»), а ключи с пустым значением
|
||
// у программы есть.
|
||
const ZNAKI_OBOLOCHKI = /["'`^&|<>()%!]/;
|
||
|
||
export function dovodNePerezhivyotSkleyku(arg) {
|
||
const s = String(arg ?? '');
|
||
if (s === '') return 'пустая строка — при склейке исчезает бесследно, и ключ молча съедает следующий довод';
|
||
if (/\s/.test(s)) return 'пробельный знак (пробел, табуляция или перевод строки) — склейка разорвёт один довод на несколько';
|
||
if (ZNAKI_OBOLOCHKI.test(s)) return 'знак, который оболочка разбирает по-своему (кавычка, &, |, <, >, %, !, ^, скобка)';
|
||
return null;
|
||
}
|
||
|
||
// Отдаёт СПИСОК бед, а не «да/нет»: чинить придётся поимённо, а не вообще.
|
||
// 🔴 `shell` смотрится на ПРАВДИВОСТЬ, а не на равенство `true`: у Node это ещё и строка
|
||
// («какой оболочкой звать») — третье состояние, которое равенством `=== true` слилось бы
|
||
// с «оболочки нет» и прошло бы сторожа насквозь.
|
||
export function proverkaSkleyki({ shell, args }) {
|
||
if (!shell) return []; // без оболочки такие доводы законны — это не беда, а обычная работа
|
||
const bedy = [];
|
||
const spisok = Array.isArray(args) ? args : [];
|
||
for (let i = 0; i < spisok.length; i += 1) {
|
||
const prichina = dovodNePerezhivyotSkleyku(spisok[i]);
|
||
if (prichina) bedy.push({ nomer: i, dovod: String(spisok[i] ?? ''), prichina });
|
||
}
|
||
return bedy;
|
||
}
|
||
|
||
export function zapretSkleyki({ shell, args, gde = 'запуск' }) {
|
||
const bedy = proverkaSkleyki({ shell, args });
|
||
if (bedy.length === 0) return;
|
||
const perechen = bedy
|
||
.map(b => ` · довод №${b.nomer} «${b.dovod.replace(/\r?\n/g, '⏎').slice(0, 60)}» — ${b.prichina}`)
|
||
.join('\n');
|
||
throw new Error(
|
||
`${gde}: доводы поданы ЧЕРЕЗ ОБОЛОЧКУ, а ${bedy.length} из них её не переживут.\n${perechen}\n` +
|
||
`Оболочка склеивает все доводы в одну строку без кавычек — программа получит НЕ ТО, ` +
|
||
`что ей отдали, и умрёт молча. Лечение одно: звать без оболочки (claudeLaunch ищет нутро ` +
|
||
`программы по записи «bin» в её package.json и отдаёт shell: false).`,
|
||
);
|
||
}
|
||
|
||
export function buildArgs({ prompt, settingsPath, sessionId, model, addDirs = [] }) {
|
||
// 🔴 Голова работника приходит из настроек владельца (`worker_model` в night.config.json)
|
||
// и НИГДЕ в коде словом не зашита. Причина денежная: разные головы едят недельный запас
|
||
// по-разному, а запас у владельца один на всё. Значения по умолчанию тут нет намеренно —
|
||
// молча подставить голову значит потратить чужой запас головой, которую не выбирали,
|
||
// и узнать об этом по счёту.
|
||
if (!model) {
|
||
throw new Error('Не сказано, какой головой запускать работника: в night.config.json нет worker_model (образец — night.config.example.json).');
|
||
}
|
||
// Предела в долларах в запуске НЕТ: своего счёта в деньгах затея не ведёт вовсе.
|
||
// Долю прогона держит надзиратель по приросту utilization у прибора системы (кусок 2).
|
||
//
|
||
// ━━━ 🔴🔴 ЛЕЧЕНИЕ 03.08.2026, МЕСТО 2 — ОДИН КЛЮЧ ИЗ ТРЁХ, А НЕ ВСЕ ━━━
|
||
// Живой платный замер (см. `sostavMasterskoyIzPotoka` выше) доказал: загрузка всей
|
||
// мастерской (50 инструментов, 51 внешняя служба, 129 умений, 156 команд) стоит платных
|
||
// токенов на КАЖДОМ запуске, а не только в крошечном замере (`probeArgs` в budget.mjs).
|
||
// Но работник — не крошечный замер: он правит код, гоняет проверки, коммитит, и ему
|
||
// мастерская НУЖНА для самой работы. Решение по каждому ключу — раздельное, не пакетом:
|
||
//
|
||
// · `--tools ''` — НЕ ставим. Без Read/Edit/Bash/Grep работник не работник вовсе:
|
||
// срезать инструменты значило бы сломать его работу, а не удешевить её.
|
||
// · `--strict-mcp-config` — СТАВИМ. Замерено по двум источникам: `worker-prompt.mjs`
|
||
// (весь протокол работника — файлы почтового ящика `.night-worker/` + `git` + команда
|
||
// проверок из плана, ни слова про MCP) и по грепу всего `tools/night/*.mjs` (ни одного
|
||
// обращения к MCP-инструменту нигде в коде затеи). 51 внешняя служба из `.mcp.json`
|
||
// (playwright, github, laravel-boost, sentry, redis, research-паки…) работнику ночью
|
||
// не нужна НИ РАЗУ — а флаг режет только их, встроенных Read/Edit/Bash/Grep/Glob
|
||
// не касаясь вовсе.
|
||
// · `--setting-sources ''` — НЕ ставим, и это НЕ забывчивость, а отдельное решение.
|
||
// Этот ключ обрубает источники настроек целиком — а из них же приходит и предупреждение
|
||
// «БОЕВОЙ ПРОД» в CLAUDE.md, и хук `prod-db-guard`, блокирующий снос боевой базы, —
|
||
// тому самому работнику, который всю ночь работает без человека рядом. Свои настройки
|
||
// (запреты, замки) работник и так получает СВОИМ, уже стоящим здесь ключом `--settings`
|
||
// (единственное число, вот он, ниже) — `--setting-sources` (множественное число) тут
|
||
// ни при чём, и трогать его ради экономии в центы значило бы менять по ошибке
|
||
// предохранитель. Если сомневаешься — не ставь; здесь не поставлен НАМЕРЕННО.
|
||
const args = [
|
||
'-p', prompt,
|
||
// Поток событий, а не один кусок в конце: надзирателю нужен ХОД дела —
|
||
// граница шага для «паузы», замеры недельного запаса по ходу и признаки жизни,
|
||
// а не только итог над трупом.
|
||
'--output-format', 'stream-json',
|
||
'--verbose', // без него Claude Code откажется отдавать поток в паре с -p
|
||
'--settings', settingsPath,
|
||
'--session-id', sessionId,
|
||
'--model', model,
|
||
'--permission-mode', 'acceptEdits',
|
||
'--strict-mcp-config',
|
||
];
|
||
for (const d of addDirs) args.push('--add-dir', d);
|
||
return args;
|
||
}
|
||
|
||
// Что работник наследует из окружения. Список ЗАКРЫТ и намеренно короток: по умолчанию
|
||
// запуск отдаёт ребёнку ВСЁ окружение запускающего, включая боевые ключи в переменных среды,
|
||
// а замок «боевых ключей нет» смотрит только на файлы и такого не поймал бы.
|
||
// ANTHROPIC_API_KEY тут нет намеренно: с ним оплата ушла бы мимо подписки владельца.
|
||
//
|
||
// ━━━ 🔴🔴 ВТОРАЯ ПОЛОВИНА СПИСКА: ОТВЕДЕНИЕ ХОЗЯЙСТВА УЛИК ПЕРЕЕЗЖАЕТ К РЕБЁНКУ ━━━
|
||
// Найдено и замерено 04.08.2026 (круг E). Пол в `paths.mjs` («под прибором проверок корень
|
||
// хозяйства не бывает боевым») узнаёт прибор ТОЛЬКО по приметам в окружении. А это окружение
|
||
// РЕЖЕТСЯ здесь — и до 04.08.2026 резалось вместе с приметами. Значит ребёнок, рождённый
|
||
// из проверки, приходил в мир без единой приметы, честно решал, что он боевой, и получал
|
||
// корнем ОПЛАЧЕННЫЕ УЛИКИ ВЛАДЕЛЬЦА.
|
||
// 🔴 Замерено командой, а не выведено: `env -i PATH=… node -e "import('./paths.mjs')"` отдаёт
|
||
// боевой `.night` рядом с хранилищем; та же команда с `VITEST=true` — временный каталог.
|
||
//
|
||
// 🪤 ПРЕЖНИЙ ДОВОД БЫЛ ЛОЖНЫМ, И ЭТО ГЛАВНОЕ. В стороже улик стояло «сегодня так никто
|
||
// не делает, оба живых зова окружение переносят целиком». Делают: `spawn.test.mjs` рождает
|
||
// живого ребёнка ровно этим урезанным списком. Спасало только то, что тот ребёнок печатает
|
||
// одну строку и модулей затеи не грузит, — то есть дверь держалась на том, что в неё пока
|
||
// не толкались. Допиши кто-нибудь в него один `import` — и пол перестал бы накрывать МОЛЧА.
|
||
//
|
||
// 🔴 ПОЧЕМУ ЭТО НЕ ТРОГАЕТ БОЕВУЮ НОЧЬ НИ НА ЗНАК. Перенос идёт по правилу «есть у родителя —
|
||
// отдай ребёнку» (ниже `if (from[key] !== undefined)`). В настоящей ночи ни одной из этих
|
||
// переменных в окружении НЕТ: приметы ставит vitest, а отведение — прибор проверок. Значит
|
||
// в бою список остаётся прежним, семь переменных, и ни одна лишняя не появляется.
|
||
// 🪤 Секретов здесь не прибавляется: это путь к временному каталогу и признак «идёт проверка».
|
||
// Ключей и паролей среди них нет — замок «боевых ключей нет» не ослаблен ни на волос.
|
||
export const WORKER_ENV_KEYS = [
|
||
'PATH', 'SystemRoot', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA',
|
||
// Куда отведено хозяйство улик, и признак того, что отведение вообще законно.
|
||
// 🔴 Имена ввозятся из `paths.mjs`, а не набраны здесь строками: набранная копия разъедется
|
||
// с оригиналом молча, и ловить это было бы нечем.
|
||
PODMENA_HOZYAYSTVA,
|
||
...PRIMETY_PRIBORA,
|
||
];
|
||
|
||
export function workerEnv({ from = process.env, dbName }) {
|
||
if (!dbName) {
|
||
throw new Error('Работнику не дали имя своей базы — запуска нет: общая база на семерых даёт ЗЕЛЁНЫЕ ВРУЩИЕ проверки, а это худший вид беды.');
|
||
}
|
||
const env = {};
|
||
for (const key of WORKER_ENV_KEYS) {
|
||
if (from[key] !== undefined) env[key] = from[key];
|
||
}
|
||
env.NIGHT_WORKER = '1';
|
||
// 🪤 app/phpunit.xml:33 жёстко задаёт DB_DATABASE=liderra_testing, и без этой переменной
|
||
// своя база работника до прогона проверок НЕ доходит: семеро затирают друг другу базу
|
||
// при зелёных тестах. Сам phpunit.xml не правим — он общий для всего продукта.
|
||
// Работает это потому, что у того объявления нет пометки «перебивать»: переменную,
|
||
// которая в окружении уже есть, проверки не трогают, и Laravel свой .env поверх
|
||
// окружения тоже не кладёт.
|
||
env.DB_DATABASE = dbName;
|
||
return env;
|
||
}
|
||
|
||
export function parseStreamLine(line) {
|
||
const s = String(line ?? '').trim();
|
||
if (!s) return null;
|
||
try { return JSON.parse(s); } catch { return null; } // порванная строка потока не валит надзирателя
|
||
}
|
||
|
||
const STEP_BOUNDARIES = new Set(['assistant', 'user', 'result']);
|
||
|
||
export function isStepBoundary(event) {
|
||
// Граница шага — то место, где «паузу» можно применить чисто: работник ответил
|
||
// или запуск кончился. Служебное начало сессии (`system`) границей не считается.
|
||
return Boolean(event) && STEP_BOUNDARIES.has(event.type);
|
||
}
|
||
|
||
// ━━━ ЗАМЕР ПРИБОРА СИСТЕМЫ ИЗ ПОТОКА ━━━
|
||
//
|
||
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ 03.08.2026, сырой поток лежит в `.night/probe-raw-2026-08-03.jsonl`.
|
||
// Живое событие целиком, знак в знак:
|
||
// {"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785712200,
|
||
// "rateLimitType":"five_hour","overageStatus":"rejected",
|
||
// "overageDisabledReason":"org_level_disabled","isUsingOverage":false}}
|
||
//
|
||
// 🔴🔴 ПОЛЯ `utilization` В НЁМ НЕТ ВОВСЕ — И ЭТО НЕ ПОЛОМКА. Программа собирает событие
|
||
// двумя дорогами: при спокойном расходе — сборкой БЕЗ доли, а с долей — только когда перейдён
|
||
// порог тревоги, и тогда `status` становится `allowed_warning`. То есть прежнее правило
|
||
// «нет доли — замера нет» ждало ЛАМПОЧКУ ТРЕВОГИ и принимало её за бензомер: пока владелец
|
||
// тратит не быстрее часов, числа не будет никогда.
|
||
//
|
||
// 🔴 Прежнее правило отвергало живое событие ДВАЖДЫ, и каждой причины хватало поодиночке:
|
||
// ждало НЕДЕЛЬНЫЙ вид предела (а приходит `five_hour`) и ждало долю (её нет). Ворота запаса
|
||
// не пропустили ни разу за всю жизнь затеи, а владелец читал «события не пришло» — враньё.
|
||
//
|
||
// Ноль вместо неизвестного — по-прежнему враньё о запасе, поэтому доли нет = `null`, не 0.
|
||
|
||
// Сырой разбор события БЕЗ оглядки на то, какой вид предела нам нужен. Нужен затем, чтобы
|
||
// уметь сказать владельцу правду по решению Р2: «система шлёт вид such-and-such, а в настройках
|
||
// ждём такой-то». Без этой дороги несовпадение вида было бы неотличимо от «события не пришло».
|
||
export function razborPredela(event) {
|
||
if (!event || event.type !== 'rate_limit_event') return null;
|
||
const info = event.rate_limit_info;
|
||
if (!info || typeof info !== 'object') return null;
|
||
// 🔴 Замер без вида предела — ничей: приписать его нашему пределу значило бы решать
|
||
// о деньгах по числу, про которое неизвестно, из какого оно окна.
|
||
const kind = typeof info.rateLimitType === 'string' && info.rateLimitType ? info.rateLimitType : null;
|
||
if (!kind) return null;
|
||
return {
|
||
kind,
|
||
status: typeof info.status === 'string' && info.status ? info.status : null,
|
||
utilization: Number.isFinite(info.utilization) ? info.utilization : null,
|
||
resets_at: Number.isFinite(info.resetsAt) ? info.resetsAt * 1000 : null,
|
||
// 🔴 ВТОРОЙ СВЕТОФОР. Светофоров в событии ДВА, а затея читала один и второй молча
|
||
// выбрасывала. Первый (`status`) говорит, как дела с запасом СЕЙЧАС. Второй
|
||
// (`overageStatus`) отвечает на другой вопрос: что будет, КОГДА запас кончится —
|
||
// дадут ли работать сверх за доплату или откажут. Замерено на пяти оплаченных следах:
|
||
// всегда `rejected`, причина `org_level_disabled`. Значит ночь, упёршись в предел,
|
||
// встанет намертво, и ждать доплаты бесполезно.
|
||
// 🪤 Нет поля — `null`, а НЕ «разрешено»: подставленное умолчание обещало бы владельцу
|
||
// продолжение работы, которого может не быть. Ноль и умолчание вместо неизвестного
|
||
// в этой затее запрещены везде, где речь о деньгах.
|
||
sverh_zapasa: typeof info.overageStatus === 'string' && info.overageStatus ? info.overageStatus : null,
|
||
sverh_pochemu: typeof info.overageDisabledReason === 'string' && info.overageDisabledReason
|
||
? info.overageDisabledReason : null,
|
||
sverh_idyot: typeof info.isUsingOverage === 'boolean' ? info.isUsingOverage : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Второй светофор — словами владельца.
|
||
*
|
||
* 🔴 Отвечает ровно на один вопрос: что будет, когда запас кончится. Это не то же самое,
|
||
* что первый светофор («как дела сейчас»), и путать их нельзя: при зелёном первом второй
|
||
* вполне может быть «откажут».
|
||
*/
|
||
export function slovaProSverh(status, pochemu) {
|
||
if (status === 'rejected') {
|
||
const hvost = pochemu === 'org_level_disabled'
|
||
? ' Так настроено на стороне владельца подписки, а не сломалось.'
|
||
: '';
|
||
return `Когда запас кончится, работать сверх не дадут — система откажет, и ночь встанет.${hvost}`;
|
||
}
|
||
if (status === 'allowed') {
|
||
return 'Когда запас кончится, работать сверх дадут — значит расход может продолжиться за доплату.';
|
||
}
|
||
return 'Дадут ли работать сверх запаса — неизвестно: система об этом не сказала.';
|
||
}
|
||
|
||
// Тот же разбор, но отсеивающий чужой вид предела.
|
||
//
|
||
// 🔴 РЕШЕНИЕ Р1: вид предела — ОБЯЗАТЕЛЬНЫЙ довод, зашитого умолчания здесь нет и быть
|
||
// не должно. Прежнее умолчание НЕДЕЛЬНЫМ видом было ровно тем умолчанием, из-за которого
|
||
// ворота молчали: зовущий, забывший назвать вид, молча получал не тот запас. Теперь такой
|
||
// зовущий падает громко. Единственное место, где вид берётся, — настройка `rate_limit_type`
|
||
// (правило `vidPredela` в budget.mjs).
|
||
export function rateLimitFromEvent(event, { kind = null } = {}) {
|
||
if (!kind) {
|
||
throw new Error('Не сказано, какой вид предела читать из события (довод `kind`). Своего умолчания здесь нет намеренно: подставленный молча вид — это чужой запас, и решать по нему о деньгах владельца нельзя.');
|
||
}
|
||
const info = razborPredela(event);
|
||
if (!info || info.kind !== kind) return null;
|
||
return info;
|
||
}
|
||
|
||
// ━━━ ГОЛОВА, КОТОРАЯ ВПРАВДУ ОТВЕЧАЛА ━━━
|
||
//
|
||
// 🔴🔴 ЗАМЕРЕНО ЖИВЬЁМ 03.08.2026 (сырой поток — `.night/probe-raw-2026-08-03.jsonl`, 11 строк,
|
||
// голое имя головы намеренно НЕ повторяем здесь словом — см. правило-сторож в spawn.test.mjs,
|
||
// «слова головы в коде затеи нет нигде»): заказали крошечный замер дешёвой головой, а в СЧЕТЕ
|
||
// оказалась (и была ОПЛАЧЕНА) ДРУГАЯ, В РАЗЫ ДОРОЖЕ ГОЛОВА — цена сошлась с её прейскурантом
|
||
// знак в знак (`total_cost_usd: 0.15743` на 41 954 токенах создания кэша, у дешёвой головы
|
||
// вышло бы вчетверо меньше). 🔴 ОТЧЕГО ТАК — НЕ ВЫЯСНЕНО. Отдельной живой проверкой (другим
|
||
// прогоном, не этим сырым файлом) подтверждено, что заказанная голова САМА ПО СЕБЕ работает —
|
||
// то есть это НЕ отказ ключа `--model`. Точная причина появления второй головы в счёте —
|
||
// открытый вопрос, датчик её не расследует и не утверждает; его дело — заметить и назвать
|
||
// вслух факт, а не поставить диагноз.
|
||
//
|
||
// 🪤 ЛОВУШКА, в которую эта проверка чуть не попала: событие `system`/`init` в начале потока
|
||
// ТОЖЕ несёт поле `model` — но там лежит ЗАКАЗ (то же самое, что попросили ключом `--model`),
|
||
// а не ИСПОЛНЕНИЕ. На живом потоке `system`/`init` честно назвал ЗАКАЗАННУЮ дешёвую голову
|
||
// (дословно — в сыром файле), а вовсе не ту, что стоит в счёте. Взять голову оттуда значило бы
|
||
// сверять заказ с самим собой — проверка сходилась бы ВСЕГДА, и беда осталась бы невидимой
|
||
// навсегда.
|
||
//
|
||
// Надёжное место — событие `assistant`: `message.model` — голова, которая СГЕНЕРИРОВАЛА
|
||
// ответ. Она же (или те же головы, если их несколько за прогон) стоит ключами в `modelUsage`
|
||
// итогового события `result`, откуда система считает `total_cost_usd` — то есть это ровно то,
|
||
// за что СПИСАНЫ деньги. Читаем по `assistant`, а не по `result`, по двум причинам: он приходит
|
||
// раньше (виден даже в потоке, оборванном по времени, где до `result` дело не дошло), и он же
|
||
// используется у события за событием — так, как читает поток надзиратель (см. `eatLine`
|
||
// в supervisor.mjs), а не только целиком после конца прогона.
|
||
export function golovaIzSobytiya(event) {
|
||
if (!event || event.type !== 'assistant') return null;
|
||
const model = event.message?.model;
|
||
return typeof model === 'string' && model ? model : null;
|
||
}
|
||
|
||
// ━━━ ГОЛОВЫ, КОТОРЫМИ ВПРАВДУ СЧИТАНЫ ДЕНЬГИ (поправка 03.08.2026) ━━━
|
||
//
|
||
// 🔴🔴 НЕ ЕДИНСТВЕННЫЙ ИСТОЧНИК ИСПОЛНЕНИЯ. Замерено командой прямо на живом потоке
|
||
// (`grep -o '"model":"[^"]*"' … | sort | uniq -c`): имя головы стоит в потоке ДВАЖДЫ — в
|
||
// `system`/`init` (это ЗАКАЗ, ловушка, описанная у `golovaIzSobytiya` выше) и в ответе
|
||
// помощника (`assistant`). А деньги (`total_cost_usd`) система считает по ТРЕТЬЕМУ полю —
|
||
// `modelUsage` итогового события `result`, ключи которого и есть головы, за которые
|
||
// СПИСАНЫ деньги. На живом потоке `modelUsage` совпадает с ответом помощника — но это
|
||
// не повод читать только один из двух источников исполнения: они не обязаны совпадать
|
||
// молча вечно, а датчик обязан ловить расхождение, в каком бы из двух оно ни показалось
|
||
// первым (например, поток мог оборваться сразу после `result`, не дойдя до чего-то ещё).
|
||
export function golovyIzModelUsage(event) {
|
||
if (!event || event.type !== 'result') return [];
|
||
const usage = event.modelUsage;
|
||
if (!usage || typeof usage !== 'object') return [];
|
||
return Object.keys(usage).filter(k => typeof k === 'string' && k);
|
||
}
|
||
|
||
// ━━━ СРАВНЕНИЕ ЗАКАЗАННОЙ ГОЛОВЫ С ТОЙ, ЧТО ВПРАВДУ ОТВЕТИЛА ━━━
|
||
//
|
||
// 🪤 Заказывают КОРОТКИМ именем (то, что кладут в night.config.json как `probe_model`/
|
||
// `worker_model` — короткие имена перечислены в night.config.example.json), а отвечает
|
||
// ПОЛНЫМ (`claude-haiku-4-5-20251001` и подобные). Строгое равенство строк сравнивало бы
|
||
// разные алфавиты и падало бы КАЖДЫЙ раз, даже когда голова верна. Сравнение здесь —
|
||
// «полное имя содержит короткое», без учёта регистра.
|
||
//
|
||
// 🔴 ЧТО ЭТО СРАВНЕНИЕ ПРОПУСТИТ (сказано вслух, а не спрятано в умолчание): если у семейства
|
||
// голов однажды заведут короткое имя, которое является подстрокой ДРУГОГО короткого имени
|
||
// (например, короткое имя новой головы содержало бы буквы, из которых полностью состоит
|
||
// нынешнее короткое имя другой головы), это сравнение ложно сочло бы их совпавшими —
|
||
// оно смотрит только «есть ли заказанное имя ВНУТРИ полного», а не «это она и есть, а не
|
||
// однофамилец». На сегодняшних трёх головах (короткие имена в `night.config.example.json`)
|
||
// подстрок друг в друге нет — беда, ради которой строился датчик (заказали одну голову,
|
||
// ответила голова из СОВСЕМ ДРУГОГО семейства), этим сравнением ловится полностью. Если
|
||
// семейство голов расширится похожими именами, сравнение придётся ужесточить.
|
||
export function golovySovpadayut(zakazana, otvetila) {
|
||
if (typeof zakazana !== 'string' || !zakazana) return null;
|
||
if (typeof otvetila !== 'string' || !otvetila) return null;
|
||
return otvetila.toLowerCase().includes(zakazana.toLowerCase());
|
||
}
|
||
|
||
// ━━━ ГЛАВНЫЙ ДАТЧИК: заказанная голова против того, что вправду стоит в счёте ━━━
|
||
//
|
||
// 🔴🔴 ПРЕЖНИЙ ВЫВОД ЗДЕСЬ ОПРОВЕРГНУТ ЗАМЕРОМ — ИСПРАВЛЕНО 03.08.2026 (вечер).
|
||
// Здесь было написано: «беда — не замена, а ДОБАВКА (заказанная голова в счёте есть, рядом
|
||
// появляется лишняя), причина открыта». Это оказалось НЕВЕРНО, и владельцу это уходило в
|
||
// утреннюю сводку как «открытый вопрос». Бесплатный замер (прибор
|
||
// `.night/probe-golova-2026-08-03.mjs`: программе подставляется свой сервер, и в теле
|
||
// настоящего запроса читается поле `model` — исполнение, а не заказ; живого обращения нет,
|
||
// цена ровно ноль) показал обратное:
|
||
// • это была именно ЗАМЕНА — заказанной головы в запросе не было вовсе;
|
||
// • причина НАЙДЕНА и названа: программа подменяет голову при СОЮЗЕ двух вещей —
|
||
// голова названа коротким именем-прозвищем И заказан режим `--permission-mode plan`.
|
||
// Порознь обе половины безобидны, оттого две смены и искали не там: событие
|
||
// `system`/`init` при подмене продолжает честно показывать ЗАКАЗАННУЮ дешёвую голову.
|
||
// • лечение уже врезано в `budget.mjs`: режим замера — `default`, а сам союз закрыт
|
||
// сторожем `zapretPodmenyGolovy` (он падает ДО запуска, а не после счёта).
|
||
// Подробный разбор — `docs/night/razbor-dorogaya-golova-2026-08-03.md`.
|
||
//
|
||
// 🔴 ЧТО ЭТО ЗНАЧИТ ДЛЯ САМОГО ДАТЧИКА. Он остаётся нужен и не переписывается под одну
|
||
// причину: его дело — заметить и назвать ФАКТ расхождения, а не поставить диагноз. Но если
|
||
// он всё-таки заговорит СЕЙЧАС, когда лечение стоит, — это уже не «открытый вопрос», а одно
|
||
// из двух: либо лечение откатили (тогда сторож `zapretPodmenyGolovy` покраснеет первым),
|
||
// либо это НОВАЯ причина, которой замер 03.08.2026 не касался. Ровно так и сказано владельцу
|
||
// в `soobshchenie` ниже — вместо прежнего «не выяснено».
|
||
//
|
||
// 🔴🔴 РЕШЕНИЕ — ПРЕДУПРЕЖДЕНИЕ (говорить), А НЕ ОТКАЗ (падать). Обоснование:
|
||
// • Найденная причина закрыта РАНЬШЕ по дороге — сторожем `zapretPodmenyGolovy` в
|
||
// `budget.mjs`, который падает ещё до запуска. Здесь же ловится всё, что этим сторожем
|
||
// не покрыто, то есть причина СЕГОДНЯ неизвестная, — а падать на неизвестной причине
|
||
// значило бы останавливать ВСЮ ночь из-за того, на что затея повлиять не может. Хуже того:
|
||
// `probeUtilization`/`startWorker` вызываются ДО того, как известно, что стоит в счёте, —
|
||
// бросок в момент сверки убил бы уже идущий, наполовину оплаченный запуск, не добавив
|
||
// владельцу ничего, кроме дыры в ночи.
|
||
// • Молчать при этом нельзя категорически — владелец платит больше, чем рассчитывал, и не
|
||
// знает — поэтому это ГРОМКОЕ предупреждение, а не тихая пометка: слова в `soobshchenie`
|
||
// названы так, чтобы их можно было положить в утреннюю сводку владельцу без перевода.
|
||
// • Тот же класс решения уже есть в затее: Р5 в `budget.mjs` (`strogoBezDoli`) отвечает
|
||
// ровно так же на похожую вилку «система не подтвердила ожидаемое одним словом» — говорит,
|
||
// а не падает, и оставляет владельцу отдельный переключатель на СВОЁ решение стать строже.
|
||
// Если лишняя голова в счёте станет систематической, владельцу нужен ровно такой
|
||
// переключатель (например, в надзирателе) — но заводить его молча внутри датчика нельзя:
|
||
// это решение владельца, а не программиста.
|
||
export function sverkaGolovy({ zakazana, potok }) {
|
||
if (typeof zakazana !== 'string' || !zakazana) {
|
||
throw new Error('Сверка головы позвана без заказанной головы (довод `zakazana`) — сверять её не с чем.');
|
||
}
|
||
const otvetivshie = [];
|
||
const dobavit = golova => { if (golova && !otvetivshie.includes(golova)) otvetivshie.push(golova); };
|
||
for (const line of String(potok ?? '').split('\n')) {
|
||
const event = parseStreamLine(line);
|
||
dobavit(golovaIzSobytiya(event));
|
||
// 🔴 Оба источника ИСПОЛНЕНИЯ, не один: ответ помощника и то, чем считаны деньги
|
||
// (см. комментарий у `golovyIzModelUsage` выше). `system`/`init` сюда не подмешивается
|
||
// никогда — там лежит заказ, а не исполнение.
|
||
for (const g of golovyIzModelUsage(event)) dobavit(g);
|
||
}
|
||
// ━━━ ТРЕТИЙ ОТВЕТ: имени головы в потоке не было ВООБЩЕ ━━━
|
||
// Это НЕ «сходится» — совпадение доказать нечем, поток мог оборваться раньше первого хода.
|
||
if (!otvetivshie.length) {
|
||
return {
|
||
sovpalo: null,
|
||
zakazana,
|
||
otvetila: [],
|
||
zakazannaya_prisutstvuet: null,
|
||
postoronnie: [],
|
||
soobshchenie: `Заказана голова «${zakazana}», а в потоке нет ни одной головы — ни в ответе помощника `
|
||
+ '(события «assistant»), ни в счёте (modelUsage у события «result») — узнать, что вправду в счёте, '
|
||
+ 'не удалось. Это НЕ "сходится": имени головы просто не пришло.',
|
||
};
|
||
}
|
||
// Совпадающие с заказом (обычно ровно она сама) — и посторонние (не заказывали, а в счёте есть).
|
||
const sovpadayushchie = otvetivshie.filter(h => golovySovpadayut(zakazana, h));
|
||
const postoronnie = otvetivshie.filter(h => !golovySovpadayut(zakazana, h));
|
||
if (!postoronnie.length) {
|
||
return {
|
||
sovpalo: true, zakazana, otvetila: otvetivshie,
|
||
zakazannaya_prisutstvuet: true, postoronnie: [], soobshchenie: null,
|
||
};
|
||
}
|
||
const zakazannayaEst = sovpadayushchie.length > 0;
|
||
// Деньги: дешёвая и дорогая голова расходятся в цене в разы (замерено — см. комментарий
|
||
// выше у `golovaIzSobytiya`). Это утверждение о ЦЕНЕ, а не о ПРИЧИНЕ появления — причину
|
||
// не называем, потому что не знаем.
|
||
// 🔴 Слова про причину — общие для обеих веток: одна известная причина НАЙДЕНА и вылечена
|
||
// (короткое имя головы + режим «plan»), поэтому «не выяснено» владельцу больше не пишем.
|
||
const proPrichinu = 'Одна причина такого расхождения найдена и уже вылечена 03.08.2026: программа молча '
|
||
+ 'меняла голову, когда её заказывали коротким именем-прозвищем вместе с режимом «plan» (разбор — '
|
||
+ 'docs/night/razbor-dorogaya-golova-2026-08-03.md). Раз эта строка всё-таки появилась, значит либо '
|
||
+ 'лечение откатили, либо причина ДРУГАЯ, ещё не разобранная.';
|
||
const soobshchenie = zakazannayaEst
|
||
? `🔴 Кроме заказанной головы «${zakazana}», в счёте появилась ЕЩЁ голова, которую не заказывали: `
|
||
+ `«${postoronnie.join(', ')}» — и она тоже оплачена. ${proPrichinu} `
|
||
+ 'Дешёвая и дорогая голова расходятся в цене в разы — если это '
|
||
+ 'повторяется каждую ночь, лишняя оплата складывается. Покажите эту строку тому, кто правит затею.'
|
||
: `🔴 Заказанная голова «${zakazana}» среди того, что в счёте, не видна вовсе — вместо неё там голова, `
|
||
+ `которую не заказывали: «${postoronnie.join(', ')}». ${proPrichinu} `
|
||
+ 'Дешёвая и дорогая голова расходятся в цене в разы — могло быть заплачено намного больше, чем '
|
||
+ 'рассчитывали. Покажите эту строку тому, кто правит затею.';
|
||
return {
|
||
sovpalo: false, zakazana, otvetila: otvetivshie,
|
||
zakazannaya_prisutstvuet: zakazannayaEst, postoronnie, soobshchenie,
|
||
};
|
||
}
|
||
|
||
// ━━━ 🔴🔴 ДАТЧИК МАСТЕРСКОЙ (03.08.2026): «КЛЮЧ ПРИНЯТ» ≠ «КЛЮЧ ПРИМЕНЁН» ━━━
|
||
//
|
||
// 🔴🔴 НАХОДКА, ЗАМЕРЕННАЯ ЖИВЬЁМ ЧАСОМ РАНЬШЕ: ключ `--tools ''` стоял в коде затеи
|
||
// с примечанием «работать этот запуск не должен вовсе» — и МОЛЧА НЕ ПРИМЕНЯЛСЯ. Крошечный
|
||
// замер (вопрос из трёх токенов, ответ из шести) платил за 41 954 токена мастерской, которую
|
||
// заказали отключить. Тот же класс беды, что уже дважды выстрелил в этом хозяйстве в тот же
|
||
// день (Inworld вернул подтверждение потолка токенов и не применил его вовсе): настройка,
|
||
// которую ПРИНЯЛИ, — не то же самое, что настройка, которую ПРИМЕНИЛИ. Верить аргументу
|
||
// командной строки нельзя; верить можно только честному ответу самой системы.
|
||
//
|
||
// 🔴 Тот честный ответ — событие `system`/`init` в начале потока. Оно называет ЧЕТЫРЕ списка
|
||
// того, что вправду загружено: `tools`, `mcp_servers`, `skills`, `slash_commands`. Датчик ниже
|
||
// читает ИХ, а не то, что было заказано ключами, — ровно так же, как `sverkaGolovy` выше
|
||
// сверяет не заказанную голову, а ту, что вправду стоит в счёте.
|
||
//
|
||
// 🔴 ДОКАЗАНО НА ДВУХ НАСТОЯЩИХ ОПЛАЧЕННЫХ ЖИВЫХ ПОТОКАХ (лежат в `.night/`, не выдумка):
|
||
// без лечения (`probe-raw-2026-08-03.jsonl`) — tools 50, mcp_servers 51, skills 129,
|
||
// slash_commands 156; с лечением, три ключа разом (`probe-lechenie-2026-08-03.jsonl`) —
|
||
// tools 0, mcp_servers 0, skills 8, slash_commands 20. Восемь умений и двадцать команд —
|
||
// ВСТРОЕННЫЙ КОСТЯК программы: до нуля они не падают даже со всеми тремя ключами разом,
|
||
// и это не недолеченный обвес, а нижний предел того, что программа умеет отдать без
|
||
// пользовательских/проектных источников настроек.
|
||
export function sostavMasterskoyIzPotoka(potok) {
|
||
for (const line of String(potok ?? '').split('\n')) {
|
||
const event = parseStreamLine(line);
|
||
if (event?.type === 'system' && event.subtype === 'init') {
|
||
const dlina = v => (Array.isArray(v) ? v.length : null);
|
||
return {
|
||
naydeno: true,
|
||
tools: dlina(event.tools),
|
||
mcp_servers: dlina(event.mcp_servers),
|
||
skills: dlina(event.skills),
|
||
slash_commands: dlina(event.slash_commands),
|
||
};
|
||
}
|
||
}
|
||
// 🔴 Событие не пришло — это ЧЕСТНОЕ «неизвестно», а не ноль. Ноль означал бы «мастерская
|
||
// пуста», а на самом деле мы её просто не видели: поток мог оборваться раньше первого хода.
|
||
return {
|
||
naydeno: false, tools: null, mcp_servers: null, skills: null, slash_commands: null,
|
||
};
|
||
}
|
||
|
||
// Сверка ЗАКАЗАННЫХ граней урезания с тем, что вправду показал поток. Заказывают ПООДИНОЧКЕ
|
||
// (`zakazanTools` за `--tools ''`, `zakazanMcp` за `--strict-mcp-config`), потому что два
|
||
// платных места затеи заказывают РАЗНОЕ: крошечный замер (budget.mjs) режет всё, рождение
|
||
// работника (этот файл, `buildArgs` выше) — только внешние службы, инструменты работнику
|
||
// нужны для самой работы. Грань, которую не заказывали, не сверяется вовсе — иначе датчик
|
||
// врал бы «не отсечено» про обвес, который никто и не просил резать.
|
||
//
|
||
// 🔴 `skills`/`slash_commands` здесь НЕ сверяются числом: у них нет чужого нуля, до которого
|
||
// падать, — есть только встроенный костяк (см. комментарий выше), а его размер не сторожили
|
||
// живым замером отдельно от `tools`/`mcp_servers`. Сверяем то, для чего есть доказанный ноль.
|
||
export function masterskayaOtsechena({ potok, zakazanTools = false, zakazanMcp = false }) {
|
||
const sostav = sostavMasterskoyIzPotoka(potok);
|
||
if (!sostav.naydeno) {
|
||
return {
|
||
otsecheno: null,
|
||
sostav,
|
||
soobshchenie: 'В потоке не было события system/init — сверить состав мастерской нечем: '
|
||
+ 'поток мог оборваться раньше первого хода. Это НЕ "сошлось": сравнивать было не с чем.',
|
||
};
|
||
}
|
||
const problemy = [];
|
||
if (zakazanTools && sostav.tools !== 0) {
|
||
problemy.push(`заказан --tools '' (инструментов быть не должно), а поток честно называет ${sostav.tools}`);
|
||
}
|
||
if (zakazanMcp && sostav.mcp_servers !== 0) {
|
||
problemy.push(`заказан --strict-mcp-config (внешних служб быть не должно), а поток честно называет ${sostav.mcp_servers}`);
|
||
}
|
||
if (!problemy.length) {
|
||
return { otsecheno: true, sostav, soobshchenie: null };
|
||
}
|
||
return {
|
||
otsecheno: false,
|
||
sostav,
|
||
soobshchenie: `🔴 Настройка ПРИНЯТА, но НЕ ПРИМЕНЕНА: ${problemy.join('; ')}. Верить ключу `
|
||
+ 'командной строки нельзя — верить можно только честному ответу самого потока '
|
||
+ '(событие system/init).',
|
||
};
|
||
}
|
||
|
||
// 🔴🔴 ЖИВЫХ ЗОВУЩИХ НЕТ, И ЭТО НЕ ДОЛГ, А МЁРТВЫЙ ОСТАТОК. ЗАМЕРЕНО 07.08.2026, СМЕНА 28.
|
||
//
|
||
// Правило берёт ГОТОВЫЙ вывод целиком и разбирает его ПОСЛЕДНЮЮ строку. Дороги, которой
|
||
// оно писано — «запусти работника, дождись конца, возьми итог», — в затее больше нет:
|
||
// `startWorker` отдаёт ЖИВОГО ребёнка, а не текст. Все четверо, кто читает потоки, названы
|
||
// поимённо, и каждый делает ДРУГУЮ работу — сводить их сюда было бы не починкой, а поломкой:
|
||
//
|
||
// · `supervisor.mjs` — разбирает поток ЖИВЬЁМ, по строке, по мере прихода: попутно кладёт
|
||
// кусок на диск до всякого разбора и считает замеры запаса. Ждать конца ему нельзя;
|
||
// · `budget.mjs` (`probeUtilization`) — перебирает ВСЕ строки, ища замеры предела,
|
||
// а не итог. Последняя строка ему не нужна вовсе;
|
||
// · `sudya-nochi.mjs` (`itogIzPotoka`) — берёт ПОСЛЕДНЕЕ событие вида `result` (а не
|
||
// последнюю строку: после итога в потоке бывает хвост), понимает `is_error` и считает
|
||
// пустой приговор за «не судил». Отдаёт ТЕКСТ приговора, а не объект события;
|
||
// · `dengi-bez-zapisi.mjs` (`razborSleda`) — берёт ПЕРВУЮ цену из следа-улики.
|
||
//
|
||
// 🪤 Расход (`total_cost_usd`), ради которого правило писалось, затея НАРОЧНО не ведёт:
|
||
// остаток запаса она не считает, а СПРАШИВАЕТ у прибора системы (`supervisor.mjs`, разбор
|
||
// потока). Подключать отсюда второй счёт денег значило бы завести вторую дорогу к тому же
|
||
// смыслу — ровно то, от чего хозяйство предупреждает в пяти местах.
|
||
//
|
||
// 🔴 Не снесено НАМЕРЕННО, и причина названа: снос — необратимее записи, слова владельца
|
||
// на него нет, а вместе с правилом ушли бы три его проверки. Сносить это — отдельное
|
||
// решение владельца, не побочный ход чужой работы.
|
||
export function readResult(stdout) {
|
||
const text = String(stdout ?? '').trim();
|
||
if (!text) return null;
|
||
const last = text.split('\n').filter(Boolean).pop();
|
||
try { return JSON.parse(last); } catch { return null; }
|
||
}
|
||
|
||
// ━━━ 🔴 ПРИГОВОР О РАБОТНИКЕ + КУДА СМОТРЕТЬ, ЕСЛИ ПРИГОВОР ПЛОХОЙ (03.08.2026) ━━━
|
||
//
|
||
// Второй довод `gde` — НЕОБЯЗАТЕЛЬНЫЙ, и это не лень, а нарочно: надзиратель зовёт
|
||
// `isRealSuccess(result)` одним доводом, как звал, и боевое поведение без второго довода
|
||
// не меняется НИ НА ЗНАК. Подашь второй — к причине неудачи допишется путь к стенограмме
|
||
// работника, то есть к полной записи всего, что с ним было (см. `stenogramma.mjs`: у молча
|
||
// умершего работника это ЕДИНСТВЕННОЕ место, где вообще написано, отчего он умер, — своего
|
||
// следа потока у затеи в этом случае нет ни байта).
|
||
//
|
||
// 🔴🔴 ПОСЛЕДНЯЯ МИЛЯ НЕ ПОДКЛЮЧЕНА, И Я ГОВОРЮ ЭТО ВСЛУХ, А НЕ ПРЯЧУ. Сегодня второй довод
|
||
// не подаёт НИКТО: единственный зовущий — `supervisor.mjs` (строка ~1794), а он в этой смене
|
||
// правится другими и мне запрещён. Хуже того, самый нужный случай — молча умерший работник —
|
||
// до `isRealSuccess` вообще НЕ ДОХОДИТ: приговор «Работник кончился, не отдав итога» собран
|
||
// в `supervisor.mjs` строками ~1786–1790 прямо на месте, отдельной веткой выше. То есть
|
||
// чтобы улика дошла до утреннего письма владельца, нужны ДВЕ строки в чужом файле:
|
||
// 1) в ветке «не отдал итога» дописать к причине `strokaVladeltsu(stenogrammaPoKartochke(…))`;
|
||
// 2) сюда, в зов `isRealSuccess`, передать вторым доводом `{ rabochiyUgol: card.workspace,
|
||
// sessionId: card.session_id }`.
|
||
// Пока их нет, улика достижима руками: `node tools/night/stenogramma.mjs <путь к worker.json>`.
|
||
export function isRealSuccess(result, gde = null) {
|
||
// Хвост «куда смотреть» считается ЛЕНИВО и только для плохого приговора: на добром
|
||
// исходе владельцу читать стенограмму незачем, а лазить по диску на каждом успехе — зря.
|
||
const kuda = () => {
|
||
if (!gde || !gde.rabochiyUgol) return '';
|
||
try {
|
||
const n = naytiStenogrammu({
|
||
rabochiyUgol: gde.rabochiyUgol,
|
||
sessionId: gde.sessionId ?? null,
|
||
ot: Number.isFinite(gde.ot) ? gde.ot : null,
|
||
do_: Number.isFinite(gde.do_) ? gde.do_ : null,
|
||
// 🪤 Домашняя папка — доводом, а не через переменную среды. Первая попытка подменяла
|
||
// проверке `USERPROFILE`/`HOME` — и это ЗАДЕЛО СОСЕДЕЙ ПО ПРОГОНУ: по `HOME` ищет
|
||
// свои настройки git, и у соседних проверок посыпалось «not a git repository».
|
||
// Замерено: `supervisor.test.mjs` в одиночку 187 из 187, а в общем прогоне 5 красных.
|
||
...(gde.home ? { home: gde.home } : {}),
|
||
});
|
||
return ` ${strokaVladeltsu(n)}`;
|
||
} catch {
|
||
// 🔴 Поиск улики НЕ ИМЕЕТ ПРАВА уронить приговор о работнике: приговор — главное,
|
||
// подсказка «куда смотреть» — добавка. Сломается диск — владелец получит приговор
|
||
// без подсказки, а не молчание вместо приговора.
|
||
return '';
|
||
}
|
||
};
|
||
if (!result) {
|
||
return { ok: false, reason: `Ответ работника не разобран — считаем, что запуск не удался.${kuda()}` };
|
||
}
|
||
if (result.is_error) {
|
||
return { ok: false, reason: `Работник вернул ошибку: ${result.result ?? result.api_error_status ?? 'причина не названа'}${kuda()}` };
|
||
}
|
||
if (typeof result.result !== 'string' || result.result.trim() === '') {
|
||
return { ok: false, reason: `Работник сообщил об успехе, но вернул пустой ответ — успехом это не считается.${kuda()}` };
|
||
}
|
||
return { ok: true, reason: null };
|
||
}
|
||
|
||
// 🔴 `model` — обязательное поле, без значения по умолчанию. Голова работника приходит
|
||
// из настроек владельца и проносится сюда насквозь: run.mjs (задача 14) читает `worker_model`
|
||
// из night.config.json → кладёт в карточку работника → supervisor.mjs (задача 12) берёт
|
||
// из карточки → сюда. Ни одно звено цепочки своего слова не подставляет.
|
||
//
|
||
// ━━━ 🔴🔴 ЗДЕСЬ И ЕСТЬ РОЖДЕНИЕ РАБОТНИКА — ЕДИНСТВЕННОЕ В ЗАТЕЕ ━━━
|
||
// Замерено командой 02.08.2026: живых запусков головы в затее ровно ДВА — крошечный платный
|
||
// замер остатка (`budget.mjs`) и вот этот. Замер был сосчитан и ограничен числом 30,
|
||
// а рождение работника — расход в разы больший, на ЧАСЫ работы — не считалось ничем.
|
||
// То есть предел владельца сторожил копейки и не сторожил рубли. Теперь и здесь стоят
|
||
// ворота счёта (`rozhdenieSoSchyotom`), и они те же самые, что у замера: второго устройства
|
||
// для этого нарочно не заведено.
|
||
//
|
||
// 🔴 ПОРЯДОК ШАГОВ — НЕ СЛУЧАЙНЫЙ. Сперва идут все отказы, которые НЕ СТОЯТ ДЕНЕГ (программу
|
||
// не нашли, голову не сказали, имя базы не дали), и только потом счёт. Иначе описка
|
||
// в настройках владельца воровала бы у него рождение, которого не было.
|
||
export function startWorker({
|
||
cwd, prompt, settingsPath, sessionId, dbName, model, addDirs = [],
|
||
// 🔴 Настройки и счёт — доводы с НАСТОЯЩИМИ умолчаниями, то есть боевое поведение
|
||
// не изменилось ни на строку: надзиратель зовёт `startWorker` как звал, без новых слов.
|
||
// Подавать их своими нужно ровно одному — проверкам: писать в настоящее хозяйство
|
||
// `.night` им нельзя, оно одно на весь рабочий угол. Второй дороги к рождению эти два
|
||
// довода не открывают: счёт идёт ВСЕГДА, они лишь говорят, в какой тетради он ведётся.
|
||
cfg = readJson(configPath()) ?? {},
|
||
attemptsPath = spawnAttemptsPath(),
|
||
}) {
|
||
// Не голое имя и не голый путь: `claudeLaunch` отвечает ещё и на вопрос «а запустится ли
|
||
// найденное». Без него `.cmd` даёт EINVAL, и работник не рождается вовсе.
|
||
const launch = claudeLaunch();
|
||
const args = [...launch.prefixArgs, ...buildArgs({ prompt, settingsPath, sessionId, model, addDirs })];
|
||
// Окружение — урезанным списком, а не наследованием. Иначе работник получил бы все
|
||
// боевые ключи из переменных среды запускающего, а файловый замок этого не увидел бы.
|
||
// 🔴 Считается ЗАРАНЕЕ, до счёта: без имени своей базы `workerEnv` падает, и этот отказ
|
||
// денег не стоит — записывать его в счёт значило бы украсть у владельца рождение.
|
||
const env = workerEnv({ dbName });
|
||
|
||
// 🔴 СТОРОЖ СКЛЕЙКИ — ЗДЕСЬ, ДО СЧЁТА: этот отказ денег не стоит, а рождение, которое
|
||
// всё равно умрёт молча через минуту, стоит ночи работы. Поручение работника многострочное,
|
||
// а путь к его настройкам лежит в каталоге с пробелом — оба довода склейку не переживут.
|
||
zapretSkleyki({ shell: launch.shell, args, gde: 'Рождение работника' });
|
||
|
||
// 🔴🔴 ВОРОТА СЧЁТА. Пропустят — родим, откажут — бросят человеческой строкой, которую
|
||
// надзиратель уже умеет ловить (`worker_spawn_failed`) и кладёт словами в утреннюю сводку.
|
||
return rozhdenieSoSchyotom({
|
||
cfg,
|
||
attemptsPath,
|
||
kto: sessionId ?? null,
|
||
rodit: () => {
|
||
// detached: false — работник обязан быть ребёнком НАДЗИРАТЕЛЯ. Отвяжи его,
|
||
// и надзиратель перестанет видеть и его поток, и его смерть, а значит перестанет
|
||
// исполнять пределы. Отвязанным живёт надзиратель, не работник.
|
||
const child = spawn(launch.command, args, {
|
||
cwd,
|
||
detached: false,
|
||
windowsHide: true,
|
||
shell: launch.shell,
|
||
env,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
});
|
||
child.stdout.setEncoding('utf8');
|
||
child.stderr.setEncoding('utf8');
|
||
return child; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог
|
||
},
|
||
});
|
||
}
|