Files
portal/tools/night/spawn.test.mjs
T
Дмитрий 32770b26d2 fix(ночь): рез примечаний ел живой код — сторож смены 28 судил по изувеченному тексту
Находка М-407, правила 247-250.

Промт смены 29 велел проверить остальных сторожей на слепоту смены 28 (правило 244).
Замер вывел на беду в самом лекарстве.

ЧТО ПОЧИНЕНО. `bezPrimechaniy`, заведённый сменой 28 против «за примечание отвечает
код», был двумя грубыми заменами подряд. На живом хозяйстве он ЕЛ ЗЕРНО:

 - `supervisor.mjs` — 53 объявления → 42, строк 2591 → 2344;
 - `spawn.mjs` — 25 → 19, строк 770 → 614;
 - `celo-li-hozyaystvo.mjs` — 13 → 11;
 - `workspace.test.mjs` — строк 2375 → 602.

Причина замерена, а не выведена: внутри обычного `//`-примечания `supervisor.mjs`
строкой 5 стоит текст `/*.mjs`. Замена читает это как начало блочного примечания
и выкусывает 230 строк ЖИВОГО КОДА до первого попавшегося `*/`.

⇒ Сторож правила 235, ради которого рез и заводился, всё это время судил по тексту,
из которого выкушено пол-файла. Он был зелёным — но зелёным по изувеченному.

Рез переписан с замен на посимвольный разбор — тем же порядком, каким уже работал
`tolkoKod` в том же файле: образец → строка → примечание. Отличие одно и ради него
рез существует: строковые значения сохраняются. Переносы внутри вырезанного блока
возвращаются на место — кто режет перед построчным обходом, иначе получил бы номера
строк от другого файла.

Доказано вырезанием: вернул прежнее тело — покраснели ровно 4 проверки, посчитанные
ДО прогона. Полный прогон после починки: 2939 чужих проверок зелены, ни одна не
сломалась. Живого долга за слепотой не нашлось — так и записано, не больше.

ДЕСЯТЬ САМОДЕЛЬНЫХ РЕЗОВ СВЕДЕНЫ В ОДИН, включая две полные копии `tolkoKod`.
Замер копии из `cli.test.mjs` против общего по 52 рабочим файлам: расходятся на 19.
На самом `cli.mjs` копия видела 148 зовов против 152 — четыре (`imyaOkna`,
`bolshoyBukvoy`, `randomUUID`, `priyomkaDir`) сторож «точка входа не зовёт имён,
которых у неё нет» не проверял вовсе.

ЗЕРКАЛЬНАЯ СЛЕПОТА ЗАМЕРЕНА (правило 243, прибором, а не глазами): 33 места, где
утверждается «в коде есть X» по сырому тексту. Живой слепоты — НОЛЬ. Зелены по двум
причинам разом — 5. Чисто — 19. Все 33 переведены на рез В МЕСТЕ ВОПРОСА, а не в
месте чтения: у `shoot.test.mjs` и `reviewer.test.mjs` к одному файлу стоят рядом три
разных вопроса, и обёртка чтения превратила бы запрет на боевой адрес в дыру.

Заведён сторож на класс `utverzhdenie-o-kode-ne-po-syromu.test.mjs` — он же стережёт
единственность реза. Доказан вырезанием: вернул одно место к сырому — покраснела
ровно одна проверка и назвала точный адрес.

247. У РЕЗА СПРАШИВАЙ НЕ ТОЛЬКО «ЧТО ОН РЕЖЕТ», НО И «НА ЧЁМ ЕГО МЕРИЛИ». Правило 234
смене 28 было задано — и получило ответ от подложек. Между ответом подложек и правдой
живого хозяйства уместилось 11 объявлений из 53.
248. ПОДЛОЖКА, НЕ ВОСПРОИЗВОДЯЩАЯ БЕДУ, ДАЁТ ЗЕЛЁНЫЙ, КОТОРЫЙ НИЧЕГО НЕ ОХРАНЯЕТ.
Две мои подложки были зелены на заведомо сломанном резе — не хватало пары знаков.
249. ОДНО ДЕЛО — ОДНО ТЕЛО, И РАСХОЖДЕНИЕ ТЕЛ ВСЕГДА МОЛЧАЛИВОЕ. Уточнение 239.
250. РЕЗ СТАВЯТ ТАМ, ГДЕ ЗАДАЮТ ВОПРОС, А НЕ ТАМ, ГДЕ ЧИТАЮТ ФАЙЛ.

Числа: проверок 2940 в 109 файлах (было 2929/108), ноль красных, код 0. Приговор
прибора 613 / 29 — состав сверен поимённо, тот же, что у смены 28. Живых рождений
ноль. Ошибок надзирателя три, все названы: правил семь мест программой из-под
оболочки вместо Edit (то самое правило, о котором предупреждала смена 28; правка
дожила, проверено сразу же); подложки были зелены на сломанном; первый вид сторожа
поймал сам себя, второй перепрыгивал через отрицание и врал номером строки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:43:49 +03:00

1054 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, PODMENA_HOZYAYSTVA, PRIMETY_PRIBORA, korenHozyaystva } 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';
import { bezPrimechaniy } from './schyot-zovushchih.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 = bezPrimechaniy(src);
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',
// Отведение хозяйства улик и приметы прибора — см. разбор у самого списка в spawn.mjs.
'NIGHT_HOZYAYSTVO_PODMENA', 'VITEST', 'VITEST_WORKER_ID', 'VITEST_POOL_ID',
]);
});
// 🔴 СТОРОЖ НА РАСХОЖДЕНИЕ ДВУХ СПИСКОВ. Имена примет живут в `paths.mjs` (их читает пол),
// а переносит их отсюда `workerEnv`. Добавь кто-нибудь примету только в одном месте —
// пол молча перестанет накрывать детей, и поймать это будет нечем.
it('🔴 перенос знает ВСЕ приметы прибора, а не часть — списки не разъехались', () => {
for (const primeta of PRIMETY_PRIBORA) {
expect(WORKER_ENV_KEYS, `примету ${primeta} ребёнку не переносят — пол его не накроет`)
.toContain(primeta);
}
expect(WORKER_ENV_KEYS).toContain(PODMENA_HOZYAYSTVA);
});
it('🔴 отведение и приметы ВПРАВДУ доезжают до ребёнка, когда они есть у родителя', () => {
const podPriborom = { ...dirty, VITEST: 'true', NIGHT_HOZYAYSTVO_PODMENA: 'C:/Temp/отвед/.night' };
const env = workerEnv({ from: podPriborom, dbName: 'night_r_1_w_1' });
expect(env.VITEST).toBe('true');
expect(env.NIGHT_HOZYAYSTVO_PODMENA).toBe('C:/Temp/отвед/.night');
});
it('🔴 в БОЕВОЙ ночи ничего лишнего не появляется — переносится только то, что есть', () => {
// 🪤 Ровно то место, где перенос мог бы навредить: выдумай он переменную от себя —
// боевая ночь решила бы, что идёт проверка, и уложила бы улики во временный каталог.
const env = workerEnv({ from: dirty, dbName: 'night_r_1_w_1' });
expect(env.VITEST).toBeUndefined();
expect(env.NIGHT_HOZYAYSTVO_PODMENA).toBeUndefined();
});
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');
// 🔴 ПОПРАВКА 03.08.2026 (вечер): прежде здесь требовалось слово «не выяснено» —
// и это ЗАКРЕПЛЯЛО в утренней сводке владельца вывод, опровергнутый замером. Причина
// найдена (короткое имя головы + режим «plan») и вылечена в budget.mjs, поэтому датчик
// обязан назвать её владельцу, а не разводить руками. Верни сюда «не выяснено» —
// покраснеет эта проверка.
expect(r.soobshchenie).toMatch(/причина .{0,40}найдена/i);
expect(r.soobshchenie).toMatch(/plan/);
expect(r.soobshchenie).not.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('добавка (а не замена): заказанная голова в счёте ЕСТЬ, но рядом — ещё одна лишняя', () => {
// 🔴 ПОПРАВКА 03.08.2026 (вечер). Прежде здесь стояло «РОВНО ТА ФОРМА БЕДЫ, КОТОРУЮ
// ЗАМЕРИЛ КООРДИНАТОР» — неправда: живьём была ЗАМЕНА (заказанной головы в запросе не
// было вовсе), и её ловит соседняя проверка на настоящем потоке. Этот же случай —
// ВТОРАЯ, пока не встреченная живьём форма расхождения: в счёте две головы разом.
// Проверка остаётся, потому что стережёт РАЗНЫЕ слова владельцу в двух случаях
// («кроме неё» против «вместо неё»), а не потому, что так было замерено.
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(/причина .{0,40}найдена/i);
expect(r.soobshchenie).not.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);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴 ЖИВОЙ БОЛЬНОЙ РЕБЁНОК: ТОТ, КТО ВПРАВДУ ЛЕЗЕТ В ХОЗЯЙСТВО УЛИК.
//
// Зачем отдельно от соседнего живого ребёнка выше. Тот печатает имя своей базы и модулей
// затеи не грузит — он ЗДОРОВ и на пол не опирается вовсе, потому и доказать полом ничего
// не может: он и без пола безвреден. Здесь ребёнок нарочно БОЛЬНОЙ — он делает ровно то,
// от чего беда: ввозит `paths.mjs` и спрашивает, где хозяйство улик.
//
// 🔴 ИМЕННО ЭТОТ СЛУЧАЙ 04.08.2026 ПРОХОДИЛ МИМО ПОЛА. Пол узнаёт прибор проверок по приметам
// в окружении, а окружение работника РЕЖЕТСЯ (`workerEnv`) — и приметы резались вместе с ним.
// Ребёнок приходил без единой приметы, честно решал, что он боевой, и получал корнем
// ОПЛАЧЕННЫЕ УЛИКИ ВЛАДЕЛЬЦА. Сторож улик при этом молчал: он смотрит на своё хранилище,
// а писал бы этот ребёнок в него же — то есть беда прошла бы у сторожа под носом.
//
// 🪤 Прежде тут стоял довод «сегодня так никто не делает». Он был ЛОЖНЫМ уже в день, когда
// его написали: соседний живой ребёнок выше рождается ровно этим урезанным списком. Дверь
// держалась не на устройстве, а на том, что в неё пока не толкались.
// ═══════════════════════════════════════════════════════════════════════════════
describe('🛡️ живой ребёнок с урезанным окружением НЕ попадает в оплаченные улики', () => {
const NODE = resolveBin('node');
const putKPaths = join(dirname(fileURLToPath(import.meta.url)), 'paths.mjs');
// Ребёнок ввозит `paths.mjs` и печатает то, что он сам считает хозяйством улик.
// 🪤 Ввоз по ссылке на файл, а не по имени: у ребёнка свой рабочий каталог.
function gdeRebyonokVidytHozyaystvo(env) {
const ssylka = new URL(`file://${putKPaths.replace(/\\/g, '/')}`).href;
return String(execFileSync(
NODE,
['-e', `import(${JSON.stringify(ssylka)}).then(m => process.stdout.write(m.NIGHT_ROOT))`],
{ env, shell: needsShell(NODE), encoding: 'utf8', timeout: PREDEL_ZHIVOGO_ZAPUSKA },
)).trim();
}
const BOEVOE = join(REPO_ROOT, '.night');
it('🔴 ГЛАВНОЕ: ребёнок, рождённый урезанным окружением, видит НЕ боевое хозяйство', () => {
// 🔴 Окружение берётся у настоящего `workerEnv` — того самого, которым рождают работника.
// Перепиши я его здесь руками, проверка доказывала бы только моё представление о нём.
const env = workerEnv({ from: process.env, dbName: 'night_r_1_w_1' });
const uRebyonka = gdeRebyonokVidytHozyaystvo(env);
expect(uRebyonka, [
'Живой ребёнок с урезанным окружением получил корнем ОПЛАЧЕННЫЕ УЛИКИ ВЛАДЕЛЬЦА.',
`Он видит: ${uRebyonka}`,
`Боевое хозяйство: ${BOEVOE}`,
'Значит перенос отведения в `WORKER_ENV_KEYS` (spawn.mjs) сломан или обойдён, и всякая',
'запись такого ребёнка идёт в улики, часть которых оплачена живыми деньгами.',
].join('\n')).not.toBe(BOEVOE);
// И положительная половина: он видит ровно то же хозяйство, что и родитель.
expect(uRebyonka).toBe(korenHozyaystva(process.env));
});
it('🔴 ВЫРЕЗАНИЕ: отними у ребёнка перенос — и он ВПРАВДУ уходит в боевое', () => {
// 🔴 Без этой половины первая ничего не охраняет: зелёный, которого не видели красным,
// не сторожит ничего. Режем ровно то, что чинили, — переносимые приметы и отведение.
const urezannoe = workerEnv({ from: process.env, dbName: 'night_r_1_w_1' });
const bezPerenosa = { ...urezannoe };
delete bezPerenosa[PODMENA_HOZYAYSTVA];
for (const primeta of PRIMETY_PRIBORA) delete bezPerenosa[primeta];
expect(gdeRebyonokVidytHozyaystvo(bezPerenosa), [
'Вырезание не сработало: ребёнок БЕЗ переноса обязан был уйти в боевое хозяйство,',
'а ушёл куда-то ещё. Значит опыт ничего не доказывает — проверьте, что режется то самое.',
].join('\n')).toBe(BOEVOE);
});
});
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/);
});
});
});