Files
portal/tools/night/spawn.test.mjs
T

442 lines
28 KiB
JavaScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
resolveClaudeBin, claudeLaunch, buildArgs, readResult, isRealSuccess, parseStreamLine,
isStepBoundary, rateLimitFromEvent, workerEnv, WORKER_ENV_KEYS,
} from './spawn.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('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);
});
});
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('имя своей базы доходит до ЖИВОГО процесса, а не только до объекта (проверка 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);
});
});