749 lines
52 KiB
JavaScript
749 lines
52 KiB
JavaScript
// Оркестратор прогона: расписание пунктов по работникам, рабочие углы и запуск НАДЗИРАТЕЛЕЙ.
|
||
// Своей мудрости не заводит: ворота — кусок 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,
|
||
};
|
||
}
|