Files
portal/tools/night/supervisor.test.mjs
T

2649 lines
178 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.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
import { PREDEL_ZHIVOGO_ZAPUSKA } from './predel-zhivogo-zapuska.mjs';
// 🔴 ПРЕДЕЛ ВРЕМЕНИ ВСЕМУ ФАЙЛУ. Внизу два describe нарочно работают на НАСТОЯЩЕМ
// хранилище: там проверяется поведение самого `git` (откат чужого файла, отличие сбоя
// от чистоты), и подставить его — значит не проверить ничего. Каждая такая проверка
// заводит хранилище десятком живых команд `git`, и при пределе по умолчанию 5000 мс
// они краснели от соседей. Откуда взято число — в `predel-zhivogo-zapuska.mjs`.
vi.setConfig({ testTimeout: PREDEL_ZHIVOGO_ZAPUSKA });
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
KILL_GRACE_MS, WORKER_STATUSES, FINAL_STATUSES,
newSpans, setSpanKind, spansNow,
refreshLife, readWorkerStatus, currentItem,
commandVerdict,
touchedFromFiles, whatWorkerTouched,
runWorkerCards, amILast, writeRunOutcome,
STATUS_WORD as SUP_STATUS_WORD,
} from './supervisor.mjs';
import { STATUS_WORD as SUM_STATUS_WORD } from './summary.mjs';
import { readJson, appendLine, writeJsonAtomic } from './state.mjs';
const T0 = Date.parse('2026-08-01T22:00:00+03:00');
const H = 3600_000;
let dir, worktree, inbox, wdir, rdir, lastRunFile;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'night-sup-'));
worktree = join(dir, 'ws');
inbox = join(worktree, '.night-worker');
rdir = join(dir, 'runs', 'r-1');
wdir = join(rdir, 'workers', 'w-1');
lastRunFile = join(dir, 'last-run.json');
mkdirSync(inbox, { recursive: true });
mkdirSync(wdir, { recursive: true });
writeJsonAtomic(join(wdir, 'worker.json'), {
worker_id: 'w-1', run_id: 'r-1', status: 'working', started_at: T0, workspace: worktree,
});
writeJsonAtomic(join(rdir, 'run.json'), {
run_id: 'r-1', status: 'running', workers: ['w-1'], task_id: 't-1',
plan_path: 'docs/superpowers/plans/пример.md', items: [{ id: '1' }, { id: '2' }],
});
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
describe('закрытые списки — их менять нельзя, на них стоят ворота других кусков', () => {
it('работник объявляет о себе ровно пять состояний', () => {
expect(WORKER_STATUSES).toEqual([
'working', 'stopping', 'waiting_answer_idle', 'waiting_answer_busy', 'waiting_service',
]);
});
it('надзиратель ставит ровно три итоговых состояния', () => {
expect(FINAL_STATUSES).toEqual(['extinguished', 'paused', 'stalled']);
});
it('между «остановись» и «умри» работнику даётся десять секунд', () => {
expect(KILL_GRACE_MS).toBe(10_000);
});
it('русские слова состояний — ОДИН список на всю затею, а не копия рядом', () => {
// 🔴 Заведи надзиратель свой список — он молча разошёлся бы со сводкой, и владелец
// читал бы про одного работника два разных слова. Проверка на то же самое ЗНАЧЕНИЕ
// этого не поймала бы, поэтому сравниваем сам предмет.
expect(SUP_STATUS_WORD).toBe(SUM_STATUS_WORD);
expect(SUP_STATUS_WORD.extinguished).toBe('погашен');
});
});
describe('отрезки времени по состояниям работника', () => {
it('один вид — один отрезок', () => {
expect(spansNow(newSpans(0, 'working'), 3 * H)).toEqual([{ kind: 'working', ms: 3 * H }]);
});
it('смена вида закрывает прежний отрезок и открывает новый', () => {
const t = newSpans(0, 'working');
setSpanKind(t, 'waiting_service', H);
expect(spansNow(t, 2 * H)).toEqual([
{ kind: 'working', ms: H },
{ kind: 'waiting_service', ms: H },
]);
});
it('тот же вид дважды нового отрезка не открывает', () => {
const t = newSpans(0, 'working');
setSpanKind(t, 'working', H);
expect(spansNow(t, H).length).toBe(1);
});
});
describe('признаки жизни — только то, что видно снаружи работника', () => {
it('коммит в ветке работника засчитан признаком жизни', () => {
const seen = {};
const kinds = refreshLife({
workerDirPath: wdir, worktreeRoot: worktree, seen, now: T0, head: () => 'sha-abc',
});
expect(kinds).toEqual(['commit']);
expect(readJson(join(wdir, 'heartbeat.json')).last_commit_sha).toBe('sha-abc');
});
it('тот же коммит второй раз признаком жизни НЕ считается', () => {
const seen = {};
refreshLife({ workerDirPath: wdir, worktreeRoot: worktree, seen, now: T0, head: () => 'sha-abc' });
const again = refreshLife({
workerDirPath: wdir, worktreeRoot: worktree, seen, now: T0 + 60_000, head: () => 'sha-abc',
});
expect(again).toEqual([]);
});
it('дописанное состояние и закрытый пункт — тоже признаки жизни', () => {
writeJsonAtomic(join(inbox, 'state.json'), { item: '5', saved_at: T0 });
appendLine(join(inbox, 'closed-items.jsonl'), { item: '4', at: T0 });
const kinds = refreshLife({
workerDirPath: wdir, worktreeRoot: worktree, seen: {}, now: T0, head: () => null,
});
expect(kinds).toEqual(['state_write', 'item_closed']);
expect(readJson(join(wdir, 'heartbeat.json')).last_closed_item).toBe('4');
});
it('«файл шевелится» признаком жизни НЕ бывает: положили посторонний файл — признаков ноль', () => {
// 🪤 Проверено живьём 20.07.2026: сторож на размере файла поднял тревогу над живыми
// работниками. Признаков ровно три, и посторонний файл среди них не появляется.
writeJsonAtomic(join(inbox, 'мусор.json'), { что_угодно: 'растёт каждую секунду' });
const kinds = refreshLife({
workerDirPath: wdir, worktreeRoot: worktree, seen: {}, now: T0, head: () => null,
});
expect(kinds).toEqual([]);
expect(readJson(join(wdir, 'heartbeat.json'))).toBeNull();
});
});
describe('что работник объявляет о себе сам', () => {
it('объявленное состояние читается из ящика', () => {
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_service', item: '5' });
expect(readWorkerStatus(worktree)).toBe('waiting_service');
});
it('работник назвал состояние, которого нет в закрытом списке — считаем, что он работает', () => {
writeJsonAtomic(join(inbox, 'status.json'), { status: 'отдыхаю', item: '5' });
expect(readWorkerStatus(worktree)).toBe('working');
});
it('пункт, на котором он стоит, читается оттуда же; ящик пуст — пункта нет', () => {
expect(currentItem(worktree)).toBeNull();
writeJsonAtomic(join(inbox, 'status.json'), { status: 'working', item: '5' });
expect(currentItem(worktree)).toBe('5');
});
});
describe('commandVerdict — правило, что надзиратель делает с командой владельца', () => {
it('«стоп» и «стоп всё» гасят немедленно, не доделывая', () => {
expect(commandVerdict('stop')).toMatchObject({ extinguish: true, wait_for_step: false, final_status: 'extinguished' });
expect(commandVerdict('stopall')).toMatchObject({ extinguish: true, wait_for_step: false, final_status: 'extinguished' });
});
it('«пауза» ждёт конца шага и метит остановку словом paused, а не «погашен»', () => {
expect(commandVerdict('pause')).toMatchObject({ extinguish: true, wait_for_step: true, final_status: 'paused' });
});
it('«продолжить» живого работника не гасит', () => {
expect(commandVerdict('resume').extinguish).toBe(false);
});
it('слово не из закрытого списка команд — ответа нет вовсе, а не «ничего страшного»', () => {
expect(commandVerdict('удали-всё')).toBeNull();
});
});
describe('что работник трогал — два источника, и признак честно назван приблизительным', () => {
it('по именам файлов в коммитах видно деньги, права и видимое клиенту', () => {
const t = touchedFromFiles([
'app/app/Services/Billing/AdWalletService.php',
'app/resources/js/pages/Client/Sms.vue',
'app/app/Policies/ProjectPolicy.php',
]);
expect(t).toEqual({ money: true, rights: true, client_visible: true });
});
it('файлы безобидные — следов не видно', () => {
expect(touchedFromFiles(['tools/night/paths.mjs'])).toEqual({ money: false, rights: false, client_visible: false });
});
it('работник объявил сам — верим и ему, даже если по файлам не видно', () => {
writeJsonAtomic(join(inbox, 'touched.json'), { money: true });
const t = whatWorkerTouched({
worktreeRoot: worktree, baseBranch: 'feat/night-dispatcher-part1', files: ['tools/night/paths.mjs'],
});
expect(t.money).toBe(true);
expect(t.client_visible).toBe(false);
// Признак приблизительный, и это записано ПОЛЕМ, а не умолчанием: владелец должен
// видеть, что тут догадка, а не точное знание.
expect(t.approximate).toBe(true);
});
});
describe('карточки работников прогона и «я последний»', () => {
it('карточки всех работников прогона читаются одним списком', () => {
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), { worker_id: 'w-2', status: 'working' });
const ids = runWorkerCards(rdir).map(c => c.worker_id).sort();
expect(ids).toEqual(['w-1', 'w-2']);
});
it('пока жив хоть один работник — я не последний', () => {
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), { worker_id: 'w-2', status: 'working' });
expect(amILast(rdir)).toBe(false);
});
it('все в итоговых состояниях — я последний', () => {
writeJsonAtomic(join(rdir, 'run.json'), { run_id: 'r-1', status: 'running', workers: ['w-1', 'w-2'] });
writeJsonAtomic(join(wdir, 'worker.json'), { worker_id: 'w-1', status: 'extinguished' });
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), { worker_id: 'w-2', status: 'stalled' });
expect(amILast(rdir)).toBe(true);
});
it('состав прогона объявлен семерыми, а карточка пока одна — я НЕ последний', () => {
// 🔴 Гонка на старте: углы заводятся минутами, по одному. Погасни первый работник,
// пока строятся остальные, — «по карточкам» он оказался бы последним и закрыл бы
// весь прогон итогом из одного. Убери сверку с объявленным составом — покраснеет здесь.
writeJsonAtomic(join(rdir, 'run.json'), {
run_id: 'r-1', status: 'running', workers: ['w-1', 'w-2', 'w-3'],
});
writeJsonAtomic(join(wdir, 'worker.json'), { worker_id: 'w-1', status: 'extinguished' });
expect(amILast(rdir)).toBe(false);
});
});
describe('итог прогона — то, чем утром живёт сводка', () => {
it('в итог кладётся всё, чем сводка заполняет строку «что трогали» и «где остановился»', () => {
writeJsonAtomic(join(wdir, 'worker.json'), {
worker_id: 'w-1', status: 'extinguished', finish_reason: 'кончил сам', workspace: worktree,
at_item: '5', touched: { money: false, rights: false, client_visible: true, approximate: true },
});
appendLine(join(inbox, 'closed-items.jsonl'), { item: '4', at: T0 });
// 🔴 Каталог следов подан РУКАМИ (кусок 4). Умолчание настоящее — боевой
// `.night/deferred/`, — и без этой строки проверка оставляла бы там настоящий отказ
// по задаче `t-1`, а следующая живая ночь в него упёрлась бы. Поймано живьём
// 02.08.2026: файл `.night/deferred/t-1.json` вправду появился в хранилище.
const outcome = writeRunOutcome({
runDirPath: rdir, runId: 'r-1', lastRunFile, now: T0, deferredPath: join(dir, 'deferred'),
});
expect(outcome.workers[0].touched.client_visible).toBe(true);
expect(outcome.workers[0].at_item).toBe('5');
expect(outcome.workers[0].closed_items).toEqual(['4']);
expect(outcome.task_id).toBe('t-1');
expect(outcome.plan_path).toBe('docs/superpowers/plans/пример.md');
});
it('итог лёг в общий файл, а прогон помечен законченным', () => {
// 🔴 `.night/last-run.json` утренняя сводка ЧИТАЕТ, а писать его было некому —
// и сводка рассказывала бы про пустоту. Каталога прогона к утру может уже не быть.
// 🔴 Свой каталог следов — тот же довод, что строкой выше.
writeRunOutcome({
runDirPath: rdir, runId: 'r-1', lastRunFile, now: T0, deferredPath: join(dir, 'deferred'),
});
expect(readJson(lastRunFile).run_id).toBe('r-1');
expect(readJson(lastRunFile).finished_at).toBe(T0);
expect(readJson(join(rdir, 'run.json')).status).toBe('finished');
});
});
// ============ часть 2: сам ночной круг ============
import { EventEmitter } from 'node:events';
import { existsSync, rmSync as rmSyncAgain } from 'node:fs';
import { supervise, main, spawnSupervisor, STATUS_WORD } from './supervisor.mjs';
import { readLines } from './state.mjs';
import { billableMs } from './limits.mjs';
import { workerDir, SUMMARY_LINES_FILE, klyuchSravneniyaPuti } from './paths.mjs';
// 🔴 Неповторимый хвост — тем же прибором, каким его берёт рабочий код (`run.mjs`).
// Зачем он здесь — в примечании к `RID_NADZIRATELYA` ниже.
import { randomUUID } from 'node:crypto';
const CFG = {
worker_hours_limit: 8,
service_wait_hours: 2,
stall_minutes: 30,
pause_wait_minutes: 20,
supervisor_tick_seconds: 30,
base_branch: 'feat/night-dispatcher-part1',
};
function fakeWorker() {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.exitCode = null;
child.signalCode = null;
child.killed = false;
child.kill = () => { child.killed = true; child.exitCode = 143; return true; };
child.say = obj => child.stdout.emit('data', JSON.stringify(obj) + '\n');
child.grumble = text => child.stderr.emit('data', text);
child.die = code => { child.exitCode = code; };
return child;
}
// 🪤 Живой работник каждый круг оставляет след — новый коммит. Постоянный отпечаток
// (`() => 'sha-1'`) означает работника, который МОЛЧИТ: при шаге круга в час «встал»
// (тридцать минут молчания) сработает на втором же круге и опередит любой предел времени.
// Там, где проверка про отсечку или про восемь часов, работник обязан быть живым —
// иначе проверка мерит не то, что написано в её названии.
function liveHead() {
let n = 0;
return () => `sha-${n++}`;
}
// Время старта ПРОГОНА кладётся в `run.json` — туда, где его пишет оркестратор (run.mjs)
// и откуда его берёт надзиратель. Прочие поля карточки прогона сохраняются: их правят
// другие помощники (состав прогона, пункты).
function putRunStartedAt(runStartedAt) {
const prev = readJson(join(rdir, 'run.json')) ?? {};
if (runStartedAt === null) {
const { started_at: _net, ...bezStarta } = prev;
writeJsonAtomic(join(rdir, 'run.json'), bezStarta);
return;
}
writeJsonAtomic(join(rdir, 'run.json'), { ...prev, started_at: runStartedAt });
}
// Круг гоняется с поддельными часами: `sleep` не спит, а двигает стрелки и по дороге
// исполняет очередной шаг сценария. Так восемь часов проходят за миллисекунду.
async function runSupervisor({
child = fakeWorker(),
script = [],
tickMs = 30_000,
spawnWorker = null,
head = () => 'sha-1',
startAt = T0,
// 🔴 Добавлено правкой К2 (дыра 4). Время старта ПРОГОНА живёт в `run.json` и к старту
// надзирателя отношения не имеет: седьмой надзиратель заводится, когда прогон идёт уже
// час. По умолчанию здесь они совпадают — ровно так эти проверки и мерили раньше,
// так что ни одна из них не поменяла смысла. `null` означает «в run.json времени нет».
runStartedAt = startAt,
// 🔴 Добавлено куском 2. Прежние проверки не про место на сервере — им нельзя зависеть
// от того, сколько его сегодня свободно на машине. Настоящий замер проверяется отдельно,
// своей проверкой «без подмены круг мерит НАСТОЯЩИЙ диск».
cfg = CFG,
probeDisk = async () => ({ ok: true, free_gb: 100, min_gb: 5, reason: null }),
// 🔴 Добавлено куском 3. Прежние проверки не про занятые файлы: у них список тронутого
// пустой — ровно такой, каким кусок 1 зашил его жёстко, — поэтому ни одна не меняется.
changedFiles = () => [],
// 🪤 `rollbackFn: null` в умолчании помощника — это не пустышка: круг зовёт откат ТОЛЬКО
// когда нашёл чужое, а у прежних проверок тронутого нет вовсе. Поставь мы здесь настоящий
// откат — прежние проверки полезли бы в хранилище без нужды.
rollbackFn = null,
// 🔴 Добавлено куском 3 СВЕРХ плана, и говорю вслух зачем (долг 17): замок судит работника
// по списку занятых файлов, лежащему В КАТАЛОГЕ ПРОГОНА. Подай не тот каталог — занятых
// там нет вовсе, и замок объявил бы чистыми ВСЕХ разом, отчитавшись, что сверка состоялась.
// Без этого довода подменить каталог из проверки нечем, а значит и сторожить нечем.
// Умолчание — настоящий каталог прогона, то есть ни одна прежняя проверка не изменилась.
runDirPath = null,
// 🔴 Добавлено куском 4. Прежние проверки не про закрытие пунктов: заявок у них нет
// ни одной, значит блок 5а до этих доводов не доходит вовсе, и ни одна не меняется.
// 🪤 Настоящий прогон проверок сюда подставлять НЕЛЬЗЯ: проверки затеи запустили бы
// сами себя, и так до конца памяти.
runTests = () => ({ ran: true, ok: true, code: 0, out: 'зелено', error: null }),
dirty = () => false,
dbName = 'night_r_1_w_1',
// 🔴 Свой каталог следов — обязателен. Умолчание круга настоящее (`.night/deferred`),
// и без подмены проверки оставляли бы в хранилище настоящие отказы по задачам.
deferredPath = null,
} = {}) {
putRunStartedAt(runStartedAt);
const clock = { t: startAt };
let tick = 0;
const out = await supervise({
runId: 'r-1',
workerId: 'w-1',
worktreeRoot: worktree,
workerDirPath: wdir,
runDirPath: runDirPath ?? rdir,
cfg,
probeDisk,
// 🔴 Раньше здесь передавались measuresPath/commandsDirPath/qPath/repliesPath — supervise()
// их больше не принимает: они не звались ни разу, а их присутствие тут выглядело
// проверкой кнопки «стоп», хотя ничего не проверяло (см. правку К1). Куски 3/5 заведут
// свои параметры вместе с зовом и своей проверкой на зов — не раньше.
lastRunFile,
spawnWorker: spawnWorker ?? (() => child),
now: () => clock.t,
sleep: async ms => {
clock.t += ms;
const step = script[tick];
tick += 1;
if (tick > 60) throw new Error('Надзиратель не кончился за шестьдесят кругов — так быть не должно.');
if (step) step(child);
},
tickMs,
head,
changedFiles,
rollbackFn,
runTests,
dirty,
dbName,
deferredPath: deferredPath ?? join(dir, 'deferred'),
});
return { out, child, clock };
}
describe('надзиратель ИСПОЛНЯЕТ пределы времени — то, чего до него не делал никто', () => {
it('восемь часов гасят работника (проверка 5)', async () => {
const { out, child } = await runSupervisor({ tickMs: 9 * H, script: [() => {}] });
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/восемь часов/i);
// Отрезки, по которым предел и посчитан, остались в карточке — не на слово.
const spans = readJson(join(wdir, 'worker.json')).spans;
expect(spans.reduce((s, x) => s + x.ms, 0)).toBeGreaterThanOrEqual(8 * H);
});
it('в сводке названо МЕСТО, где работник остановился (проверка 5)', async () => {
writeJsonAtomic(join(inbox, 'status.json'), { status: 'working', item: '5' });
await runSupervisor({ tickMs: 9 * H, script: [() => {}] });
// Имя файла сводки знает ОДНО место (paths.mjs SUMMARY_LINES_FILE) — проверка читает
// оттуда же, а не по зашитой строке-копии, иначе сторожила бы копию, а не правило.
const lines = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(lines).toContain('w-1');
expect(lines).toContain('5');
expect(lines).toContain(STATUS_WORD.extinguished);
});
it('погашенный считается мёртвым: в карточке не осталось «working» (проверка 5)', async () => {
await runSupervisor({ tickMs: 9 * H, script: [() => {}] });
const card = readJson(join(wdir, 'worker.json'));
expect(card.status).toBe('extinguished');
expect(FINAL_STATUSES).toContain(card.status);
});
it('два часа ждал чужую службу — погас с причиной «служба не ответила» (проверка 13)', async () => {
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_service', item: '5' });
const { out, child } = await runSupervisor({ tickMs: 2.5 * H, script: [() => {}] });
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/служба не ответила/i);
});
it('настройки прогона молчат про порог ожидания службы — берётся умолчание ИЗ ПРАВИЛА (limits.mjs), а не число, зашитое здесь второй раз', async () => {
// 🔴 Вырежи `SERVICE_WAIT_LIMIT_HOURS` из вызова в supervisor.mjs, оставь голое число —
// эта проверка не покраснеет (числа сегодня совпадают), но покраснеет ЛЮБАЯ проверка
// в limits.test.mjs, где сам порог поменяют: здесь мы проверяем, что настройка cfg БЕЗ
// service_wait_hours всё равно даёт ровно то же поведение, что константа правила.
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_service', item: '5' });
const { service_wait_hours, ...cfgNoServiceWait } = CFG;
const { out, child } = await runSupervisor({ tickMs: 2.5 * H, script: [() => {}], cfg: cfgNoServiceWait });
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/служба не ответила/i);
});
it('эти два часа его восемь часов НЕ съели (проверка 13)', async () => {
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_service', item: '5' });
await runSupervisor({ tickMs: 2.5 * H, script: [() => {}] });
const spans = readJson(join(wdir, 'worker.json')).spans;
// Ждал два с половиной часа, а восемь часов у него не тронуты вовсе.
expect(spans.find(s => s.kind === 'waiting_service').ms).toBeGreaterThanOrEqual(2 * H);
expect(billableMs(spans)).toBeLessThan(60_000);
});
it('вечерний прогон захватил тихие часы — в 8:00 погас, хотя восемь часов не вышли (проверки 31, 35)', async () => {
// Работник с 22:00 до 02:00 ничего не делал (ждал ответа и стоял), с 02:00 работал.
// К 8:00 он выработал шесть часов из восьми — и всё равно погашен отсечкой.
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_answer_idle', item: '5' });
const script = [
() => {}, () => {}, () => {},
() => writeJsonAtomic(join(inbox, 'status.json'), { status: 'working', item: '5' }),
];
// Работник живой: каждый круг новый коммит. С постоянным отпечатком он молчал бы час
// между кругами, и его назвали бы вставшим задолго до 8:00 — проверка мерила бы не то.
const { out, child } = await runSupervisor({ tickMs: H, script, startAt: T0, head: liveHead() });
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/8:00|восьми утра|тихие часы/i);
expect(out.reason).not.toMatch(/восемь часов работы/i);
const spans = readJson(join(wdir, 'worker.json')).spans;
expect(billableMs(spans)).toBeLessThan(8 * H);
// Проверка 31: к 8:00 тот, кто ещё работал, — погашен, и это записано в карточке.
expect(readJson(join(wdir, 'worker.json')).status).toBe('extinguished');
});
it('прогон шёл целиком вне тихих часов — отсечка не применилась, прожил свои восемь часов (проверка 34)', async () => {
// Старт в 9 утра по Москве: восемь часов кончаются в 17:00, до 8:00 дело не доходит.
const startAt = Date.parse('2026-08-01T09:00:00+03:00');
// Тот же живой работник: молчащему «встал» пришёл бы на втором круге, в 10 утра.
const { out } = await runSupervisor({ tickMs: H, script: [], startAt, head: liveHead() });
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/восемь часов/i);
expect(out.reason).not.toMatch(/8:00|тихие часы/i);
});
it('в 8:00 ЖДУЩЕГО ЧУЖУЮ СЛУЖБУ круг НЕ гасит — у него свой срок в два часа (решение владельца)', async () => {
// 🔴 Полчаса ожидания — свой предел ещё не выбран (он два часа), и отсечка 8:00 его тоже
// не трогает. Погаснуть он должен по своей причине и позже, а не безымянно по отсечке.
// Вырежи ветку `waiting_service` из `morningCutoff` — покраснеет здесь.
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_service', item: '5' });
const startAt = Date.parse('2026-08-02T07:30:00+03:00'); // тихие часы захвачены
const { out, child } = await runSupervisor({
tickMs: 30 * 60_000, startAt, head: liveHead(),
script: [() => {}, c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }],
});
expect(child.killed).toBe(false);
expect(out.reason ?? '').not.toMatch(/8:00|тихие часы|восьми утра/i);
});
it('в 8:00 молчаливый работник назван ПОГАШЕННЫМ по отсечке, а не «вставшим» (порядок правил)', async () => {
// 🔴 Он два часа молчал — «встал» тоже правда. Но у нас есть причина, названная
// поимённо, и она обязана победить: «встал» зовёт владельца чинить работника,
// «погашен» — просто продолжить с места. `isStalled` слова «погашен» не знает,
// он умеет считать только молчание, поэтому его черёд в круге ПОСЛЕДНИЙ.
// Переставь «встал» выше пределов — покраснеет здесь.
const startAt = Date.parse('2026-08-02T06:00:00+03:00'); // тихие часы захвачены
const { out } = await runSupervisor({ tickMs: 2 * H, script: [() => {}], startAt });
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/8:00|тихие часы|восьми утра/i);
expect(out.reason).not.toMatch(/признак/i);
});
});
describe('итоговый статус и провал запуска пишет НАДЗИРАТЕЛЬ', () => {
it('провал запуска записан, а не проглочен — иначе это 335-й молчаливый провал', async () => {
const { out } = await runSupervisor({ spawnWorker: () => { throw new Error('spawn claude ENOENT'); } });
expect(out.status).toBe('extinguished');
expect(out.reason).toContain('ENOENT');
expect(readJson(join(wdir, 'worker.json')).status).toBe('extinguished');
const events = readLines(join(rdir, 'events.jsonl')).map(e => e.kind);
expect(events).toContain('worker_spawn_failed');
const lines = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(lines).toContain('ENOENT');
});
it('итоговый статус в worker.json переписан — иначе ворота «от прошлого прогона никого» закроют затею навсегда', async () => {
expect(readJson(join(wdir, 'worker.json')).status).toBe('working');
await runSupervisor({ script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }] });
const card = readJson(join(wdir, 'worker.json'));
expect(card.status).toBe('extinguished');
expect(typeof card.finished_at).toBe('number');
});
it('в карточку записано, что работник трогал, — иначе строке сводки нечем питаться', async () => {
writeJsonAtomic(join(inbox, 'touched.json'), { client_visible: true });
await runSupervisor({ script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }] });
expect(readJson(join(wdir, 'worker.json')).touched.client_visible).toBe(true);
expect(readJson(join(wdir, 'worker.json')).touched.approximate).toBe(true);
});
it('работник кончился, не отдав итога, — статус stalled, а не «успех»', async () => {
const { out } = await runSupervisor({ script: [c => { c.grumble('spawn claude ENOENT'); c.die(1); }] });
expect(out.status).toBe('stalled');
expect(out.reason).toContain('ENOENT');
});
it('итоговая строка пришла разорванной на два куска — она всё равно прочитана', async () => {
// 🪤 Поток идёт кусками, и разрыв приходится куда попало. Итоговая строка приходит
// последней, то есть под самый разрыв. Разбирай куски по отдельности — живой работник,
// честно отдавший итог, записался бы «кончился, не отдав итога», и владелец получил бы
// письмо про несуществующую поломку. Верни разбор по кускам — покраснеет здесь.
const child = fakeWorker();
const whole = JSON.stringify({ type: 'result', is_error: false, result: 'сделал всё' });
const { out } = await runSupervisor({
child,
script: [c => {
c.stdout.emit('data', whole.slice(0, 20));
c.stdout.emit('data', whole.slice(20)); // хвост БЕЗ перевода строки
c.die(0);
}],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/закончил своё дело сам/i);
});
it('«успех» с ПУСТЫМ ответом успехом не считается', async () => {
// 🪤 334 фоновых прогона с мая возвращали ровно это: is_error false, ответ пустой,
// работы никакой. Три месяца никто не заметил.
const { out } = await runSupervisor({ script: [c => { c.say({ type: 'result', is_error: false, result: '' }); c.die(0); }] });
expect(out.reason).not.toMatch(/закончил своё дело сам/i);
});
it('тридцать минут без единого признака жизни — назван вставшим и погашен', async () => {
let calls = 0;
const head = () => (calls++ === 0 ? 'sha-1' : null);
const { out, child } = await runSupervisor({ tickMs: 31 * 60_000, script: [() => {}], head });
expect(child.killed).toBe(true);
expect(out.status).toBe('stalled');
expect(out.reason).toMatch(/признак/i);
});
it('порог молчания в письме владельцу — ТО ЖЕ число, каким гасили, а не второй "?? 30" рядом', async () => {
// 🔴 Настройки МОЛЧАТ про stall_minutes нарочно — вступает УМОЛЧАНИЕ. Если условие
// (isStalled) и текст письма считают порог каждый своим отдельным "?? 30", числа сегодня
// совпадают случайно; разойдись эти литералы при правке одного места — работник погас бы
// по одному числу, а владелец в письме прочитал бы другое. Проверка ловит именно это:
// письмо обязано назвать РОВНО тот порог (30), от которого реально сработало условие.
const { stall_minutes, ...cfgNoStall } = CFG;
let calls = 0;
const head = () => (calls++ === 0 ? 'sha-1' : null);
const { out, child } = await runSupervisor({
tickMs: 31 * 60_000, script: [() => {}], head, cfg: cfgNoStall,
});
expect(child.killed).toBe(true);
expect(out.status).toBe('stalled');
expect(out.reason).toContain('30 минут');
});
});
describe('признаки жизни обновляет он же — каждый круг', () => {
it('после первого же круга признаки жизни записаны', async () => {
await runSupervisor({ script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }] });
const life = readJson(join(wdir, 'heartbeat.json'));
expect(life.last_commit_sha).toBe('sha-1');
expect(life.last_life_kind).toBe('commit');
});
});
describe('последний погасший закрывает прогон — иначе сводке нечем жить', () => {
it('все погасли — последний пишет итог прогона в общий файл', async () => {
await runSupervisor({ script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }] });
expect(amILast(rdir)).toBe(true);
const last = readJson(lastRunFile);
expect(last.run_id).toBe('r-1');
expect(last.workers[0].worker_id).toBe('w-1');
expect(last.workers[0].status).toBe('extinguished');
expect(readJson(join(rdir, 'run.json')).status).toBe('finished');
});
it('не последний итога НЕ пишет — он ещё изменится', async () => {
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), { worker_id: 'w-2', status: 'working' });
await runSupervisor({ script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }] });
expect(readJson(lastRunFile)).toBeNull();
});
});
// 🔴🔴 ИМЯ ПРОГОНА У КАЖДОГО ЗАПУСКА ПРИБОРА ПРОВЕРОК — СВОЁ. Проверка ниже пишет
// в НАСТОЯЩИЙ `.night/` хозяйства (см. примечание внутри неё), и уборка сносит каталог
// целиком. Стой здесь ПОСТОЯННОЕ имя — два прибора, запущенные разом в одном рабочем углу,
// брали бы один каталог, и уборка одного сносила бы хозяйство другому посреди опыта.
// Это замерено живьём на `run.test.mjs` 02.08.2026 (10 красных из 12, красные каждый раз
// разные). Хвост берётся ТЕМ ЖЕ прибором, что и у рабочего кода, а не вторым, своим.
// Сторож на этот класс — `nastoyashchee-hozyaystvo-v-proverkah.test.mjs`.
const RID_NADZIRATELYA = `проба-надзирателя-${randomUUID().slice(0, 8)}`;
describe('отвязанная жизнь и точка входа', () => {
it('надзиратель запускается ОТВЯЗАННЫМ — вот это и есть «переживает конец сессии» (проверка 1)', () => {
const calls = [];
const fake = (bin, args, opts) => {
calls.push({ bin, args, opts });
return { pid: 4242, unref: () => {} };
};
// 🪤 Эта проверка пишет в НАСТОЯЩИЙ `.night/` хозяйства: путь журнала считает сам
// `spawnSupervisor` через `workerDir`, и подменить его нечем — подпись задана договором
// о стыках, четвёртый аргумент под путь ей добавлять нельзя. Поэтому уборка стоит
// в `finally`: упади проверка до неё — мусор остался бы в живом состоянии затеи
// и попал бы в следующий прогон.
try {
const r = spawnSupervisor({ runId: RID_NADZIRATELYA, workerId: 'w-1', spawnFn: fake });
expect(r.pid).toBe(4242);
expect(calls[0].opts.detached).toBe(true);
expect(calls[0].args).toEqual(expect.arrayContaining(['--run', RID_NADZIRATELYA, '--worker', 'w-1']));
// Свой журнал обязателен: молча падающий надзиратель — ровно тот класс беды,
// из-за которого 334 прогона умирали три месяца незамеченными.
expect(existsSync(join(workerDir(RID_NADZIRATELYA, 'w-1'), 'supervisor.log'))).toBe(true);
} finally {
// 🪤 Сносится каталог ПРОГОНА целиком, а не угол работника: прежняя уборка убирала
// только `workers/w-1`, и в живом `.night/` навсегда оставался пустой скелет
// `runs/проба-надзирателя/workers` — он и лежал там на диске, найден 02.08.2026.
rmSyncAgain(katalogProgonaNastoyashchiy(RID_NADZIRATELYA), { recursive: true, force: true });
}
});
it('надзирателю не сказали, за кем следить, — он падает понятным словом, а не молча', async () => {
await expect(main(['node', 'supervisor.mjs', '--run', 'r-1'])).rejects.toThrow(/--worker/);
});
it('прогона или настроек нет — падает названным словом', async () => {
await expect(main(['node', 'supervisor.mjs', '--run', 'такого-нет', '--worker', 'w-9']))
.rejects.toThrow(/Настроек нет|Карточки работника нет/);
});
});
// ============ кусок 2: запас, место и пределы в живом круге ============
import { recordMeasure as zapisatZamer } from './budget.mjs';
// 🔴 С 03.08.2026 замер обязан нести светофор системы и вид предела: решение о запуске
// принимает светофор, а доля лишь уточняет его, когда приходит (а приходит она только вместе
// с тревогой). Предмет ЭТОГО файла — пределы прогона и работника по ПРИРОСТУ доли, поэтому
// светофор подставляется обёрткой, зелёный и только здесь.
// 🪤 Обёртка ничего не ослабляет: требование «замер без светофора не пишется» сторожится
// своими проверками в `svetofor-i-vid-predela.test.mjs`, и вырезание красит именно там.
const recordMeasure = (path, row) => zapisatZamer(path, { status: 'allowed', kind: 'seven_day', ...row });
const R1 = Date.parse('2026-08-08T00:00:00+03:00'); // когда обновится недельный запас
const CFG2 = {
worker_hours_limit: 8,
service_wait_hours: 2,
stall_minutes: 30,
pause_wait_minutes: 20,
supervisor_tick_seconds: 30,
base_branch: 'feat/night-dispatcher-part1',
rate_limit_type: 'seven_day',
run_share: 1 / 7,
start_floor_share: 5 / 7,
disk_free_min_gb: 5,
};
// Подставной замер места — «всё хорошо». 🔴 Умолчанием он НЕ стоит нигде: каждая проверка,
// которой место безразлично, подаёт его РУКАМИ (`probeDisk: DISK_OK`). Тогда пустышку видно
// в самой проверке, а не в подписи помощника, где её никто не замечает. Ровно такую невидимую
// пустышку кусок 1 оставил в надзирателе, и её пришлось выкорчёвывать шагом 5.
const DISK_OK = async () => ({ ok: true, free_gb: 100, min_gb: 5, reason: null });
// Свой запуск круга — с местом на диске и своим журналом замеров. Помощник куска 1
// (`runSupervisor`) их не принимает, а трогать его нельзя: на нём стоят его 22 проверки.
async function runK2({
child = fakeWorker(),
script = [],
tickMs = 30_000,
head = () => 'sha-1',
startAt = T0,
// 🔴 См. тот же довод у помощника куска 1: время старта ПРОГОНА живёт в `run.json`,
// а не берётся у надзирателя. `null` — «в run.json времени нет».
runStartedAt = startAt,
// 🔴 Умолчания у замера места здесь НЕТ намеренно. Ничего не передали — значит круг мерит
// диск ПО-НАСТОЯЩЕМУ (своим умолчанием `supervise`, шаг 5). Подставной замер приходит
// только тогда, когда его дали руками: `probeDisk: DISK_OK`.
probeDisk,
cfg = CFG2,
measuresPath = undefined,
// Ответ git о том, что работник трогал. Пустой список — НАСТОЯЩАЯ чистота; проверка
// про сбой git подаёт сюда свой ответ «не знаю».
changedFiles = () => [],
// 🔴 Добавлено куском 3. Шаг 7 меняет умолчание `rollbackFn` в `supervise()` на НАСТОЯЩИЙ
// откат — значит без этой строки все шестнадцать проверок куска 2, стоящих на `runK2`,
// начали бы звать живой `git` во временном каталоге, который хранилищем не является.
// Умолчание повторяет то, что у куска 2 было зашито жёстко (откат не звался вовсе),
// поэтому ни одна его проверка не меняет своего ответа.
rollbackFn = null,
// 🔴 Добавлено куском 3, задача 7. Каталог команд у помощника настоящий — но одной
// проверке («каталога команд не дали вовсе») нужно подать сюда `null`, иначе круг,
// которому команд подавать нечем, некому было бы проверить.
commandsDirPath = join(dir, 'commands'),
// 🔴 Добавлено куском 4. Шаг 8 меняет умолчания `supervise()` на НАСТОЯЩИЕ прогон
// проверок и опрос угла — значит без этих строк шестнадцать проверок куска 2 начали бы
// каждый круг звать живой `git` и живой `npm run test:tools` во временном каталоге,
// который хранилищем не является, а прогон проверок затеи запускал бы сам себя.
// Заявок у этих проверок нет ни одной, значит блок 5а до доводов не доходит вовсе.
runTests = () => ({ ran: true, ok: true, code: 0, out: 'зелено', error: null }),
dirty = () => false,
dbName = 'night_r_1_w_1',
} = {}) {
putRunStartedAt(runStartedAt);
const clock = { t: startAt };
let tick = 0;
const out = await supervise({
runId: 'r-1',
workerId: 'w-1',
worktreeRoot: worktree,
workerDirPath: wdir,
runDirPath: rdir,
// 🔴 Свой каталог следов: умолчание круга — боевой `.night/deferred`.
deferredPath: join(dir, 'deferred'),
cfg,
measuresPath: measuresPath === undefined ? join(dir, 'budget-measures.jsonl') : measuresPath,
commandsDirPath,
qPath: join(dir, 'questions.jsonl'),
repliesPath: join(dir, 'owner-replies.jsonl'),
lastRunFile,
spawnWorker: () => child,
now: () => clock.t,
sleep: async ms => {
clock.t += ms;
const step = script[tick];
tick += 1;
// 🔴 Это сторож от вечного круга, а не рабочий предел. У куска 1 он стоял ровно
// на шестидесятом круге — там же, где срабатывает правило «встал» (тридцать минут
// молчания по тридцать секунд на круг). Запаса нет вовсе: сдвинься правило «встал»
// на один круг — и проверка падала бы по сторожу, а смена искала бы поломку не там.
// Здесь запас вдвое.
if (tick > 120) throw new Error('Надзиратель не кончился за сто двадцать кругов — так быть не должно.');
if (step) step(child);
},
tickMs,
// 🔴 Не дали замер места — не подставляем НИЧЕГО: `supervise` возьмёт своё умолчание,
// то есть настоящий диск. Тот же приём, что строкой выше с `measuresPath`.
...(probeDisk === undefined ? {} : { probeDisk }),
head,
changedFiles,
rollbackFn,
runTests,
dirty,
dbName,
});
return { out, child, clock };
}
// Сказать в поток замер прибора системы — ровно тем видом события, каким его шлёт `claude`.
function sayLimit(c, utilization, kind = 'seven_day') {
c.say({ type: 'rate_limit_event', rate_limit_info: { rateLimitType: kind, utilization, resetsAt: R1 / 1000 } });
}
function finishNormally(c) {
c.say({ type: 'result', is_error: false, result: 'сделал' });
c.die(0);
}
// Прогон из двоих: доля каждого — половина седьмой части.
function twoWorkersInRun() {
const prev = readJson(join(rdir, 'run.json'));
writeJsonAtomic(join(rdir, 'run.json'), { ...prev, workers: ['w-1', 'w-2'] });
}
describe('замер прибора системы берётся ИЗ ПОТОКА работника — своего счёта в деньгах нет', () => {
it('замер из потока попал в журнал, и у него есть прогон, работник и `phase`', async () => {
await runK2({ probeDisk: DISK_OK, script: [c => { sayLimit(c, 0.25); finishNormally(c); }] });
const mine = readLines(join(dir, 'budget-measures.jsonl')).filter(m => m.worker_id === 'w-1');
expect(mine.length).toBeGreaterThanOrEqual(1);
expect(mine[0].utilization).toBeCloseTo(0.25, 6);
expect(mine[0].run_id).toBe('r-1');
expect(mine[0].resets_at).toBe(R1);
expect(mine[0].phase).toBe('tick');
});
it('пятичасовой предел в журнал НЕ пишется — это другой запас, не недельный', async () => {
// 🔴 Решать о недельных деньгах по пятичасовому прибору — то же, что мерить бензин
// указателем температуры. Вид предела берётся из настройки `rate_limit_type`.
await runK2({ probeDisk: DISK_OK, script: [c => { sayLimit(c, 0.9, 'five_hour'); finishNormally(c); }] });
expect(readLines(join(dir, 'budget-measures.jsonl')).filter(m => m.worker_id === 'w-1')).toEqual([]);
});
it('🔴 замер пришёл БЕЗ ЧИСЛА — пишем строку, но числа не выдумываем', async () => {
// 🔴🔴 ЭТА ПРОВЕРКА СТОРОЖИЛА МИНУ, И ЕЁ ЗЕЛЕНЬ БЫЛА ЧАСТЬЮ БЕДЫ. Прежде она требовала
// «в журнал НЕ пишем». Замерено живьём 03.08.2026: доля приходит ТОЛЬКО вместе с тревогой,
// при спокойном расходе её нет вовсе — то есть за нормальную ночь в журнал не легло бы
// НИ ОДНОГО замера, а по этому журналу считаются оба денежных предела ночи (прогона
// и работника). Правило «не понял ответ — молчи» здесь равнялось «не сторожи».
// Теперь строка пишется, а «доли нет» остаётся честным `null` и НЕ становится нулём.
await runK2({ probeDisk: DISK_OK, script: [c => { sayLimit(c, 'много'); finishNormally(c); }] });
const moi = readLines(join(dir, 'budget-measures.jsonl')).filter(m => m.worker_id === 'w-1');
expect(moi).toHaveLength(1);
expect(moi[0].utilization).toBeNull();
expect(moi[0].utilization).not.toBe(0);
});
it('запись замера сорвалась — надзиратель это ПЕРЕЖИВАЕТ и говорит об этом в ленту', async () => {
// 🪤 Запись идёт из обработчика потока. Брошенная там ошибка убивает весь процесс
// надзирателя молча — тот же класс беды, что 334 прогона. Убери обёртку — покраснеет здесь.
const { out } = await runK2({
measuresPath: null, // писать некуда
probeDisk: DISK_OK,
script: [c => { sayLimit(c, 0.25); finishNormally(c); }],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/закончил своё дело сам/i);
expect(readLines(join(rdir, 'events.jsonl')).map(e => e.kind)).toContain('budget_measure_failed');
});
});
describe('общий предел прогона гасит ВСЕХ — проверка 14 в живом круге', () => {
it('прогон упёрся в седьмую часть недельного запаса — гаснет по запасу', async () => {
// 🔴 Первый прогон — подготовительный: он кладёт в журнал точку отсчёта и обязан
// КОНЧИТЬСЯ САМ. Без `finishNormally` работнику нечем закончиться: он молчит, и круг
// идёт до правила «встал» на тридцатой минуте — то есть держится на сторожевом обрыве.
await runK2({ probeDisk: DISK_OK, script: [c => { sayLimit(c, 0.20); finishNormally(c); }] }); // точка отсчёта
const { out, child } = await runK2({ probeDisk: DISK_OK, script: [c => sayLimit(c, 0.20 + 1 / 7)] });
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/седьмую часть/i);
});
it('погашен, хотя своих часов не выбрал вовсе — «даже те, кто не выбрал свои часы»', async () => {
await runK2({ probeDisk: DISK_OK, script: [c => { sayLimit(c, 0.20); finishNormally(c); }] });
const { out } = await runK2({ probeDisk: DISK_OK, script: [c => sayLimit(c, 0.20 + 1 / 7)] });
expect(out.status).toBe('extinguished');
// 🔴 Причину спрашиваем обязательно. Без неё проверка зеленела бы и от сторожа «встал»:
// молчащего работника гасит он же, и статус выходит тот же самый.
expect(out.reason).toMatch(/седьмую часть/i);
const spans = readJson(join(wdir, 'worker.json')).spans;
expect(billableMs(spans)).toBeLessThan(8 * H); // до восьми часов ему было далеко
});
it('в строке сводки сказано про ЗАПАС, а не про время', async () => {
await runK2({ probeDisk: DISK_OK, script: [c => { sayLimit(c, 0.20); finishNormally(c); }] });
await runK2({ probeDisk: DISK_OK, script: [c => sayLimit(c, 0.20 + 1 / 7)] });
// Имя файла сводки берётся из одного места (paths.mjs) и здесь тоже: набери его руками —
// проверка сторожила бы копию, а не правило.
const lines = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(lines).toMatch(/запас/i);
expect(lines).not.toMatch(/восемь часов/i);
});
});
describe('своя доля работника — разогнавшийся гаснет ОДИН', () => {
it('в прогоне двое: мой выбрал свою половину седьмой части — гаснет он, и причина его', async () => {
twoWorkersInRun();
await runK2({ probeDisk: DISK_OK, script: [c => { sayLimit(c, 0.20); finishNormally(c); }] });
const { out, child } = await runK2({ probeDisk: DISK_OK, script: [c => sayLimit(c, 0.20 + 1 / 14)] });
expect(child.killed).toBe(true);
expect(out.reason).toMatch(/свою долю/i);
});
it('тот же замер, но работник в прогоне ОДИН — не гаснет: состав читается из `run.json`', async () => {
// 🔴 Вот эта пара проверок и доказывает, что число работников берётся из карточки прогона,
// а не выдумано. Убери чтение `run.json` — покраснеет либо эта, либо предыдущая.
await runK2({ probeDisk: DISK_OK, script: [c => { sayLimit(c, 0.20); finishNormally(c); }] });
const { out, child } = await runK2({
probeDisk: DISK_OK,
script: [c => { sayLimit(c, 0.20 + 1 / 14); finishNormally(c); }],
});
expect(child.killed).toBe(false);
expect(out.reason).toMatch(/закончил своё дело сам/i);
});
it('разогнался СОСЕД — мой работает дальше и кончает сам: чужой вклад его не гасит', async () => {
// 🪤 Прибор общий на весь счёт: чужая трата видна и в общем приросте. Погасить по ней
// своего работника значит погасить невиновного.
twoWorkersInRun();
const mpath = join(dir, 'budget-measures.jsonl');
recordMeasure(mpath, { utilization: 0.20, resets_at: R1, at: T0 - 2000, run_id: 'r-1', phase: 'start' });
recordMeasure(mpath, { utilization: 0.20 + 1 / 14, resets_at: R1, at: T0 - 1000, run_id: 'r-1', worker_id: 'w-2' });
const { out } = await runK2({ probeDisk: DISK_OK, script: [c => finishNormally(c)] });
expect(out.reason).toMatch(/закончил своё дело сам/i);
expect(out.reason).not.toMatch(/долю/i);
});
});
describe('место на сервере меряется КАЖДЫЙ круг', () => {
it('места нет — гаснет, и в сводке отдельная строка «будить владельца»', async () => {
const { out, child } = await runK2({
probeDisk: async () => ({ ok: false, free_gb: 1.5, min_gb: 5, reason: 'На сервере кончается место: свободно 1.5 ГБ при пороге 5 ГБ.' }),
script: [() => {}],
});
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
const lines = readLines(join(rdir, SUMMARY_LINES_FILE));
expect(lines.some(l => l.wake_owner === true)).toBe(true);
expect(lines.find(l => l.wake_owner === true).line).toMatch(/ТРЕВОГА/);
});
it('замерить место не удалось — гаснет так же: это запрет, а не «наверное хватит»', async () => {
const { out } = await runK2({
probeDisk: async () => ({ ok: false, free_gb: null, min_gb: 5, reason: 'Свободное место замерить не удалось — работать вслепую нельзя.' }),
script: [() => {}],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/замерить не удалось/i);
});
it('без подмены круг мерит НАСТОЯЩИЙ диск, а не отвечает «всё хорошо»', async () => {
// 🔴 Ворота, которым нечем питаться, всегда отвечают «всё хорошо» и пропускают что угодно.
// Замер места здесь НЕ подаётся вовсе — это и есть суть проверки: круг обязан взять
// настоящий замер сам. Ставим порог до небес: настоящий замер обязан его не пройти.
// Верни в `supervise` подставной `probeDisk`, который всегда `{ ok: true }`, — покраснеет
// здесь. Подай сюда `probeDisk: DISK_OK` — покраснеет тоже, и это правильно.
const { out } = await runK2({
cfg: { ...CFG2, disk_free_min_gb: 1e9 },
script: [() => {}],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/место/i);
});
it('прежние проверки куска 1 НЕ зависят от свободного места на машине', async () => {
// 🔴 Настоящий замер места (шаг 5) встаёт под ВСЕ 49 прежних проверок надзирателя:
// помощник куска 1 `runSupervisor` своего `probeDisk` не подавал. Упади свободное
// место на машине разработки ниже порога — покраснели бы разом 49 зелёных проверок
// по причине, к ним не относящейся, и виноватой выглядела бы последняя правка.
// Поэтому у `runSupervisor` теперь свой подставной замер места (шаг 5, вторая правка).
// Порог до небес: живой замер его заведомо не прошёл бы. Убери подставной замер
// из `runSupervisor` — покраснеет здесь.
const { out } = await runSupervisor({
cfg: { ...CFG, disk_free_min_gb: 1e9 },
script: [c => finishNormally(c)],
});
expect(out.reason).toMatch(/закончил своё дело сам/i);
expect(out.reason).not.toMatch(/место/i);
});
});
// ============ правка К2: шесть дыр, найденных воротами ============
import { readFileSync } from 'node:fs';
import { stallExempt, changedFilesOfWorker as changedFilesReal, DISK_BAD_PROBES_DEFAULT } from './supervisor.mjs';
import { budgetMeasuresPath } from './paths.mjs';
import { buildSummary } from './summary.mjs';
import { morningCutoff } from './limits.mjs';
// Замер места «не удалось» — им проверяется, что осечка требует подтверждения.
const DISK_NE_ZAMERILI = { ok: false, free_gb: null, min_gb: 5, reason: 'Свободное место замерить не удалось — работать вслепую нельзя.' };
describe('дыра 1: живой вход обязан сказать надзирателю, ГДЕ журнал замеров', () => {
// 🔴 Проверять это на `supervise()` бесполезно: любая проверка круга подаёт путь руками
// и потому слепа к тому, что живой вход его не подаёт. Поэтому здесь зовётся сам `main()`,
// а `supervise` подменён — смотрим ровно на состав доводов, который он собирает.
async function mainArgs(extra = {}) {
const cfgFile = join(dir, 'night.config.json');
writeJsonAtomic(cfgFile, { ...CFG2, worker_model: 'голова-работника' });
let got = null;
const out = await main(['node', 'supervisor.mjs', '--run', 'r-1', '--worker', 'w-1'], {
cfgFile,
workerDirFn: () => wdir,
superviseFn: async args => { got = args; return { status: 'extinguished', reason: 'подмена', lines: [] }; },
...extra,
});
return { got, out };
}
it('main() кладёт путь к журналу замеров — без него весь предел недельного запаса мёртв', async () => {
// 🔴 Вырежи строку `measuresPath: budgetMeasuresPath()` из main() — покраснеет здесь.
// Живьём это выглядело бы так: ни один замер за ночь в журнал не попал (recordMeasure
// падает строкой в ленту), журнал «пуст», ответ по запасу «слепой», и по деньгам
// не гасит никогда — включая неприкосновенные две седьмых владельца.
const { got } = await mainArgs();
expect(got.measuresPath).toBeTruthy();
expect(got.measuresPath).toBe(budgetMeasuresPath());
});
it('вот цена пропущенного довода: без пути замер в журнал не ложится вовсе', () => {
// 🪤 Живьём это выглядело безобидно: `recordMeasure` падает внутри обработчика потока,
// надзиратель это переживает и пишет одну строку в ленту — а замеров за ночь нет ни
// одного. Журнал «пуст», ответ по запасу «слепой», и по деньгам не гасит НИКОГДА.
// 🔴 В настоящий журнал хозяйства эта проверка не пишет ни строки — только показывает,
// что бывает без пути.
expect(() => recordMeasure(undefined, { utilization: 0.3, at: T0, run_id: 'r-1' })).toThrow();
});
it('main() не растерял по дороге и остальные доводы — работника заводить всё ещё есть чем', async () => {
const { got } = await mainArgs();
expect(got.runId).toBe('r-1');
expect(got.workerId).toBe('w-1');
expect(got.worktreeRoot).toBe(worktree);
expect(got.workerDirPath).toBe(wdir);
expect(typeof got.spawnWorker).toBe('function');
});
});
describe('дыра 2: имя файла сводки знает ОДНО место', () => {
it('в надзирателе имя файла сводки не набрано руками ни разу', () => {
// 🔴 Сторож на возврат беды. Копия этого имени уже возвращалась второй раз — её нашли
// ворота прошлой смены в другом месте, починили, и она пришла снова, потому что
// сторожа не было. Имя для сравнения берётся из paths.mjs, а не набрано и здесь тоже.
const src = readFileSync(new URL('./supervisor.mjs', import.meta.url), 'utf8');
expect(src.includes(SUMMARY_LINES_FILE)).toBe(false);
expect(src).toContain('SUMMARY_LINES_FILE');
});
it('строка «разбудить владельца» ложится в файл, названный ЭТИМ именем, а не соседним', async () => {
// 🔴 Обе стороны — и запись, и чтение — берут имя из одного места. Верни в надзиратель
// строку-копию с другим именем файла — покраснеет здесь.
const { out } = await runK2({ probeDisk: async () => DISK_NE_ZAMERILI, script: [() => {}] });
expect(out.status).toBe('extinguished');
const lines = readLines(join(rdir, SUMMARY_LINES_FILE));
expect(lines.some(l => l.wake_owner === true)).toBe(true);
});
});
describe('дыра 3: «встал» не про тех, кто не работает нарочно', () => {
it('ждущий чужую службу молчит по построению — вставшим его не зовут', async () => {
// 🔴 Порог молчания — тридцать минут, круг здесь тридцать один. Без починки работник
// был бы назван «вставшим» на втором же круге, и владелец пошёл бы утром искать поломку
// там, где её нет. С починкой он доживает до СВОЕГО предела в два часа и гаснет
// с честной причиной. Верни `isStalled` на любое состояние — покраснеет здесь.
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_service', item: '5' });
const { out } = await runK2({
probeDisk: DISK_OK, tickMs: 31 * 60_000, script: [() => {}],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/служба не ответила/i);
expect(out.reason).not.toMatch(/признак/i);
});
it('стоящий в чистом простое доживает до ответа владельца, а не назван вставшим', async () => {
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_answer_idle', item: '5' });
const { out } = await runK2({
probeDisk: DISK_OK, tickMs: 31 * 60_000,
script: [() => {}, () => {}, c => finishNormally(c)],
});
expect(out.reason).toMatch(/закончил своё дело сам/i);
expect(out.reason).not.toMatch(/признак/i);
});
it('молчащий РАБОТНИК вставшим по-прежнему зовётся — исключение не съело само правило', async () => {
writeJsonAtomic(join(inbox, 'status.json'), { status: 'working', item: '5' });
let calls = 0;
const head = () => (calls++ === 0 ? 'sha-1' : null);
const { out, child } = await runK2({
probeDisk: DISK_OK, tickMs: 31 * 60_000, script: [() => {}], head,
});
expect(child.killed).toBe(true);
expect(out.status).toBe('stalled');
expect(out.reason).toMatch(/признак/i);
});
it('список исключений СПРОШЕН у правила отсечки, а не переписан рядом', () => {
// 🔴 Своего списка рядом нет: ответ идёт за правилом. Подменим правило — ответ обязан
// пойти за подменой. Перепиши список состояний в надзирателе своими руками —
// покраснеют две последние строки.
expect(stallExempt('working')).toBe(false);
expect(stallExempt('waiting_answer_busy')).toBe(false);
expect(stallExempt('waiting_service')).toBe(true);
expect(stallExempt('waiting_answer_idle')).toBe(true);
expect(stallExempt('paused')).toBe(true);
expect(stallExempt('working', () => ({ extinguish: false }))).toBe(true);
expect(stallExempt('waiting_service', () => ({ extinguish: true }))).toBe(false);
});
it('исключения совпадают с теми, кого бережёт САМА отсечка — по всем словам работника', () => {
// Сверка идёт с живым правилом (limits.mjs), а не с переписанным списком: разъедься
// они — покраснеет здесь, а не ночью на живом работнике.
const startedAt = Date.parse('2026-01-01T02:00:00+03:00');
const nowMsk10 = Date.parse('2026-01-01T10:00:00+03:00');
for (const status of WORKER_STATUSES) {
const beregyot = !morningCutoff({ status, runStartedAt: startedAt }, nowMsk10).extinguish;
expect(stallExempt(status)).toBe(beregyot);
}
});
});
describe('дыра 4: отсечка 8:00 считается от старта ПРОГОНА', () => {
it('надзиратель завёлся в 8:10, а прогон шёл с вечера — отсечка его достала', async () => {
// 🔴 Углы работников строятся по одному и долго: седьмой надзиратель заводится, когда
// прогон идёт уже час и больше. Считай он от себя — тихих часов «не задел», отсечка
// не применилась бы вовсе, и он жёг бы запас днём вместе с владельцем свои восемь часов.
// Верни `runStartedAt: startedAt` (своё время) — покраснеет здесь: причина станет
// «восемь часов» вместо отсечки.
const { out, child } = await runK2({
probeDisk: DISK_OK,
tickMs: 30 * 60_000,
startAt: Date.parse('2026-08-02T08:10:00+03:00'),
runStartedAt: Date.parse('2026-08-01T22:00:00+03:00'),
head: liveHead(),
script: [() => {}],
});
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/8:00/);
expect(out.reason).not.toMatch(/восемь часов/i);
});
it('времени старта прогона в run.json нет — сказано ВСЛУХ, а не подставлено молча', async () => {
const { out } = await runK2({
probeDisk: DISK_OK, runStartedAt: null, script: [c => finishNormally(c)],
});
expect(out.status).toBe('extinguished');
const lines = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(lines).toMatch(/время старта прогона не записано/i);
expect(readLines(join(rdir, 'events.jsonl')).map(e => e.kind)).toContain('run_started_at_missing');
});
it('время старта прогона есть — лишней строки владельцу не пишем', async () => {
const { out } = await runK2({ probeDisk: DISK_OK, script: [c => finishNormally(c)] });
expect(out.status).toBe('extinguished');
const lines = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(lines).not.toMatch(/время старта прогона не записано/i);
});
});
describe('дыра 5: одна осечка замера места — ещё не приговор', () => {
it('первая осечка не гасит и владельца среди ночи не будит', async () => {
// 🔴 Каталог мигнул, антивирус подержал, угол убрали — место при этом целое. Первая же
// ложная побудка обесценивает единственный ночной сигнал, ради которого сделано
// исключение из тихих часов. Убери подтверждение — покраснеет здесь.
let calls = 0;
const probeDisk = async () => {
calls += 1;
return calls === 1 ? DISK_NE_ZAMERILI : { ok: true, free_gb: 100, min_gb: 5, reason: null };
};
const { out, child } = await runK2({ probeDisk, script: [c => finishNormally(c)] });
expect(child.killed).toBe(false);
expect(out.reason).toMatch(/закончил своё дело сам/i);
expect(readLines(join(rdir, SUMMARY_LINES_FILE)).some(l => l.wake_owner === true)).toBe(false);
expect(readLines(join(rdir, 'events.jsonl')).map(e => e.kind)).toContain('disk_probe_unconfirmed');
});
it('осечка подтвердилась вторым кругом — гасим и будим: «не замерили — значит нельзя» осталось', async () => {
// 🔴 Правило НЕ отменено: подтвердившееся «не замерили» гасит так же, как нехватка места.
let calls = 0;
const probeDisk = async () => { calls += 1; return DISK_NE_ZAMERILI; };
const { out, child } = await runK2({ probeDisk, script: [() => {}] });
expect(calls).toBe(2);
expect(child.killed).toBe(true);
expect(out.reason).toMatch(/замерить не удалось/i);
expect(readLines(join(rdir, SUMMARY_LINES_FILE)).some(l => l.wake_owner === true)).toBe(true);
});
it('хороший замер посередине обнуляет счёт — подтверждением считаются осечки ПОДРЯД', async () => {
const otvety = [DISK_NE_ZAMERILI, { ok: true, free_gb: 100, min_gb: 5, reason: null }, DISK_NE_ZAMERILI];
let calls = 0;
const probeDisk = async () => otvety[calls++] ?? { ok: true, free_gb: 100, min_gb: 5, reason: null };
const { out, child } = await runK2({ probeDisk, script: [() => {}, () => {}, c => finishNormally(c)] });
expect(child.killed).toBe(false);
expect(out.reason).toMatch(/закончил своё дело сам/i);
});
it('сколько осечек подряд считать ответом — из настроек, а не числом в коде', async () => {
let calls = 0;
const probeDisk = async () => { calls += 1; return DISK_NE_ZAMERILI; };
const { child } = await runK2({
cfg: { ...CFG2, disk_bad_probes: 4 }, probeDisk, script: [() => {}],
});
expect(calls).toBe(4);
expect(child.killed).toBe(true);
expect(DISK_BAD_PROBES_DEFAULT).toBeGreaterThan(1);
});
});
describe('дыра 6: сбой git — это «не знаю», а не «чисто»', () => {
it('git не ответил — список изменённых файлов не пустой, а отсутствующий', () => {
const molchit = () => { throw new Error('git не ответил'); };
expect(changedFilesReal(worktree, 'feat/night-dispatcher-part1', molchit)).toBeNull();
// Сравнивать не с чем — тоже «не знаю», а не «он ничего не трогал».
expect(changedFilesReal(worktree, null, () => 'что угодно')).toBeNull();
});
it('пустой список остался за НАСТОЯЩЕЙ чистотой', () => {
const t = whatWorkerTouched({ worktreeRoot: worktree, baseBranch: 'b', changedFiles: () => [] });
expect(t).toMatchObject({ money: false, rights: false, client_visible: false, approximate: true });
});
it('владелец читает «сведений нет», а не «деньги — следов не видно»', () => {
// 🔴 Вот цена дыры: владелец принял бы за спокойную ночь ту, в которой трогали деньги.
// Ветка «сведений нет» в сводке была готова, но до неё не доходило дело.
// Верни пустой список на сбой git — покраснеет здесь.
writeJsonAtomic(join(inbox, 'touched.json'), { money: true });
const t = whatWorkerTouched({ worktreeRoot: worktree, baseBranch: 'b', changedFiles: () => null });
expect(t).toBeNull();
const { text } = buildSummary({
run_id: 'r-1',
workers: [{ worker_id: 'w-1', status: 'extinguished', touched: t }],
now: T0,
});
expect(text).toMatch(/сведений нет/i);
expect(text).not.toMatch(/деньги — следов не видно/i);
});
it('в живом круге сбой git пишет в карточку «не знаю» и отдельную запись в ленту', async () => {
const { out } = await runK2({
probeDisk: DISK_OK,
script: [c => finishNormally(c)],
changedFiles: (root, base) => changedFilesReal(root, base, () => { throw new Error('git не ответил'); }),
});
expect(out.touched).toBeNull();
expect(readJson(join(wdir, 'worker.json')).touched).toBeNull();
expect(readLines(join(rdir, 'events.jsonl')).map(e => e.kind)).toContain('touched_unknown');
});
});
describe('общий предел гасит ВСЕХ: два надзирателя, один прогон, один журнал замеров', () => {
// Второго надзирателя поднимают ОБЕ проверки ниже, поэтому он вынесен в помощницу.
// 🔴 Проверка «про обоих», поднимающая одного, доказывает не то, что обещает именем.
async function podnyatVtorogo(mpath) {
const wdir2 = join(rdir, 'workers', 'w-2');
mkdirSync(wdir2, { recursive: true });
writeJsonAtomic(join(wdir2, 'worker.json'), {
worker_id: 'w-2', run_id: 'r-1', status: 'working', started_at: T0, workspace: worktree,
});
const clock = { t: T0 };
const child2 = fakeWorker();
const out2 = await supervise({
runId: 'r-1',
workerId: 'w-2',
worktreeRoot: worktree,
workerDirPath: wdir2,
runDirPath: rdir,
cfg: CFG2,
measuresPath: mpath,
commandsDirPath: join(dir, 'commands'),
qPath: join(dir, 'questions.jsonl'),
repliesPath: join(dir, 'owner-replies.jsonl'),
lastRunFile,
spawnWorker: () => child2,
now: () => clock.t,
sleep: async ms => { clock.t += ms; },
tickMs: 30_000,
probeDisk: DISK_OK,
head: () => 'sha-1',
changedFiles: () => [],
// 🔴 Куском 4: настоящие умолчания круга (прогон проверок, опрос угла, каталог
// следов) во временном каталоге звать нельзя — заявок у этой проверки нет,
// но подстраховаться дешевле, чем разбираться утром.
runTests: () => ({ ran: true, ok: true, code: 0, out: 'зелено', error: null }),
dirty: () => false,
dbName: 'night_r_1_w_2',
deferredPath: join(dir, 'deferred'),
});
return { out2, child2, wdir2 };
}
// Точка отсчёта прогона и весь его расход — на счету ПЕРВОГО работника.
function zhurnalGdeRazognalsyaPervyy(mpath) {
recordMeasure(mpath, { utilization: 0.20, resets_at: R1, at: T0 - 2000, run_id: 'r-1', phase: 'start' });
recordMeasure(mpath, { utilization: 0.20 + 1 / 7, resets_at: R1, at: T0 - 1000, run_id: 'r-1', worker_id: 'w-1' });
}
it('разогнался первый — гаснут оба, и второй не выбрал ни часов, ни своей доли', async () => {
twoWorkersInRun();
const mpath = join(dir, 'budget-measures.jsonl');
zhurnalGdeRazognalsyaPervyy(mpath);
// Первый надзиратель — за первым работником.
const first = await runK2({ probeDisk: DISK_OK, script: [() => {}] });
expect(first.out.status).toBe('extinguished');
expect(first.out.reason).toMatch(/седьмую часть/i);
// Второй надзиратель — за вторым работником, тем же прогоном, тем же журналом.
// Он не потратил НИЧЕГО и не отработал ни часа.
const { out2, child2, wdir2 } = await podnyatVtorogo(mpath);
expect(out2.status).toBe('extinguished');
expect(out2.reason).toMatch(/седьмую часть/i);
expect(out2.reason).not.toMatch(/свою долю/i); // он гаснет не за свой расход
expect(child2.killed).toBe(true);
// Часов у него ноль — и его всё равно погасили.
expect(billableMs(readJson(join(wdir2, 'worker.json')).spans)).toBeLessThan(2 * 60_000);
});
it('в сводке про ОБОИХ сказано, что упёрлись в запас, а не во время', async () => {
// 🔴 Раньше эта проверка поднимала ОДНОГО надзирателя, а имя обещало двоих: строки
// сводки про второго не было ни одной, и «про обоих» держалось на честном слове.
twoWorkersInRun();
const mpath = join(dir, 'budget-measures.jsonl');
zhurnalGdeRazognalsyaPervyy(mpath);
await runK2({ probeDisk: DISK_OK, script: [() => {}] });
await podnyatVtorogo(mpath);
const rows = readLines(join(rdir, SUMMARY_LINES_FILE));
const lines = rows.map(r => r.line).join(' ');
// Строка есть про КАЖДОГО из двоих — и у каждого сказано про запас.
expect(rows.some(r => r.worker_id === 'w-1')).toBe(true);
expect(rows.some(r => r.worker_id === 'w-2')).toBe(true);
expect(rows.filter(r => /запас/i.test(r.line)).length).toBeGreaterThanOrEqual(2);
expect(lines).toMatch(/запас/i);
expect(lines).not.toMatch(/восемь часов/i);
});
});
// ============ кусок 3: замок «двое не правят один файл» в живом круге ============
import { execFileSync as zapustit } from 'node:child_process';
import { mkdirSync as sozdatKatalog, writeFileSync as zapisatFayl, mkdtempSync as vremennyKatalog, rmSync as ubratKatalog, readFileSync as prochestFaylom } from 'node:fs';
import { tmpdir as vremennyKoren } from 'node:os';
import { resolveBin as naytiProgrammu } from './paths.mjs';
import { claimFiles as zanyatFayly, mayTouch as mozhnoLiTrogat } from './file-claims.mjs';
import { rollbackForeignFiles, closedItemsOfRun, changedFilesOrNull } from './supervisor.mjs';
const GIT3 = naytiProgrammu('git');
// Настоящее маленькое хранилище: точка ветвления `baza`, поверх неё один коммит работника,
// в котором он тронул СВОЙ файл, ЧУЖОЙ файл и завёл ЧУЖОЙ файл, которого раньше не было.
function repoRabotnika() {
const r = vremennyKatalog(join(vremennyKoren(), 'night-otkat-'));
zapustit(GIT3, ['init', '-q', '-b', 'baza'], { cwd: r });
// 🔴 Подпись тут — РАБОТНИКА, не ночи. Она нарочно другая: откат обязан ложиться
// подписью ночной затеи, и отличить одно от другого можно только так.
zapustit(GIT3, ['config', 'user.email', 'rabotnik@liderra.local'], { cwd: r });
zapustit(GIT3, ['config', 'user.name', 'работник'], { cwd: r });
sozdatKatalog(join(r, 'app'), { recursive: true });
zapisatFayl(join(r, 'app', 'A.php'), 'чужой файл до ночи\n');
zapisatFayl(join(r, 'app', 'B.php'), 'свой файл до ночи\n');
zapustit(GIT3, ['add', '.'], { cwd: r });
zapustit(GIT3, ['commit', '-qm', 'точка ветвления'], { cwd: r });
zapustit(GIT3, ['checkout', '-qb', 'night/r-1/w-2'], { cwd: r });
zapisatFayl(join(r, 'app', 'A.php'), 'работник полез в чужое\n');
zapisatFayl(join(r, 'app', 'B.php'), 'своя честная работа\n');
zapisatFayl(join(r, 'app', 'C.php'), 'чужой файл, которого раньше не было\n');
zapustit(GIT3, ['add', '.'], { cwd: r });
zapustit(GIT3, ['commit', '-qm', 'работа работника'], { cwd: r });
return r;
}
function izmenennye(r) {
return String(zapustit(GIT3, ['diff', '--name-only', 'baza...HEAD'], { cwd: r, encoding: 'utf8' }))
.split('\n').map(s => s.trim()).filter(Boolean);
}
describe('откат чужого файла — на НАСТОЯЩЕМ хранилище, а не на подставке', () => {
let repo;
beforeEach(() => { repo = repoRabotnika(); });
afterEach(() => { ubratKatalog(repo, { recursive: true, force: true }); });
it('чужой файл после отката исчез из списка изменённого — замок сработал по-настоящему', () => {
expect(izmenennye(repo)).toContain('app/A.php');
rollbackForeignFiles({ worktreeRoot: repo, baseBranch: 'baza', files: ['app/A.php'] });
expect(izmenennye(repo)).not.toContain('app/A.php');
});
it('своя работа осталась — откат съел ровно чужое', () => {
// 🔴 Решение диспетчера: откатывается ровно чужой файл, а не весь коммит. Иначе
// работнику нечем было бы продолжать с другого пункта, и замок губил бы честную ночь.
rollbackForeignFiles({ worktreeRoot: repo, baseBranch: 'baza', files: ['app/A.php'] });
expect(izmenennye(repo)).toContain('app/B.php');
});
it('чужой файл, которого в точке ветвления не было, откат УДАЛЯЕТ', () => {
// 🪤 `git checkout baza -- app/C.php` на такой файл падает: в точке ветвления его нет.
// Не обработай мы это — новый чужой файл оставался бы в ветке, и замок врал бы.
const r = rollbackForeignFiles({ worktreeRoot: repo, baseBranch: 'baza', files: ['app/C.php'] });
expect(r.deleted).toContain('app/C.php');
expect(izmenennye(repo)).not.toContain('app/C.php');
});
it('откат СОХРАНЁН коммитом, и подписан НОЧЬЮ, а не работником', () => {
// 🔴 Подпись задаётся при самом зове, а не берётся из настроек угла. Две причины,
// и обе живые: во-первых, у машины может не быть общей подписи вовсе — тогда
// `git commit` падает, откат уходит в ошибки, а чужой файл остаётся правленым;
// во-вторых, откат — работа ночи, а не работника, и в его истории он обязан
// выглядеть чужой рукой, а не его собственной.
const r = rollbackForeignFiles({ worktreeRoot: repo, baseBranch: 'baza', files: ['app/A.php', 'app/C.php'] });
expect(r.committed).toBe(true);
expect(r.restored).toContain('app/A.php');
expect(r.errors).toEqual([]);
const kto = String(zapustit(GIT3, ['log', '-1', '--format=%ae'], { cwd: repo, encoding: 'utf8' })).trim();
expect(kto).toBe('noch@liderra.local');
});
it('🔴 откат упал по ДРУГОЙ причине — файл НЕ удаляется', () => {
// 🔴🔴 Слепое `git rm -f` на любой сбой — это починка чужой беды своим вредительством.
// `git checkout` падает не только тогда, когда файла не было в точке ветвления:
// угол бывает занят, ветки может не быть, диск может кончиться. Удали мы файл
// в этих случаях — замок съел бы работу, которую обязан был сберечь.
const r = rollbackForeignFiles({ worktreeRoot: repo, baseBranch: 'vetki-takoy-net', files: ['app/A.php'] });
expect(r.deleted).toEqual([]);
expect(r.restored).toEqual([]);
expect(r.errors.length).toBe(1);
expect(r.committed).toBe(false);
});
it('откатывать нечего — коммита не делается и ошибок нет', () => {
const r = rollbackForeignFiles({ worktreeRoot: repo, baseBranch: 'baza', files: [] });
expect(r.committed).toBe(false);
expect(r.errors).toEqual([]);
});
});
describe('🔴 сбой `git` отличается от чистоты — на настоящем хранилище', () => {
// 🔴 ПЛАН ЗДЕСЬ СОВРАЛ, И ГОВОРЮ ЭТО ВСЛУХ. Он утверждает, что у `changedFilesOfWorker`
// куска 1 стоит `catch { return []; }` и потому сбой неотличим от чистоты. В файле стоит
// `catch { return null; }` — кусок 2 починил это своей правкой «дыра 6». Поэтому вторая
// копия той же команды `git` здесь НЕ заводится: две копии одного правила расходятся
// молча, об этом хозяйство предупреждает в четырёх местах. `changedFilesOrNull` — то самое
// правило под именем, которое зовёт круг; проверки ниже сторожат, что оно так и осталось
// отличать сбой (`null`) от честной чистоты (пустой список).
let repo;
beforeEach(() => { repo = repoRabotnika(); });
afterEach(() => { ubratKatalog(repo, { recursive: true, force: true }); });
it('живое хранилище — отдаётся список тронутого, как и раньше', () => {
expect(changedFilesOrNull(repo, 'baza')).toContain('app/A.php');
});
it('каталог не хранилище вовсе — `null`, а НЕ пустой список', () => {
const pusto = vremennyKatalog(join(vremennyKoren(), 'night-ne-repo-'));
try {
expect(changedFilesOrNull(pusto, 'baza')).toBeNull();
} finally {
ubratKatalog(pusto, { recursive: true, force: true });
}
});
it('ветки-основы с таким именем нет — тоже `null`, а не «ничего не трогал»', () => {
// Ровно этот ответ уходит в `auditWorkerFiles`, и он объявляет сверку несделанной.
expect(changedFilesOrNull(repo, 'vetka-kotoroy-net')).toBeNull();
});
it('хранилище живо и работник вправду ничего не трогал — пустой список, а не `null`', () => {
// 🔴 Обратная сторона. Отдавай мы `null` на всякий случай — замок отказывал бы каждую
// ночь в первые полчаса, пока никто не сделал ни одного коммита, и его бы выключили.
const chistyy = vremennyKatalog(join(vremennyKoren(), 'night-chisto-'));
try {
zapustit(GIT3, ['init', '-q', '-b', 'baza'], { cwd: chistyy });
zapustit(GIT3, ['config', 'user.email', 'noch@liderra.local'], { cwd: chistyy });
zapustit(GIT3, ['config', 'user.name', 'ночная затея'], { cwd: chistyy });
zapisatFayl(join(chistyy, 'readme.md'), 'до ночи\n');
zapustit(GIT3, ['add', '.'], { cwd: chistyy });
zapustit(GIT3, ['commit', '-qm', 'точка ветвления'], { cwd: chistyy });
expect(changedFilesOrNull(chistyy, 'baza')).toEqual([]);
} finally {
ubratKatalog(chistyy, { recursive: true, force: true });
}
});
it('🔴 СВЕРХ ПЛАНА: правило про тронутое — ОДНО на затею, а не копия рядом', () => {
// 🔴 Сторож на класс, а не на место. Проверки выше зеленели бы и у второй копии команды
// `git`, набранной рядом, — а копии расходятся молча: поправят одну, забудут другую,
// и замок откроется ровно там, где никто не смотрит. Сравниваем сам предмет, как это
// уже сделано в этом же файле со списком русских слов состояний.
expect(changedFilesOrNull).toBe(changedFilesReal);
});
});
describe('надзиратель ЗОВЁТ замок каждый круг (проверки 51 и 52)', () => {
function planPunktov(items, workers = ['w-1', 'w-2']) {
writeJsonAtomic(join(rdir, 'run.json'), {
run_id: 'r-1', status: 'running', task_id: 'r-1', workers, items,
});
}
it('полез в чужой файл — приказ на откат отдан РОВНО по чужому файлу', async () => {
zanyatFayly(rdir, 'w-2', ['app/A.php']);
zanyatFayly(rdir, 'w-1', ['app/B.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }, { id: '2', files: ['app/A.php'] }]);
const otkaty = [];
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: () => ['app/B.php', 'app/A.php'],
rollbackFn: ({ files }) => {
otkaty.push(...files);
return { restored: files, deleted: [], committed: true, errors: [] };
},
});
expect(otkaty).toEqual(['app/A.php']);
});
it('в сводке есть строка о пересечении — с именем файла и именем хозяина', async () => {
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }]);
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: () => ['app/A.php'],
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroki).toContain('app/A.php');
expect(stroki).toContain('w-2');
expect(stroki).toContain('w-1');
});
it('работник УЗНАЛ: в ящик лёг claims.json с отклонённым файлом и номером свободного пункта', async () => {
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }, { id: '2', files: ['app/A.php'] }]);
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: () => ['app/A.php'],
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
const zayavka = readJson(join(inbox, 'claims.json'));
expect(zayavka.denied[0].file).toBe('app/A.php');
expect(zayavka.denied[0].clash_with).toBe('w-2');
expect(zayavka.next_item).toBe('1');
});
it('🔴 работнику выдаётся ДРУГОЙ пункт, а не его собственный', async () => {
// 🔴🔴 Шов с задачей 3 и с живой пробой замка. Карточка работника здесь говорит, что
// он стоит на пункте «1». Не подай круг этот номер в `nextFreeItem` — правило вернуло
// бы ему пункт «1» обратно (файлы своего пункта заняты им самим и своему пункту
// не мешают), и работник крутился бы на месте до утра.
writeJsonAtomic(join(wdir, 'worker.json'), {
worker_id: 'w-1', run_id: 'r-1', status: 'working', started_at: T0, workspace: worktree,
item: { id: '1', files: ['app/B.php'] },
});
zanyatFayly(rdir, 'w-1', ['app/B.php']);
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([
{ id: '1', files: ['app/B.php'] }, // его собственный
{ id: '2', files: ['app/A.php'] }, // чужой, за w-2
{ id: '3', files: ['app/C.php'] }, // свободный
]);
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: () => ['app/A.php'],
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
expect(readJson(join(inbox, 'claims.json')).next_item).toBe('3');
});
it('🔴 выданный свободный пункт ЗАНЯТ за работником в ту же минуту, а не «назван и забыт»', async () => {
// 🔴🔴 Решение диспетчера по вопросу 6, путь «а». Назвать номер и не запереть файлы —
// значит позволить двум надзирателям в одну секунду выдать своим работникам ОДИН
// и тот же пункт. Ради этого весь кусок и построен.
// 🪤 Смотреть занятия ПОСЛЕ прогона нельзя: `finish()` освобождает файлы погасшего
// работника, и список окажется пуст при любом коде. Поэтому снимаем список
// занятых на КАЖДОМ круге — вторым кругом видно, что файлы пункта «3» уже за w-1.
writeJsonAtomic(join(wdir, 'worker.json'), {
worker_id: 'w-1', run_id: 'r-1', status: 'working', started_at: T0, workspace: worktree,
item: { id: '1', files: ['app/B.php'] },
});
zanyatFayly(rdir, 'w-1', ['app/B.php']);
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([
{ id: '1', files: ['app/B.php'] }, // его собственный
{ id: '2', files: ['app/A.php'] }, // чужой, за w-2
{ id: '3', files: ['app/C.php'] }, // свободный — его и выдадут
]);
const snimki = [];
await runSupervisor({
tickMs: 1000,
script: [() => {}, c => c.die(0)],
changedFiles: () => {
snimki.push(readJson(join(rdir, 'file-claims.json')) ?? {});
return ['app/A.php'];
},
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
// 🪤 Список занятий хранится по КЛЮЧУ СРАВНЕНИЯ, а не по имени файла — решение
// владельца 7а-ИСПРАВЛЕНО от 02.08.2026: словарь занятий отвечает на вопрос «это один
// и тот же файл?», значит и ключи в нём такие же (`APP/A.php` и `app/A.php` обязаны
// попасть в одну ячейку). Спрашиваем его тем же прибором, а не набранной строчной
// строкой: приклей я сюда `app/c.php`, проверка проверяла бы мою память о правиле,
// а не само правило.
expect(snimki.length).toBeGreaterThan(1);
expect(snimki[0][klyuchSravneniyaPuti('app/C.php')]).toBeUndefined(); // до выдачи пункт ничей
expect(snimki[1][klyuchSravneniyaPuti('app/C.php')]).toBe('w-1'); // выдали — и он тут же занят
});
it('пойманного НЕ гасят, пока есть куда идти (решение диспетчера)', async () => {
// 🔴🔴 Работник ПЕРЕЖИВАЕТ три круга, будучи пойманным на каждом, и только потом
// кончается сам. Дай мы ему умереть на первом же круге — проверка зеленела бы
// и у противоположного кода: гасили бы мы пойманного или нет, он всё равно
// успел бы кончиться раньше, и «не убит» ничего бы не значило.
// 🔴🔴 ПЕРЕПИСАНО ПРОТИВ ПЛАНА, и говорю вслух почему. В плановом виде проверка была
// зелёной ДО написания кода: она ждала, что ничего не случилось, — а без зова замка
// ничего и не случается. Такая проверка не сторожит ничего. Теперь она сперва
// ДОКАЗЫВАЕТ, что работника ловили на каждом из трёх кругов (три зова отката, три
// строки владельцу), и только потом — что его при этом не погасили.
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }]);
let lovili = 0;
const { out, child } = await runSupervisor({
tickMs: 1000,
script: [() => {}, () => {}, c => c.die(0)],
changedFiles: () => ['app/A.php'],
rollbackFn: () => {
lovili += 1;
return { restored: ['app/A.php'], deleted: [], committed: true, errors: [] };
},
});
// Ловили на КАЖДОМ круге — счётчика прощений нет.
expect(lovili).toBe(3);
const pojman = readLines(join(rdir, SUMMARY_LINES_FILE))
.filter(r => /полез в чужой файл/i.test(String(r.line))).length;
expect(pojman).toBe(3);
// Работник кончился САМ, а не был убит замком: гашение — только когда брать нечего.
expect(child.killed).toBe(false);
expect(out.status).not.toBe('extinguished');
expect(out.reason).not.toMatch(/свободного пункта/i);
});
it('🔴 про ПРОВАЛ отката сказано ОДИН раз, а не каждые тридцать секунд', async () => {
// 🔴 Тот же класс, что и у несделанной сверки: работник живёт часами, откат падает
// каждый круг (нет подписи, занят угол), и к утру сводка была бы из сотен одинаковых
// строк, в которых потерялось бы всё остальное.
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }]);
await runSupervisor({
tickMs: 1000,
script: [() => {}, () => {}, c => c.die(0)],
changedFiles: () => ['app/A.php'],
rollbackFn: () => ({
restored: [], deleted: [], committed: false,
errors: [{ file: 'app/A.php', error: 'угол занят' }],
}),
});
const skolko = readLines(join(rdir, SUMMARY_LINES_FILE))
.filter(r => /НЕ УДАЛСЯ/i.test(String(r.line))).length;
expect(skolko).toBe(1);
});
it('🔴 закрытый пункт второй раз НЕ выдаётся — закрытые вправду читаются', async () => {
// 🔴🔴 Без этой проверки `closedItemsOfRun` можно было бы вырезать целиком, и не
// покраснело бы ничего: в куске 3 файл закрытых пунктов не пишет никто, ответ всегда
// пуст. Здесь мы кладём строку в этот файл РУКОЙ — и тогда видно, читают его или нет.
// 🪤 Это не «доказали закрытие пункта» (его строит кусок 4), а «доказали, что вход
// для него подан и работает». Говорю вслух, чтобы не сочли большим, чем оно есть.
// 🔴 Правка куска 4, названа вслух в его задаче 5: закрытые пункты берутся теперь
// из ПРИГОВОРОВ надзирателя (`items-verified.jsonl` в каталоге работника), а не
// из слова работника (`closed-items.jsonl` в его углу). Смысл проверки не меняется:
// она по-прежнему доказывает ровно одно — вход для закрытых пунктов подан и читается.
sozdatKatalog(wdir, { recursive: true });
zapisatFayl(join(wdir, 'items-verified.jsonl'), JSON.stringify({ item: '3', tests_green: true }) + '\n');
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([
{ id: '1', files: ['app/A.php'] }, // чужой, за w-2
{ id: '3', files: ['app/C.php'] }, // свободен, но УЖЕ ЗАКРЫТ
]);
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: () => ['app/A.php'],
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
expect(readJson(join(inbox, 'claims.json')).next_item).toBeNull();
});
it('свободного пункта не осталось — погас с ПОМЕТКОЙ, а не крутится впустую (проверка 52)', async () => {
zanyatFayly(rdir, 'w-2', ['app/A.php', 'app/B.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }, { id: '2', files: ['app/A.php'] }]);
const { out, child } = await runSupervisor({
tickMs: 1000,
script: [() => {}],
changedFiles: () => ['app/A.php'],
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/свободного пункта/i);
expect(readJson(join(wdir, 'worker.json')).finish_reason).toMatch(/свободного пункта/i);
});
it('ходил только по своим — ни отката, ни строки, ни claims.json', async () => {
// 🔴 Страховка от замка, который отказывает ВСЕГДА: он зеленил бы пять проверок выше
// и убивал бы каждую ночь на первом же коммите работника.
// 🔴🔴 ПЕРЕПИСАНО ПРОТИВ ПЛАНА, и говорю вслух почему. В плановом виде проверка была
// зелёной ДО написания кода: «ни отката, ни строки, ни claims.json» — ровно то, что
// видно и у надзирателя, который замок не зовёт ВОВСЕ. Молчание чистого замка
// неотличимо от молчания незваного — это тот же класс, что и долг 17, только с другой
// стороны. Поэтому круг оставляет один-единственный след «сверка состоялась, чисто»
// (первый удавшийся раз за жизнь работника), и проверка требует его.
zanyatFayly(rdir, 'w-1', ['app/B.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }]);
let zvali = 0;
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: () => ['app/B.php'],
rollbackFn: () => { zvali += 1; return { restored: [], deleted: [], committed: false, errors: [] }; },
});
// Замок ЗВАЛСЯ и сказал «чисто» — а не промолчал, потому что его никто не позвал.
expect(readLines(join(rdir, 'events.jsonl')).map(e => e.kind)).toContain('claims_audit_ok');
expect(zvali).toBe(0);
expect(existsSync(join(inbox, 'claims.json'))).toBe(false);
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroki).not.toMatch(/полез в чужой файл/i);
});
it('хранилище не ответило — сверка объявлена НЕСДЕЛАННОЙ, и откат не идёт', async () => {
// 🪤 «Успех» с пустым ответом — не успех. Пустой список при сбое `git` — это сбой сверки,
// а не «работник ничего не трогал»; замок отказывает, а не пропускает.
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }]);
let zvali = 0;
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
head: () => null,
changedFiles: () => [],
rollbackFn: () => { zvali += 1; return { restored: [], deleted: [], committed: false, errors: [] }; },
});
expect(zvali).toBe(0);
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroki).toMatch(/сверка занятых файлов НЕ СДЕЛАНА/i);
});
it('про несделанную сверку сказано ОДИН раз, а не каждые тридцать секунд', async () => {
// Иначе к утру сводка была бы из шестисот одинаковых строк, и в ней потерялось бы всё.
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }]);
await runSupervisor({
tickMs: 1000,
script: [() => {}, () => {}, c => c.die(0)],
head: () => null,
changedFiles: () => [],
});
const skolko = readLines(join(rdir, SUMMARY_LINES_FILE))
.filter(r => /НЕ СДЕЛАНА/i.test(String(r.line))).length;
expect(skolko).toBe(1);
});
it('🔴 хранилище ЕСТЬ, а `git` не ответил — это тоже НЕСДЕЛАННАЯ сверка', async () => {
// 🔴 Беда живой ночи: ветка на месте, коммит на месте, а `git diff` упал — например,
// подрался за `index.lock` с самим работником, который в эту секунду коммитит.
// Ответ на такой сбой обязан быть `null`, а не пустой список, неотличимый
// от «ничего не трогал»: иначе замок объявил бы сверку сделанной и чистой,
// то есть молча открылся бы.
zanyatFayly(rdir, 'w-2', ['app/A.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }]);
let zvali = 0;
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
head: () => 'sha-1', // хранилище живо
changedFiles: () => null, // а вот `git diff` не ответил
rollbackFn: () => { zvali += 1; return { restored: [], deleted: [], committed: false, errors: [] }; },
});
expect(zvali).toBe(0);
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroki).toMatch(/сверка занятых файлов НЕ СДЕЛАНА/i);
});
it('🔴 УМОЛЧАНИЕ круга — `changedFilesOrNull`, а не что-нибудь ещё', () => {
// 🔴🔴 Без этой проверки весь шов держался бы на честном слове. Все проверки круга
// подают `changedFiles` подставкой явно — значит подмени кто-нибудь умолчание
// в подписи `supervise()`, и не покраснела бы ни одна.
// 🪤 Смотрим ТЕКСТ файла, а не поведение, и говорю это вслух: помощник `runSupervisor`
// подставляет свой `changedFiles` раньше, чем до дела доходит умолчание `supervise()`,
// и добраться до него поведением из проверки нечем.
// 🪤 Смотрим ровно ПОДПИСЬ `supervise()`, а не весь файл: план велел искать по всему
// тексту, но `whatWorkerTouched` в этом же файле честно имеет довод
// `changedFiles = changedFilesOfWorker` — по всему тексту проверка краснела бы
// на исправном коде. Датчик, который врёт на исправном, не датчик.
const tekst = prochestFaylom(new URL('./supervisor.mjs', import.meta.url), 'utf8');
const nachalo = tekst.indexOf('export async function supervise({');
expect(nachalo).toBeGreaterThan(-1); // не нашли подпись — это сбой датчика, а не чистота
const konec = tekst.indexOf('\n}) {', nachalo);
expect(konec).toBeGreaterThan(nachalo);
const podpis = tekst.slice(nachalo, konec);
expect(podpis).toMatch(/changedFiles\s*=\s*changedFilesOrNull\s*,/);
expect(podpis).not.toMatch(/changedFiles\s*=\s*changedFilesOfWorker/);
});
it('работник погас — его файлы освобождены, и другой их берёт', async () => {
// Не освободи — назавтра ночь начнётся с замков за мертвецами, и работать станет некому.
zanyatFayly(rdir, 'w-1', ['app/B.php']);
planPunktov([{ id: '1', files: ['app/B.php'] }]);
expect(mozhnoLiTrogat(rdir, 'w-2', 'app/B.php').ok).toBe(false);
await runSupervisor({ tickMs: 1000, script: [c => c.die(0)], changedFiles: () => [] });
expect(mozhnoLiTrogat(rdir, 'w-2', 'app/B.php').ok).toBe(true);
});
});
describe('🔴 ДОЛГ 17 (сверх плана): замок судит по каталогу ПРОГОНА — не тот каталог обязан быть слышен', () => {
// 🔴🔴 Замок берёт список занятых файлов из каталога прогона. Подай ему посторонний
// каталог — занятых там нет вовсе, `mayTouch` разрешает всё, и замок объявляет чистыми
// ВСЕХ работников разом, отчитавшись, что сверка состоялась. Никто не падает, все
// довольны, а сверки не было. Это тот же класс «успех с пустым ответом», только
// со стороны хранилища; изнутри замка (задача 4) отличить «законно пусто» от «смотрим
// не туда» нечем — там пустой список занятий законен (первый работник, все освободились).
// Поэтому сторож стоит здесь, у зовущего, и он на КЛАСС: не «нет файла занятий»,
// а «этот каталог вообще не про наш идущий прогон».
function chuzhoyKatalog(chto = null) {
const d = join(dir, 'chuzhoy-progon');
mkdirSync(d, { recursive: true });
if (chto) writeJsonAtomic(join(d, 'run.json'), chto);
return d;
}
async function progonV(katalog) {
zanyatFayly(rdir, 'w-2', ['app/A.php']); // чужой файл занят в НАСТОЯЩЕМ каталоге
let zvali = 0;
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
runDirPath: katalog,
changedFiles: () => ['app/A.php'], // работник вправду полез в чужое
rollbackFn: () => { zvali += 1; return { restored: [], deleted: [], committed: false, errors: [] }; },
});
return {
zvali,
stroki: readLines(join(katalog, SUMMARY_LINES_FILE)).map(r => String(r.line)).join(' '),
vidy: readLines(join(katalog, 'events.jsonl')).map(e => e.kind),
};
}
it('каталога прогона нет вовсе — сказано ВСЛУХ, а не «все чисты»', async () => {
const { zvali, stroki, vidy } = await progonV(chuzhoyKatalog());
expect(stroki).toMatch(/сверка занятых файлов НЕ СДЕЛАНА/i);
expect(stroki).toMatch(/каталог прогона/i);
expect(vidy).toContain('claims_audit_wrong_dir');
expect(zvali).toBe(0);
});
it('каталог ЧУЖОГО прогона — тоже не сверка, и в строке названо чьего', async () => {
// 🔴 Сторож на класс: «run.json на месте» мало. Каталог соседнего прогона выглядит
// настоящим до последнего знака, а занятых файлов НАШЕГО прогона в нём нет ни одного.
const { zvali, stroki, vidy } = await progonV(chuzhoyKatalog({
run_id: 'r-9', status: 'running', workers: ['w-1'], items: [{ id: '1', files: ['app/B.php'] }],
}));
expect(stroki).toMatch(/сверка занятых файлов НЕ СДЕЛАНА/i);
expect(stroki).toContain('r-9');
expect(vidy).toContain('claims_audit_wrong_dir');
expect(zvali).toBe(0);
});
it('каталог НАСТОЯЩИЙ — ни строки про «не тот каталог», замок работает как обычно', async () => {
// 🔴 Обратная сторона: сторож, отказывающий ВСЕГДА, зеленил бы обе проверки выше
// и убивал бы каждую ночь на первом же круге. Здесь каталог свой — и замок ловит.
zanyatFayly(rdir, 'w-2', ['app/A.php']);
let zvali = 0;
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: () => ['app/A.php'],
rollbackFn: () => { zvali += 1; return { restored: ['app/A.php'], deleted: [], committed: true, errors: [] }; },
});
expect(zvali).toBe(1);
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => String(r.line)).join(' ');
expect(stroki).toMatch(/полез в чужой файл/i);
expect(stroki).not.toMatch(/каталог прогона/i);
expect(readLines(join(rdir, 'events.jsonl')).map(e => e.kind)).not.toContain('claims_audit_wrong_dir');
});
});
// ============ кусок 3: команды снаружи и «стоп всё» ============
import { readCommand as readCmd } from './items.mjs';
import { commandsDir, runDir as katalogProgonaNastoyashchiy } from './paths.mjs';
const CMDDIR = () => join(dir, 'commands');
// Положить команду в каталог ровно так, как её кладёт панель или рука владельца.
function velet(name, body = { at: 1 }) {
mkdirSync(CMDDIR(), { recursive: true });
writeJsonAtomic(join(CMDDIR(), name), body);
}
// Прогон, в котором двое: чтобы было видно, когда общая команда ещё не исполнена всеми.
function dvoeVProgone() {
const prev = readJson(join(rdir, 'run.json'));
writeJsonAtomic(join(rdir, 'run.json'), { ...prev, workers: ['w-1', 'w-2'] });
}
describe('команда владельца читается КАЖДЫЙ круг и главнее всех пределов', () => {
it('команды нет — круг идёт как прежде, работник кончает своё дело сам', async () => {
// 🔴 Эта проверка ждёт, что НИЧЕГО не произошло, и на неправленом круге зелена
// с первого раза. Названа честно: сторожем она станет только после шага 7.
const { out, child } = await runK2({
probeDisk: DISK_OK,
script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/закончил своё дело сам/i);
expect(child.killed).toBe(false);
});
it('личная команда «стоп» — работник погашен, и причина словами владельца', async () => {
velet('w-1.stop.json');
const { out, child } = await runK2({ probeDisk: DISK_OK, script: [() => {}] });
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/команда владельца «стоп»/i);
});
it('исполненная личная команда убрана — второй раз она не сработает', async () => {
velet('w-1.stop.json');
await runK2({ probeDisk: DISK_OK, script: [() => {}] });
expect(readCmd(CMDDIR(), 'w-1')).toBeNull();
});
it('команда «пауза» — итог `paused`, и работнику дали доделать шаг', async () => {
// 🔴 «Пауза» значит «доделай текущий шаг чисто и заглохни», а не «умри на середине».
// Признак того, что ждали границы шага, — файл `stopping.json` в каталоге работника.
// 🔴🔴 ПЛАН ЗДЕСЬ СОВРАЛ, И Я ГОВОРЮ ЭТО ВСЛУХ. Он велел подать сюда пустой шаг
// (`script: [() => {}]`), то есть работника, который границы шага НЕ достигает
// никогда. «Пауза» его и ждёт — двадцать минут по секунде, то есть тысячу двести
// кругов, — и проверка падала сторожем «не кончился за сто двадцать кругов».
// Работник обязан дойти до границы шага: `assistant` — она и есть (spawn.mjs,
// `STEP_BOUNDARIES`). Так проверка мерит то, что написано в её названии: паузу
// ЖДАЛИ, а не убили на середине.
velet('w-1.pause.json');
const { out } = await runK2({
probeDisk: DISK_OK,
script: [c => c.say({ type: 'assistant', message: { content: [] } })],
});
expect(out.status).toBe('paused');
expect(out.reason).toMatch(/пауза/i);
expect(readJson(join(wdir, 'stopping.json'))?.why).toBe('пауза');
});
it('«стоп всё» гасит и того, кому лично ничего не давали', async () => {
velet('all.stopall.json');
const { out, child } = await runK2({ probeDisk: DISK_OK, script: [() => {}] });
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/стоп всё/i);
});
it('в прогоне двое — «стоп всё» остаётся лежать, и в ней отмечен исполнивший', async () => {
// 🔴 Вот здесь и видно, что общая команда не съедается первым: второй её ещё не получил.
dvoeVProgone();
velet('all.stopall.json');
await runK2({ probeDisk: DISK_OK, script: [() => {}] });
expect(readJson(join(CMDDIR(), 'all.stopall.json')).done).toEqual(['w-1']);
expect(readCmd(CMDDIR(), 'w-2').cmd).toBe('stopall');
});
it('в прогоне один — он же и последний: «стоп всё» убрана, файла нет', async () => {
// Состав берётся из `run.json`; по умолчанию в нём один `w-1`.
velet('all.stopall.json');
await runK2({ probeDisk: DISK_OK, script: [() => {}] });
expect(existsSync(join(CMDDIR(), 'all.stopall.json'))).toBe(false);
});
it('слово не из закрытого списка — работник НЕ гаснет и надзиратель не падает', async () => {
velet('w-1.udali-vsyo.json');
const { out, child } = await runK2({
probeDisk: DISK_OK,
script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }],
});
expect(child.killed).toBe(false);
expect(out.reason).toMatch(/закончил своё дело сам/i);
// 🔴 Эта проверка сторожит ровно одно: непонятое слово НЕ исполняется. Что владелец
// о нём узнаёт — доказывают две следующие, и это разные вещи.
const kinds = readLines(join(rdir, 'events.jsonl')).map(e => e.kind);
expect(kinds).not.toContain('worker_spawn_failed');
// Файл владельца при этом на месте: непонятое не съедается молча.
expect(existsSync(join(CMDDIR(), 'w-1.udali-vsyo.json'))).toBe(true);
});
it('🔴 непонятая команда даёт СТРОКУ В СВОДКУ — владелец узнаёт, что его не поняли', async () => {
// 🔴🔴 Решение диспетчера. Прежде тут не происходило ничего: владелец нажимал «паузу»
// с опечаткой, уходил спать и был уверен, что прогон встал. Молчание в ответ на команду
// хозяина недопустимо.
velet('w-1.pausa.json');
await runK2({
probeDisk: DISK_OK,
script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }],
});
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroki).toMatch(/НЕ ПОНЯТА/);
expect(stroki).toContain('pausa'); // само непонятое слово названо
expect(stroki).toContain('w-1.pausa.json'); // и где лежит файл
expect(stroki).toMatch(/ничего не сделано/i); // и что по ней не сделано ничего
// Файл не съеден: владелец найдёт его утром на месте и переименует.
expect(existsSync(join(CMDDIR(), 'w-1.pausa.json'))).toBe(true);
});
it('🔴 про одну и ту же непонятую команду сказано ОДИН раз, а не каждые тридцать секунд', async () => {
// 🪤 Файл владельца никуда не денется сам, а работник живёт часами: без ограничителя
// к утру сводка была бы из шестисот одинаковых строк, и в них потерялось бы всё.
velet('w-1.pausa.json');
await runK2({ probeDisk: DISK_OK, script: [() => {}, () => {}, c => c.die(0)] });
const skolko = readLines(join(rdir, SUMMARY_LINES_FILE))
.filter(r => /НЕ ПОНЯТА/.test(String(r.line))).length;
expect(skolko).toBe(1);
});
it('команда владельца главнее восьми часов — причина его, а не наша', async () => {
// 🔴 Это и есть доказательство порядка. Работник заведомо выбрал восемь часов:
// круг длиной в девять часов. Но владелец сказал «стоп» — и в письме будет «стоп».
velet('w-1.stop.json');
const { out } = await runK2({ probeDisk: DISK_OK, tickMs: 9 * H, script: [() => {}] });
expect(out.reason).toMatch(/команда владельца/i);
expect(out.reason).not.toMatch(/восемь часов/i);
});
it('в сводке про погашенного по команде сказано словами владельца, а не про время', async () => {
velet('all.stopall.json');
await runK2({ probeDisk: DISK_OK, tickMs: 9 * H, script: [() => {}] });
const lines = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(lines).toMatch(/стоп всё/i);
expect(lines).not.toMatch(/восемь часов/i);
});
it('каталога команд не дали вовсе — круг живёт по-прежнему и не падает', async () => {
// 🪤 В первую ночь каталога `.night/commands` может не быть. Упади круг здесь —
// умер бы весь надзиратель, и работник остался бы без присмотра до утра.
const { out } = await runK2({
probeDisk: DISK_OK,
commandsDirPath: null,
script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }],
});
expect(out.reason).toMatch(/закончил своё дело сам/i);
});
});
describe('🔴 ЖИВОЙ запуск надзирателя — не помощник проверок, а сам `main()`', () => {
// 🔴🔴 Ради этого блока надзиратель и заводился. Все тринадцать проверок выше зелены
// потому, что каталог команд им подаёт помощник `runK2` — руками. В живой ночи круг
// поднимает `main()`, и не дойди каталог до него — `readCommand(null, …)` всегда пусто:
// правило написано, проверки зелёные, а «стоп всё» не срабатывает ни разу. Это ровно
// тот класс беды, ради которого затея и завела надзирателя.
// 🪤 Живого платного работника здесь не рождается: `superviseFn` подменён, круг не
// заводится вовсе, а `spawnWorker` остаётся неразвёрнутым свёртком, и его никто не зовёт.
// 🔴🔴 ПЛАН ЗДЕСЬ СОВРАЛ, И Я ГОВОРЮ ЭТО ВСЛУХ. Он велел завести у `main()` подмены
// `readCfg` и `readCard` и показывал «было» без подмен вовсе. Открыл файл: подмены там
// УЖЕ стоят с прошлой смены и называются иначе — `cfgFile` и `workerDirFn`, и на них
// стоят три живые проверки «дыры 1». Заведи я вторую пару имён на то же самое — это
// два правила на одно дело, ровно тот класс, от которого хозяйство предупреждает
// в пяти местах. Поэтому зову существующими именами, а новых не завожу.
async function pozvatMain() {
const cfgFile = join(dir, 'night.config.json');
writeJsonAtomic(cfgFile, { ...CFG2, worker_model: 'модель-работника' });
const zhivoyWdir = join(dir, 'живой-каталог-работника');
mkdirSync(zhivoyWdir, { recursive: true });
writeJsonAtomic(join(zhivoyWdir, 'worker.json'), {
workspace: join(dir, 'угол'),
prompt: 'поручение',
settings_path: join(dir, 'settings.json'),
session_id: 's-1',
db_name: 'liderra_w1',
});
let dovody = null;
await main(['node', 'supervisor.mjs', '--run', 'проба-зова', '--worker', 'w-1'], {
cfgFile,
workerDirFn: () => zhivoyWdir,
superviseFn: a => { dovody = a; return null; },
});
return { dovody, zhivoyWdir };
}
it('🔴 каталог команд владельца доходит до круга из ЖИВОГО запуска, а не только из помощника', async () => {
const { dovody } = await pozvatMain();
expect(dovody.commandsDirPath).toBe(commandsDir());
});
it('прежние доводы живого запуска на месте — угол, каталог прогона и каталог работника', async () => {
// 🪤 Страховка от «починки» одной строкой поверх всего списка: дописать каталог команд
// и потерять при этом угол работника — значит сменить одну немую беду на другую.
const { dovody, zhivoyWdir } = await pozvatMain();
expect(dovody.runId).toBe('проба-зова');
expect(dovody.workerId).toBe('w-1');
expect(dovody.worktreeRoot).toBe(join(dir, 'угол'));
expect(dovody.runDirPath).toBe(katalogProgonaNastoyashchiy('проба-зова'));
expect(dovody.workerDirPath).toBe(zhivoyWdir);
expect(dovody.measuresPath).toBe(budgetMeasuresPath());
expect(typeof dovody.spawnWorker).toBe('function');
});
});
// ============ кусок 4: замок «пункт закрыт» в живом круге ============
// Единственный новый ввоз — имя БОЕВОГО каталога следов: одна проверка ниже доказывает,
// что круг в него не пишет. Имя берётся у того же единственного места, что и в рабочем
// коде, а не набирается здесь строкой: набери я его руками — сторож зеленел бы, сравнивая
// свою выдумку со своей выдумкой.
import { deferredDir } from './deferred.mjs';
import { readdirSync as readdirSyncK4 } from 'node:fs';
// Что лежит в БОЕВОМ каталоге следов. Снимок снимается при разборе файла — то есть
// до первой проверки; сторож в самом низу сравнивает с ним.
function sledyBoevye() {
return existsSync(deferredDir()) ? readdirSyncK4(deferredDir()).sort() : [];
}
const SLEDY_DO_PROVEROK = sledyBoevye();
// 🔴 Про прочие ввозы честно: НОВЫХ строк здесь НЕТ, и это отступление от плана,
// названное вслух. План велел ввезти `closedItemsOfRun as prigovoryProgona` и
// `writeRunOutcome as itogProgona`. Открыл файл: оба имени уже ввезены выше кусками 1 и 3
// (строки 19 и 1280). Второй ввоз тех же имён под другими прозвищами — два имени на одно
// дело, ровно тот класс, от которого хозяйство предупреждает в пяти местах.
// Положить заявку работника — ровно так это делает он сам, строкой в свой файл.
function zayavit(nomer, extra = {}) {
sozdatKatalog(inbox, { recursive: true });
zapisatFayl(join(inbox, 'closed-items.jsonl'), JSON.stringify({ item: nomer, at: T0, ...extra }) + '\n');
}
function planPunktov4(items, workers = ['w-1']) {
writeJsonAtomic(join(rdir, 'run.json'), {
run_id: 'r-1', status: 'running', task_id: 'r-1', plan_path: 'docs/план.md', workers, items,
});
}
const ZELENO = () => ({ ran: true, ok: true, code: 0, out: 'зелено', error: null });
const KRASNO = () => ({ ran: true, ok: false, code: 1, out: 'два упали', error: null });
describe('замок «пункт закрыт» зовётся из круга (проверки 38 и 40)', () => {
it('заявка есть и проверки зелёные — приговор записан, и коммит в нём настоящий', async () => {
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
await runSupervisor({ tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-из-угла', runTests: ZELENO });
const prigovory = readLines(join(wdir, 'items-verified.jsonl'));
expect(prigovory.length).toBe(1);
expect(prigovory[0].item).toBe('1');
expect(prigovory[0].commit_sha).toBe('sha-из-угла');
expect(prigovory[0].tests_green).toBe(true);
});
it('🔴 замок гоняет проверки САМ — командой ЭТОГО ПУНКТА и базой работника', async () => {
// 🔴🔴 Сердце решения владельца 96 и главный шов этой задачи. Не подай круг команду
// и имя базы — прогон каждый раз отвечал бы «замерить не удалось», ни один пункт
// не засчитывался бы никогда, и владелец узнал бы об этом только к утру по пустой
// сводке. Ломается ТИХО — значит закрывается проверкой.
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
const dovody = [];
await runSupervisor({
tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-1',
dbName: 'night_r_1_w_7',
runTests: d => { dovody.push(d); return ZELENO(); },
});
expect(dovody.length).toBe(1);
// Команда посчитана ИЗ `tests` судимого пункта — правилом `komandaProverokPunkta`.
expect(dovody[0].command).toBe('npm run test:tools -- a.test.mjs');
expect(dovody[0].dbName).toBe('night_r_1_w_7');
expect(dovody[0].item.id).toBe('1');
});
it('🔴🔴 ВТОРОЙ пункт судится проверками ВТОРОГО пункта, а не первого', async () => {
// 🔴🔴🔴 ГЛАВНАЯ проверка всего куска. Карточка работника пишется ОДИН раз — при
// заведении угла, по ПЕРВОМУ его пункту. А пунктов работник за ночь берёт много.
// Возьми замок команду проверок из карточки — со второго пункта он гонял бы проверки
// ПЕРВОГО, и пункт засчитывался бы по ЧУЖОЙ зелени. Ломается ТИХО и полностью
// невидимо: все прочие проверки остаются зелёными, потому что смотрят один пункт.
sozdatKatalog(inbox, { recursive: true });
zapisatFayl(join(inbox, 'closed-items.jsonl'),
JSON.stringify({ item: '1', at: T0 }) + '\n' + JSON.stringify({ item: '2', at: T0 }) + '\n');
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
]);
const komandy = [];
await runSupervisor({
tickMs: 1000, script: [() => {}, () => {}], head: () => 'sha-1',
runTests: d => { komandy.push(d.command); return ZELENO(); },
});
expect(komandy).toEqual([
'npm run test:tools -- a.test.mjs',
'npm run test:tools -- b.test.mjs',
]);
});
it('проверки КРАСНЫЕ — приговора нет, и работника НЕ гасят', async () => {
// 🔴 Решение диспетчера: непризнанный пункт оставляет работника работать — у него
// есть ночь, чтобы дочинить свой же пункт.
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
const { out, child } = await runSupervisor({
tickMs: 1000, script: [() => {}, c => c.die(0)], head: () => 'sha-1', runTests: KRASNO,
});
expect(readLines(join(wdir, 'items-verified.jsonl'))).toEqual([]);
expect(child.killed).toBe(false);
expect(out.status).not.toBe('extinguished');
});
it('🔴 та же заявка на том же коммите — второй раз проверки НЕ гоняются', async () => {
// 🔴🔴 Прогон проверок идёт минутами, а круг — каждые тридцать секунд. Судим мы
// заявку каждый круг — надзиратель до утра гонял бы один и тот же набор, работник
// стоял бы, и ночь ушла бы в никуда при полностью зелёных проверках.
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
let skolko = 0;
await runSupervisor({
tickMs: 1000, script: [() => {}, () => {}, c => c.die(0)],
head: () => 'sha-одна-и-та-же',
runTests: () => { skolko += 1; return KRASNO(); },
});
expect(skolko).toBe(1);
});
it('работник дочинил и сделал НОВЫЙ коммит — судим снова', async () => {
// 🔴 Обратная сторона предыдущей. Запомни мы «уже судили» насовсем — работник чинил бы
// пункт всю ночь, а надзиратель второй раз на него не посмотрел бы никогда.
// 🪤 Коммит НЕЛЬЗЯ подавать списком по числу зовов: `head` за один круг спрашивают
// несколько раз, и список разъехался бы молча. Меняем его после ПЕРВОГО прогона.
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
let kommit = 'sha-первая';
let skolko = 0;
await runSupervisor({
tickMs: 1000, script: [() => {}, () => {}, c => c.die(0)],
head: () => kommit,
runTests: () => { skolko += 1; if (skolko === 1) kommit = 'sha-вторая'; return KRASNO(); },
});
expect(skolko).toBe(2);
});
it('🔴 в углу есть несохранённые правки — суд ОТЛОЖЕН, проверки не гонялись', async () => {
// 🔴 Распоряжение диспетчера: судим по КОММИТУ, а не по живому каталогу. Иначе
// намеряли бы красноту от недописанного кода и не засчитали бы честно сделанный пункт.
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
let skolko = 0;
await runSupervisor({
tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-1',
dirty: () => true,
runTests: () => { skolko += 1; return ZELENO(); },
});
expect(skolko).toBe(0);
expect(readLines(join(wdir, 'items-verified.jsonl'))).toEqual([]);
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroki).toMatch(/несохранённые правки/i);
});
it('🔴 пункт «в расчёте на ответ» без ответа — НЕ засчитан, хоть проверки зелёные (проверка 40)', async () => {
// 🔴 Правило пишет `closeItem` (задача 2), а зовёт его этот круг. Не позови — правило
// зеленело бы на своих проверках и не срабатывало бы ни разу.
zayavit('1', { on_assumption: true, answer_received: false });
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
await runSupervisor({ tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-1', runTests: ZELENO });
expect(readLines(join(wdir, 'items-verified.jsonl'))).toEqual([]);
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroki).toMatch(/в расчёте на ответ/i);
});
});
describe('очередь пунктов и конец задачи (проверки 24 и 25)', () => {
it('засчитали пункт — следующий лёг работнику в ящик (проверка 24)', async () => {
zayavit('1');
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
]);
await runSupervisor({ tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-1', runTests: ZELENO });
expect(readJson(join(inbox, 'next-item.json')).item.id).toBe('2');
});
it('🔴 выданный пункт ЗАНЯТ за работником в ту же минуту, а не «назван и забыт»', async () => {
// 🔴🔴 Решение диспетчера, то же, что в блоке 5 куска 3. Назвать номер и не запереть
// файлы — значит позволить двум надзирателям в одну секунду выдать своим работникам
// ОДИН пункт. Ради этого построен весь кусок 3, и кусок 4 обязан правило соблюдать.
//
// 🔴🔴 ПЛАН ЗДЕСЬ БЫЛ НЕРАБОТОСПОСОБЕН, И Я ГОВОРЮ ЭТО ВСЛУХ. Он велел смотреть занятие
// ПОСЛЕ того, как круг кончился, — а `finish()` (кусок 1) на каждом выходе из круга
// зовёт `releaseClaims`: погас работник — его файлы отпущены, и это правильно, иначе
// назавтра ночь началась бы с замков за мертвецами. Проверка в плановом виде краснела
// на исправном коде, то есть мерила не то, что написано в её названии. Смотрим занятие
// ЖИВЬЁМ, пока работник ещё жив: ровно тот миг, ради которого занятие и делается.
zayavit('1');
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
]);
let snimok = null;
await runSupervisor({
tickMs: 1000,
// Первый шаг сценария идёт СРАЗУ после круга, засчитавшего пункт, — работник ещё жив.
script: [() => { snimok = mozhnoLiTrogat(rdir, 'w-9', 'app/B.php'); }, c => c.die(0)],
head: () => 'sha-1',
runTests: ZELENO,
});
expect(snimok.ok).toBe(false);
expect(snimok.clash_with).toBe('w-1');
// 🪤 И обратная сторона, чтобы не сочли занятие вечным: работник погас — файл отпущен.
expect(mozhnoLiTrogat(rdir, 'w-9', 'app/B.php').ok).toBe(true);
});
it('уже засчитанный пункт второй раз НЕ выдаётся', async () => {
sozdatKatalog(wdir, { recursive: true });
zapisatFayl(join(wdir, 'items-verified.jsonl'), JSON.stringify({ item: '2', tests_green: true }) + '\n');
zayavit('1');
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
{ id: '3', title: 'Третий', tests: ['c.test.mjs'], files: ['app/C.php'], long: false },
]);
await runSupervisor({ tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-1', runTests: ZELENO });
expect(readJson(join(inbox, 'next-item.json')).item.id).toBe('3');
});
it('🔴 «что уже сделано» берётся из ПРИГОВОРОВ надзирателя, а не из слова работника', async () => {
// 🔴🔴 Кусок 3 честно оговорил, что его `closedItemsOfRun` читает СЛОВО работника
// и «это не закрытие пункта». Оставь её так — замок стоял бы и краснел, а очередь
// пунктов всё равно шла бы по слову, и владелец этого не увидел бы никогда.
// Здесь слово работника и приговор надзирателя намеренно РАСХОДЯТСЯ.
sozdatKatalog(inbox, { recursive: true });
zapisatFayl(join(inbox, 'closed-items.jsonl'), JSON.stringify({ item: '9' }) + '\n');
sozdatKatalog(wdir, { recursive: true });
// 🔴 Вторая строка — приговор БЕЗ зелени. Сочти её сделанным — пункт «8» не достался бы
// работнику больше никогда, а задача досрочно объявилась бы законченной.
// 🔴🔴 Третья строка — вырезание ВТОРОГО РОДА, поймано живьём 02.08.2026. Сперва
// сторож ждал только строку БЕЗ слова о зелени, и подмена строгого сравнения
// на «лишь бы не пусто» (`row.tests_green` вместо `row.tests_green === true`)
// прошла мимо него: все 160 проверок остались зелёными. А в живой ночи любое
// непустое слово в этом поле — хоть «нет», хоть единица — засчитывало бы пункт,
// и работник не получил бы его больше никогда. Строка ниже это закрывает.
zapisatFayl(join(wdir, 'items-verified.jsonl'),
JSON.stringify({ item: '7', tests_green: true }) + '\n'
+ JSON.stringify({ item: '8', closed: false }) + '\n'
+ JSON.stringify({ item: '6', tests_green: 'нет' }) + '\n');
expect(closedItemsOfRun(rdir)).toEqual(['7']);
expect(closedItemsOfRun(rdir)).not.toContain('9');
expect(closedItemsOfRun(rdir)).not.toContain('8');
expect(closedItemsOfRun(rdir)).not.toContain('6');
});
it('пункты кончились и все засчитаны — погас, приёмщику поднят флаг, следующая задача НЕ начата (проверка 25)', async () => {
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
const { out, child } = await runSupervisor({
tickMs: 1000, script: [() => {}], head: () => 'sha-1', runTests: ZELENO,
});
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
const run = readJson(join(rdir, 'run.json'));
expect(run.reviewer_starts).toBe(true);
expect(run.start_next_task).toBe(false);
expect(run.items_all_verified).toBe(true);
});
it('🔴🔴 пункт отложен, а свободных больше нет — приёмщику флага НЕТ, задача НЕ доделана', async () => {
// 🔴🔴 Отложенный пункт лежит в «сделано» ТОЛЬКО чтобы очередь не выдавала его снова.
// Сочти его засчитанным — приёмщику подняли бы флаг на недоделанной задаче, а тот же
// прогон положил бы след «не влезла в ночь»: одна ночь, два противоречащих знака,
// и утром владелец не знает, которому верить. Ломается ТИХО и полностью невидимо.
sozdatKatalog(inbox, { recursive: true });
zapisatFayl(join(inbox, 'closed-items.jsonl'),
JSON.stringify({ item: '1', at: T0 }) + '\n' + JSON.stringify({ item: '2', at: T0 }) + '\n');
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
]);
let kommit = 'sha-1';
let shag = 0;
await runSupervisor({
tickMs: 1000, script: [() => {}, () => {}, () => {}],
head: () => kommit,
// Пункт 1 краснеет дважды и откладывается; пункт 2 зелёный и засчитан.
// 🪤 Коммит меняем после каждого прогона — иначе второй раз тот же пункт не судят.
runTests: d => { shag += 1; kommit = `sha-${shag + 1}`; return d.item.id === '1' ? KRASNO() : ZELENO(); },
});
const run = readJson(join(rdir, 'run.json'));
expect(run.items_all_verified).toBe(false);
expect(run.reviewer_starts).toBe(false);
});
});
describe('непризнанный пункт: работник узнаёт, и попыток у него две', () => {
it('не засчитан — в ящик лёг item-verdict.json с честной причиной, и это НЕ claims.json', async () => {
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
await runSupervisor({ tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-1', runTests: KRASNO });
const verdikt = readJson(join(inbox, 'item-verdict.json'));
expect(verdikt.item).toBe('1');
expect(verdikt.closed).toBe(false);
expect(verdikt.reason).toMatch(/красн/i);
// 🔴 С файлом куска 3 не слит: тот про чужие файлы, этот про непризнанный пункт.
expect(existsSync(join(inbox, 'claims.json'))).toBe(false);
});
it('первая неудача — следующего пункта работнику НЕ дают', async () => {
// 🔴 Решение диспетчера: пока этот пункт не закрыт честно, нового он не получает —
// иначе к утру было бы семь начатых пунктов и ни одного доведённого.
zayavit('1');
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
]);
await runSupervisor({ tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-1', runTests: KRASNO });
expect(existsSync(join(inbox, 'next-item.json'))).toBe(false);
});
it('🔴 второй раз тот же пункт не сошёлся — пункт отложен, и выдан ДРУГОЙ', async () => {
zayavit('1');
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
]);
let kommit = 'sha-первая';
let skolko = 0;
await runSupervisor({
tickMs: 1000, script: [() => {}, () => {}, c => c.die(0)],
head: () => kommit,
runTests: () => { skolko += 1; if (skolko === 1) kommit = 'sha-вторая'; return KRASNO(); },
});
const dalshe = readJson(join(inbox, 'next-item.json'));
expect(dalshe.item.id).toBe('2');
expect(dalshe.reason).toMatch(/отложен/i);
});
it('пункт отложен, а брать больше нечего — погас с ПОМЕТКОЙ, а не крутится впустую', async () => {
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
let kommit = 'sha-первая';
let skolko = 0;
const { out, child } = await runSupervisor({
tickMs: 1000, script: [() => {}, () => {}, () => {}],
head: () => kommit,
runTests: () => { skolko += 1; if (skolko === 1) kommit = 'sha-вторая'; return KRASNO(); },
});
expect(child.killed).toBe(true);
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/отложен/i);
});
});
describe('след отложенной задачи кладёт надзиратель последнего погасшего (проверка 28а)', () => {
it('прогон кончился, а пункты остались незасчитанными — след положен', async () => {
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
]);
sozdatKatalog(wdir, { recursive: true });
zapisatFayl(join(wdir, 'items-verified.jsonl'), JSON.stringify({ item: '1', tests_green: true }) + '\n');
writeJsonAtomic(join(wdir, 'worker.json'), { worker_id: 'w-1', run_id: 'r-1', status: 'extinguished', workspace: worktree });
const slozheno = [];
writeRunOutcome({
runDirPath: rdir, runId: 'r-1', lastRunFile: join(dir, 'last-run.json'), now: T0,
deferredPath: join(dir, 'deferred'),
markDeferredFn: (p, sled) => { slozheno.push(sled); return sled; },
});
expect(slozheno.length).toBe(1);
expect(slozheno[0].left_items).toEqual(['2']);
expect(slozheno[0].reason).toBe('not_finished');
expect(slozheno[0].plan_path).toBe('docs/план.md');
});
it('все пункты засчитаны — следа НЕТ, иначе назавтра ночь начнётся с чужого отказа', async () => {
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
sozdatKatalog(wdir, { recursive: true });
zapisatFayl(join(wdir, 'items-verified.jsonl'), JSON.stringify({ item: '1', tests_green: true }) + '\n');
writeJsonAtomic(join(wdir, 'worker.json'), { worker_id: 'w-1', run_id: 'r-1', status: 'extinguished', workspace: worktree });
const slozheno = [];
writeRunOutcome({
runDirPath: rdir, runId: 'r-1', lastRunFile: join(dir, 'last-run.json'), now: T0,
deferredPath: join(dir, 'deferred'),
markDeferredFn: (p, sled) => { slozheno.push(sled); return sled; },
});
expect(slozheno).toEqual([]);
});
it('🔴🔴 СВЕРХ ПЛАНА: круг довозит до итога СВОЙ каталог следов, а не боевой', async () => {
// 🔴🔴 Шов, которого в плане задачи 5 нет вовсе, и он живой. Итог прогона зовут ДВОЕ:
// проверки напрямую (обе выше — со своим каталогом) и сам круг через `closeRunIfLast`,
// куда подставить было нечем. Оставь как в плане — каждая проверка круга, доходящая
// до гашения при незасчитанных пунктах, писала бы НАСТОЯЩИЙ отказ в боевой
// `.night/deferred/`, и следующая живая ночь упёрлась бы в него. Ломается ТИХО:
// проверки при этом остаются зелёными все до одной.
zayavit('1');
planPunktov4([
{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false },
{ id: '2', title: 'Второй', tests: ['b.test.mjs'], files: ['app/B.php'], long: false },
]);
const sledy = join(dir, 'свои-следы');
await runSupervisor({
tickMs: 1000, script: [c => c.die(0)], head: () => 'sha-1', runTests: KRASNO,
deferredPath: sledy,
});
// Работник погас последним → итог прогона написан, пункты остались → след положен,
// и положен он ТУДА, КУДА СКАЗАЛИ.
expect(existsSync(sledy)).toBe(true);
// И боевого каталога затея при этом не касалась.
expect(sledy).not.toBe(deferredDir());
});
});
describe('🔴 ЖИВОЙ запуск надзирателя — замок кормится из настоящего `main()`', () => {
// 🔴🔴 Ради этого блока задача и написана. Все проверки выше зелены потому, что имя базы
// им подаёт помощник `runSupervisor` — руками. В живой ночи круг поднимает `main()`,
// и не дойди до него это имя — у замка своих умолчаний на этот счёт НЕТ: прогон каждого
// пункта ответил бы «замерить не удалось», ни один пункт не засчитался бы за всю ночь,
// и владелец узнал бы об этом только к утру по пустой сводке.
// 🔴 Команды проверок здесь НЕТ и быть не должно: её считает сам круг из судимого пункта.
// 🪤 Живого платного работника здесь не рождается: `superviseFn` подменён, круг не
// заводится вовсе, `spawnWorker` остаётся неразвёрнутым свёртком, и его никто не зовёт.
// 🔴🔴 ПЛАН ЗДЕСЬ СОВРАЛ, И Я ГОВОРЮ ЭТО ВСЛУХ. Он велел подменять у `main()` `readCfg`
// и `readCard`. Открыл файл: таких подмен там нет и не было — подмены называются
// `cfgFile` и `workerDirFn`, и на них стоят живые проверки «дыры 1» и куска 3. Завести
// вторую пару имён на то же дело — два правила на одно, ровно тот класс, от которого
// хозяйство предупреждает в пяти местах. Зову существующими именами.
async function pozvatMainK4(dbName = 'night_r_1_w_1') {
const cfgFile = join(dir, 'night.config.json');
writeJsonAtomic(cfgFile, { ...CFG2, worker_model: 'модель-работника', item_tests_timeout_minutes: 10 });
const zhivoyWdir = join(dir, 'живой-каталог-к4');
mkdirSync(zhivoyWdir, { recursive: true });
writeJsonAtomic(join(zhivoyWdir, 'worker.json'), {
workspace: join(dir, 'угол'),
prompt: 'поручение',
settings_path: join(dir, 'settings.json'),
session_id: 's-1',
...(dbName === null ? {} : { db_name: dbName }),
test_command: 'npm run test:tools',
});
let dovody = null;
await main(['node', 'supervisor.mjs', '--run', 'проба-замка', '--worker', 'w-1'], {
cfgFile,
workerDirFn: () => zhivoyWdir,
superviseFn: a => { dovody = a; return null; },
});
return dovody;
}
it('🔴 имя СВОЕЙ базы работника доходит до круга из ЖИВОГО запуска', async () => {
// Без него у замка не остаётся ни одного рабочего пути: прогон отвечает «замерить
// не удалось», и ни один пункт не засчитывается за всю ночь — при зелёном виде.
const dovody = await pozvatMainK4();
expect(dovody.dbName).toBe('night_r_1_w_1');
});
it('🔴 в карточке имени базы нет — круг получает честный `null`, а не выдумку', async () => {
// 🪤 Второй конец того же шва. Подставь живой вход что-нибудь от себя (общую базу,
// имя работника) — семеро затёрли бы друг другу ответ, и замок засчитывал бы пункты
// по чужому прогону. Честный `null` заставит замок сказать «замерить не удалось»,
// и это видно в сводке, а не спрятано.
const dovody = await pozvatMainK4(null);
expect(dovody.dbName).toBeNull();
});
it('🔴 УМОЛЧАНИЯ круга — настоящие приборы, а не пустышки', async () => {
// 🪤 Смотрим ТЕКСТ файла, и говорю это вслух: оба помощника подставляют свои `runTests`
// и `dirty` раньше, чем до дела доходит умолчание `supervise()`, и добраться до него
// поведением из проверки нечем. Слабее живой проверки — но сторожит ровно то место,
// где поломка была бы невидимой: поставь кто-нибудь умолчанием «всё зелено», и каждый
// пункт засчитывался бы без единого прогона, при полностью зелёных проверках.
const tekst = prochestFaylom(new URL('./supervisor.mjs', import.meta.url), 'utf8');
expect(tekst).toMatch(/runTests\s*=\s*progonProverokPunkta/);
expect(tekst).toMatch(/dirty\s*=\s*gryazenLiUgol/);
// 🔴 А довода `testCommand` в этом файле нет ВОВСЕ — ни в подписи круга, ни в зове,
// ни умолчанием. Заведись он снова — со второго пункта работник судился бы командой
// ПЕРВОГО, и это ровно корневая беда куска.
expect(tekst).not.toMatch(/testCommand\s*[=,:)]/);
expect(tekst).toMatch(/komandaProverokPunkta\(/);
});
it('🔴🔴 СВЕРХ ПЛАНА: «грязен ли угол» вправду ЗОВЁТСЯ, а не только объявлен умолчанием', () => {
// 🔴🔴 Замерено 02.08.2026: до этой задачи `gryazenLiUgol` был построен и не позван
// НИКЕМ — во всей затее он встречался только в своём файле и своих проверках.
// Защита, которую никто не зовёт, читается как подключённая: у неё зелёные проверки,
// а дела она не делает. Он закрывает целый класс обмана — работник правит наряд,
// которым его меряют, И НЕ КОММИТИТ правку: сверка смотрит на коммиты и видит пустоту,
// а прогон читает подменённое с диска.
// 🪤 Поведением это уже сторожит проверка «в углу есть несохранённые правки», но она
// подаёт свой `dirty` руками и слепа к тому, что умолчание подменили. Здесь — второй
// конец шва: имя настоящего прибора вправду ввезено и вправду стоит умолчанием.
const tekst = prochestFaylom(new URL('./supervisor.mjs', import.meta.url), 'utf8');
expect(tekst).toMatch(/import\s*\{[^}]*gryazenLiUgol[^}]*\}\s*from\s*'\.\/item-audit\.mjs'/);
expect(tekst).toMatch(/dirty\s*=\s*gryazenLiUgol/);
expect(tekst).toMatch(/dirty\(worktreeRoot\)/);
});
it('🔴 карточку прогона правят ПОД ЗАМКОМ и перечитывают ВНУТРИ него', () => {
// 🪤 Смотрим ТЕКСТ файла, и говорю вслух почему: беда — гонка семерых РАЗНЫХ процессов,
// и внутри одного процесса она не воспроизводится вовсе. Кусок 3 свою такую же
// доказывал на ЧЕТЫРЁХ живых процессах; повторять это здесь значит городить второй
// такой стенд ради двух строк. Слабее живой проверки — но сторожит ровно то место,
// где поломка молчалива: правка одного надзирателя исчезает под снимком другого,
// и все проверки при этом зелёные.
const tekst = prochestFaylom(new URL('./supervisor.mjs', import.meta.url), 'utf8');
// 1. Запись карточки в файле ровно ОДНА — голых записей не осталось НИ ОДНОЙ.
const zapisi = tekst.match(/writeJsonAtomic\(join\(runDirPath, 'run\.json'\)/g) ?? [];
expect(zapisi.length).toBe(1);
// 2. И эта одна стоит под замком, а карточка перечитывается уже ВНУТРИ него.
expect(tekst).toMatch(/function pravitKartochkuProgona[\s\S]*?podZamkom\(runDirPath/);
expect(tekst).toMatch(/podZamkom\(runDirPath[\s\S]*?readJson\(join\(runDirPath, 'run\.json'\)\)[\s\S]*?writeJsonAtomic\(join\(runDirPath, 'run\.json'\)/);
// 3. И `writeRunOutcome` правит карточку через того же помощника, а не своей рукой.
expect(tekst).toMatch(/pravitKartochkuProgona\(runDirPath, \{ status: 'finished', finished_at: now \}\)/);
});
it('🔴🔴 СВЕРХ ПЛАНА: пункт берут у `nextFreeItem`, а «пора ли гаснуть» — у `afterItemDone`', () => {
// 🔴🔴 Замерено 02.08.2026: на одном и том же хозяйстве `nextFreeItem` отвечает «пункт 2»
// (она видит занятые чужими файлы), а `afterItemDone` — «пункт 1», на котором сидит
// сосед: занятий ей не передают и передать нечем. Возьми круг САМ ПУНКТ у второй —
// двое молча правили бы одни файлы всю ночь при зелёных проверках, а замок занятий
// не сработал бы ни разу: его просто не спросили. Граница записана в `items.mjs`,
// и на неё стоят два сторожа в `items.test.mjs` — этот третий стоит с моей стороны,
// со стороны ЗОВУЩЕГО, потому что войти не в ту дверь можно только здесь.
const tekst = prochestFaylom(new URL('./supervisor.mjs', import.meta.url), 'utf8');
// У `afterItemDone` спрашивают «есть ли что брать» — и её ответ идёт в `ochered`,
// а не прямо в ящик работника.
expect(tekst).toMatch(/const ochered = afterItemDone\(/);
// Сам пункт берётся ТОЛЬКО у `nextFreeItem`.
expect(tekst).toMatch(/ochered\.next_item\s*\n?\s*\?\s*nextFreeItem\(/);
// И в ящик кладётся именно то, что выдала `nextFreeItem` (и что заперто за работником).
expect(tekst).toMatch(/writeJsonAtomic\(join\(yashchik, 'next-item\.json'\), \{\s*\n\s*item: svobodnyy\.item,/);
// 🪤 Обратная сторона: `afterItemDone` каталога прогона и занятий не получает.
expect(tekst).not.toMatch(/afterItemDone\(\{[^}]*runDirPath/);
expect(tekst).not.toMatch(/afterItemDone\(\{[^}]*claims/);
});
it('🔴🔴 СВЕРХ ПЛАНА: проверки не оставили в БОЕВОМ каталоге следов ни одного отказа', () => {
// 🔴🔴 Сторож поставлен по живой поимке 02.08.2026: после первого же прогона этих
// проверок в хранилище лежал настоящий файл `.night/deferred/t-1.json` — отказ по
// задаче, которой никто не откладывал. Следующая живая ночь упёрлась бы в него
// и не начала бы задачу вовсе, а починить это владелец не смог бы ничем: он про
// этот файл не знает. Проверки при этом были зелёные все до одной.
// 🪤 Сторож стоит ПОСЛЕДНИМ в файле нарочно: он смотрит на след, оставленный
// проверками, которые шли до него.
// 🪤 Сравниваем со СНИМКОМ, снятым до первой проверки этого файла, а не с пустотой:
// в боевом каталоге могут лежать настоящие следы настоящей ночи, и требовать
// от него пустоты значило бы завести сторожа, который врёт на исправном хозяйстве.
// Датчик, который врёт на исправном, — не датчик.
// 🔴 Первая моя попытка сторожила по именам («t-1», «r-1») и пропустила ПЯТЬ следов
// из восьми: при вырезе в каталоге оказались ещё `z-proba` и пять
// `r-progon-bez-rabotnikov-…`. Узкий список имён — сторож наполовину.
// 🪤 И честно про его слабость: набор проверок гоняется вперемешку, поэтому покраснеть
// он может и от СОСЕДНЕГО файла проверок, оставившего след в ту же минуту. Тогда
// виноват не этот файл — искать надо того, кто зовёт `writeRunOutcome` или круг
// без своего `deferredPath`. Сторож всё равно нужен: след в боевом каталоге —
// настоящая беда, от чьей бы руки он ни лёг.
expect(sledyBoevye()).toEqual(SLEDY_DO_PROVEROK);
});
});