235 lines
18 KiB
JavaScript
235 lines
18 KiB
JavaScript
// Запуск работника. Работник — ребёнок НАДЗИРАТЕЛЯ (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; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог
|
||
},
|
||
});
|
||
}
|