197 lines
14 KiB
JavaScript
197 lines
14 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);
|
||
}
|
||
|
||
// Замер ПРИБОРА СИСТЕМЫ из потока. Проверено живьём: приходит событие `rate_limit_event`,
|
||
// внутри `rate_limit_info` с полями `rateLimitType`, `utilization`, `resetsAt`.
|
||
// Берём ТОЛЬКО `seven_day` — это и есть «недельный запас»; пятичасовой и прочие пределы
|
||
// про другое, и путать их нельзя. Ноль вместо неизвестного — враньё о запасе, поэтому null.
|
||
export function rateLimitFromEvent(event, { kind = 'seven_day' } = {}) {
|
||
if (!event || event.type !== 'rate_limit_event') return null;
|
||
const info = event.rate_limit_info;
|
||
if (!info || info.rateLimitType !== kind) return null;
|
||
if (!Number.isFinite(info.utilization)) return null;
|
||
return {
|
||
utilization: info.utilization,
|
||
resets_at: Number.isFinite(info.resetsAt) ? info.resetsAt * 1000 : null,
|
||
status: info.status ?? null,
|
||
kind,
|
||
};
|
||
}
|
||
|
||
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; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог
|
||
},
|
||
});
|
||
}
|