рождение работника проходит через ворота счёта: порядок шагов бережёт кошелёк владельца

This commit is contained in:
Дмитрий
2026-08-02 17:56:49 +03:00
parent a886f70813
commit b8eae0f6aa
2 changed files with 135 additions and 19 deletions
+53 -17
View File
@@ -8,7 +8,9 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { resolveBin, needsShell } from './paths.mjs';
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 } : {});
@@ -136,25 +138,59 @@ export function isRealSuccess(result) {
// из настроек владельца и проносится сюда насквозь: run.mjs (задача 14) читает `worker_model`
// из night.config.json → кладёт в карточку работника → supervisor.mjs (задача 12) берёт
// из карточки → сюда. Ни одно звено цепочки своего слова не подставляет.
export function startWorker({ cwd, prompt, settingsPath, sessionId, dbName, model, addDirs = [] }) {
//
// ━━━ 🔴🔴 ЗДЕСЬ И ЕСТЬ РОЖДЕНИЕ РАБОТНИКА — ЕДИНСТВЕННОЕ В ЗАТЕЕ ━━━
// Замерено командой 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 })];
// detached: false — работник обязан быть ребёнком НАДЗИРАТЕЛЯ. Отвяжи его,
// и надзиратель перестанет видеть и его поток, и его смерть, а значит перестанет
// исполнять пределы. Отвязанным живёт надзиратель, не работник.
const child = spawn(launch.command, args, {
cwd,
detached: false,
windowsHide: true,
shell: launch.shell,
// Окружение — урезанным списком, а не наследованием. Иначе работник получил бы все
// боевые ключи из переменных среды запускающего, а файловый замок этого не увидел бы.
env: workerEnv({ dbName }),
stdio: ['ignore', 'pipe', 'pipe'],
// Окружение — урезанным списком, а не наследованием. Иначе работник получил бы все
// боевые ключи из переменных среды запускающего, а файловый замок этого не увидел бы.
// 🔴 Считается ЗАРАНЕЕ, до счёта: без имени своей базы `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; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог
},
});
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
return child; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог
}
+82 -2
View File
@@ -1,15 +1,17 @@
import { describe, it, expect, vi } from 'vitest';
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 } from 'node:fs';
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 мс это краснело от соседей, а не
@@ -326,3 +328,81 @@ describe('имя своей базы доходит до ЖИВОГО проце
})).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);
});
});