Files
portal/tools/night/run.mjs
T

749 lines
52 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.
// Оркестратор прогона: расписание пунктов по работникам, рабочие углы и запуск НАДЗИРАТЕЛЕЙ.
// Своей мудрости не заводит: ворота — кусок 2, углы — workspace.mjs, ночная жизнь —
// supervisor.mjs. Здесь только связка.
//
// 🔴 Порождаются НАДЗИРАТЕЛИ, по одному на работника, а не работники напрямую. Работника
// заводит уже надзиратель — он же исполняет пределы и переписывает итоговый статус.
// Учёта «по возвращении процесса» здесь нет и быть не может: родитель умирает вместе
// с сессией помощника.
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { createWorkspace, workerDbName } from './workspace.mjs';
// 🔴 `writeRunOutcome` ввозится в ту же строку, что и `spawnSupervisor`: второго ввоза
// из того же файла не заводим. Это ЕДИНСТВЕННОЕ место затеи, объявляющее прогон кончившимся
// (`run.json` → `finished` плюс итог в `last-run.json`); своего второго правила «прогон
// кончился» здесь нет и быть не должно — они разъехались бы молча.
import { spawnSupervisor, writeRunOutcome } from './supervisor.mjs';
import { buildWorkerPrompt, buildSmokePrompt } from './worker-prompt.mjs';
// 🔴 БЕДА 8. `MAX_WORKERS_DEFAULT` ввозится в ту же строку, что и `startGates`: число
// «сколько работников разом» живёт в ОДНОМ месте затеи — в воротах, — и здесь оно только
// берётся, а не заводится заново. Своя семёрка строкой в этом файле означала бы два ответа
// на один вопрос; замерено, что они уже разъезжались (см. шапку правила ниже).
import { startGates, MAX_WORKERS_DEFAULT } from './gates.mjs';
import { claimFiles, releaseClaims } from './file-claims.mjs';
import { logEvent } from './heartbeat.mjs';
// 🔴 `readJson` ввозится в ту же строку: кусок 6 читает карточку СТАРОГО работника, чтобы
// проставить ей отметки «продолжен» и «куда». Второго способа читать карточки не заводим.
import { writeJsonAtomic, appendLine, readJson } from './state.mjs';
import {
REPO_ROOT, runDir, workerDir, priyomkaDir, summaryLinesPath,
workspaceRootFor, workerSettingsPath, SMOKE_NOTES_REL,
} from './paths.mjs';
// 🔴🔴 БЕДА 8 — ПОТОЛОК СЕМИ ПРИМЕНЯЛСЯ ДВАЖДЫ И ПО-РАЗНОМУ (замерено счётом мест 02.08.2026).
//
// На вопрос «сколько работников разом можно» отвечали ДВА места, и отвечали не одинаково:
// · `gates.mjs` (`capacityFree`) брал число из НАСТРОЕК ВЛАДЕЛЬЦА (`max_workers_per_task`),
// запасное — `MAX_WORKERS_DEFAULT`, и при переборе ОТКАЗЫВАЛ ВСЛУХ;
// · этот файл держал СВОЮ семёрку строкой `export const MAX_WORKERS = 7`, настроек
// владельца не спрашивал вовсе и лишние пункты срезал МОЛЧА (`items.slice(0, 7)`).
//
// Разъезд живой: напиши владелец «десять» — ворота пропустили бы десять, а срезка оставила
// бы семь, и три пункта не взял бы никто без единой строки в сводке.
//
// 🔴 Теперь число тут НЕ ЗАВОДИТСЯ, а только берётся из ворот. Имя оставлено прежним:
// его знают проверки и оно читается как «сколько работников разом», — но за ним стоит
// ровно та же постоянная, что и у ворот, а не вторая такая же.
export const MAX_WORKERS = MAX_WORKERS_DEFAULT;
// Сколько работников разом можно ИМЕННО СЕЙЧАС. Слово владельца (`max_workers_per_task`,
// доезжает сюда полем `capacity.max_workers`) всегда главнее запасного числа.
// 🔴 Описку в этой настройке (не число, ноль, отрицательное) ловят ВОРОТА и отказывают
// раньше нас — второй такой проверки здесь нет намеренно: два правила на одну описку
// разошлись бы молча.
function potolokRabotnikov(input) {
const skazalVladelec = input?.capacity?.max_workers;
return Number.isFinite(skazalVladelec) ? skazalVladelec : MAX_WORKERS;
}
export function planRun(input) {
// 🔴 ВОРОТА ПЕРЕД ПРОГОНОМ — первой строкой, до всего остального. Место размечено куском 1,
// поля входа (`budget`, `owner`, `disk`, `spec`, `papers`, `planSeal`, `previousWorkers`,
// `capacity`, `newTaskFiles`, `runningTasksFiles`) приходят сюда с теми же именами.
//
// 🔴 Первые непройденные называют себя, но печатаются ВСЕ: починив одно и упёршись
// во второе, владелец потерял бы ночь.
const gates = startGates(input);
if (!gates.ok) {
return {
ok: false,
reason: gates.reason,
run_id: null,
base_branch: input.base_branch ?? null,
run_share_limit: input.budget?.run_share_limit ?? null,
workers: [],
// 🔴 Берём `gates.failed` — это уже список пар `{ gate, reason }`. `all_reasons`
// не трогаем вовсе: там строки, в которых имя ворот приклеено спереди, и владелец
// получил бы `[object Object]: запас: Запас кончился.` вместо причины.
failed_gates: (gates.failed ?? []).map(f => ({
gate: f?.gate ?? 'ворота',
reason: f?.reason ?? 'Причина не названа.',
})),
};
}
// Точка ветвления рабочих углов — ВЕТКА ЗАТЕИ (`base_branch` из настроек), а не главная:
// от главной в углу нет ни модулей затеи, ни поручения, и первый же коммит работника
// лёг бы мимо всего дела.
const baseBranch = input.base_branch;
if (!baseBranch) {
return {
ok: false,
reason: 'Не названа точка ветвления рабочих углов (base_branch в настройках) — запуска нет.',
run_id: null,
base_branch: null,
run_share_limit: input.budget?.run_share_limit ?? null,
workers: [],
failed_gates: [{ gate: 'точка ветвления рабочих углов', reason: 'не названа' }],
};
}
// 🪤 Во имя прогона входит ВРЕМЯ, а не только дата. С одной датой два прогона за сутки
// получили бы один номер — и второй затёр бы каталог, ветку и итог первого молча.
//
// 🔴🔴 ПОПРАВЛЕНО ПО ЖИВОЙ БЕДЕ (найдена воротами прогоном, а не чтением). Примечание выше
// обещало «время», а обрезка стояла ДО МИНУТЫ: `slice(0, 16)`. Две разные задачи, заведённые
// в одну минуту, получали ОДИН номер — карточка второй затирала первую, строки сводки обоих
// смешивались в одном каталоге, а ветки сталкивались живьём («ветка night/... уже
// существует»). Ночью две задачи разом — это расписание, а не редкость.
//
// Лечение из двух половин, и обе нужны:
// — секунды (`slice(0, 19)`): их хватает почти всегда и они ЧИТАЮТСЯ человеком;
// — случайный хвост: две задачи заводит один и тот же человек в одну и ту же секунду,
// а «почти всегда» — это ровно тот сорт защиты, который ломается в живую ночь.
// 🔴 Хвост берётся из `randomUUID`, который здесь УЖЕ ввезён (им же метится сессия
// работника ниже). Второго прибора случайности ради восьми знаков не заводим.
//
// 🪤 Читаемость — не украшение: этот номер владелец видит в сводке и по нему же ищет
// каталог прогона. Поэтому день и время стоят СПЕРЕДИ, а хвост — сзади.
// 🪤 Знаки имени проверены на обоих, кто его носит: `night/<номер>/<работник>` (git примет
// цифры, буквы и дефис) и `night_<номер>_<работник>` (имя базы — `assertOwnDbName` в
// workspace.mjs пропускает только строчные, цифры и подчёркивание, а PostgreSQL — 63 знака;
// тут выходит 38).
const stamp = new Date().toISOString().slice(0, 19).replace('T', '-').replace(/:/g, '');
const runId = input.run_id ?? `r-${stamp}-${randomUUID().slice(0, 8)}`;
// 🔴 БЕДА 8. Потолок берётся ОДИН раз и из одного места — из слова владельца, а если
// он ничего не сказал, из запасного числа ворот. Своей семёрки здесь больше нет.
const potolok = potolokRabotnikov(input);
const vsePunkty = input.items ?? [];
const items = vsePunkty.slice(0, potolok);
// 🔴 И ГЛАВНОЕ: срезанное НАЗЫВАЕТСЯ. Раньше эти пункты исчезали молча — владелец
// утром читал сводку и не знал, что часть работы не бралась вовсе.
const neVzyato = Math.max(0, vsePunkty.length - items.length);
if (!items.length) {
return {
ok: false,
reason: 'В плане не нашлось ни одного пункта для работы — заводить некого.',
run_id: runId,
base_branch: baseBranch,
run_share_limit: input.budget?.run_share_limit ?? null,
workers: [],
failed_gates: [{ gate: 'есть что делать', reason: 'пунктов ноль' }],
};
}
// 🪤 Денежных полей у работника нет ни одного. Прежде каждому из семи выдавался предел
// ВСЕГО прогона в долларах — семеро по двадцать — это весь недельный запас за одну ночь.
// Своего счёта в деньгах у затеи нет: доли недельного запаса держат надзиратели (кусок 2).
const workers = items.map((item, i) => {
const workerId = `w-${i + 1}`;
return {
worker_id: workerId,
item,
db_name: workerDbName(runId, workerId),
branch: `night/${runId}/${workerId}`,
base_branch: baseBranch,
};
});
return {
ok: true,
reason: null,
run_id: runId,
base_branch: baseBranch,
run_share_limit: input.budget?.run_share_limit ?? null,
workers,
failed_gates: [],
// 🔴 БЕДА 8. Сколько пунктов не взято сразу — числом (для проверок и сводки) и словами
// (для владельца). Ноль пунктов — значит `null`, а не пустая строка: лишняя пустая
// строка в сводке каждую ночь учит владельца сводку не читать.
ne_vzyato: neVzyato,
ne_vzyato_slovami: slovaProNevzyatoe(vsePunkty.length, items.length, neVzyato),
};
}
// Что владелец прочтёт про пункты, которые сразу никому не достались. 🔴 Владелец
// НЕ ПРОГРАММИСТ: ни одного имени файла, настройки или прибора здесь быть не должно —
// только про работу и про то, что делать.
//
// 🔴 И это не «пропало»: пункты сверх потолка ложатся в очередь прогона целиком
// (`startAll` кладёт в карточку ВЕСЬ план), и освободившийся работник берёт следующий.
// Обещание проверено по живому ходу надзирателя, а не написано для красоты.
export function slovaProNevzyatoe(vsego, vzyato, neVzyato) {
if (!neVzyato) return null;
return `Пунктов в плане: ${vsego}, а работников разом можно ${vzyato}. Сразу не взято пунктов: ${neVzyato}. `
+ 'Они не потеряны — их разберут те работники, кто освободится за ночь. Если ждать нельзя, '
+ 'поднимите в настройках, сколько работников пускать разом, либо разнесите работу на два прогона.';
}
// `destroyWorkspace` здесь НЕ зовётся: угол и база убираются не после смерти работника,
// а после того, как задача прошла приёмку владельца (кусок 7), либо по сроку — утренней
// уборкой в `cli.mjs mail` (задача 15).
export function startAll({
runId,
workers,
// 🔴 ВСЕ пункты плана, а не только пункты заведённых работников. Это ОЧЕРЕДЬ: из неё
// блок 5а берёт работнику следующий пункт. Клади сюда пункты одних лишь семерых —
// пункты с восьмого не достались бы никому, и ночь кончилась бы на середине плана
// при зелёных проверках. Умолчание честное — пункты работников, как было до куска 4.
items = null,
planPath,
baseBranch,
workerModel,
taskId = null,
testCommandFor = () => 'npm run test:tools',
promptOverride = null, // нужен пробному долгому прогону: у него дело другое и безобидное
// Два необязательных аргумента сверх договора о стыках — названы вслух в шапке задачи.
// Без них порядок «сначала карточка прогона, потом надзиратели» нечем проверить.
createWorkspaceFn = createWorkspace,
spawnSupervisorFn = spawnSupervisor,
// 🆕 Третья точка впрыска, того же рода и по той же причине. Живой ход берёт `writeRunOutcome`
// надзирателя — ЕДИНСТВЕННОЕ место затеи, которое объявляет прогон кончившимся; проверкам
// нужно увести `last-run.json` во временный каталог, потому что настоящий лежит в общем
// `.night/` и трогать его проверкой нельзя.
writeRunOutcomeFn = writeRunOutcome,
}) {
if (!baseBranch) {
throw new Error('Не названа точка ветвления рабочих углов (base_branch) — заводить углы нельзя: от главной ветки в них нет ничего из затеи.');
}
// 🔴 Голова работника — из настроек владельца (`worker_model`), своего слова здесь нет.
// Падаем ЗДЕСЬ, до заведения углов: узнать об этом после десяти минут `composer install`
// в семи углах — значит выбросить всю работу и разбудить владельца впустую.
if (!workerModel) {
throw new Error('Не сказано, какой головой запускать работников: в night.config.json нет worker_model (образец — night.config.example.json).');
}
const started = [];
// 🔴 СОСТАВ ПРОГОНА ОБЪЯВЛЯЕТСЯ ДО ПЕРВОГО НАДЗИРАТЕЛЯ. Заведение угла идёт минутами,
// и всё это время первый надзиратель уже живёт. Не объяви состав заранее — первый же
// погасший увидит в каталоге одну карточку, решит, что он последний, и закроет весь
// прогон итогом из одного работника. Ниже, после цикла, карточка дописывается тем,
// что стало известно только по ходу (кто вправду завёлся).
const runCard = {
run_id: runId,
status: 'running',
started_at: Date.now(),
plan_path: planPath,
task_id: taskId ?? runId,
base_branch: baseBranch,
// 🔴 Пункт несёт ещё и проверки: без них закрыть его нечем (проверка 39), и пометку
// «длинный»: по ней проверяется план (проверка 41). Пунктов здесь ВЕСЬ план,
// а не только семерых: это очередь, и из неё берут следующий.
items: (items ?? workers.map(w => w.item)).filter(Boolean).map(i => ({
id: i?.id,
title: i?.title ?? null,
tests: i?.tests ?? [],
files: i?.files ?? [],
long: Boolean(i?.long),
})),
workers: workers.map(w => w.worker_id),
};
mkdirSync(runDir(runId), { recursive: true });
writeJsonAtomic(join(runDir(runId), 'run.json'), runCard);
// 🔴🔴 БЕДА 8, ВТОРАЯ ПОЛОВИНА: «А МОЙ ОТВЕТ КТО-НИБУДЬ СЛУШАЕТ?». Число непрочитанных
// пунктов `planRun` возвращает полем — но печатает ли его кто-нибудь, от `planRun`
// не зависит вовсе, и владелец мог бы снова не узнать ничего. Поэтому строка кладётся
// ещё и В СВОДКУ, которую владелец читает утром, — то есть в тот канал, который до него
// доходит наверняка.
//
// 🔴 Слова берутся ТОЙ ЖЕ функцией, что и у `planRun`. Второй текст рядом разошёлся бы
// с первым — ровно та беда, которую этот кусок и чинит.
const punktyVsego = (items ?? workers.map(w => w.item)).filter(Boolean).length;
const neVzyatoSrazu = Math.max(0, punktyVsego - workers.length);
const slova = slovaProNevzyatoe(punktyVsego, workers.length, neVzyatoSrazu);
if (slova) {
appendLine(summaryLinesPath(runId), { at: Date.now(), worker_id: null, line: slova });
logEvent(runDir(runId), { kind: 'items_over_cap', ne_vzyato: neVzyatoSrazu });
}
// 🔴 ЗАМКИ РАЗДАЮТСЯ ДО ПЕРВОГО НАДЗИРАТЕЛЯ И ВСЕ РАЗОМ. Заведение угла — это `composer
// install` и `npm ci`, то есть минуты. Занимай мы файлы по одному вместе с углами,
// первый работник уже правил бы то, за что седьмой ещё не расписался.
// Держит замок надзиратель (задача 5), раздаёт — вот это место. Один без другого мёртв.
// 🔴 Пункт, для которого в плане не записано ни одной проверки, работнику НЕ выдаётся
// (проверка 39). Закрыть его всё равно будет нечем, а правило куска 1 бросает
// на таком пункте ошибку прямо в поручении — и убило бы ВЕСЬ прогон вместе с шестью
// исправными работниками. Это правится в плане, а не ночью.
const bezProverok = new Set(
workers.filter(w => !(w.item?.tests ?? []).length).map(w => w.worker_id),
);
const zanyatieNeVyshlo = new Map();
for (const w of workers) {
// Файлы пункта без проверок не занимаем: работника по нему не будет, а замок держал бы
// файл всю ночь за того, кого нет, и пункт не смог бы взять никто.
if (bezProverok.has(w.worker_id)) continue;
const fajlyPunkta = w.item?.files ?? [];
// 🔴🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух. План звал `claimFiles` на любом пункте и всякий
// отказ считал столкновением. Но `claimFiles` отказывает ДВУМЯ разными отказами:
// «этот файл занят другим» и «занимать нечего — списка файлов нет вовсе». Свалив их
// в один, мы гасили бы работника пункта БЕЗ файлов со словами «два пункта плана назвали
// один и тот же файл» — то есть врали бы владельцу причиной, которой не было, и он
// чинил бы в плане несуществующее пересечение. Живьём такой пункт до сюда не доходит
// (девятые ворота отказывают пустому списку файлов раньше), но три проверки куска 1
// заводят работников именно так — и они правы: занимать нечего это не столкновение.
if (!fajlyPunkta.length) {
appendLine(summaryLinesPath(runId), {
at: Date.now(),
worker_id: w.worker_id,
line: `${w.worker_id}: у его пункта не назван ни один файл — запереть пункт нечем, замок за этим работником не держит ничего. Это правится в плане: разрежьте работу по файлам.`,
});
continue;
}
const zayavka = claimFiles(runDir(runId), w.worker_id, fajlyPunkta);
if (!zayavka.ok) zanyatieNeVyshlo.set(w.worker_id, zayavka.reason);
}
// 🔴 Кого не пустил замок — того в прогоне НЕТ, и в карточке прогона его быть не должно.
// Поле `workers` — это состав, по которому задача 7 решает, все ли исполнили общую команду
// «стоп всё», и по которому считается «погас последний». Оставь мы там работника, которого
// никогда не заводили, — отметку «исполнил» ставить некому: общий файл команды не уберётся
// никогда, и следующая ночь начнётся с чужого «стоп всё». Ровно та беда, от которой
// задача 7 предостерегает вслух.
if (zanyatieNeVyshlo.size || bezProverok.size) {
runCard.workers = (runCard.workers ?? [])
.filter(id => !zanyatieNeVyshlo.has(id) && !bezProverok.has(id));
writeJsonAtomic(join(runDir(runId), 'run.json'), runCard);
}
// Кто какой файл занял — чтобы каждому сказать поимённо, куда ему нельзя.
// 🟡 СВЕРХ ПЛАНА, называю вслух: отсеянные по «нет проверок» сюда НЕ идут. Этот список
// служит ровно одному делу — сказать живому работнику поимённо, куда ему нельзя. Оставь
// мы их здесь, работник прочёл бы в поручении «файл app/C.php занят работником w-3»,
// которого не существует, и не тронул бы файл, свободный всю ночь. Замок их уже
// не держит (выше), и это место обязано говорить то же самое, а не своё.
const allFiles = new Map();
for (const w of workers) {
if (bezProverok.has(w.worker_id)) continue;
for (const f of w.item?.files ?? []) allFiles.set(f, w.worker_id);
}
for (const w of workers) {
// 🔴 Пункт без единой проверки (проверка 39): работник по нему не заводится вовсе,
// а владельцу про это сказано строкой сводки — молчаливый пропуск выглядел бы как
// «пункт сделан».
if (bezProverok.has(w.worker_id)) {
const failDir = workerDir(runId, w.worker_id);
mkdirSync(failDir, { recursive: true });
const reason = `Пункту ${w.item?.id ?? '?'} в плане не записано ни одной проверки — закрыть его будет нечем, и работник по нему не заводится. Это правится в плане, а не ночью.`;
writeJsonAtomic(join(failDir, 'worker.json'), {
worker_id: w.worker_id,
run_id: runId,
item: w.item,
branch: w.branch,
base_branch: w.base_branch ?? baseBranch,
status: 'extinguished',
finish_reason: reason,
started_at: Date.now(),
finished_at: Date.now(),
// Та же пара, что у прочих карточек — схема одна на все пути записи.
resumed_at: null,
resumed_into: null,
});
logEvent(runDir(runId), { kind: 'item_without_tests', worker: w.worker_id }, Date.now());
appendLine(summaryLinesPath(runId), {
at: Date.now(),
worker_id: w.worker_id,
line: `${w.worker_id}: не начал работу — ${reason}`,
});
continue;
}
// Файл его пункта уже занят другим — работать ему нечем. Заводить угол на десять минут
// ради того, чтобы он упёрся в замок с первой правки, — это выброшенная ночь.
// 🔴 Такое бывает от плана, а не от работника: два пункта назвали один и тот же файл.
if (zanyatieNeVyshlo.has(w.worker_id)) {
const failDir = workerDir(runId, w.worker_id);
mkdirSync(failDir, { recursive: true });
const reason = `Работать нечем: ${zanyatieNeVyshlo.get(w.worker_id)} Два пункта плана назвали один и тот же файл — это правится в плане, а не ночью.`;
writeJsonAtomic(join(failDir, 'worker.json'), {
worker_id: w.worker_id,
run_id: runId,
item: w.item,
branch: w.branch,
base_branch: w.base_branch ?? baseBranch,
status: 'extinguished',
finish_reason: reason,
started_at: Date.now(),
finished_at: Date.now(),
// Та же пара, что у обеих карточек ниже — схема одна на все три пути записи.
resumed_at: null,
resumed_into: null,
});
logEvent(runDir(runId), { kind: 'claim_failed', worker: w.worker_id }, Date.now());
appendLine(summaryLinesPath(runId), {
at: Date.now(),
worker_id: w.worker_id,
line: `${w.worker_id}: не начал работу — ${reason}`,
});
continue;
}
// Где живёт угол, знает paths.mjs: он лежит РЯДОМ с хозяйством, а не внутри —
// git worktree внутрь собственного хранилища не заводится.
const workspaceRoot = workspaceRootFor(runId, w.worker_id);
// 🔴 Угол одного работника не завёлся — остальные всё равно заводятся, а провалившемуся
// пишется карточка с итоговым словом и причиной. Брось мы здесь всё, как раньше:
// прогон остался бы навсегда «идущим» (некому объявить себя последним), уже заведённые
// углы висели бы до принудительной уборки, а владелец не узнал бы ни строчки о причине.
let ws;
try {
// Точка ветвления передаётся ЯВНО: без неё git взял бы текущую ветку хозяйства,
// а это может быть что угодно, вплоть до главной, где затеи нет вовсе.
ws = createWorkspaceFn({
repoRoot: REPO_ROOT,
runId,
workerId: w.worker_id,
branch: w.branch,
workspaceRoot,
baseBranch: w.base_branch ?? baseBranch,
dbName: w.db_name,
});
} catch (err) {
const failDir = workerDir(runId, w.worker_id);
mkdirSync(failDir, { recursive: true });
const reason = `Рабочий угол не завёлся: ${err?.message ?? err}`;
writeJsonAtomic(join(failDir, 'worker.json'), {
worker_id: w.worker_id,
run_id: runId,
item: w.item,
branch: w.branch,
base_branch: w.base_branch ?? baseBranch,
status: 'extinguished',
finish_reason: reason,
started_at: Date.now(),
finished_at: Date.now(),
// Та же пара, что у живой карточки ниже — схема одна на оба пути записи.
resumed_at: null,
resumed_into: null,
});
// 🔴 Угла нет — работника не будет, а замок за ним остался бы держать файл всю ночь,
// и пункт не смог бы взять никто. Надзиратель освободить его не может: надзирателя
// за этого работника не подняли.
releaseClaims(runDir(runId), w.worker_id);
logEvent(runDir(runId), { kind: 'workspace_failed', worker: w.worker_id, error: String(err?.message ?? err) });
appendLine(summaryLinesPath(runId), {
at: Date.now(),
worker_id: w.worker_id,
line: `${w.worker_id}: не начал работу — ${reason}`,
});
continue;
}
// 🔴 Урезанные настройки запуска работника — это и есть замки на физике (проверки 47–49).
// Кладёт их в угол сам `createWorkspace` (задача 6, `writeWorkerSettings`) — здесь только
// берём путь. Класть второй раз значило бы завести второе место, знающее замки по-своему.
const settingsPath = ws.settingsPath ?? workerSettingsPath(workspaceRoot);
const otherWorkersFiles = [...allFiles.entries()]
.filter(([, owner]) => owner !== w.worker_id)
.map(([file]) => file);
// 🔴 Считаем ОДИН раз и кладём в ОБА места — в поручение работнику и в его карточку.
// Два зова подряд разошлись бы молча: работник читал бы одну команду, а надзиратель
// подавал бы замку другую.
const komandaProverok = testCommandFor(w);
const prompt = promptOverride
? promptOverride(w)
: buildWorkerPrompt({
workerId: w.worker_id,
runId,
planPath,
item: w.item,
branch: w.branch,
otherWorkersFiles,
testCommand: komandaProverok,
});
// Карточка работника пишется ДО старта: из неё надзиратель узнаёт, кого и чем заводить.
// Менять её потом — дело надзирателя, не наше.
const wdir = workerDir(runId, w.worker_id);
mkdirSync(wdir, { recursive: true });
const sessionId = randomUUID();
writeJsonAtomic(join(wdir, 'worker.json'), {
worker_id: w.worker_id,
run_id: runId,
item: w.item,
db_name: ws.dbName,
branch: w.branch,
base_branch: w.base_branch ?? baseBranch,
workspace: workspaceRoot,
settings_path: settingsPath,
session_id: sessionId,
prompt,
// 🔴 Отсюда её берёт надзиратель и подаёт замку (задача 5). Не положи — замок стоит,
// а меряет пустоту: каждый пункт получит «замерить не удалось», и ни один
// не засчитается ни одной ночью, при зелёных проверках везде.
test_command: komandaProverok,
// Голова работника — та, что стоит у владельца в настройках. Она ложится в карточку,
// и надзиратель запускает работника ровно ею; своего слова тут нет ни у кого.
model: workerModel,
status: 'working',
started_at: Date.now(),
// 🆕 Долг перед кусками 6 и 7 (позиция 10 списка долгов плана куска 7). Заводит поля
// ЗДЕСЬ кусок 1, а читает и пишет их при подъёме кусок 6: `resumed_at` (когда работника
// подняли по кнопке «продолжить») и `resumed_into` (номер прогона, куда подняли).
// Без них второе нажатие «продолжить» не отличило бы уже поднятого работника от свежего
// и подняло бы ВТОРОГО живого работника в тот же угол и ту же базу — поверх первого.
resumed_at: null,
resumed_into: null,
});
const sup = spawnSupervisorFn({ runId, workerId: w.worker_id });
logEvent(runDir(runId), { kind: 'supervisor_start', worker: w.worker_id, supervisor_pid: sup.pid });
started.push({
...w,
supervisor_pid: sup.pid,
session_id: sessionId,
workspace: workspaceRoot,
db_name: ws.dbName,
});
}
// 🔴 `run.json` — не украшение: из него надзиратель берёт СОСТАВ прогона (кусок 3 — кого
// касается общая команда «стоп всё») и ПУНКТЫ (кусок 4 — есть ли что брать дальше),
// а последний погасший — задачу, для которой заводить приёмщика (кусок 7).
// Состав уже объявлен ДО цикла и здесь НЕ сужается: он перечисляет всех, кому положена
// карточка, включая тех, у кого угол не завёлся, — иначе прогон некому было бы закрыть.
// Дописываем только то, что стало известно по ходу.
writeJsonAtomic(join(runDir(runId), 'run.json'), {
...runCard,
all_started_at: Date.now(),
started_workers: started.map(s => s.worker_id),
});
// 🔴🔴 ПРОГОН, В КОТОРОМ НЕ ЗАВЁЛСЯ НИ ОДИН РАБОТНИК, ЗАКРЫВАЕТСЯ ЗДЕСЬ ЖЕ И СРАЗУ.
// Найдено воротами живьём: «Прогон заведён. Работников: 0» — и запись «идёт», навсегда
// занимающая одно из двух мест в потолке задач. Признак «идёт» ставится ВЫШЕ, до заведения
// углов (иначе первый погасший закрыл бы прогон итогом из одного работника), а признак
// «кончился» ставит надзиратель ПОСЛЕДНЕГО погасшего: `amILast` отвечает «нет», пока
// карточек ноль, и при нуле работников ставить его просто некому. Два таких призрака
// запирают хозяйство наглухо, и разбирать это приходится руками.
//
// 🔴 Закрывает прогон ТО ЖЕ место, что закрывает его после последнего погасшего
// (`writeRunOutcome`): оно пишет и `run.json` → `finished`, и итог в `last-run.json`,
// из которого живёт утренняя сводка. Своего второго правила «прогон кончился» здесь нет.
if (!started.length) {
const reason = 'Прогон кончился, не начавшись: не завёлся НИ ОДИН работник. Причины — в карточках работников (поле finish_reason). Место в потолке задач этот прогон не держит: он закрыт сразу.';
logEvent(runDir(runId), { kind: 'run_empty', workers: workers.length });
appendLine(summaryLinesPath(runId), { at: Date.now(), worker_id: null, line: reason });
writeRunOutcomeFn({ runDirPath: runDir(runId), runId });
}
return started;
}
// ═══════════════════════════════════════════════════════════════════════════════
// КУСОК 6 — ПОДЪЁМ ПРОДОЛЖЕНИЯ. Двенадцатый шаг кнопки «продолжить».
//
// 🔴 ЗАЧЕМ ЭТО ВООБЩЕ. Датчик зовущих (`schyot-zovushchih.mjs`) 03.08.2026 показал: у СЕМИ
// из семи правил куска 6 зовущих НОЛЬ. Всё построено, проверено и зелено — и не подключено
// ни к чему. Правило метода 2: защита, которую никто не зовёт, не бывает проверенной.
//
// 🔴 ЧЕМ ЭТО НЕ ЯВЛЯЕТСЯ: это НЕ `startAll`. Две дороги подъёма в затее — названное
// столкновение С-14, и они не сводятся: у `startAll` одна точка ветвления на всех и заведение
// углов, у продолжения — свой угол на каждого и заведения угла нет вовсе.
//
// 🟡 ОТСТУПЛЕНИЕ ОТ ПОДПИСИ ДОГОВОРА, НАЗЫВАЮ ВСЛУХ. В разделе 7 плана подпись записана
// без довода `runId`. Взять его здесь самому нельзя: решение 10 (шаг 5) требует кормить
// `budgetState(..., { runId, workerId, workersCount: 1 })` НОВЫМ номером прогона ещё
// до заведения карточки — то есть номер рождается в точке входа, на шаге 5, за семь шагов
// до подъёма. Выдай я его тут второй раз — доля прогона мерилась бы по номеру, которого
// в хозяйстве нет, и предел прогона не сработал бы НИ РАЗУ, оставаясь зелёным.
// Поэтому номер приходит доводом, и он обязателен.
export function zavestiProdolzhenie({
runId,
worker,
planPath,
taskId = null,
workerModel,
testCommandFor = () => 'npm run test:tools',
spawnSupervisorFn = spawnSupervisor,
// Часы доводом — ради проверок «съеденного времени ноль»; живой зовущий их не подаёт.
now = Date.now,
}) {
if (!runId) {
throw new Error('Продолжение поднимают без номера нового прогона (довод `runId`). Номер выдаётся раньше — на шаге замера денег, — и подать его обязан зовущий: выдумай мы свой, доля прогона мерилась бы по чужому номеру.');
}
if (!worker?.worker_id || !worker?.run_id) {
throw new Error('Продолжение поднимают без карточки продолжаемого работника (довод `worker`). Из неё берутся угол, ветка, точка ветвления и база — в `last-run.json` этого нет.');
}
// 🔴 ГОЛОВА — ДО ВСЕГО. Это единственный отказ, который обязан случиться ПЕРЕД подъёмом
// надзирателя: надзиратель рождает живого работника, то есть тратит деньги владельца.
// Правило то же, что у `startAll`, и слово в слово та же строка с именем настройки.
if (!workerModel) {
throw new Error('Не сказано, какой головой запускать работников: в night.config.json нет worker_model (образец — night.config.example.json).');
}
const t = now();
// 🔴 Угол НЕ заводится: он уже есть, и в нём лежит всё наработанное (решение 1).
// `createWorkspace` отсюда не зовётся ни разу — на это стоит сторож по коду.
const workspaceRoot = worker.workspace;
const item = worker.item ?? null;
// Карточка НОВОГО прогона — до подъёма надзирателя, как и у `startAll`: иначе первый же
// погасший не нашёл бы состава и закрыл бы прогон итогом из пустоты.
const runCard = {
run_id: runId,
status: 'running',
started_at: t,
plan_path: planPath,
task_id: taskId ?? runId,
base_branch: worker.base_branch ?? null,
// 🔴 Чем этот прогон отличается от обычного: он ПРОДОЛЖАЕТ. Без этих двух полей
// назавтра никто не восстановит, откуда взялся угол с чужой веткой (П-13).
resumed_from: worker.run_id,
resumed_worker: worker.worker_id,
items: item ? [{
id: item.id,
title: item.title ?? null,
tests: item.tests ?? [],
files: item.files ?? [],
long: Boolean(item.long),
}] : [],
workers: [worker.worker_id],
};
mkdirSync(runDir(runId), { recursive: true });
writeJsonAtomic(join(runDir(runId), 'run.json'), runCard);
const komandaProverok = testCommandFor({ ...worker, item });
const sessionId = randomUUID();
const wdir = workerDir(runId, worker.worker_id);
mkdirSync(wdir, { recursive: true });
writeJsonAtomic(join(wdir, 'worker.json'), {
worker_id: worker.worker_id,
run_id: runId,
item,
// ━━━ ВЗЯТО У СТАРОГО: угол, ветка, точка ветвления, база и настройки ━━━
// Продолжение идёт В ТОМ ЖЕ УГЛУ (решение 1). Заведи мы новое — работник потерял бы
// всё наработанное, а `composer install` съел бы десять минут ночи.
workspace: workspaceRoot,
branch: worker.branch,
base_branch: worker.base_branch ?? null,
db_name: worker.db_name,
settings_path: worker.settings_path,
// ━━━ СВОЁ, НОВОЕ ━━━
// 🔴 Сессия новая: та же вернула бы работника в оборванный вчерашний разговор,
// из которого его и выбросило.
session_id: sessionId,
test_command: komandaProverok,
// Голова — из настроек владельца, а НЕ вчерашняя из старой карточки: настройку могли
// поменять между ночами, и карточка помнит вчерашнее слово.
model: workerModel,
status: 'working',
// 🔴🔴 УЧЁТЧИК ОТРЕЗКОВ ПУСТ, И ЭТО РЕШЕНИЕ 6, А НЕ НЕДОСМОТР. Продолжение — НОВЫЙ
// прогон: восемь часов идут заново, вчерашние отрезки не переносятся ничем (П-25, П-26).
// 🪤 Перенеси их — правило `serviceWaitHit` этого НЕ заметит: перенесённые легли бы
// ДО идущего, а оно смотрит на последний. Ловится только счётом, потому сторож и стоит
// на самой карточке.
spans: [],
started_at: t,
// Этот работник сам ещё никем не продолжен.
resumed_at: null,
resumed_into: null,
});
// 🔴 ОТМЕТКИ В СТАРУЮ КАРТОЧКУ — ДО подъёма. Без них второе нажатие «продолжить»
// не отличило бы уже поднятого работника от свежего и подняло бы ВТОРОГО живого
// работника в тот же угол и ту же базу, поверх первого.
const staryjPut = join(workerDir(worker.run_id, worker.worker_id), 'worker.json');
const staraya = readJson(staryjPut);
if (staraya) {
writeJsonAtomic(staryjPut, { ...staraya, resumed_at: t, resumed_into: runId });
}
let sup;
try {
sup = spawnSupervisorFn({ runId, workerId: worker.worker_id });
} catch (err) {
// 🔴🔴 КУСОК ПРО «ПЕРЕЖИТЬ ПАДЕНИЕ» ОБЯЗАН ПЕРЕЖИТЬ И СВОЁ (П-45). Упади мы здесь молча —
// прогон навсегда числился бы идущим: закрыть его некому, последнего погасшего не будет,
// потому что не было и первого. Он держал бы место в потолке задач, и следующее нажатие
// кнопки упиралось бы в прогон-призрак.
const reason = `Надзиратель не поднялся: ${err?.message ?? err}`;
writeJsonAtomic(join(runDir(runId), 'run.json'), {
...runCard,
status: 'finished',
finished_at: now(),
finish_reason: reason,
});
logEvent(runDir(runId), { kind: 'resume_supervisor_failed', worker: worker.worker_id, error: String(err?.message ?? err) });
appendLine(summaryLinesPath(runId), { at: now(), worker_id: worker.worker_id, line: `${worker.worker_id}: продолжение не началось — ${reason}` });
return { ok: false, reason, run_id: runId, worker_id: worker.worker_id, supervisor_pid: null };
}
logEvent(runDir(runId), { kind: 'resume_start', worker: worker.worker_id, supervisor_pid: sup?.pid ?? null, resumed_from: worker.run_id });
return { ok: true, reason: null, run_id: runId, worker_id: worker.worker_id, supervisor_pid: sup?.pid ?? null };
}
export async function smokeLongRun({ cfg, minutes, runId = 'smoke' }) {
// Пробный долгий прогон: один работник, безобидное дело, много времени.
// Нужен ровно для проверки 1 — что движение есть и через три часа после того,
// как окно помощника закрыли.
//
// 🔴 Имя прогона можно назвать своё, и это не украшение. С намертво зашитым `smoke`
// вторая попытка невозможна: ветка `night/smoke/w-1` уже есть, и `git worktree add -b`
// на неё не встанет, а каталог прошлого прогона трогать нельзя — из него живёт сводка.
// А вторая попытка нужна заведомо: короткая проба перед ночью и повтор, если работник
// кончится раньше срока.
//
// 🔴 Файл следа лежит в ХРАНИЛИЩЕ (docs/superpowers/priyomka/), а НЕ в .night/:
// .night/ закрыт от хранилища, работник не смог бы сделать в нём ни одного коммита,
// и главная проверка 1 не доказалась бы ничем.
mkdirSync(priyomkaDir(), { recursive: true });
const worker = {
worker_id: 'w-1',
item: {
id: 'smoke',
title: 'Пробный долгий прогон — доказательство отдельной жизни',
files: [SMOKE_NOTES_REL],
tests: [SMOKE_NOTES_REL],
},
branch: `night/${runId}/w-1`,
base_branch: cfg.base_branch,
db_name: workerDbName(runId, 'w-1'),
};
const [started] = startAll({
runId,
workers: [worker],
planPath: 'docs/superpowers/plans/2026-07-31-kusok1-rabotnik-zhivyot-i-pismo.md',
baseBranch: cfg.base_branch,
// Голова работника — из настроек владельца. Здесь она только передаётся дальше.
workerModel: cfg.worker_model,
taskId: 'smoke',
promptOverride: () => buildSmokePrompt({ minutes, notesPath: SMOKE_NOTES_REL, branch: worker.branch }),
});
// 🔴 Угол мог не завестись: `startAll` в этом случае НЕ бросает — он пишет провалившемуся
// карточку с причиной и идёт дальше, а список заведённых возвращает пустым. Не проверь
// этого — здесь вылетело бы «cannot read worker_id of undefined», и владелец три часа
// спустя нашёл бы непонятную поломку вместо внятной жалобы.
if (!started) {
throw new Error(
`Рабочий угол пробного долгого прогона не завёлся — запускать нечего. Причина записана в карточке работника: ${join(workerDir(runId, 'w-1'), 'worker.json')} (поле finish_reason). Чаще всего это несобравшийся composer/npm в углу или занятое имя ветки ${worker.branch}.`,
);
}
return {
worker_id: started.worker_id,
heartbeat_path: join(workerDir(runId, 'w-1'), 'heartbeat.json'),
notes_path: SMOKE_NOTES_REL,
supervisor_pid: started.supervisor_pid,
};
}