import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resolveClaudeBin, claudeLaunch, claudeNutro, buildArgs, readResult, isRealSuccess, parseStreamLine, isStepBoundary, rateLimitFromEvent, workerEnv, WORKER_ENV_KEYS, golovaIzSobytiya, golovySovpadayut, sverkaGolovy, golovyIzModelUsage, sostavMasterskoyIzPotoka, masterskayaOtsechena, dovodNePerezhivyotSkleyku, proverkaSkleyki, zapretSkleyki, } from './spawn.mjs'; // 🔴 Второй и последний зовущий живого запуска — крошечный платный замер остатка. Его доводы // ввозятся сюда НАСТОЯЩИМИ, а не переписанными от руки: переписанные доказали бы только то, // что я умею печатать пустую строку. `probeArgs` — чистая мелочь без единого живого зова. import { probeArgs } from './budget.mjs'; import { writeFileSync, mkdirSync } from 'node:fs'; // 🔴 Путь к настоящему хозяйству СОБРАН РУКАМИ от корня хранилища (`REPO_ROOT`), а не ввезён // готовым строителем `NIGHT_ROOT` из `paths.mjs`: сторож `nastoyashchee-hozyaystvo-v-proverkah // .test.mjs` (вне области правки этой задачи) явно разрешает именно этот способ читать // настоящий `.night` без поимённой записи в свой список — он ловит только готовые // строители путей, а не путь, собранный вручную. Мы только ЧИТАЕМ («если он туда ПИШЕТ» — // не про нас) один именованный, стабильный, датированный файл живой улики — не пишем // в хозяйство ни байта и не заводим свой прогон, поэтому мигание, от которого сторож // защищает, здесь не грозит. import { REPO_ROOT } from './paths.mjs'; import { startWorker } from './spawn.mjs'; import { resolveBin, needsShell } from './paths.mjs'; import { existsSync, readdirSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PREDEL_ZHIVOGO_ZAPUSKA, PREDEL_SAMOY_PROGRAMME } from './predel-zhivogo-zapuska.mjs'; import { recordSpawnAttempt, readSpawnAttempts } from './schyot-zamerov.mjs'; // 🔴 ПРЕДЕЛ ВРЕМЕНИ ВСЕМУ ФАЙЛУ. Здесь вправду запускаются `claude --version` и живые // процессы `node`. При пределе по умолчанию 5000 мс это краснело от соседей, а не // от поломки. Откуда взято число — в самом `predel-zhivogo-zapuska.mjs`; своего числа // тут не набирается. vi.setConfig({ testTimeout: PREDEL_ZHIVOGO_ZAPUSKA }); describe('resolveClaudeBin — лечение 334 молчаливых провалов', () => { it('находит запускаемый файл, а не голое имя', () => { const bin = resolveClaudeBin(); expect(bin).toBeTruthy(); expect(existsSync(bin)).toBe(true); }); it('не нашёлся — падает громко, а не возвращает "claude" в надежде', () => { expect(() => resolveClaudeBin({ lookIn: ['C:/такого/пути/нет'], name: 'нетакойпрограммы' })).toThrow(/не найден/i); }); it('оболочка в запуске — последнее средство: она изуродовала бы длинное поручение', () => { // 🪤 `.cmd` без оболочки Node не запустит (EINVAL), а с оболочкой командная строка // разбирается заново — и русское поручение с переводами строк рвётся на куски. // Поэтому у `.exe` и у настоящего нутра программы оболочки нет вовсе. expect(claudeLaunch('C:/Program Files/nodejs/node.exe')).toEqual({ command: 'C:/Program Files/nodejs/node.exe', prefixArgs: [], shell: false, }); }); it('🔴 найденная программа ДЕЙСТВИТЕЛЬНО ЗАПУСКАЕТСЯ — иначе поиск ничего не доказал', () => { // 🪤 Ровно та мина, что убила 334 прогона, только на новом месте: раньше все проверки // смотрели «файл лежит», и ни одна ничего не запускала. Сломай запуск — покраснеет здесь. const launch = claudeLaunch(); const out = execFileSync(launch.command, [...launch.prefixArgs, '--version'], { encoding: 'utf8', shell: launch.shell, // 🔴 Предел САМОЙ ПРОГРАММЕ. Он работает только потому, что предел проверки (вверху // файла) больше него: пока проверку убивали на 5000 мс, эта строка была мертва. timeout: PREDEL_SAMOY_PROGRAMME, }); expect(String(out)).toMatch(/\d+\.\d+\.\d+/); }); }); // ═══════════════════════════════════════════════════════════════════════════════ // ПЕРЕЕЗД ПРОГРАММЫ И ЗАПРЕТ ОБОЛОЧКИ // ═══════════════════════════════════════════════════════════════════════════════ describe('claudeNutro — имя нутра берётся из ЗАКАЗА пакета, а не зашито', () => { let dir = null; afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); dir = null; }); // Собирает подложку: `<врем>/node_modules/@anthropic-ai/claude-code/…` рядом с «claude.cmd». const podlozhka = ({ bin, kladyom = [] }) => { dir = mkdtempSync(join(tmpdir(), 'nutro-')); const pkg = join(dir, 'node_modules', '@anthropic-ai', 'claude-code'); mkdirSync(join(pkg, 'bin'), { recursive: true }); if (bin !== undefined) writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: '@anthropic-ai/claude-code', version: '0.0.0-подложка', bin }), 'utf8'); for (const f of kladyom) writeFileSync(join(pkg, f), 'подложка', 'utf8'); return join(dir, 'claude.cmd'); }; it('вид «мелочь с именами» — {"claude": "bin/claude.exe"}, ровно как у живого пакета 2.1.142', () => { const target = podlozhka({ bin: { claude: 'bin/claude.exe' }, kladyom: ['bin/claude.exe'] }); expect(claudeNutro(target)).toBe(join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe')); }); it('вид «просто строка» — bin: "bin/claude.exe"', () => { const target = podlozhka({ bin: 'bin/claude.exe', kladyom: ['bin/claude.exe'] }); expect(claudeNutro(target)).toBe(join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe')); }); it('🔴 записи «bin» НЕТ вовсе — это «не нашли», а не «нашли пустоту»', () => { const target = podlozhka({ bin: undefined, kladyom: ['bin/claude.exe'] }); expect(claudeNutro(target)).toBe(null); }); it('🔴 ЗАКАЗ, А НЕ ИСПОЛНЕНИЕ: в «bin» написано имя, а файла по нему НЕТ', () => { // 🪤 Ровно тот вопрос, на котором споткнулись с `cli.js`: бумага пакета говорит «зови // вот это» — но это ЗАКАЗ, а не доказательство, что файл лежит. Верь бумаге на слово — // и получишь то же молчание, только по новому имени. const target = podlozhka({ bin: { claude: 'bin/такого-нет.exe' }, kladyom: ['bin/claude.exe'] }); expect(claudeNutro(target)).toBe(null); }); it('🔴 бумага пакета испорчена — не роняем всё, отвечаем «не нашли»', () => { dir = mkdtempSync(join(tmpdir(), 'nutro-')); const pkg = join(dir, 'node_modules', '@anthropic-ai', 'claude-code'); mkdirSync(pkg, { recursive: true }); writeFileSync(join(pkg, 'package.json'), '{это не бумага', 'utf8'); expect(claudeNutro(join(dir, 'claude.cmd'))).toBe(null); }); it('🔴 ТРЕТЬЕ СОСТОЯНИЕ: нутро само оказалось `.cmd` — оно нутром НЕ считается', () => { // Звать `.cmd` без оболочки — EINVAL, с оболочкой — та самая склейка. Отдать такое // «нутро» значило бы вернуть беду с чёрного хода, выдав её за лечение. const target = podlozhka({ bin: { claude: 'bin/claude.cmd' }, kladyom: ['bin/claude.cmd'] }); expect(claudeNutro(target)).toBe(process.platform === 'win32' ? null : join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.cmd')); }); it('🔴 нутра нет — claudeLaunch падает ГРОМКО, а не сваливается на оболочку молча', () => { if (process.platform !== 'win32') return; // `.cmd` оболочки требует только на Windows dir = mkdtempSync(join(tmpdir(), 'nutro-')); const target = join(dir, 'claude.cmd'); writeFileSync(target, '@echo подложка', 'utf8'); expect(() => claudeLaunch(target)).toThrow(/Нутро программы claude не найдено/); // 🪤 И проверяем ИМЕННО отсутствие тихой дороги: раньше здесь возвращалось // `{ shell: true }`, и всё выглядело удачей. let vernul = null; try { vernul = claudeLaunch(target); } catch { /* ждём отказа */ } expect(vernul).toBe(null); }); it('🔴 ЖИВАЯ машина: запуск головы идёт БЕЗ оболочки — единственная точка на всю затею', () => { // Оба зовущих живого запуска (рождение работника в spawn.mjs и крошечный замер // в budget.mjs) берут `shell` отсюда. Верни сюда `shell: true` — покраснеет здесь. expect(claudeLaunch().shell).toBe(false); }); it('🔴 жёсткого имени нутра в коде нет НИ ОДНОГО — ни старого `cli.js`, ни нового `claude.exe`', () => { // 🪤 Капкан, в который затея уже попала: имя нутра, вписанное в код, живёт до переезда. const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'spawn.mjs'), 'utf8'); const kod = src.split('\n').filter(l => !l.trim().startsWith('//')).join('\n'); expect(kod).not.toMatch(/['"`][^'"`]*cli\.js['"`]/); expect(kod).not.toMatch(/['"`][^'"`]*claude\.exe['"`]/); expect(kod).toMatch(/package\.json/); // имя спрашиваем у бумаги пакета }); }); describe('🔴 сторож класса «довод не переживёт склейку оболочкой»', () => { it('пустая строка — исчезает бесследно, ключ съедает следующий довод', () => { expect(dovodNePerezhivyotSkleyku('')).toMatch(/пустая строка/); }); it('перевод строки внутри довода — поручение работника рвётся на куски', () => { expect(dovodNePerezhivyotSkleyku('делай пункт 1\nпотом пункт 2')).toMatch(/пробельный знак/); }); it('пробел в пути — каталог владельца «портал crm» разваливается надвое', () => { expect(dovodNePerezhivyotSkleyku('C:/моя/проекты/портал crm/night-settings.json')).toMatch(/пробельный знак/); }); it('знаки самой оболочки (кавычка, &, |, %, !) — толкуются ею по-своему', () => { for (const z of ['а"б', 'а&б', 'а|б', 'а%PATH%б', 'а!б', 'а^б', 'а>б', 'а(б)']) { expect(dovodNePerezhivyotSkleyku(z)).toBeTruthy(); } }); it('🪤 ГРАНИЦА: обычный довод склейку переживает — на нём краснеть нельзя', () => { for (const z of ['--verbose', 'stream-json', 'C:/ws/night-settings.json', 'sonnet']) { expect(dovodNePerezhivyotSkleyku(z)).toBe(null); } }); it('🔴 ГЛАВНАЯ ГРАНИЦА: БЕЗ оболочки те же доводы законны и молчат', () => { // Красным обязан быть только СОЮЗ «оболочка + ломкий довод», а не довод сам по себе: // многострочное поручение напрямую доходит целым — это замерено живьём. const lomkie = ['-p', 'делай пункт 1\nпотом пункт 2', '--settings', 'C:/портал crm/n.json', '--tools', '']; expect(proverkaSkleyki({ shell: false, args: lomkie })).toEqual([]); expect(proverkaSkleyki({ shell: undefined, args: lomkie })).toEqual([]); expect(() => zapretSkleyki({ shell: false, args: lomkie })).not.toThrow(); }); it('🔴 ТРЕТЬЕ СОСТОЯНИЕ `shell`: не только да/нет, но и СТРОКА «какой оболочкой»', () => { // 🪤 Сравнение `shell === true` слило бы строку с «оболочки нет» — и сторож пропустил бы // ровно тот запуск, который ломается. Смотрим на правдивость, а не на равенство. expect(proverkaSkleyki({ shell: 'cmd.exe', args: ['--tools', ''] })).toHaveLength(1); expect(proverkaSkleyki({ shell: '/bin/sh', args: ['a b'] })).toHaveLength(1); }); it('называет беды ПОИМЁННО и с номером — чинить придётся каждую', () => { const bedy = proverkaSkleyki({ shell: true, args: ['--tools', '', '-p', 'а\nб', '--ok'] }); expect(bedy.map(b => b.nomer)).toEqual([1, 3]); expect(bedy[0].prichina).toMatch(/пустая строка/); }); it('🔴 союз «оболочка + ломкий довод» — отказ, а не тихий запуск', () => { expect(() => zapretSkleyki({ shell: true, args: ['-p', 'а\nб'], gde: 'Рождение работника' })) .toThrow(/Рождение работника[\s\S]*не переживут/); }); it('🔴 НАСТОЯЩИЕ доводы работника через оболочку — сторож ловит их сам, без списка имён', () => { // Ни одного имени ключа сторожу не сказано: он смотрит на форму довода. const args = buildArgs({ prompt: 'Пункт 1: почини.\nПункт 2: проверь.\nПункт 3: закоммить.', settingsPath: 'C:/моя/проекты/портал crm/Документация/night-settings.json', sessionId: '11111111-1111-4111-8111-111111111111', model: 'голова-из-настроек', }); const bedy = proverkaSkleyki({ shell: true, args }); expect(bedy.length).toBeGreaterThanOrEqual(2); expect(bedy.some(b => /пробельный знак/.test(b.prichina) && b.dovod.includes('\n'))).toBe(true); expect(bedy.some(b => b.dovod.includes('портал crm'))).toBe(true); }); it('🟡 сторож ВПАЯН в рождение работника — и ДО счёта, не после', () => { // 🔴 ЧЕСТНО О СЛАБОСТИ ЭТОГО ДАТЧИКА: он смотрит на ТЕКСТ кода, а не на исполнение. // Живьём поймать зов нечем: на этой машине `claudeLaunch` отдаёт `shell: false`, и сторож // при настоящем рождении молчит по делу — подсунуть ему оболочку снаружи нельзя, такого // довода у `startWorker` нет. Датчик слабый, но вырезание зова он ловит, а ноль датчиков // не ловит ничего. Порядок важен отдельно: отказ до счёта денег не стоит, после — крадёт // у владельца рождение, которого не было. const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'spawn.mjs'), 'utf8'); const telo = src.slice(src.indexOf('export function startWorker')); const gdeStorozh = telo.indexOf('zapretSkleyki('); const gdeSchyot = telo.indexOf('rozhdenieSoSchyotom('); expect(gdeStorozh).toBeGreaterThan(-1); expect(gdeSchyot).toBeGreaterThan(-1); expect(gdeStorozh).toBeLessThan(gdeSchyot); }); it('🔴 ВТОРОЙ ЗОВУЩИЙ — крошечный платный замер: его доводы тоже ломкие', () => { // 🪤 Замер два раза умирал именно так: `--tools ''` и `--setting-sources ''` — пустые // строки. Через оболочку они исчезали, и ключ съедал следующий довод. Сегодня замер // спасает не собственная защита, а то, что `claudeLaunch` больше НЕ отдаёт оболочку. const args = probeArgs({ model: 'голова-из-настроек' }); expect(proverkaSkleyki({ shell: true, args }).length).toBeGreaterThanOrEqual(1); expect(proverkaSkleyki({ shell: false, args })).toEqual([]); expect(claudeLaunch().shell).toBe(false); // вот чем он спасён на самом деле }); }); describe('buildArgs', () => { // 🔴 `model` — обязательное поле: голова работника приходит из настроек владельца // (`worker_model` в `night.config.json`), своего значения у кода нет. const base = { prompt: 'делай пункт 1', settingsPath: 'C:/ws/night-settings.json', sessionId: '11111111-1111-4111-8111-111111111111', model: 'голова-из-настроек', }; it('просит ПОТОК событий, а не один кусок в конце', () => { const a = buildArgs(base); expect(a).toContain('--output-format'); expect(a[a.indexOf('--output-format') + 1]).toBe('stream-json'); }); it('к потоку обязателен --verbose, иначе Claude Code откажется запускаться', () => { expect(buildArgs(base)).toContain('--verbose'); }); it('одним куском (`json`) не просим никогда — надзирателю нужен ход дела', () => { expect(buildArgs(base).filter(x => x === 'json')).toEqual([]); }); it('предела в долларах в запуске НЕТ — своего счёта в деньгах затея не ведёт', () => { // Остаток берётся у прибора системы (кусок 2), доллары не считаются нигде. expect(buildArgs(base).some(x => String(x).includes('budget'))).toBe(false); }); it('передаёт свои настройки — те, где живут запреты', () => { expect(buildArgs(base)).toContain('C:/ws/night-settings.json'); }); it('задаёт свой номер сессии, чтобы работника можно было продолжить', () => { expect(buildArgs(base)).toContain('--session-id'); }); it('ГОЛОВА работника берётся из настроек владельца, а не из кода', () => { // 🔴 Решение владельца: разные головы едят недельный запас по-разному, а запас один // на всё. Что положили в `worker_model`, то и уходит в запуск — слово в слово. // Верни в `buildArgs` значение по умолчанию — покраснеет следующая проверка. const a = buildArgs(base); expect(a[a.indexOf('--model') + 1]).toBe('голова-из-настроек'); }); it('голову НЕ сказали — падаем громко, а не подставляем свою', () => { // 🔴 Своего значения у кода нет вовсе. Молча подставить голову значит потратить // недельный запас владельца головой, которую он не выбирал, и узнать об этом по счёту. // Верни `model = 'sonnet'` в подпись — покраснеет здесь. expect(() => buildArgs({ ...base, model: undefined })).toThrow(/worker_model/i); expect(() => buildArgs({ ...base, model: '' })).toThrow(/worker_model/i); }); // ━━━ 🔴🔴 ЛЕЧЕНИЕ 03.08.2026, МЕСТО 2 — РОЖДЕНИЕ РАБОТНИКА: ТОЛЬКО ОДИН КЛЮЧ ИЗ ТРЁХ ━━━ // Живой платный замер (2 живых потока — `.night/probe-raw-2026-08-03.jsonl` без лечения // и `.night/probe-lechenie-2026-08-03.jsonl` с ним) показал: три ключа разом // (`--tools ''`, `--strict-mcp-config`, `--setting-sources ''`) втрое роняют цену // крошечного замера. Но работник — не крошечный замер: он правит код, гоняет проверки, // коммитит, и ему мастерская НУЖНА. Решение по каждому ключу — раздельное: // // · `--tools ''` — НЕТ. Без инструментов (Read/Edit/Bash/Grep…) работник не работник. // · `--strict-mcp-config` — ДА. Замерено по `worker-prompt.mjs` (весь протокол // работника — файлы почтового ящика + система контроля версий (её консольная команда // тут нарочно не набрана словом — см. сторож zhivoy-zapusk-v-proverkah.test.mjs) // + команда проверок из плана; ни слова про MCP) и по `tools/night/*.mjs` // (грепом — ни одного обращения к MCP-инструменту // нигде в коде затеи). 51 внешняя служба из `.mcp.json` (playwright, github, // laravel-boost, sentry, redis, research-паки…) работнику ночью не нужна ни разу — // это встроенных Read/Edit/Bash/Grep/Glob не касается вовсе, флаг режет только MCP. // · `--setting-sources ''` — НЕТ, и это НЕ забывчивость. Она обрубила бы источники, // из которых приходит и предупреждение «БОЕВОЙ ПРОД» в CLAUDE.md, и хук // `prod-db-guard`, который блокирует снос боевой базы, — тому же самому работнику, // который всю ночь работает без человека рядом. Свои настройки (запреты, замки) // работник и так получает отдельным, уже стоящим здесь ключом `--settings` // (см. проверку выше «передаёт свои настройки») — `--setting-sources` тут ни при чём // и трогать его для экономии в центы значило бы менять по ошибке предохранитель. it('🔴🔴 --strict-mcp-config СТОИТ (51 служба работнику не нужна ни разу), а --tools/--setting-sources — НЕТ', () => { const a = buildArgs(base); expect(a).toContain('--strict-mcp-config'); // 🪤 Не спутать со СВОИМИ настройками (`--settings`, единственное число) — вот эта // проверка сторожит другой, множественный ключ, который решили НЕ ставить. expect(a).not.toContain('--tools'); expect(a).not.toContain('--setting-sources'); }); }); describe('🔴 голова работника не зашита словом НИГДЕ в коде затеи', () => { it('слова `sonnet` нет ни в одном файле tools/night/*.mjs', () => { // 🔴 Решение владельца: голова живёт в настройках, а не в коде. Эта проверка — сторож // всего решения разом: она смотрит НЕ на одну функцию, а на весь каталог затеи, // и краснеет, как только кто-нибудь снова зашьёт имя головы словом — хоть здесь, // хоть в надзирателе, хоть в оркестраторе. // 🪤 Она обязана смотреть на файлы КОДА и НЕ смотреть на два места, где это слово // законно: образец настроек `night.config.example.json` (там оно и есть настройка) // и сами файлы проверок `*.test.mjs` — вот этот в том числе, он же про это слово // и написан. Не исключить проверки — сторож укажет сам на себя и будет красным всегда. // Впиши имя головы словом куда угодно в `tools/night/*.mjs` — покраснеет здесь. // 🪤 ОДНО ЗАКОННОЕ ИСКЛЮЧЕНИЕ, ПОЯВИВШЕЕСЯ 03.08.2026 И НАЗВАННОЕ ВСЛУХ. // У системы есть ВИД ПРЕДЕЛА с именем `seven_day_sonnet` (замерено в самой программе // `claude`, всего видов пять), и перечень этих пяти лежит в `budget.mjs` — по нему // проверяется настройка владельца `rate_limit_type`. Это не голова работника: головой // такое значение не бывает никогда, его даже не с чем спутать. // 🔴 Сторож от этого не ослаб: вырезаются РОВНО два полных имени видов предела, а голое // слово `sonnet` где угодно — хоть в настройках головы, хоть в подписи, хоть в строке — // по-прежнему красит эту проверку. Проверено вырезанием: временно вписанное // `model = 'sonnet'` в `spawn.mjs` краснит её и с этим исключением тоже. const bezVidovPredela = text => text.replace(/seven_day_(sonnet|opus)/g, '<вид-предела>'); const dir = dirname(fileURLToPath(import.meta.url)); const guilty = readdirSync(dir) .filter(f => f.endsWith('.mjs') && !f.endsWith('.test.mjs')) .filter(f => /sonnet/i.test(bezVidovPredela(readFileSync(join(dir, f), 'utf8')))); expect(guilty).toEqual([]); }); }); describe('workerEnv — урезанное окружение работника', () => { const dirty = { PATH: 'C:/bin', SystemRoot: 'C:/Windows', TEMP: 'C:/Temp', USERPROFILE: 'C:/Users/Administrator', APPDATA: 'C:/Users/Administrator/AppData/Roaming', ANTHROPIC_API_KEY: 'sk-живой-ключ', YOOKASSA_SECRET_KEY: 'живой', DADATA_TOKEN: 'живой', MAIL_PASSWORD: 'живой', SMSC_PASSWORD: 'живой', PGPASSWORD: 'живой', }; it('список наследуемого закрыт и известен поимённо', () => { expect(WORKER_ENV_KEYS).toEqual(['PATH', 'SystemRoot', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA']); }); it('пропускает только то, без чего работник не сможет работать', () => { const env = workerEnv({ from: dirty, dbName: 'night_r_1_w_1' }); expect(Object.keys(env).sort()).toEqual( ['APPDATA', 'DB_DATABASE', 'NIGHT_WORKER', 'PATH', 'SystemRoot', 'TEMP', 'USERPROFILE'].sort(), ); }); it('ни одного ключа наружу не уходит — замок держится на отсутствии, а не на запрете', () => { const env = workerEnv({ from: dirty, dbName: 'night_r_1_w_1' }); for (const key of ['ANTHROPIC_API_KEY', 'YOOKASSA_SECRET_KEY', 'DADATA_TOKEN', 'MAIL_PASSWORD', 'SMSC_PASSWORD', 'PGPASSWORD']) { expect(env[key]).toBeUndefined(); } }); it('имя СВОЕЙ базы уходит окружением — иначе проверки пойдут в общую базу', () => { // 🪤 app/phpunit.xml:33 жёстко задаёт DB_DATABASE=liderra_testing. Правим не файл // (он общий для всего продукта), а окружение. expect(workerEnv({ from: dirty, dbName: 'night_r_1_w_3' }).DB_DATABASE).toBe('night_r_1_w_3'); }); it('двое работников получают РАЗНЫЕ базы — семеро не затрут друг другу проверки', () => { const a = workerEnv({ from: dirty, dbName: 'night_r_1_w_1' }); const b = workerEnv({ from: dirty, dbName: 'night_r_1_w_2' }); expect(a.DB_DATABASE).not.toBe(b.DB_DATABASE); }); it('имени базы не дали — запуска нет: врущая зелень хуже отказа', () => { expect(() => workerEnv({ from: dirty, dbName: null })).toThrow(/имя своей базы/i); }); }); describe('isRealSuccess — «успех» с пустым ответом успехом не считается', () => { it('настоящий успех', () => { expect(isRealSuccess({ is_error: false, result: 'сделал', total_cost_usd: 0.4 }).ok).toBe(true); }); it('пустой ответ при is_error=false — это НЕ успех (случай 334 провалов)', () => { const r = isRealSuccess({ is_error: false, result: '', total_cost_usd: 0 }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/пустой ответ/i); }); it('ответа нет вовсе — не успех', () => { expect(isRealSuccess({ is_error: false }).ok).toBe(false); }); it('явная ошибка — не успех, и причина сохранена', () => { const r = isRealSuccess({ is_error: true, result: 'spawn claude ENOENT' }); expect(r.ok).toBe(false); expect(r.reason).toContain('ENOENT'); }); it('вовсе не разобрали ответ — не успех', () => { expect(isRealSuccess(null).ok).toBe(false); }); }); describe('readResult', () => { it('вытаскивает расход и номер сессии из ответа', () => { const line = JSON.stringify({ type: 'result', is_error: false, result: 'ок', total_cost_usd: 0.049, session_id: 'abc' }); const r = readResult(line); expect(r.total_cost_usd).toBeCloseTo(0.049, 6); expect(r.session_id).toBe('abc'); }); it('в потоке много строк — годится ТОЛЬКО последняя, и берётся именно она', () => { // 🪤 Живой поток — это десятки строк подряд, и итог лежит в самой последней. // Проверка на потоке из одной строки про «последнюю» ничего не доказывает: // одна строка и первая, и последняя разом. Здесь их четыре, и годна одна. const stream = [ JSON.stringify({ type: 'system', subtype: 'init' }), JSON.stringify({ type: 'assistant', text: 'делаю' }), JSON.stringify({ type: 'user', text: 'дальше' }), JSON.stringify({ type: 'result', is_error: false, result: 'готово', total_cost_usd: 0.7, session_id: 'zzz' }), '', // живой поток кончается переводом строки — пустой хвост не должен всё сломать ].join('\n'); const r = readResult(stream); expect(r.type).toBe('result'); expect(r.session_id).toBe('zzz'); expect(r.total_cost_usd).toBeCloseTo(0.7, 6); }); it('мусор вместо ответа — null, а не падение', () => { expect(readResult('не json')).toBeNull(); }); }); describe('разбор потока — то, чем надзиратель видит ход дела', () => { it('строка потока разбирается в событие', () => { expect(parseStreamLine('{"type":"assistant"}').type).toBe('assistant'); }); it('порванная строка потока — null, а не падение всего надзирателя', () => { expect(parseStreamLine('{"type":"assis')).toBeNull(); expect(parseStreamLine('')).toBeNull(); expect(parseStreamLine(null)).toBeNull(); }); it('ответ работника — граница шага: на ней можно применить «паузу» чисто', () => { expect(isStepBoundary({ type: 'assistant' })).toBe(true); expect(isStepBoundary({ type: 'user' })).toBe(true); expect(isStepBoundary({ type: 'result' })).toBe(true); }); it('служебное начало сессии границей шага НЕ считается', () => { expect(isStepBoundary({ type: 'system', subtype: 'init' })).toBe(false); expect(isStepBoundary(null)).toBe(false); }); // 🔴 Вид предела ВЕЗДЕ называется доводом: зашитого умолчания у затеи больше нет (решение Р1). const NEDELYA = 'seven_day'; it('замер запаса берётся из события прибора системы', () => { const m = rateLimitFromEvent({ type: 'rate_limit_event', rate_limit_info: { status: 'allowed_warning', resetsAt: 1785474000, rateLimitType: NEDELYA, utilization: 0.92 }, }, { kind: NEDELYA }); expect(m.utilization).toBeCloseTo(0.92, 6); expect(m.resets_at).toBe(1785474000 * 1000); expect(m.status).toBe('allowed_warning'); }); it('чужой вид предела не берётся вовсе — это другой запас', () => { expect(rateLimitFromEvent({ type: 'rate_limit_event', rate_limit_info: { rateLimitType: 'five_hour', status: 'allowed', utilization: 0.1, resetsAt: 1785474000 }, }, { kind: NEDELYA })).toBeNull(); }); it('событие не про пределы или пусто — null', () => { expect(rateLimitFromEvent({ type: 'assistant' }, { kind: NEDELYA })).toBeNull(); expect(rateLimitFromEvent(null, { kind: NEDELYA })).toBeNull(); }); it('🔴 доля не число — это «доли НЕТ» (null), а не ноль: ноль соврал бы про запас', () => { // 🔴 ИСПРАВЛЕНО 03.08.2026. Прежде такое событие отбрасывалось ЦЕЛИКОМ, и вместе с долей // терялся светофор — а он-то как раз пришёл. Ровно на этом ворота запаса и стояли: // живая система при спокойном расходе доли не присылает вовсе. const m = rateLimitFromEvent({ type: 'rate_limit_event', rate_limit_info: { rateLimitType: NEDELYA, status: 'allowed', utilization: 'много' }, }, { kind: NEDELYA }); expect(m.utilization).toBeNull(); expect(m.utilization).not.toBe(0); expect(m.status).toBe('allowed'); }); it('🔴 вид предела не назвали — падаем громко, а не подставляем недельный молча', () => { expect(() => rateLimitFromEvent({ type: 'rate_limit_event', rate_limit_info: { rateLimitType: NEDELYA, status: 'allowed', utilization: 0.5 }, })).toThrow(/вид/i); }); }); describe('сверка головы: заказанная против той, что вправду ответила (03.08.2026)', () => { it('тянет имя ИЗ ОТВЕТА ПОМОЩНИКА (assistant), а не из заказа в system/init', () => { // 🔴🔴 ЗАМЕРЕНО ЖИВЬЁМ: событие `system`/`init` тоже несёт поле `model`, но там лежит // ЗАКАЗ (то, что попросили ключом --model), а не ИСПОЛНЕНИЕ. На живом потоке // (.night/probe-raw-2026-08-03.jsonl) заказ был haiku, а сгенерировал ответ sonnet — // и `system`/`init` при этом соврал бы «haiku», спрятав беду. Взять голову оттуда // значило бы сверять заказ с самим собой, и проверка сходилась бы ВСЕГДА. expect(golovaIzSobytiya({ type: 'system', subtype: 'init', model: 'claude-haiku-4-5-20251001' })).toBeNull(); expect(golovaIzSobytiya({ type: 'assistant', message: { model: 'claude-sonnet-4-6' } })).toBe('claude-sonnet-4-6'); }); it('событие без имени головы (или не assistant) — null, а не пустая строка', () => { expect(golovaIzSobytiya({ type: 'assistant', message: {} })).toBeNull(); expect(golovaIzSobytiya({ type: 'assistant' })).toBeNull(); expect(golovaIzSobytiya({ type: 'result' })).toBeNull(); expect(golovaIzSobytiya(null)).toBeNull(); }); describe('golovyIzModelUsage — имена ГОЛОВ, КОТОРЫМИ СЧИТАНЫ ДЕНЬГИ (поправка координатора)', () => { // 🔴🔴 ПОПРАВКА: заказ (`system`/`init`) — не единственная ловушка. Замерено на живом потоке // командой `grep -o '"model":"[^"]*"' … | sort | uniq -c`: имя головы стоит в ДВУХ местах // сразу — в `init` (заказ) и в ответе помощника (исполнение), а деньги (`total_cost_usd`) // считаются по третьему полю, `modelUsage` итогового события `result`, и на живом потоке // оно совпадает с ответом помощника, а не с заказом. Сверяем по обоим источникам ИСПОЛНЕНИЯ // (`assistant` и `modelUsage`), а не только по одному — они не обязаны молча совпадать // вечно, и датчик обязан ловить расхождение, в каком бы из двух оно ни показалось первым. it('тянет ключи modelUsage из события result', () => { const event = { type: 'result', modelUsage: { 'claude-sonnet-4-6': { inputTokens: 3 } } }; expect(golovyIzModelUsage(event)).toEqual(['claude-sonnet-4-6']); }); it('событие не result, или modelUsage нет вовсе — пустой список, а не падение', () => { expect(golovyIzModelUsage({ type: 'assistant' })).toEqual([]); expect(golovyIzModelUsage({ type: 'result' })).toEqual([]); expect(golovyIzModelUsage({ type: 'result', modelUsage: null })).toEqual([]); expect(golovyIzModelUsage(null)).toEqual([]); }); }); describe('golovySovpadayut — заказывают КОРОТКИМ именем, отвечает ПОЛНЫМ', () => { // 🪤 Строгое равенство строк сравнивало бы разные алфавиты и падало бы КАЖДЫЙ раз, // даже когда голова верна: заказывают `haiku`, а отвечает `claude-haiku-4-5-20251001`. it('короткое имя внутри полного — совпадение', () => { expect(golovySovpadayut('haiku', 'claude-haiku-4-5-20251001')).toBe(true); }); it('регистр не важен', () => { expect(golovySovpadayut('Haiku', 'claude-haiku-4-5-20251001')).toBe(true); }); it('заказана одна голова, ответила другая — расхождение', () => { // 🔴 Ровно та беда, ради которой строился датчик: заказали дешёвую, ответила другая. expect(golovySovpadayut('haiku', 'claude-sonnet-4-6')).toBe(false); }); it('без одного из имён сравнивать нечего — null, а не ложное совпадение/расхождение', () => { expect(golovySovpadayut(null, 'claude-haiku-4-5-20251001')).toBeNull(); expect(golovySovpadayut('haiku', null)).toBeNull(); expect(golovySovpadayut('', '')).toBeNull(); }); }); describe('sverkaGolovy — ГЛАВНАЯ ПРИЁМКА: на настоящем сыром живом потоке', () => { // 🔴🔴 НЕ синтетика: тот самый файл, которым доказана сегодняшняя беда, // `.night/probe-raw-2026-08-03.jsonl`, читается здесь как есть, 11 строк. const potok = readFileSync(join(REPO_ROOT, '.night', 'probe-raw-2026-08-03.jsonl'), 'utf8'); it('🔴 ловит сегодняшнюю беду: заказан haiku, а на этом потоке в счёте только другая голова', () => { // 🔴🔴 ПОПРАВКА КООРДИНАТОРА (замерено ОТДЕЛЬНОЙ живой проверкой): не «ключ --model // не сработал» — заказанная голова сама по себе работает. На ЭТОМ КОНКРЕТНОМ потоке // среди отвечавших/оплаченных нет ни одной головы, совпадающей с заказом, — датчик // говорит именно это, а не выдумывает причину («заменили», «ключ не сработал»). const r = sverkaGolovy({ zakazana: 'haiku', potok }); expect(r.sovpalo).toBe(false); expect(r.otvetila).toEqual(['claude-sonnet-4-6']); expect(r.zakazannaya_prisutstvuet).toBe(false); expect(r.postoronnie).toEqual(['claude-sonnet-4-6']); expect(r.soobshchenie).toContain('haiku'); expect(r.soobshchenie).toContain('claude-sonnet-4-6'); // Не утверждаем причину — ни "не сработал", ни "заменили": не выяснено. expect(r.soobshchenie).toMatch(/не выяснено/i); expect(r.soobshchenie).not.toMatch(/--model/); }); it('заказанная голова совпадает с тем, что в счёте, — сходится, слов владельцу нет', () => { const r = sverkaGolovy({ zakazana: 'sonnet', potok }); expect(r.sovpalo).toBe(true); expect(r.zakazannaya_prisutstvuet).toBe(true); expect(r.postoronnie).toEqual([]); expect(r.soobshchenie).toBeNull(); }); it('в потоке нет ни одного ответа помощника — третий, отдельный ответ (не "сходится")', () => { const bezOtveta = [ JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-haiku-4-5-20251001' }), JSON.stringify({ type: 'result', total_cost_usd: 0.01 }), ].join('\n'); const r = sverkaGolovy({ zakazana: 'haiku', potok: bezOtveta }); expect(r.sovpalo).toBeNull(); expect(r.otvetila).toEqual([]); expect(r.soobshchenie).toMatch(/не пришл/i); }); it('заказанную голову назвать забыли — падаем громко: сверять её не с чем', () => { expect(() => sverkaGolovy({ zakazana: null, potok })).toThrow(/заказанной головы/i); expect(() => sverkaGolovy({ zakazana: '', potok })).toThrow(/заказанной головы/i); }); it('🔴 ловит расхождение ДАЖЕ БЕЗ события assistant — по одному только modelUsage(result)', () => { // Поправка координатора: не единственный источник, откуда берётся исполнение. // Оборванный поток (обрезали до result) — редкий, но возможный случай: событие // assistant могло не долететь, а result с modelUsage — самостоятельный источник правды. const tolkoResult = [ JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-haiku-4-5-20251001' }), JSON.stringify({ type: 'result', total_cost_usd: 0.15, modelUsage: { 'claude-sonnet-4-6': { inputTokens: 3 } } }), ].join('\n'); const r = sverkaGolovy({ zakazana: 'haiku', potok: tolkoResult }); expect(r.sovpalo).toBe(false); expect(r.otvetila).toEqual(['claude-sonnet-4-6']); }); it('🔴🔴 ДОБАВКА, а не замена: заказанная голова в счёте ЕСТЬ, но рядом — ещё одна лишняя', () => { // 🔴🔴 РОВНО ТА ФОРМА БЕДЫ, КОТОРУЮ ЗАМЕРИЛ КООРДИНАТОР ОТДЕЛЬНОЙ ЖИВОЙ ПРОВЕРКОЙ: // в счёте стоят ДВЕ головы разом — заказанная (она работает) и ещё одна, которую // не заказывали. Слова владельцу здесь другие, чем при «заказанной нет вовсе»: // не «вместо неё», а «кроме неё». const dveGolovy = [ JSON.stringify({ type: 'assistant', message: { model: 'claude-haiku-4-5-20251001' } }), JSON.stringify({ type: 'result', total_cost_usd: 0.2, modelUsage: { 'claude-haiku-4-5-20251001': { inputTokens: 3 }, 'claude-sonnet-4-6': { inputTokens: 3 }, }, }), ].join('\n'); const r = sverkaGolovy({ zakazana: 'haiku', potok: dveGolovy }); expect(r.sovpalo).toBe(false); expect(r.zakazannaya_prisutstvuet).toBe(true); expect(r.postoronnie).toEqual(['claude-sonnet-4-6']); expect(r.soobshchenie).toMatch(/кроме заказанной/i); expect(r.soobshchenie).not.toMatch(/вместо неё/i); expect(r.soobshchenie).toMatch(/не выяснено/i); }); }); }); describe('второе место платных запусков — рождение работника: датчик готов, но НЕ ПОДКЛЮЧЁН', () => { // 🔴🔴 ГРАНИЦА ЗАДАЧИ, СКАЗАННАЯ ВСЛУХ. Платных мест в затее ровно два: крошечный замер // остатка (`probeUtilization` в budget.mjs — датчик подключён, см. выше) и рождение работника // (`startWorker` в этом файле). У рождения работника поток читает НЕ этот файл — его построчно // разбирает `eatLine` в `tools/night/supervisor.mjs` (там же, где сейчас пишутся замеры // остатка через `recordMeasure`). `supervisor.mjs` НЕ входит в список файлов этой задачи // (там прямо сейчас работает другая смена) — датчик туда НЕ подключается здесь. // // Что доказано вместо подключения: `sverkaGolovy`/`golovaIzSobytiya`/`golovyIzModelUsage` // читают события ОБЩЕЙ формы (`assistant`, `result`) — той же самой, что несёт и поток // замера, и поток работника (оба порождены одним и тем же `claude … --output-format // stream-json`, см. `probeArgs` и `buildArgs` выше). Ниже — поток МНОГОШАГОВОГО вида // (несколько `assistant`, как у настоящего работника, а не один, как у замера) — сенсор // ловит расхождение и на нём той же логикой, без переделки. it('голова совпадает на многошаговом потоке работника — сходится', () => { const potokRabotnika = [ JSON.stringify({ type: 'system', subtype: 'init', model: 'claude-opus-4-1-20250805' }), JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-1-20250805' } }), JSON.stringify({ type: 'user', message: { content: 'дальше' } }), JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-1-20250805' } }), JSON.stringify({ type: 'result', total_cost_usd: 1.2, modelUsage: { 'claude-opus-4-1-20250805': { inputTokens: 100 } } }), ].join('\n'); const r = sverkaGolovy({ zakazana: 'opus', potok: potokRabotnika }); expect(r.sovpalo).toBe(true); }); it('🔴 та же расходящаяся голова ловится и на многошаговом потоке работника, не только на замере', () => { const potokRabotnika = [ JSON.stringify({ type: 'assistant', message: { model: 'claude-haiku-4-5-20251001' } }), JSON.stringify({ type: 'user', message: { content: 'дальше' } }), JSON.stringify({ type: 'assistant', message: { model: 'claude-haiku-4-5-20251001' } }), JSON.stringify({ type: 'result', total_cost_usd: 1.2, modelUsage: { 'claude-haiku-4-5-20251001': { inputTokens: 100 }, 'claude-sonnet-4-6': { inputTokens: 5 } }, }), ].join('\n'); const r = sverkaGolovy({ zakazana: 'opus', potok: potokRabotnika }); expect(r.sovpalo).toBe(false); expect(r.postoronnie.sort()).toEqual(['claude-haiku-4-5-20251001', 'claude-sonnet-4-6'].sort()); }); }); describe('имя своей базы доходит до ЖИВОГО процесса, а не только до объекта (проверка 46)', () => { // 🪤 Ни одну внешнюю программу не зовём голым именем — даже здесь. const NODE = resolveBin('node'); function chtoVidytRebyonok(dbName) { // Настоящий ребёнок с настоящим урезанным окружением. Он печатает то, что видит сам. // 🪤 `shell` подаём ВСЕГДА, а не «когда понадобится»: найденный по полному пути // `.cmd`/`.bat` Node 24 без него падает `EINVAL`, и падает не здесь, а через неделю // на чужой машине. `needsShell` сама решает, нужна оболочка этому файлу или нет. return execFileSync(NODE, ['-e', 'process.stdout.write(String(process.env.DB_DATABASE))'], { env: workerEnv({ from: process.env, dbName }), shell: needsShell(NODE), encoding: 'utf8', }).trim(); } it('рождённый ребёнок видит СВОЮ базу своим окружением', () => { expect(chtoVidytRebyonok('night_r_1_w_3')).toBe('night_r_1_w_3'); }); it('двое живых детей видят РАЗНЫЕ базы — семеро не затрут друг другу проверки', () => { expect(chtoVidytRebyonok('night_r_1_w_1')).not.toBe(chtoVidytRebyonok('night_r_1_w_2')); }); it('в живом ребёнке общей базы проверок нет — она не «запрещена», её там просто нет', () => { expect(chtoVidytRebyonok('night_r_1_w_1')).not.toBe('liderra_testing'); }); it('имени базы не дали — ребёнок НЕ РОЖДАЕТСЯ вовсе', () => { // 🔴 Проверка 46: «не стартовал вовсе, и ни одной проверки по его пункту не пошло». // Бросок случается при сборке настроек запуска — то есть ДО самого рождения ребёнка: // родись он, никакого броска бы не было, а был бы живой работник с чужой базой. // // 🔴🔴 ПОЧЕМУ `cwd` указывает в НЕСУЩЕСТВУЮЩИЙ каталог — это не описка. Шаг 8 нарочно // вырезает из `workerEnv` тот самый бросок. Укажи мы сюда рабочий каталог — на шаге 8 // ничто бы не остановило запуск, и `startWorker` РОДИЛ БЫ НАСТОЯЩЕГО работника `claude` // прямо в хранилище: живой платный процесс с поручением «поручение», которого никто // не гасит. С несуществующим каталогом ребёнок не заводится ни при каком исходе: // с правилом — бросок про базу (зелено), без правила — отказ на самом запуске, // и слов про базу в нём нет, значит проверка краснеет. Ровно этого мы и ждём. expect(() => startWorker({ cwd: join(process.cwd(), 'net-takogo-kataloga-i-ne-budet'), prompt: 'поручение', settingsPath: 'night-settings.json', sessionId: '00000000-0000-4000-8000-000000000000', dbName: null, model: 'sonnet', })).toThrow(/имя своей базы/i); }); }); describe('🔴 рождение работника идёт ЧЕРЕЗ СЧЁТ владельца, а не мимо него', () => { // 🔴🔴 ЗДЕСЬ НЕ РОЖДАЕТСЯ НИ ОДНОГО НАСТОЯЩЕГО РАБОТНИКА, и держится это не на обещании, // а на устройстве: `cwd` во всех проверках ниже указывает в НЕСУЩЕСТВУЮЩИЙ каталог. // Приём взят у соседней проверки выше (про имя базы) и по той же причине: с таким `cwd` // система отказывает ДО создания процесса (ENOENT на рабочий каталог), то есть живой // `claude` не заводится ни при каком исходе — ни когда счёт отказал, ни когда пропустил. // Замерено прямо здесь, отдельным запуском: `spawn` возвращает объект, а ошибка приходит // событием — процесса при этом не создаётся вовсе. // // 🔴 Счёт подаётся СВОИМ, временным (`attemptsPath`), а настройки — своими (`cfg`). // Без этого проверка писала бы в настоящее хозяйство `.night`, а его на рабочий угол // один: соседний прибор проверок затирал бы нам счёт посреди опыта. let dir; let schyot; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'rozhdenie-rabotnika-')); schyot = join(dir, 'worker-spawns.jsonl'); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); const dovody = { cwd: join(process.cwd(), 'net-takogo-kataloga-i-ne-budet'), prompt: 'поручение', settingsPath: 'night-settings.json', sessionId: '00000000-0000-4000-8000-000000000000', dbName: 'night_r_1_w_1', model: 'sonnet', }; it('🔴 на исчерпанном пределе работник НЕ рождается, и отказ — ЧЕЛОВЕЧЕСКАЯ строка', () => { // 🔴 Вырежи счёт из `startWorker` — и здесь не будет никакого броска вовсе: рождение // пойдёт как шло, предел владельца не сторожит ничего. Проверено вырезанием. const now = Date.now(); for (let i = 0; i < 3; i += 1) recordSpawnAttempt(schyot, { at: now - 1000 + i, kto: 'w-старый' }); let beda = null; try { startWorker({ ...dovody, cfg: { spawn_budget_per_shift: 3 }, attemptsPath: schyot }); } catch (err) { beda = err; } expect(beda, 'работник родился при исчерпанном пределе — предел не сторожит рубли').not.toBeNull(); expect(beda.message).toContain('spawn_budget_per_shift'); expect(beda.message).toContain('night.config.json'); expect(beda.message).toContain(schyot); expect(beda.message, 'отказ похож на след стека, а не на слова для владельца').toMatch(/Что делать/); // Лишней строки в счёт не легло: рождения не было. expect(readSpawnAttempts(schyot)).toHaveLength(3); }); it('🔴 попытка пишется ДО запуска: даже провалившееся рождение съедает счёт', () => { // 🪤 Самый тихий обход: считай мы удачные рождения, сломанный запуск жёг бы деньги // владельца сколько угодно раз при счётчике на нуле. Здесь запуск ВСЕГДА проваливается // (каталога нет), и строка в счёте обязана появиться всё равно. const child = startWorker({ ...dovody, cfg: { spawn_budget_per_shift: 30 }, attemptsPath: schyot }); // 🪤 Ошибка запуска придёт событием; без слушателя она убила бы весь прибор проверок. child?.on?.('error', () => {}); expect(readSpawnAttempts(schyot), 'запуск был, а счёт остался на нуле').toHaveLength(1); }); it('🔴 отказ по ненаписанным настройкам счёта НЕ съедает: их описка денег не стоит', () => { // Голову не сказали — бросок про `worker_model` случается ДО счёта, и правильно: // красть у владельца рождение, которого не было, нельзя. expect(() => startWorker({ ...dovody, model: undefined, cfg: { spawn_budget_per_shift: 30 }, attemptsPath: schyot, })).toThrow(/worker_model/i); expect(readSpawnAttempts(schyot)).toHaveLength(0); expect(() => startWorker({ ...dovody, dbName: null, cfg: { spawn_budget_per_shift: 30 }, attemptsPath: schyot, })).toThrow(/имя своей базы/i); expect(readSpawnAttempts(schyot)).toHaveLength(0); }); }); // ━━━ 🔴🔴 ДАТЧИК МАСТЕРСКОЙ (03.08.2026): «ключ ПРИНЯТ» ≠ «ключ ПРИМЕНЁН» ━━━ // Ровно та беда, что уже дважды выстрелила в этот же день (Inworld тихо не применил потолок // токенов; `--tools ''` стоял в коде и не срезал ничего). Лечить не программу — лечить умение // её ПРОВЕРИТЬ: событие `system`/`init` в потоке честно называет, что вправду загружено // (`tools`, `mcp_servers`, `skills`, `slash_commands`), и верить нужно ЕМУ, а не аргументам // командной строки, которые сами по себе ничего не доказывают. describe('sostavMasterskoyIzPotoka / masterskayaOtsechena — датчик по событию system/init', () => { // 🔴🔴 НЕ СИНТЕТИКА, ОБА ПОТОКА ЖИВЫЕ И ОПЛАЧЕННЫЕ: // без лечения — `.night/probe-raw-2026-08-03.jsonl` (тот же файл, что доказывает беду // выше, у sverkaGolovy): tools 50, mcp_servers 51, skills 129, slash_commands 156; // с лечением (`--tools '' --strict-mcp-config --setting-sources ''`) — // `.night/probe-lechenie-2026-08-03.jsonl`: tools 0, mcp_servers 0, skills 8, // slash_commands 20. Восемь умений и двадцать команд — встроенный костяк программы, // а не недолеченный обвес: до нуля они не падают даже при всех трёх ключах разом. const potokBezLecheniya = readFileSync(join(REPO_ROOT, '.night', 'probe-raw-2026-08-03.jsonl'), 'utf8'); const potokSLecheniem = readFileSync(join(REPO_ROOT, '.night', 'probe-lechenie-2026-08-03.jsonl'), 'utf8'); describe('sostavMasterskoyIzPotoka — сырые числа из события init', () => { it('без лечения — обвес целиком: 50 инструментов, 51 служба, 129 умений, 156 команд', () => { expect(sostavMasterskoyIzPotoka(potokBezLecheniya)).toEqual({ naydeno: true, tools: 50, mcp_servers: 51, skills: 129, slash_commands: 156, }); }); it('с лечением — мастерская вправду отсечена: 0 инструментов, 0 служб, 8 умений, 20 команд', () => { expect(sostavMasterskoyIzPotoka(potokSLecheniem)).toEqual({ naydeno: true, tools: 0, mcp_servers: 0, skills: 8, slash_commands: 20, }); }); it('события system/init в потоке не было вовсе — честное "не найдено", а не нули', () => { // 🔴 Ноль вместо «неизвестно» здесь было бы враньём того же класса, что и «доли нет» // в budget.mjs: ноль означает «мастерская пуста», а не «мы не смотрели». const bezInit = JSON.stringify({ type: 'result', is_error: false, result: 'есть' }); expect(sostavMasterskoyIzPotoka(bezInit)).toEqual({ naydeno: false, tools: null, mcp_servers: null, skills: null, slash_commands: null, }); expect(sostavMasterskoyIzPotoka('')).toEqual({ naydeno: false, tools: null, mcp_servers: null, skills: null, slash_commands: null, }); }); }); describe('masterskayaOtsechena — говорит вслух, только если сравнивать есть с чем', () => { it('🔴 ГЛАВНАЯ ПРИЁМКА: на потоке БЕЗ лечения — говорит вслух (заказаны оба урезания)', () => { const r = masterskayaOtsechena({ potok: potokBezLecheniya, zakazanTools: true, zakazanMcp: true }); expect(r.otsecheno).toBe(false); expect(r.sostav.tools).toBe(50); expect(r.sostav.mcp_servers).toBe(51); expect(r.soobshchenie).toMatch(/ПРИНЯТА.*НЕ ПРИМЕНЕНА/is); expect(r.soobshchenie).toContain('50'); expect(r.soobshchenie).toContain('51'); }); it('🔴 ГЛАВНАЯ ПРИЁМКА: на потоке С лечением — молчит (заказ выполнен)', () => { const r = masterskayaOtsechena({ potok: potokSLecheniem, zakazanTools: true, zakazanMcp: true }); expect(r.otsecheno).toBe(true); expect(r.soobshchenie).toBeNull(); }); it('ключ не заказывали — эта грань не сверяется вовсе, молчим и про необрезанный обвес', () => { // Место 2 (рождение работника) не заказывает `--tools ''` — сверять его нечем, // и датчик не должен врать «не отсечено» про грань, которую никто не резал. const r = masterskayaOtsechena({ potok: potokBezLecheniya, zakazanTools: false, zakazanMcp: false }); expect(r.otsecheno).toBe(true); expect(r.soobshchenie).toBeNull(); }); it('заказан только --strict-mcp-config (место 2) — сверяется только служба, не инструменты', () => { const r = masterskayaOtsechena({ potok: potokSLecheniem, zakazanMcp: true }); expect(r.otsecheno).toBe(true); const rBeda = masterskayaOtsechena({ potok: potokBezLecheniya, zakazanMcp: true }); expect(rBeda.otsecheno).toBe(false); expect(rBeda.soobshchenie).toContain('51'); expect(rBeda.soobshchenie).not.toContain('--tools'); }); it('события init в потоке не было — сверить нечем, otsecheno = null, а не ложное "сошлось"', () => { const r = masterskayaOtsechena({ potok: '', zakazanTools: true, zakazanMcp: true }); expect(r.otsecheno).toBeNull(); expect(r.soobshchenie).toMatch(/init/); }); }); });