// Запуск работника. Работник — ребёнок НАДЗИРАТЕЛЯ (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 } from './paths.mjs'; import { readJson } from './state.mjs'; import { rozhdenieSoSchyotom, spawnAttemptsPath } from './schyot-zamerov.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 тут нет намеренно: с ним оплата ушла бы мимо подписки владельца. export const WORKER_ENV_KEYS = ['PATH', 'SystemRoot', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA']; 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, }; } // Тот же разбор, но отсеивающий чужой вид предела. // // 🔴 РЕШЕНИЕ Р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: заказанная голова САМА ПО СЕБЕ // работает исправно. Беда — не «заказали одно, ответила другая» (замена), а «в счёте, КРОМЕ // заказанной, появляется ЕЩЁ голова, которую не заказывали» (добавка). Слова `soobshchenie` // ниже названы под это уточнение и не утверждают того, чего не знаем: отчего лишняя голова // появляется в счёте — открытый вопрос, датчик его не расследует. // // 🔴🔴 РЕШЕНИЕ — ПРЕДУПРЕЖДЕНИЕ (говорить), А НЕ ОТКАЗ (падать). Обоснование: // • Причина лишней головы в счёте НЕ ВЫЯСНЕНА — а падать на невыясненной причине значило бы // останавливать ВСЮ ночь из-за того, что затея пока не понимает и повлиять не может. Если // это повторяется каждую ночь, следующая ночь встанет по той же причине, и так до // бесконечности, пока причину не найдут отдельным разбором (не здесь). Хуже того: // `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`). Это утверждение о ЦЕНЕ, а не о ПРИЧИНЕ появления — причину // не называем, потому что не знаем. const soobshchenie = zakazannayaEst ? `🔴 Кроме заказанной головы «${zakazana}», в счёте появилась ЕЩЁ голова, которую не заказывали: ` + `«${postoronnie.join(', ')}» — и она тоже оплачена. Отчего вторая голова появляется в счёте — ` + 'не выяснено, это открытый вопрос. Дешёвая и дорогая голова расходятся в цене в разы — если это ' + 'повторяется каждую ночь, лишняя оплата складывается. Покажите эту строку тому, кто правит затею.' : `🔴 Заказанная голова «${zakazana}» среди того, что в счёте, не видна вовсе — вместо неё там голова, ` + `которую не заказывали: «${postoronnie.join(', ')}». Отчего так — не выяснено, это открытый вопрос. ` + 'Дешёвая и дорогая голова расходятся в цене в разы — могло быть заплачено намного больше, чем ' + 'рассчитывали. Покажите эту строку тому, кто правит затею.'; 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).', }; } 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; } } export function isRealSuccess(result) { if (!result) return { ok: false, reason: 'Ответ работника не разобран — считаем, что запуск не удался.' }; if (result.is_error) { return { ok: false, reason: `Работник вернул ошибку: ${result.result ?? result.api_error_status ?? 'причина не названа'}` }; } if (typeof result.result !== 'string' || result.result.trim() === '') { return { ok: false, reason: 'Работник сообщил об успехе, но вернул пустой ответ — успехом это не считается.' }; } 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; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог }, }); }