Files
portal/tools/night/spawn.mjs
T

235 lines
18 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.
// Запуск работника. Работник — ребёнок НАДЗИРАТЕЛЯ (supervisor.mjs), а не сессии помощника:
// отвязанным (detached) живёт надзиратель, он и переживает конец сессии.
// Учёта расхода через onExit родителя здесь НЕТ: родитель умирает вместе с сессией,
// и его onExit не сработает никогда. Расход пишет надзиратель.
// 🪤 334 прежних фоновых прогона в этом хозяйстве упали с "spawn claude ENOENT":
// на Windows `claude` — скрипт без .cmd, и по голому имени его не находят.
// Путь к любой внешней программе строит ОДНО место — resolveBin в paths.mjs.
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { resolveBin, needsShell, configPath } from './paths.mjs';
import { readJson } from './state.mjs';
import { rozhdenieSoSchyotom, spawnAttemptsPath } from './schyot-zamerov.mjs';
export function resolveClaudeBin({ lookIn = null, name = 'claude' } = {}) {
return resolveBin(name, lookIn ? { lookIn } : {});
}
// 🔴 Найти файл — половина дела. Вторая половина: Node с 18.20/20.12 (у нас 24) ОТКАЗЫВАЕТСЯ
// запускать `.cmd`/`.bat` без оболочки (закрытая дыра CVE-2024-27980, ответ EINVAL),
// а `claude` на этой машине — ровно `.cmd`.
//
// 🪤 И просто включить оболочку тут нельзя: оболочка разбирает командную строку ЗАНОВО,
// а мы передаём длинное русское поручение с переводами строк и кавычками — она его изуродует
// или разорвёт. Поэтому оболочка здесь последнее средство: сначала ищем настоящее нутро
// программы (`cli.js`) рядом с найденным `.cmd` и запускаем его напрямую через `node` —
// тогда поручение уходит одним аргументом как есть, и оболочки в цепочке нет вовсе.
export function claudeLaunch(bin) {
const target = bin ?? resolveClaudeBin();
if (!needsShell(target)) return { command: target, prefixArgs: [], shell: false };
const js = join(dirname(target), 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
if (existsSync(js)) return { command: resolveBin('node'), prefixArgs: [js], shell: false };
return { command: target, prefixArgs: [], shell: true };
}
export function buildArgs({ prompt, settingsPath, sessionId, model, addDirs = [] }) {
// 🔴 Голова работника приходит из настроек владельца (`worker_model` в night.config.json)
// и НИГДЕ в коде словом не зашита. Причина денежная: разные головы едят недельный запас
// по-разному, а запас у владельца один на всё. Значения по умолчанию тут нет намеренно —
// молча подставить голову значит потратить чужой запас головой, которую не выбирали,
// и узнать об этом по счёту.
if (!model) {
throw new Error('Не сказано, какой головой запускать работника: в night.config.json нет worker_model (образец — night.config.example.json).');
}
// Предела в долларах в запуске НЕТ: своего счёта в деньгах затея не ведёт вовсе.
// Долю прогона держит надзиратель по приросту utilization у прибора системы (кусок 2).
const args = [
'-p', prompt,
// Поток событий, а не один кусок в конце: надзирателю нужен ХОД дела —
// граница шага для «паузы», замеры недельного запаса по ходу и признаки жизни,
// а не только итог над трупом.
'--output-format', 'stream-json',
'--verbose', // без него Claude Code откажется отдавать поток в паре с -p
'--settings', settingsPath,
'--session-id', sessionId,
'--model', model,
'--permission-mode', 'acceptEdits',
];
for (const d of addDirs) args.push('--add-dir', d);
return args;
}
// Что работник наследует из окружения. Список ЗАКРЫТ и намеренно короток: по умолчанию
// запуск отдаёт ребёнку ВСЁ окружение запускающего, включая боевые ключи в переменных среды,
// а замок «боевых ключей нет» смотрит только на файлы и такого не поймал бы.
// ANTHROPIC_API_KEY тут нет намеренно: с ним оплата ушла бы мимо подписки владельца.
export const WORKER_ENV_KEYS = ['PATH', 'SystemRoot', 'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA'];
export function workerEnv({ from = process.env, dbName }) {
if (!dbName) {
throw new Error('Работнику не дали имя своей базы — запуска нет: общая база на семерых даёт ЗЕЛЁНЫЕ ВРУЩИЕ проверки, а это худший вид беды.');
}
const env = {};
for (const key of WORKER_ENV_KEYS) {
if (from[key] !== undefined) env[key] = from[key];
}
env.NIGHT_WORKER = '1';
// 🪤 app/phpunit.xml:33 жёстко задаёт DB_DATABASE=liderra_testing, и без этой переменной
// своя база работника до прогона проверок НЕ доходит: семеро затирают друг другу базу
// при зелёных тестах. Сам phpunit.xml не правим — он общий для всего продукта.
// Работает это потому, что у того объявления нет пометки «перебивать»: переменную,
// которая в окружении уже есть, проверки не трогают, и Laravel свой .env поверх
// окружения тоже не кладёт.
env.DB_DATABASE = dbName;
return env;
}
export function parseStreamLine(line) {
const s = String(line ?? '').trim();
if (!s) return null;
try { return JSON.parse(s); } catch { return null; } // порванная строка потока не валит надзирателя
}
const STEP_BOUNDARIES = new Set(['assistant', 'user', 'result']);
export function isStepBoundary(event) {
// Граница шага — то место, где «паузу» можно применить чисто: работник ответил
// или запуск кончился. Служебное начало сессии (`system`) границей не считается.
return Boolean(event) && STEP_BOUNDARIES.has(event.type);
}
// ━━━ ЗАМЕР ПРИБОРА СИСТЕМЫ ИЗ ПОТОКА ━━━
//
// 🔴 ЗАМЕРЕНО ЖИВЬЁМ 03.08.2026, сырой поток лежит в `.night/probe-raw-2026-08-03.jsonl`.
// Живое событие целиком, знак в знак:
// {"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785712200,
// "rateLimitType":"five_hour","overageStatus":"rejected",
// "overageDisabledReason":"org_level_disabled","isUsingOverage":false}}
//
// 🔴🔴 ПОЛЯ `utilization` В НЁМ НЕТ ВОВСЕ — И ЭТО НЕ ПОЛОМКА. Программа собирает событие
// двумя дорогами: при спокойном расходе — сборкой БЕЗ доли, а с долей — только когда перейдён
// порог тревоги, и тогда `status` становится `allowed_warning`. То есть прежнее правило
// «нет доли — замера нет» ждало ЛАМПОЧКУ ТРЕВОГИ и принимало её за бензомер: пока владелец
// тратит не быстрее часов, числа не будет никогда.
//
// 🔴 Прежнее правило отвергало живое событие ДВАЖДЫ, и каждой причины хватало поодиночке:
// ждало НЕДЕЛЬНЫЙ вид предела (а приходит `five_hour`) и ждало долю (её нет). Ворота запаса
// не пропустили ни разу за всю жизнь затеи, а владелец читал «события не пришло» — враньё.
//
// Ноль вместо неизвестного — по-прежнему враньё о запасе, поэтому доли нет = `null`, не 0.
// Сырой разбор события БЕЗ оглядки на то, какой вид предела нам нужен. Нужен затем, чтобы
// уметь сказать владельцу правду по решению Р2: «система шлёт вид such-and-such, а в настройках
// ждём такой-то». Без этой дороги несовпадение вида было бы неотличимо от «события не пришло».
export function razborPredela(event) {
if (!event || event.type !== 'rate_limit_event') return null;
const info = event.rate_limit_info;
if (!info || typeof info !== 'object') return null;
// 🔴 Замер без вида предела — ничей: приписать его нашему пределу значило бы решать
// о деньгах по числу, про которое неизвестно, из какого оно окна.
const kind = typeof info.rateLimitType === 'string' && info.rateLimitType ? info.rateLimitType : null;
if (!kind) return null;
return {
kind,
status: typeof info.status === 'string' && info.status ? info.status : null,
utilization: Number.isFinite(info.utilization) ? info.utilization : null,
resets_at: Number.isFinite(info.resetsAt) ? info.resetsAt * 1000 : null,
};
}
// Тот же разбор, но отсеивающий чужой вид предела.
//
// 🔴 РЕШЕНИЕ Р1: вид предела — ОБЯЗАТЕЛЬНЫЙ довод, зашитого умолчания здесь нет и быть
// не должно. Прежнее умолчание НЕДЕЛЬНЫМ видом было ровно тем умолчанием, из-за которого
// ворота молчали: зовущий, забывший назвать вид, молча получал не тот запас. Теперь такой
// зовущий падает громко. Единственное место, где вид берётся, — настройка `rate_limit_type`
// (правило `vidPredela` в budget.mjs).
export function rateLimitFromEvent(event, { kind = null } = {}) {
if (!kind) {
throw new Error('Не сказано, какой вид предела читать из события (довод `kind`). Своего умолчания здесь нет намеренно: подставленный молча вид — это чужой запас, и решать по нему о деньгах владельца нельзя.');
}
const info = razborPredela(event);
if (!info || info.kind !== kind) return null;
return info;
}
export function readResult(stdout) {
const text = String(stdout ?? '').trim();
if (!text) return null;
const last = text.split('\n').filter(Boolean).pop();
try { return JSON.parse(last); } catch { return null; }
}
export function isRealSuccess(result) {
if (!result) return { ok: false, reason: 'Ответ работника не разобран — считаем, что запуск не удался.' };
if (result.is_error) {
return { ok: false, reason: `Работник вернул ошибку: ${result.result ?? result.api_error_status ?? 'причина не названа'}` };
}
if (typeof result.result !== 'string' || result.result.trim() === '') {
return { ok: false, reason: 'Работник сообщил об успехе, но вернул пустой ответ — успехом это не считается.' };
}
return { ok: true, reason: null };
}
// 🔴 `model` — обязательное поле, без значения по умолчанию. Голова работника приходит
// из настроек владельца и проносится сюда насквозь: run.mjs (задача 14) читает `worker_model`
// из night.config.json → кладёт в карточку работника → supervisor.mjs (задача 12) берёт
// из карточки → сюда. Ни одно звено цепочки своего слова не подставляет.
//
// ━━━ 🔴🔴 ЗДЕСЬ И ЕСТЬ РОЖДЕНИЕ РАБОТНИКА — ЕДИНСТВЕННОЕ В ЗАТЕЕ ━━━
// Замерено командой 02.08.2026: живых запусков головы в затее ровно ДВА — крошечный платный
// замер остатка (`budget.mjs`) и вот этот. Замер был сосчитан и ограничен числом 30,
// а рождение работника — расход в разы больший, на ЧАСЫ работы — не считалось ничем.
// То есть предел владельца сторожил копейки и не сторожил рубли. Теперь и здесь стоят
// ворота счёта (`rozhdenieSoSchyotom`), и они те же самые, что у замера: второго устройства
// для этого нарочно не заведено.
//
// 🔴 ПОРЯДОК ШАГОВ — НЕ СЛУЧАЙНЫЙ. Сперва идут все отказы, которые НЕ СТОЯТ ДЕНЕГ (программу
// не нашли, голову не сказали, имя базы не дали), и только потом счёт. Иначе описка
// в настройках владельца воровала бы у него рождение, которого не было.
export function startWorker({
cwd, prompt, settingsPath, sessionId, dbName, model, addDirs = [],
// 🔴 Настройки и счёт — доводы с НАСТОЯЩИМИ умолчаниями, то есть боевое поведение
// не изменилось ни на строку: надзиратель зовёт `startWorker` как звал, без новых слов.
// Подавать их своими нужно ровно одному — проверкам: писать в настоящее хозяйство
// `.night` им нельзя, оно одно на весь рабочий угол. Второй дороги к рождению эти два
// довода не открывают: счёт идёт ВСЕГДА, они лишь говорят, в какой тетради он ведётся.
cfg = readJson(configPath()) ?? {},
attemptsPath = spawnAttemptsPath(),
}) {
// Не голое имя и не голый путь: `claudeLaunch` отвечает ещё и на вопрос «а запустится ли
// найденное». Без него `.cmd` даёт EINVAL, и работник не рождается вовсе.
const launch = claudeLaunch();
const args = [...launch.prefixArgs, ...buildArgs({ prompt, settingsPath, sessionId, model, addDirs })];
// Окружение — урезанным списком, а не наследованием. Иначе работник получил бы все
// боевые ключи из переменных среды запускающего, а файловый замок этого не увидел бы.
// 🔴 Считается ЗАРАНЕЕ, до счёта: без имени своей базы `workerEnv` падает, и этот отказ
// денег не стоит — записывать его в счёт значило бы украсть у владельца рождение.
const env = workerEnv({ dbName });
// 🔴🔴 ВОРОТА СЧЁТА. Пропустят — родим, откажут — бросят человеческой строкой, которую
// надзиратель уже умеет ловить (`worker_spawn_failed`) и кладёт словами в утреннюю сводку.
return rozhdenieSoSchyotom({
cfg,
attemptsPath,
kto: sessionId ?? null,
rodit: () => {
// detached: false — работник обязан быть ребёнком НАДЗИРАТЕЛЯ. Отвяжи его,
// и надзиратель перестанет видеть и его поток, и его смерть, а значит перестанет
// исполнять пределы. Отвязанным живёт надзиратель, не работник.
const child = spawn(launch.command, args, {
cwd,
detached: false,
windowsHide: true,
shell: launch.shell,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
return child; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог
},
});
}