From b8eae0f6aae5964d6f4fdff63ab63e65d7042a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 2 Aug 2026 17:56:49 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BD=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=D0=B2=D0=BE=D1=80=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=20=D1=81=D1=87=D1=91=D1=82=D0=B0:=20=D0=BF=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20=D1=88=D0=B0=D0=B3=D0=BE=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B6=D1=91=D1=82=20=D0=BA=D0=BE=D1=88=D0=B5?= =?UTF-8?q?=D0=BB=D1=91=D0=BA=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=86=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/night/spawn.mjs | 70 +++++++++++++++++++++++-------- tools/night/spawn.test.mjs | 84 +++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 19 deletions(-) diff --git a/tools/night/spawn.mjs b/tools/night/spawn.mjs index 3733a96b..f83b28cd 100644 --- a/tools/night/spawn.mjs +++ b/tools/night/spawn.mjs @@ -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; // сырой ребёнок: поток читает надзиратель, он же пишет замеры и итог } diff --git a/tools/night/spawn.test.mjs b/tools/night/spawn.test.mjs index 1b3c54d3..cf390d57 100644 --- a/tools/night/spawn.test.mjs +++ b/tools/night/spawn.test.mjs @@ -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); + }); +});