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

3732 lines
264 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,
liveForeignCorners,
STATUS_WORD as SUP_STATUS_WORD,
slovoSostoyaniya, prichinaKonca,
} from './supervisor.mjs';
import { STATUS_WORD as SUM_STATUS_WORD } from './summary.mjs';
// 🔴 Список годных к платному подъёму ввозится ИЗ resume.mjs, а не переписывается здесь:
// своя копия зеленела бы, даже когда настоящий список пустил бы мертворождённого к деньгам.
import { PRODOLZHAEMYE } from './resume.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('надзиратель ставит ровно четыре итоговых состояния', () => {
// 🔴 Четвёртое (`born_dead`) добавлено 03.08.2026 и добавлено ПРО ДЕНЬГИ: прежде
// «встал» и «умер, не начав» звались одним словом `stalled`, а по этому слову решается,
// платить ли за подъём работника. Список `PRODOLZHAEMYE` (resume.mjs) — разрешительный,
// и новое слово в него не попадает: мертворождённый к платному подъёму больше не годен.
expect(FINAL_STATUSES).toEqual(['extinguished', 'paused', 'stalled', 'born_dead']);
});
it('🔴 новое состояние НЕ пролезло в список годных к платному подъёму', () => {
// 🔴🔴 ВОТ ЭТО И ЕСТЬ ЗАМОК НА ДЕНЬГИ, и стоит он здесь, а не в resume.mjs: там список
// разрешительный и про новое слово ничего не знает, а знать и не должен. Сломается это
// ровно одним способом — если кто-то допишет `born_dead` в `PRODOLZHAEMYE`, решив,
// что «раз состояние итоговое, значит продолжаемое». Тогда покраснеет здесь.
expect(PRODOLZHAEMYE).not.toContain('born_dead');
expect(FINAL_STATUSES).toContain('born_dead');
});
it('🪤 незнакомое состояние печатается КАК ЕСТЬ, а не словом «undefined»', () => {
// 🔴 Прежде строка сводки звала `STATUS_WORD[final_status]` голым обращением. Первое же
// состояние, которого нет в словаре сводки, напечатало бы владельцу «undefined — Работник…»:
// это не просто непонятное слово, это выглядит как поломка самой сводки.
expect(slovoSostoyaniya('extinguished')).toBe('погашен');
expect(slovoSostoyaniya('born_dead')).toBe('не начал работу вовсе');
expect(slovoSostoyaniya('слова-такого-нет')).toBe('слова-такого-нет');
expect(slovoSostoyaniya(undefined)).not.toMatch(/undefined/);
});
it('🔴 у каждого итогового состояния есть человеческое слово — ни одного пропуска', () => {
// Заведут пятое состояние и забудут слово — покраснеет здесь, а не в глазах владельца.
// 🪤 `not.toBe(s)` ОДНОГО МАЛО, и это поймано вырезанием на самой этой проверке:
// `undefined` тоже «не равно» слову, и проверка зеленела на сломанном правиле.
// Требуем русских букв: английское слово или пустота владельцу ничего не говорят.
for (const s of FINAL_STATUSES) {
const slovo = slovoSostoyaniya(s);
expect(typeof slovo, `слова для «${s}» нет вовсе`).toBe('string');
expect(slovo, `нет русского слова для «${s}»`).not.toBe(s);
expect(slovo, `слово для «${s}» не по-русски: ${slovo}`).toMatch(/[а-яё]/i);
}
});
it('причина конца различает все четыре ночи, а не сваливает их в одну', () => {
// 🔴🔴 Две оси: сказал ли он хоть слово × сделал ли он хоть одно дело. Прежде все четыре
// сочетания накрывались одной строкой «кончился, не отдав итога» — и владелец не мог
// отличить «работник не запустился вовсе» от «работал и умер на ходу».
const bez = { sawStream: false, nichegoNeSdelal: true };
const molcha = { sawStream: false, nichegoNeSdelal: false };
const boltal = { sawStream: true, nichegoNeSdelal: true };
const rabotal = { sawStream: true, nichegoNeSdelal: false };
const teksty = [bez, molcha, boltal, rabotal].map(x => prichinaKonca(x));
expect(new Set(teksty).size, 'четыре разные ночи — четыре разных объяснения').toBe(4);
// 🔴 Про деньги сказано ровно там, где продолжать нечем, и НЕ сказано там, где есть чем.
expect(prichinaKonca(bez)).toMatch(/платить за продолжение такого нельзя/i);
expect(prichinaKonca(boltal)).toMatch(/платить за продолжение такого нельзя/i);
expect(prichinaKonca(molcha)).not.toMatch(/платить за продолжение такого нельзя/i);
expect(prichinaKonca(rabotal)).not.toMatch(/платить за продолжение такого нельзя/i);
// 🪤 «Ничего не сказал напоследок» — это ответ, а не пустое место в строке.
expect(prichinaKonca(bez)).toMatch(/напоследок он не сказал ничего/i);
expect(prichinaKonca({ ...bez, stderr: 'ENOENT' })).toContain('ENOENT');
});
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);
});
});
// 🔴🔴 ЗАКРЫТИЕ ДЫРЫ 03.08.2026, ВТОРОЙ КРУГ: `claimFiles` умеет спрашивать факт живых
// чужих углов (file-claims.mjs), но своего понятия «кто вообще живой сосед» заводить
// не должен — прибор факта подаётся зовущим явно (проверено в file-claims.test.mjs).
// Эти сведения есть здесь, в круге: карточки работников прогона несут и корень угла
// (`workspace`), и точку отсчёта (`base_branch`) — их кладёт `run.mjs` ДО старта работника.
// Второй разведки не заводим — читаем то же самое, чем уже живут `amILast`/`writeRunOutcome`
// (`runWorkerCards`).
describe('liveForeignCorners — какие сведения о живых соседях есть у круга НА ДЕЛЕ', () => {
it('своя же карточка не в счёт — сосед должен быть ЧУЖИМ', () => {
// beforeEach уже завёл карточку w-1 с workspace = worktree.
expect(liveForeignCorners(rdir, 'w-1')).toEqual({});
});
it('у соседа есть карточка со своим углом и веткой — он в списке живых', () => {
const sosedRoot = join(dir, 'sosed-2');
mkdirSync(sosedRoot, { recursive: true });
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), {
worker_id: 'w-2', status: 'working', workspace: sosedRoot, base_branch: 'feat/x',
});
expect(liveForeignCorners(rdir, 'w-1')).toEqual({
'w-2': { root: sosedRoot, baseSha: 'feat/x' },
});
});
it('🪤 у соседа каталог угла на диске СНЕСЁН — он не живой, спросить о нём нечем', () => {
// 🔴 Каталог указан в карточке, но его больше нет (уборка `workspace.mjs` могла его
// снести). Включи мы такой угол в список — прибор факта честно ответит `null`
// (`git` не найдёт каталог), и `claimFiles` откажет всем ровно потому, что сосед,
// которого физически больше нет, «не ответил». Это не тот случай, который лечит
// задача 03.08.2026: угла нет ни в каком смысле, и держать замок за ним нельзя.
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), {
worker_id: 'w-2', status: 'working', workspace: join(dir, 'net-takogo-katalog'), base_branch: 'feat/x',
});
expect(liveForeignCorners(rdir, 'w-1')).toEqual({});
});
it('у соседа нет base_branch в карточке — он пропущен, а не считается опасным для всех', () => {
const sosedRoot = join(dir, 'sosed-3');
mkdirSync(sosedRoot, { recursive: true });
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), {
worker_id: 'w-2', status: 'working', workspace: sosedRoot,
});
expect(liveForeignCorners(rdir, 'w-1')).toEqual({});
});
it('трое живых соседей — все трое в списке, кроме себя самого', () => {
for (const wid of ['w-2', 'w-3', 'w-4']) {
const root = join(dir, `sosed-${wid}`);
mkdirSync(root, { recursive: true });
writeJsonAtomic(join(rdir, 'workers', wid, 'worker.json'), {
worker_id: wid, status: 'working', workspace: root, base_branch: 'feat/x',
});
}
const corners = liveForeignCorners(rdir, 'w-3');
expect(Object.keys(corners).sort()).toEqual(['w-2', 'w-4']);
});
});
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 }),
// 🔴 Добавлено 05.08.2026 вместе с правилом «работа заработана, а не унаследована».
// Угол у ЭТИХ проверок — обычный временный каталог, а не хранилище: настоящее правило
// отвечало бы им `fatal: not a git repository` и рубило бы каждый приговор ещё до
// прогона. Само правило и его вырезание живут в `zarabotannyy-kommit.test.mjs`,
// и подставки там нет ни одной — там настоящий `git` на настоящем рабочем угле.
zarabotano = () => ({ ok: true, base: 'sha-рождения-угла', commits: ['sha-работника'], reason: 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,
zarabotano,
dbName,
deferredPath: deferredPath ?? join(dir, 'deferred'),
});
return { out, child, clock };
}
describe('надзиратель ИСПОЛНЯЕТ пределы времени — то, чего до него не делал никто', () => {
// 🆕 08.08.2026, решение владельца 37: слово «восемь» из ожиданий этого раздела убрано.
// Проверки НЕ удалены и замысел их не тронут — надзиратель по-прежнему обязан гасить
// работника, выбравшего предел (здесь подставная настройка `CFG` даёт восемь часов).
// Изменилось одно: причина владельцу называет предел числом, а не заученным словом,
// и требовать в ней именно «восемь часов» значило бы закреплять отменённое.
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);
// 🆕 08.08.2026: сторожевое слово переставлено с «восемь часов» на «предел часов»
// (решение 37). Оставь я прежнее — этот сторож проходил бы ВСЕГДА и не сторожил бы ничего.
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'); } });
// 🔴 Прежде здесь стояло `extinguished` — «погашен», то есть годен к ПЛАТНОМУ подъёму
// наравне с тем, кто честно отработал ночь. А запустить его не удалось вовсе: подъём
// такого — не продолжение работы, а вторая попытка запустить то же самое, и деньги
// ушли бы на повторение той же осечки. Это самое буквальное «не родился» в затее.
expect(out.status).toBe('born_dead');
expect(PRODOLZHAEMYE).not.toContain(out.status);
expect(out.reason).toContain('ENOENT');
expect(readJson(join(wdir, 'worker.json')).status).toBe('born_dead');
expect(readJson(join(wdir, 'worker.json')).kak_konchil).toBe('ne_zapustilsya');
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('работник кончился, не отдав итога, — это не «успех»', async () => {
// 🔴 Он не сказал ни слова И не сделал ни одного дела — это `born_dead`, а не `stalled`.
// Прежде здесь стояло `stalled`, и проверка ЗАКРЕПЛЯЛА беду: по этому слову затея
// признавала мертворождённого годным к платному подъёму.
const { out } = await runSupervisor({ script: [c => { c.grumble('spawn claude ENOENT'); c.die(1); }] });
expect(out.status).toBe('born_dead');
expect(out.reason).toContain('ENOENT');
expect(readJson(join(wdir, 'worker.json')).kak_konchil).toBe('ne_skazal_ni_slova');
// Владельцу — словами, а не слово `born_dead` в одиночку.
expect(out.reason).toMatch(/не сказал ни слова/i);
expect(out.reason).toMatch(/платить за продолжение такого нельзя/i);
});
it('🔴 говорил, но оборвался без итога, и дела за ним ЕСТЬ — продолжаемый, а не мертворождённый', async () => {
// 🔴🔴 ЭТО ВТОРАЯ ПОЛОВИНА РАЗРЕЗА, И БЕЗ НЕЁ ПОЧИНКА БЫЛА БЫ ХУЖЕ БЕДЫ. Работник
// говорил, сделал коммит и умер на ходу. Назови мы его мертворождённым — владелец
// потерял бы право поднять его, а вместе с правом и всю сделанную им за ночь работу.
let calls = 0;
const head = () => (calls++ === 0 ? 'sha-rozhdenie' : 'sha-ego-rabota');
const { out } = await runSupervisor({
head,
script: [c => { c.say({ type: 'assistant', message: { content: 'работаю' } }); }, c => c.die(1)],
});
expect(out.status).toBe('stalled');
expect(PRODOLZHAEMYE).toContain(out.status);
expect(readJson(join(wdir, 'worker.json')).kak_konchil).toBe('oborvalsya_bez_itoga');
expect(out.reason).toMatch(/продолжать его можно/i);
});
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);
expect(out.reason).toMatch(/пустой ответ/i);
});
it('тридцать минут без единого признака жизни — назван вставшим и погашен', async () => {
// 🔴 Первый зов `head` съедает ЗАМЕР ВЕРШИНЫ ДО РОЖДЕНИЯ (она работнику не в счёт),
// второй отдаёт его настоящий коммит — вот он и есть признак жизни, третий молчит.
// Работал и замолчал — это `stalled`, и продолжать его есть с чего.
let calls = 0;
const head = () => (calls++ === 0 ? 'sha-rozhdenie' : (calls === 2 ? 'sha-ego-rabota' : null));
const { out, child } = await runSupervisor({ tickMs: 31 * 60_000, script: [() => {}], head });
expect(child.killed).toBe(true);
expect(out.status).toBe('stalled');
expect(PRODOLZHAEMYE).toContain(out.status);
expect(out.reason).toMatch(/признак/i);
});
it('🔴 тридцать минут и НИ ОДНОГО дела за всю жизнь — не «встал», а «не начал вовсе»', async () => {
// 🔴🔴 БЕДА ПРО ДЕНЬГИ ЦЕЛИКОМ, ОТ НАЧАЛА ДО КОНЦА. За работником нет ничего: ни коммита,
// ни записи о себе, ни закрытого пункта. Продолжать его «с места» нечем — места нет.
// Верни `final_status: 'stalled'` на эту ветку — покраснеет здесь, и вместе с этим
// затея снова начнёт платить живыми деньгами за подъём того, кто не родился.
const head = () => 'sha-rozhdenie'; // вершина не двигалась ни разу — работник ничего не сделал
const { out, child } = await runSupervisor({ tickMs: 31 * 60_000, script: [() => {}], head });
expect(child.killed).toBe(true);
expect(out.status).toBe('born_dead');
expect(PRODOLZHAEMYE).not.toContain(out.status);
expect(out.reason).toMatch(/не сделал ни одного дела/i);
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-rozhdenie' : (calls === 2 ? 'sha-ego-rabota' : 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 () => {
// 🔴🔴 ПРЕЖДЕ ЗДЕСЬ СТОЯЛА ОБРАТНАЯ ПРОВЕРКА, И ОНА ЗАКРЕПЛЯЛА БЕДУ. `seen` начинался
// пустым, первый круг видел вершину, стоявшую в ветке ДО рождения работника, не узнавал
// её и засчитывал ему как «коммит». Счёт «встал» шёл от рождения, а не от первого дела:
// молчащий работник каждую ночь считался живым лишние полчаса.
// Убери замер вершины перед рождением — покраснеет здесь.
const head = () => 'sha-do-rozhdeniya'; // вершина не двигалась ни разу
await runSupervisor({ head, script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }] });
expect(readJson(join(wdir, 'heartbeat.json'))).toBeNull();
});
it('настоящий коммит работника признаком жизни считается', async () => {
let calls = 0;
const head = () => (calls++ === 0 ? 'sha-do-rozhdeniya' : 'sha-ego-rabota');
await runSupervisor({ head, 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-ego-rabota');
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 }),
// 🔴 Добавлено 05.08.2026 вместе с правилом «работа заработана, а не унаследована».
// Угол у ЭТИХ проверок — обычный временный каталог, а не хранилище: настоящее правило
// отвечало бы им `fatal: not a git repository` и рубило бы каждый приговор ещё до
// прогона. Само правило и его вырезание живут в `zarabotannyy-kommit.test.mjs`,
// и подставки там нет ни одной — там настоящий `git` на настоящем рабочем угле.
zarabotano = () => ({ ok: true, base: 'sha-рождения-угла', commits: ['sha-работника'], reason: 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,
zarabotano,
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);
// 🆕 08.08.2026, решение 37: сторожевое слово переставлено на «предел часов». Прежнее
// «восемь часов» после правки не встречалось бы нигде — сторож проходил бы всегда.
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' });
// 🔴 Первый зов — вершина ДО рождения (не в счёт), второй — настоящий коммит работника,
// дальше молчание. Работал и замолчал: `stalled`, продолжать есть с чего.
let calls = 0;
const head = () => (calls++ === 0 ? 'sha-rozhdenie' : (calls === 2 ? 'sha-ego-rabota' : 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);
// 🆕 08.08.2026, решение 37: сторожевое слово переставлено на «предел часов». Прежнее
// «восемь часов» после правки не встречалось бы нигде — сторож проходил бы всегда.
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);
});
});
// 🔴🔴 ЗАКРЫТИЕ ДЫРЫ 03.08.2026, ВТОРОЙ КРУГ, ДОВЕДЕНО ДО ЖИВОГО КРУГА. Прошлый круг проверил
// `mayTouchFakt` — вход, который в живом коде почти не звучит. Опасный миг — это ЗАНЯТИЕ
// файла (`claimFiles`), и он звучит здесь же, в цикле выдачи свободного пункта (блок 5
// круга, три зова `claimFiles`). Проверка ниже доказывает СКВОЗНОЕ поведение: круг сам
// собирает живых чужих соседей (`liveForeignCorners`) и подаёт их в `claimFiles` вместе
// с прибором факта — тем же `changedFiles`, каким круг уже меряет СВОЁ собственное
// пересечение (довод `whatWorkerTouched`). Второго прибора для этого не заводится.
describe('🔴🔴 надзиратель спрашивает ФАКТ живых чужих углов при занятии пункта (закрытие дыры 03.08.2026, дверь пошире)', () => {
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 () => {
// 🔴 w-1 стоит на пункте «1» и пойман на чужом файле w-2 (app/A.php) — обычный ход
// круга ищет ему замену. Пункт «3» по СЛОВАРЮ занятий свободен: его файл (app/C.php)
// никто не заявлял. Но живой угол w-2 (карточка ниже) на деле уже тронул этот же файл
// ВНЕ своего заявленного пункта — ровно та дыра, которую задача 03.08.2026 закрывает.
// Без правки круг выдал бы w-1 пункт «3» как чистый; с правкой — обязан отказать.
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'] },
});
const sosedRoot = join(dir, 'sosed-w2');
mkdirSync(sosedRoot, { recursive: true });
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), {
worker_id: 'w-2', run_id: 'r-1', status: 'working', workspace: sosedRoot, base_branch: CFG.base_branch,
});
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'] }, // по заявке свободен, а по факту угла w-2 уже тронут
]);
const { out } = await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: root => {
if (root === worktree) return ['app/A.php']; // w-1 поймали на чужом файле
if (root === sosedRoot) return ['app/C.php']; // а сосед на деле тронул именно тот, что «свободен»
return [];
},
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
// Пункт «3» называется свободным по заявке КАЖДЫЙ заход (словарь занятий его отказ фактом
// не помнит — `claimFiles` ничего не пишет при отказе), а факт отказывает ему каждый раз —
// ровно то поведение, каким уже живёт «занятие может не удаться» при столкновении двух
// надзирателей. Круг исчерпывает попытки и гасит работника с уже существующей пометкой
// «занять его файлы не удалось», а НЕ выдаёт пункт «3», в котором на деле уже лежит чужое.
expect(readJson(join(inbox, 'claims.json')).next_item).toBeNull();
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/занять его файлы не удалось/i);
});
it('живой сосед чист по факту — свободный пункт выдаётся как обычно', async () => {
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'] },
});
const sosedRoot = join(dir, 'sosed-w2-chist');
mkdirSync(sosedRoot, { recursive: true });
writeJsonAtomic(join(rdir, 'workers', 'w-2', 'worker.json'), {
worker_id: 'w-2', run_id: 'r-1', status: 'working', workspace: sosedRoot, base_branch: CFG.base_branch,
});
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'] },
{ id: '3', files: ['app/C.php'] },
]);
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: root => (root === worktree ? ['app/A.php'] : []), // сосед на деле чист
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
expect(readJson(join(inbox, 'claims.json')).next_item).toBe('3');
});
it('🪤 ДОРОГОВИЗНА: живых чужих соседей нет — прибор факта в цикле занятия не звучит вовсе', async () => {
// 🔴 Без карточки w-2 `liveForeignCorners` отдаёт пустой список, и `claimFiles` не идёт
// в хранилище ни разу за весь круг — цена лечения, применённая к горячему месту.
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'] },
{ id: '3', files: ['app/C.php'] },
]);
let zovovNaChuzhieUgly = 0;
await runSupervisor({
tickMs: 1000,
script: [c => c.die(0)],
changedFiles: root => {
if (root !== worktree) zovovNaChuzhieUgly += 1; // единственный «чужой» корень в этой пробе — свой же worktree
return root === worktree ? ['app/A.php'] : [];
},
rollbackFn: () => ({ restored: ['app/A.php'], deleted: [], committed: true, errors: [] }),
});
expect(zovovNaChuzhieUgly).toBe(0);
expect(readJson(join(inbox, 'claims.json')).next_item).toBe('3');
});
});
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 { sledRaboty } from './sled-raboty.mjs';
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 { NIGHT_ROOT } from './paths.mjs';
// 🔴 Корень БОЕВОГО хозяйства берётся у сторожа на диске — единственного места во всей
// затее, где он посчитан РУКАМИ, от собственного файла, и нарочно не через `paths.mjs`.
// Второго такого счёта здесь не заводим: разойдись они — сторож смотрел бы не туда.
import { BOEVOE as BOEVOE_HOZYAYSTVO } from './storozh-boevogo-hozyaystva.mjs';
import { readdirSync as readdirSyncK4 } from 'node:fs';
import { relative as otKornya } from 'node:path';
// 🔴🔴 ПОЧЕМУ ЗДЕСЬ НЕ `deferredDir()`, ХОТЯ ИМЕННО ОН И ЕСТЬ «ЕДИНСТВЕННОЕ МЕСТО».
// Замерено 04.08.2026 в полном прогоне: сторож внизу файла краснел на `r-chasy-<хвост>.json`,
// а поодиночке файл был зелёный, 199 из 199. Разобрано так: снимок «до проверок» вышел
// ПУСТЫМ (`[]`), тогда как в настоящем боевом каталоге лежат шесть настоящих улик. Значит
// `deferredDir()` под прибором проверок показывает НЕ БОЕВОЕ: с 04.08.2026 `korenHozyaystva`
// (`paths.mjs`) под прибором отводит корень хозяйства во временное место ВСЕГДА — с настройкой,
// без настройки и с забытой. Подтверждение с другого конца, не моей дорогой: независимый
// сторож на диске (`storozh-boevogo-hozyaystva.mjs`) в том же прогоне промолчал, а сами шесть
// улик на диске не изменились ни именем, ни размером.
// ⇒ Сторож называл «БОЕВЫМ» временный каталог и краснел от ЗАКОННОЙ записи соседа по прогону
// (`run.test.mjs`, блок «часы работника»: заводит `r-chasy-…` и убирает за собой). Красное
// не по делу — хуже дырки: его учатся не замечать.
//
// 🔴 ВИНОВНИК НАЗВАН ВЫРЕЗАНИЕМ, а не вычислен глазами (прошлая смена оставила его
// неустановленным). Верни сюда `deferredDir()` и погоняй ДВА файла вместе
// (`npx vitest run --config vitest.config.tools.mjs supervisor.test run.test`) — сторож
// краснеет на `r-chasy-<хвост>.json`, а каталог в отказе печатается временным:
// `…\Temp\2\night-proverki-<хвост>\.night\deferred`. Те же два файла с нынешним наведением —
// 263 из 263 зелёных. Замерено 04.08.2026.
//
// 🔴 Лечение: смотреть в НАСТОЯЩИЙ боевой каталог. Собирается он из двух половин, и обе
// взяты у хозяев, а не выдуманы здесь:
// · корень боевого хозяйства — у сторожа на диске (он считает его руками нарочно);
// · последний кусок пути («куда именно кладут следы») — у самой `deferred.mjs`, отмерянный
// от её же корня. Переименуют каталог следов — переедет и сторож.
const KUSOK_SLEDOV = otKornya(NIGHT_ROOT, deferredDir()).split(/[\\/]/).filter(Boolean).join('/');
// 🔴 Громкий отказ вместо тихого «ну ладно»: уведи кто-нибудь каталог следов из-под корня
// хозяйства — путь получился бы шагом вверх, сторож смотрел бы в чужое место и молчал бы
// всегда. Молчащий сторож не отличим от исправного, и это ровно тот класс, от которого
// вся затея строится.
if (!KUSOK_SLEDOV || KUSOK_SLEDOV.startsWith('..')) {
throw new Error(
`Каталог следов (${deferredDir()}) больше не лежит внутри корня хозяйства (${NIGHT_ROOT}) — ` +
`отмерить его от боевого корня нечем. Сторож «проверки не наследили в боевом каталоге» ` +
`без этого смотрел бы не туда и молчал бы всегда.`,
);
}
const BOEVOY_KATALOG_SLEDOV = join(BOEVOE_HOZYAYSTVO, KUSOK_SLEDOV);
// Что лежит в БОЕВОМ каталоге следов. Снимок снимается при разборе файла — то есть
// до первой проверки; сторож в самом низу сравнивает с ним.
//
// 🔴🔴 КАТАЛОГ — ДОВОД, А НЕ ПРИКЛЕЕННАЯ ВЕЛИЧИНА, И ЭТО НЕ УКРАШЕНИЕ ПОДПИСИ.
// Долг, найденный судьёй смены 12 и проверенный своей рукой 04.08.2026: после
// перенаведения сторожа его стало НЕВОЗМОЖНО показать красным, не испортив оплаченных
// улик владельца, — обе половины (куда смотреть и с чем сравнивать) были приклеены
// на уровне файла, и подменить их на время приёмки было нечем. Приёмка, которую тогда
// провели, ломала СТАРОЕ, уже негодное наведение (`deferredDir()`) — то есть проверяла
// не починку, а то, что до починки было плохо. Сторож, которого никто не видел красным
// в его НЫНЕШНЕМ виде, не сторожит ничего.
// ⇒ Подмена сделана там, где величина БЕРЁТСЯ, а не там, где она объявлена: обе мелочи
// принимают каталог доводом, и приёмка (в самом низу файла) гоняет их на СВОЁМ
// одноразовом каталоге. Боевое хозяйство при этом не трогается ни байтом.
function sledyBoevye(dir = BOEVOY_KATALOG_SLEDOV) {
return existsSync(dir) ? readdirSyncK4(dir).sort() : [];
}
const SLEDY_DO_PROVEROK = sledyBoevye();
// Чистое сравнение двух снимков — правило без единого прикосновения к диску.
// Ответ: `null` — чисто; строка — готовый отказ словами.
// 🔴 Вынесено из тела проверки нарочно: слова отказа тоже приёмка должна уметь прочитать,
// а не верить, что они там есть. Отказ вида «что-то изменилось» заставляет читателя гадать,
// а не назови он случай «рядом шла настоящая ночь» — первый же ложный красный объявят
// поломкой сторожа и снесут сторожа, а не беду.
function otkazSledovBoevyh(doProgona, posle, { gde = BOEVOY_KATALOG_SLEDOV } = {}) {
const novye = posle.filter(f => !doProgona.includes(f));
const propavshie = doProgona.filter(f => !posle.includes(f));
if (!novye.length && !propavshie.length) return null;
return [
'В БОЕВОМ каталоге следов за прогон проверок что-то изменилось.',
`Каталог: ${gde}`,
'',
'Чем это вредит: след отложенной задачи — это отказ, в который упрётся следующая',
'живая ночь. Владелец про такой файл не знает и починить его ничем не может.',
'',
...(novye.length ? [`Появилось (${novye.length}): ${novye.join(', ')}`] : []),
...(propavshie.length ? [`ПРОПАЛО (${propavshie.length}): ${propavshie.join(', ')}`] : []),
'',
'Куда смотреть: кто-то склеил путь к боевому хозяйству РУКАМИ мимо `paths.mjs`',
'или запустил ребёнка с очищённым окружением (без примет прибора проверок —',
'тогда `korenHozyaystva` честно отдаёт ему БОЕВОЙ корень).',
'',
'🟡 Если рядом на этой машине вправду шла НАСТОЯЩАЯ ночь — это ложная тревога.',
].join('\n');
}
// 🔴 Про прочие ввозы честно: НОВЫХ строк здесь НЕТ, и это отступление от плана,
// названное вслух. План велел ввезти `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`. Сторож всё равно нужен: след в боевом каталоге —
// настоящая беда, от чьей бы руки он ни лёг.
// 🔴 04.08.2026 сторож ПЕРЕНАВЕДЁН: он смотрел не в боевой каталог, а во временный
// (разбор — у `BOEVOY_KATALOG_SLEDOV` выше), и краснел от законной записи соседа.
// 🪤 ЧТО ОСТАЁТСЯ НЕ ОХРАНЁННЫМ — вслух. Записи проверок во ВРЕМЕННОЕ хозяйство
// (то, куда `paths.mjs` отводит корень на время прогона) этот сторож не считает
// бедой вовсе — и правильно: временное на то и временное, соседи там пишут законно.
// 🪤 И вторая слабость, общая с независимым сторожем на диске: отличить след, положенный
// проверкой, от следа НАСТОЯЩЕЙ ночи, идущей на этой же машине в ту же минуту, он
// не умеет. Тогда это ложная тревога, и имя файла в отказе даёт её распознать глазом.
// 🟢 04.08.2026: правило вынесено в `otkazSledovBoevyh` и ПРИНЯТО ВЫРЕЗАНИЕМ на
// одноразовом каталоге (блок в самом низу файла) — прежде показать его красным
// было нечем, кроме порчи оплаченных улик.
expect(otkazSledovBoevyh(SLEDY_DO_PROVEROK, sledyBoevye())).toBe(null);
});
});
// ━━━ ПРИЁМКА ЭТОГО СТОРОЖА ВЫРЕЗАНИЕМ — НА СВОЁМ ОДНОРАЗОВОМ КАТАЛОГЕ ━━━
// 🔴🔴 ЗАЧЕМ ОНА ЕСТЬ. Сторож выше зелен всегда, пока хозяйство исправно, — а зелёный,
// которого не видели красным, не доказывает ничего. Показать его красным «по-настоящему»
// значило бы положить файл в оплаченные улики владельца, то есть сломать ровно то, что
// он бережёт. Поэтому обе его половины берут каталог доводом, и приёмка гоняет их на
// каталоге, который заводит и сносит сама.
// 🔴 БОЕВОЕ ХОЗЯЙСТВО ЗДЕСЬ НЕ ТРОГАЕТСЯ НИ БАЙТОМ. Замерено счётом файлов в `.night`
// до и после работы — число одно и то же.
describe('🛡️ приёмка сторожа боевых следов: сломай — покраснеет, верни — погаснет', () => {
function odnorazovyy(delo) {
const dir = vremennyKatalog(join(vremennyKoren(), 'boevye-sledy-'));
try {
zapisatFayl(join(dir, 'nastoyashchiy-sled.json'), '{"reason":"not_finished"}', 'utf8');
return delo(dir);
} finally {
ubratKatalog(dir, { recursive: true, force: true });
}
}
it('ничего не менялось — сторож молчит', () => {
odnorazovyy(dir => {
expect(otkazSledovBoevyh(sledyBoevye(dir), sledyBoevye(dir), { gde: dir })).toBe(null);
});
});
it('🔴 ВЫРЕЗАНИЕ: проверка положила след — сторож краснеет и называет файл ПОИМЁННО', () => {
// 🔴 Это и есть живая поимка 02.08.2026: после прогона проверок в хозяйстве лежал
// настоящий `t-1.json` — отказ по задаче, которой никто не откладывал.
odnorazovyy(dir => {
const doProgona = sledyBoevye(dir);
zapisatFayl(join(dir, 't-1.json'), '{"reason":"not_finished"}', 'utf8');
const otkaz = otkazSledovBoevyh(doProgona, sledyBoevye(dir), { gde: dir });
expect(otkaz, 'сторож не увидел следа, положенного проверкой').not.toBe(null);
expect(otkaz).toContain('t-1.json');
expect(otkaz).toContain(dir);
// 🪤 Отказ обязан говорить, ЧЕМ это вредит и куда смотреть, иначе его снесут,
// а не починят.
expect(otkaz).toMatch(/живая ночь/);
expect(otkaz).toMatch(/склеил путь/);
expect(otkaz).toMatch(/ложная тревога/);
});
});
it('🔴 УБРАЛИ СЛЕД — красное гаснет САМО, без единой правки сторожа', () => {
odnorazovyy(dir => {
const doProgona = sledyBoevye(dir);
zapisatFayl(join(dir, 't-1.json'), '{}', 'utf8');
expect(otkazSledovBoevyh(doProgona, sledyBoevye(dir), { gde: dir })).not.toBe(null);
ubratKatalog(join(dir, 't-1.json'), { force: true });
expect(otkazSledovBoevyh(doProgona, sledyBoevye(dir), { gde: dir })).toBe(null);
});
});
it('🔴 настоящая улика ПРОПАЛА — сторож видит и это', () => {
// 🪤 Сторож «только на новое» пропустил бы уничтожение улики, а это хуже лишнего файла.
odnorazovyy(dir => {
const doProgona = sledyBoevye(dir);
ubratKatalog(join(dir, 'nastoyashchiy-sled.json'), { force: true });
const otkaz = otkazSledovBoevyh(doProgona, sledyBoevye(dir), { gde: dir });
expect(otkaz).toContain('ПРОПАЛО');
expect(otkaz).toContain('nastoyashchiy-sled.json');
});
});
it('🔴 каталога нет вовсе — снимок пуст, и это не падение', () => {
expect(sledyBoevye(join(vremennyKoren(), 'takogo-kataloga-net-нет'))).toEqual([]);
});
it('🔴 сторож целится в НАСТОЯЩЕЕ боевое хозяйство, а не в отведённое', () => {
// 🪤 Ровно та беда, которую чинили 04.08.2026 с другого конца: сторож смотрел
// во ВРЕМЕННЫЙ каталог и краснел от законной записи соседа по прогону. Здесь
// закреплено, что он вернулся в боевое и остался там.
expect(BOEVOY_KATALOG_SLEDOV.startsWith(BOEVOE_HOZYAYSTVO)).toBe(true);
expect(BOEVOY_KATALOG_SLEDOV.startsWith(NIGHT_ROOT), 'сторож снова целится в отведённый корень').toBe(false);
// И он не смотрит в пустоту: боевое хозяйство вправду на месте.
expect(existsSync(BOEVOE_HOZYAYSTVO), `боевого хозяйства нет по пути ${BOEVOE_HOZYAYSTVO}`).toBe(true);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// ОБЩИЙ ОТКАТЧИК ДО КОММИТА — единственный на всю затею (решение диспетчера 3).
//
// 🔴 Зовущих двое и оба чужие: блок 2в куска 5 (владелец ответил «нет») и шаг 11 дороги
// продолжения куска 6. Своего откатчика ни тот, ни другой не заводят — в этом и смысл
// вывоза наружу. `sledOtkata` в `resume.mjs` уже построен и ждёт этот зов ОБЯЗАТЕЛЬНЫМ
// доводом: без него он отвечает «откатчик не подан — откатывать нечем».
//
// 🔴 Хранилище тут НАСТОЯЩЕЕ, и это не прихоть: подставной `git` доказал бы только моё
// представление о `git`. Ровно на этом обожглись 02.08 — три формы отсева отвечали
// одинаково и разошлись лишь у настоящего.
// ═══════════════════════════════════════════════════════════════════════════════
import { otkatDoKommita } from './supervisor.mjs';
// 🔴 Обёртка соседа (кусок 6) — она и есть первый настоящий зовущий этого откатчика.
// Без живого зова правило осталось бы «построено, зелено и никем не позвано».
import { sledOtkata } from './resume.mjs';
describe('🔴 общий откатчик до коммита — один на затею', () => {
// Хранилище: точка ветвления, затем ДВА коммита работы поверх неё.
// Откатывать будем до первого — то есть снимать второй.
function repoSDvumyaKommitami() {
const r = vremennyKatalog(join(vremennyKoren(), 'night-otkat-do-'));
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');
zapustit(GIT3, ['add', '.'], { cwd: r });
zapustit(GIT3, ['commit', '-qm', 'засчитанный пункт'], { cwd: r });
const zachtennyy = String(zapustit(GIT3, ['rev-parse', 'HEAD'], { cwd: r, encoding: 'utf8' })).trim();
// Поверх засчитанного — работа, которую придётся снять: правка старого файла
// и НОВЫЙ файл, которого в засчитанном пункте не было вовсе.
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 });
const vershina = String(zapustit(GIT3, ['rev-parse', 'HEAD'], { cwd: r, encoding: 'utf8' })).trim();
return { r, zachtennyy, vershina };
}
const golova = r => String(zapustit(GIT3, ['rev-parse', 'HEAD'], { cwd: r, encoding: 'utf8' })).trim();
it('🔴 откат ложится НОВЫМ коммитом, а прежний ОСТАЁТСЯ в ветке', () => {
const { r, zachtennyy, vershina } = repoSDvumyaKommitami();
try {
const o = otkatDoKommita({ worktreeRoot: r, toSha: zachtennyy });
expect(o.ok).toBe(true);
// Вершина сменилась — значит лёг новый коммит, а не переписалась история.
expect(golova(r)).not.toBe(vershina);
// 🔴 «Откаченное отложено в сторону»: прежний коммит по-прежнему в истории ветки.
const istoriya = String(zapustit(GIT3, ['rev-list', 'HEAD'], { cwd: r, encoding: 'utf8' })).trim().split('\n');
expect(istoriya).toContain(vershina);
expect(o.kept_aside_sha).toBe(vershina);
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴 дерево угла ВПРАВДУ вернулось к засчитанному пункту — мерим деревом', () => {
// 🪤 Замерено соседом и подтверждено здесь: после отката `git diff --name-only <пункт> --`
// отдаёт ПУСТО, а `git rev-list <пункт>..HEAD` — НЕ пусто (там лежит сам коммит отката).
// Мерить «откатили ли» числом коммитов сверху нельзя: правило сделало бы второй откат
// поверх первого.
const { r, zachtennyy } = repoSDvumyaKommitami();
try {
otkatDoKommita({ worktreeRoot: r, toSha: zachtennyy });
const derevo = String(zapustit(GIT3, ['diff', '--name-only', zachtennyy, '--'], { cwd: r, encoding: 'utf8' })).trim();
expect(derevo).toBe('');
const kommity = String(zapustit(GIT3, ['rev-list', `${zachtennyy}..HEAD`], { cwd: r, encoding: 'utf8' })).trim();
expect(kommity).not.toBe('');
// И по содержимому: старый файл вернулся, заведённый после — исчез.
// 🪤 ЗАМЕРЕНО на этой машине: `core.autocrlf=true`, и git при выкладке дерева сам
// переводит концы строк в `\r\n`. Сравнение знак в знак краснело бы ЗДЕСЬ на исправном
// откате — то есть от настройки машины, а не от поломки. Concы строк поэтому сводятся.
// 🔴 Предмет проверки от этого не слабеет: главное доказательство выше — сам git сказал,
// что дерево совпало (`diff --name-only` пуст), а это сильнее любого сравнения строк.
const bezKoncov = s => String(s).replace(/\r\n/g, '\n');
expect(bezKoncov(prochestFaylom(join(r, 'app', 'A.php'), 'utf8'))).toBe('первая правда\n');
expect(existsSync(join(r, 'app', 'B.php'))).toBe(false);
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴 подпись отката — НОЧНАЯ ЗАТЕЯ, а не работник', () => {
const { r, zachtennyy } = repoSDvumyaKommitami();
try {
otkatDoKommita({ worktreeRoot: r, toSha: zachtennyy });
const avtor = String(zapustit(GIT3, ['log', '-1', '--pretty=%an <%ae>'], { cwd: r, encoding: 'utf8' })).trim();
expect(avtor).toMatch(/ночн/i);
expect(avtor).not.toMatch(/работник/i);
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴 список откаченного считает САМА, и в нём оба файла', () => {
const { r, zachtennyy } = repoSDvumyaKommitami();
try {
const o = otkatDoKommita({ worktreeRoot: r, toSha: zachtennyy });
expect(o.rolled_back_files).toEqual(expect.arrayContaining(['app/A.php', 'app/B.php']));
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴 ГРЯЗНЫЙ угол отката НЕ получает — и ничего не тронуто', () => {
const { r, zachtennyy, vershina } = repoSDvumyaKommitami();
try {
zapisatFayl(join(r, 'app', 'A.php'), 'несохранённая правка работника\n');
const o = otkatDoKommita({ worktreeRoot: r, toSha: zachtennyy });
expect(o.ok).toBe(false);
expect(o.reason).toMatch(/несохранённ|грязн/i);
// 🔴 Ни коммита, ни потери чужой работы.
expect(golova(r)).toBe(vershina);
expect(String(prochestFaylom(join(r, 'app', 'A.php'), 'utf8'))).toBe('несохранённая правка работника\n');
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴 хранилище не ответило — отказ, а не молчаливое «наверное откатили»', () => {
const { r, zachtennyy, vershina } = repoSDvumyaKommitami();
try {
const o = otkatDoKommita({
worktreeRoot: r, toSha: zachtennyy,
run: () => { throw new Error('git обвалился'); },
});
expect(o.ok).toBe(false);
expect(o.reason).toBeTruthy();
expect(golova(r)).toBe(vershina);
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴 коммита не назвали — отказ до всякого git', () => {
const { r, vershina } = repoSDvumyaKommitami();
try {
for (const net of [undefined, null, '']) {
const o = otkatDoKommita({ worktreeRoot: r, toSha: net });
expect(o.ok).toBe(false);
expect(o.reason).toMatch(/коммит/i);
}
expect(golova(r)).toBe(vershina);
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴 откатывать нечего — это НЕ беда, но и не новый коммит', () => {
// Откатываем до САМОЙ вершины: дерево уже совпадает.
const { r, vershina } = repoSDvumyaKommitami();
try {
const o = otkatDoKommita({ worktreeRoot: r, toSha: vershina });
expect(o.ok).toBe(true);
expect(o.rolled_back_files).toEqual([]);
expect(golova(r)).toBe(vershina); // лишнего пустого коммита не завелось
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴🔴 ЗОВ ЖИВЬЁМ: sledOtkata соседа вправду принимает этот откатчик', () => {
// 🔴 Это и есть смысл всего круга: пока правило некому позвать, оно не проверено.
// Здесь `sledOtkata` (кусок 6) зовётся с настоящим `otkatDoKommita` на настоящем
// хранилище — то самое место, где раньше стояло «откатчик не подан».
const { r, zachtennyy, vershina } = repoSDvumyaKommitami();
try {
const s = sledOtkata({ worktreeRoot: r, commit_sha: zachtennyy, otkat: otkatDoKommita });
expect(s.ok).toBe(true);
expect(s.reason).not.toMatch(/не подан/);
expect(s.rollback_commit).toBe(golova(r));
expect(s.rollback_commit).not.toBe(vershina);
// След для владельца: ветка и коммиты, вправду оставшиеся в истории.
expect(s.otlozheno?.commits).toContain(vershina);
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
it('🔴 второй зов подряд безвреден: дерево уже совпало — второго отката нет', () => {
// 🪤 Ровно то, ради чего «мерим деревом»: после первого отката сверху лежит коммит
// отката, и правило «есть коммиты сверху» сделало бы откат поверх отката.
const { r, zachtennyy } = repoSDvumyaKommitami();
try {
sledOtkata({ worktreeRoot: r, commit_sha: zachtennyy, otkat: otkatDoKommita });
const posleFirst = golova(r);
const s2 = sledOtkata({ worktreeRoot: r, commit_sha: zachtennyy, otkat: otkatDoKommita });
expect(s2.ok).toBe(true);
expect(s2.rollback_commit).toBeNull();
expect(golova(r)).toBe(posleFirst);
} finally { ubratKatalog(r, { recursive: true, force: true }); }
});
});
// --- СЛЕД СЫРОГО ПОТОКА РАБОТНИКА В ЖИВОМ КРУГЕ -------------------------------
// 🔴 Сам прибор проверен отдельно (`sled-potoka-rabotnika.test.mjs`). Здесь — ЗОВ:
// вправду ли круг его открывает, кладёт в него поток и закрывает. Правило хозяйства,
// оплаченное пять раз за три куска: правило, которое никто не зовёт, не проверено.
import { readFileSync as chitatFayl } from 'node:fs';
import { spawnSync as zapustitProgrammu } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { resolveBin as naytiProgrammu, needsShell as nuzhnaObolochka } from './paths.mjs';
import { POTOK_SLED_FILE, otkrytSledPotoka as otkrytSledPotokaZov } from './supervisor.mjs';
describe('круг ПИШЕТ сырой поток работника на диск', () => {
it('🔴 ЖИВОЙ СЛУЧАЙ 03.08.2026: работник умер молча — след объявляет пустоту вслух', async () => {
// Работник родился и умер, не сказав ни слова: ровно `zhivoe-5/w-1`.
const { out } = await runSupervisor({ script: [c => c.die(1)] });
// 🔴 Он не сказал ни слова и не сделал ни одного дела — с 03.08.2026 это отдельное
// состояние `born_dead`, а не общее со «встал»: платить за его подъём нельзя.
expect(out.status).toBe('born_dead');
expect(readJson(join(wdir, 'exit.json')).kak_konchil).toBe('ne_skazal_ni_slova');
// Файл есть (значит след вправду открывался), в нём ноль байт.
expect(existsSync(join(wdir, POTOK_SLED_FILE))).toBe(true);
expect(chitatFayl(join(wdir, POTOK_SLED_FILE), 'utf8')).toBe('');
// И владелец узнаёт об этом СЛОВАМИ, а не по отсутствию файла.
const lines = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(lines).toMatch(/ПУСТ — 0 байт/);
// Состояние следа лежит рядом с причиной смерти, в exit.json.
const exit = readJson(join(wdir, 'exit.json'));
expect(exit.stream_raw.bayt).toBe(0);
expect(exit.stream_raw.path).toContain(POTOK_SLED_FILE);
});
it('🔴 всё сказанное работником лежит на диске КАК ПРИШЛО, включая не-JSON', async () => {
const { out } = await runSupervisor({
script: [
c => {
c.say({ type: 'assistant', message: { content: 'привет' } });
c.stdout.emit('data', 'это вообще не JSON\n'); // мусор — тоже улика
c.say({ type: 'result', subtype: 'success', is_error: false, result: 'готово' });
c.die(0);
},
],
});
expect(out.status).toBe('extinguished');
const text = chitatFayl(join(wdir, POTOK_SLED_FILE), 'utf8');
expect(text).toContain('"type":"assistant"');
expect(text).toContain('это вообще не JSON');
expect(text).toContain('"subtype":"success"');
// Пустой след не объявляется, поэтому лишней строки в сводке нет.
const lines = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(lines).not.toMatch(/сырой поток/);
});
it('🔴 кусок ложится на диск ДО того, как круг кончился, — ради убитого работника', async () => {
let vidnoNaDiske = null;
await runSupervisor({
script: [
c => { c.say({ type: 'assistant', message: { content: 'первое слово' } }); },
() => { vidnoNaDiske = chitatFayl(join(wdir, POTOK_SLED_FILE), 'utf8'); },
c => { c.die(137); }, // SIGKILL — итога не будет никогда
],
});
// Работник ещё жив, круг ещё идёт, а слово уже на диске.
expect(vidnoNaDiske).toContain('первое слово');
});
it('🔴 след лежит там, куда git не заглядывает — иначе тайны уехали бы в хранилище', () => {
// 🔴🔴 ЦЕНА СЛЕДА, НАЗВАННАЯ ВСЛУХ. Работник читает файлы и правит код — в его поток
// попадёт ВСЁ, что он прочёл: содержимое `.env`, пароли базы, выданные командой ключи.
// Раньше это жило только в его сессии и умирало вместе с ней; теперь ложится на диск.
// Затирать тайны нарочно не берусь: известной формы у них нет, а замазка, ловящая три
// образца из десяти, — ровно «выглядит защитой, не будучи ею»: после неё со следом
// обращались бы как с безопасным. Вместо замазки — ГРАНИЦА: след не покидает машину.
// 🔴 Сторож на границу — ЖИВОЙ `git`, а не чтение `.gitignore` глазами: правило можно
// отменить строкой-исключением ниже по файлу, и текстовый датчик этого не увидит.
// Приговор «спрятан/не спрятан» выносит тот, кто и будет решать это при коммите.
const git = naytiProgrammu('git');
const put = join('.night', 'runs', 'r-1', 'workers', 'w-1', POTOK_SLED_FILE);
const kod = zapustitProgrammu(git, ['check-ignore', '-q', put], {
cwd: fileURLToPath(new URL('../..', import.meta.url)),
shell: nuzhnaObolochka(git),
windowsHide: true,
}).status;
expect(kod, `${put} НЕ спрятан от git — сырой поток работника уедет в хранилище`).toBe(0);
});
it('🔴 порядок: на диск кладётся РАНЬШЕ разбора — поведением это не увидеть', () => {
// 🪤 ЭТОТ ДАТЧИК ЧУТЬ НЕ СОВРАЛ, И ГОВОРЮ ВСЛУХ КАК. Вырезав зов и оставив его буквы
// в примечании (`/* ВЫРЕЗ sledPotoka?.kusok(chunk); */`), я получил ЗЕЛЁНУЮ проверку
// на мёртвом коде: датчик по тексту не отличает зов от упоминания. Поэтому примечания
// из текста вырезаются ДО поиска — иначе это не датчик, а поиск по буквам.
// 🔴 Сам факт записи сторожат ПОВЕДЕНЧЕСКИЕ проверки выше (обе краснеют от выреза).
// Здесь остаётся только то, чего поведением не достать: ПОРЯДОК двух строк.
const syroy = chitatFayl(new URL('./supervisor.mjs', import.meta.url), 'utf8');
const tekst = syroy
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.split('\n').map(s => s.replace(/\/\/.*$/, '')).join('\n');
const gdeZapis = tekst.indexOf('sledPotoka?.kusok(chunk)');
const gdeRazbor = tekst.indexOf('tail += String(chunk)');
expect(gdeZapis, 'зова записи в живом коде нет — только в примечании').toBeGreaterThan(0);
expect(gdeRazbor).toBeGreaterThan(0);
expect(gdeZapis).toBeLessThan(gdeRazbor);
// Прибор вправду вывезен и зовётся тем же именем, каким его зовёт круг.
expect(typeof otkrytSledPotokaZov).toBe('function');
});
});
// ━━━ ЖИВОЕ ХРАНИЛИЩЕ: ПРИБОР КРУГА + ПРАВИЛО «СДЕЛАЛ ЛИ ДЕЛО», БЕЗ ПОДСТАВОК ━━━
//
// 🔴 Здесь нарочно нет ни одной подставки: настоящий git, настоящая ветка, настоящий
// `changedFilesOfWorker`. Больной и ЗДОРОВЫЙ образцы — на ОДНОМ хранилище, различаются
// ровно одним: сделал работник коммит или нет. Половина работы — совпасть с больным;
// вторая половина — НЕ совпасть со здоровым, и её пропускают почти всегда.
// 🪤 Живого образца в оплаченных уликах не нашлось НИ ОДНОГО (11 карточек работников
// на 149 прогонов, ни в одной нет сделанной работы) — поэтому здоровый собран здесь.
// 🔴 Стоит в ЭТОМ файле, а не рядом с правилом: живой запуск внешних программ разрешён
// поимённо (`zhivoy-zapusk-v-proverkah.test.mjs`), и этот файл в списке уже есть.
// Заводить новую строку в списке ради того, что уже разрешено, — расшатывать сторожа.
function zavestiHranilishcheSleda() {
const git = naytiProgrammu('git');
const koren = vremennyKatalog(join(vremennyKoren(), 'sled-raboty-'));
const g = (...args) => zapustit(git, args, { cwd: koren, encoding: 'utf8' });
g('init', '-q', '-b', 'osnova');
g('config', 'user.email', 'proba@example.invalid');
g('config', 'user.name', 'проба');
zapisatFayl(join(koren, 'nachalo.txt'), 'начало\n');
g('add', '-A');
g('commit', '-q', '-m', 'начало');
g('checkout', '-q', '-b', 'rabotnik');
return { koren, g };
}
describe('живое хранилище: больной и здоровый образцы одним прибором', () => {
it('🔴 БОЛЬНОЙ (случай живой пробы rabota-1): работник вышел, ветка не двинулась — следа нет', () => {
const { koren } = zavestiHranilishcheSleda();
const spisok = changedFilesReal(koren, 'osnova');
expect(spisok).toEqual([]); // сверка ПРОШЛА и показала пусто — это не «не знаю»
expect(sledRaboty({ izmenennyeFayly: spisok, zakrytyePunkty: [] }).est).toBe(false);
});
it('🔴 ЗДОРОВЫЙ: работник вправду сделал коммит — та же примета на нём НЕ срабатывает', () => {
const { koren, g } = zavestiHranilishcheSleda();
// 🪤 Имя файла ЛАТИНИЦЕЙ нарочно. Замерено здесь же: на кириллическом имени
// `git diff --name-only` отдаёт его в кавычках и восьмеричными кодами
// (`"\321\200..."`). Правилу «сделал ли дело» это безразлично — оно считает длину
// списка, — но соседнему правилу «что работник трогал» (`touchedFromFiles`) НЕ
// безразлично: его приметы (`billing`, `\.vue$`) по такому имени не сойдутся никогда.
// Это отдельная беда, названная вслух и здесь не чинимая.
zapisatFayl(join(koren, 'rabota.md'), 'строка 1 — жив, работник w-1\n');
g('add', '-A');
g('commit', '-q', '-m', 'проба: жив, строка 1');
const spisok = changedFilesReal(koren, 'osnova');
expect(spisok).toEqual(['rabota.md']);
expect(sledRaboty({ izmenennyeFayly: spisok, zakrytyePunkty: [] }).est).toBe(true);
});
it('🔴 ГРАНИЦА, ВЫБРАННАЯ НАРОЧНО: правил файлы, но не сохранил — следом это НЕ считается', () => {
// Годный по форме случай мимо допущения «работа видна в ветке»: работник менял файлы
// и не сделал коммита. Прибор круга такого не видит — и это ВЫБОР, а не недосмотр:
// · угол работника временный, несохранённое из него всё равно не переживёт ночь;
// · ошибка в эту сторону пессимистична — владельцу скажут «дело не сделано» про того,
// кто что-то делал. Ошибка в другую сторону — та самая, из-за которой всё затеяно:
// владелец принимает несделанное за сделанное.
// Захочет кто-то считать иначе — здесь покраснеет, и решение будет принято с открытыми
// глазами, а не подкрадётся молча.
const { koren } = zavestiHranilishcheSleda();
zapisatFayl(join(koren, 'nesohranyonnoe.md'), 'работал, но не сохранил\n');
expect(changedFilesReal(koren, 'osnova')).toEqual([]);
});
});
// ━━━ 🔴🔴 СВОДКА НЕ ВЫДАЁТ СПОКОЙНЫЙ ВЫХОД ЗА СДЕЛАННОЕ ДЕЛО ━━━
//
// Образец беды — живая оплаченная проба `.night/runs/rabota-1/` (03.08.2026, 0,57 доллара):
// работник не создал обещанного файла, не сделал ни одного коммита, сам написал о себе
// «0 строк дописано, 0 коммитов сделано», вышел спокойно — и владельцу ушла строка
// «w-1: погашен — Работник закончил своё дело сам».
//
// 🔴 ОХРАНЯЕТСЯ ЗДЕСЬ ИМЕННО ЗОВ ЖИВОГО КРУГА. Само правило (`sled-raboty.mjs`) сторожат
// свои проверки; но правило, которое круг не зовёт, не охраняет ничего — этой ценой затея
// заплатила шесть раз. Поэтому здесь настоящий `supervise` от начала до конца, и читается
// та самая строка сводки, которую утром прочтёт владелец.
describe('сводка не выдаёт спокойный выход за сделанное дело', () => {
const chestnyeSlovaRabotnika = 'Ждать нечем: команда `sleep 540` заблокирована политикой '
+ 'оболочки. Итог: **0 строк дописано, 0 коммитов сделано**.';
it('🔴 БОЛЬНОЙ (случай rabota-1): вышел спокойно, следа не оставил — владельцу это сказано', async () => {
const { out } = await runSupervisor({
script: [c => { c.say({ type: 'result', is_error: false, result: chestnyeSlovaRabotnika }); c.die(0); }],
changedFiles: () => [], // ветка не двинулась — сверка ПРОШЛА и показала пусто
});
expect(out.status).toBe('extinguished');
// Головная мерка: владелец не может принять несделанную работу за сделанную.
const stroka = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroka).toMatch(/следов работы не осталось/i);
expect(stroka).toMatch(/дело НЕ сделано/);
expect(stroka).not.toMatch(/дело сделано/);
// Тот же приговор лежит и в карточке — числом, а не только словами.
expect(readJson(join(wdir, 'worker.json')).sled_raboty.est).toBe(false);
});
it('🔴 места работник не назвал, а пункт ему выдан — сводка говорит, с чего продолжать', async () => {
// Живая проба `rabota-1`: строка сводки была «Остановился на пункте (пункт не назван)»,
// хотя выданный пункт лежал в карточке того же работника. Продолжать было не с чего.
const prezhde = readJson(join(wdir, 'worker.json')) ?? {};
writeJsonAtomic(join(wdir, 'worker.json'), {
...prezhde, item: { id: 'smoke', title: 'Пробный долгий прогон' },
});
await runSupervisor({
script: [c => { c.say({ type: 'result', is_error: false, result: chestnyeSlovaRabotnika }); c.die(0); }],
changedFiles: () => [],
});
const stroka = readLines(join(rdir, SUMMARY_LINES_FILE)).map(r => r.line).join(' ');
expect(stroka).toMatch(/выдан ему был пункт smoke/);
expect(stroka).not.toMatch(/пункт не назван/);
});
it('🔴 ЗДОРОВЫЙ: работник вправду тронул файлы — слова о сделанном деле ОСТАЮТСЯ', async () => {
// Вторая половина работы: примета беды не смеет срабатывать на исправном работнике.
const { out } = await runSupervisor({
script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал всё' }); c.die(0); }],
changedFiles: () => ['docs/superpowers/priyomka/smoke-notes.md'],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/дело сделано: изменённых файлов: 1/);
expect(readJson(join(wdir, 'worker.json')).sled_raboty.est).toBe(true);
});
it('🪤 сверка не удалась — это НЕ «следов нет» и НЕ «дело сделано»', async () => {
const { out } = await runSupervisor({
script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал всё' }); c.die(0); }],
changedFiles: () => null, // git не ответил
});
expect(out.reason).toMatch(/проверить это не удалось/i);
expect(out.reason).not.toMatch(/следов работы не осталось/);
expect(readJson(join(wdir, 'worker.json')).sled_raboty.est).toBe(null);
});
it('🔴 закрытый пункт — тоже след: он попадает в карточку, хоть файлов и не тронуто', async () => {
// Работник закрыл пункт, вся работа которого — прогон проверок. Назови мы это
// «следов нет» — исправного работника объявили бы бездельником.
// 🪤 ЧЕСТНО О ГРАНИЦЕ ЭТОГО СТОРОЖА. До ветки «вышел сам» такой работник не доходит:
// засчитанный пункт кончает его РАНЬШЕ и своей причиной («пункты задачи кончились»),
// и трогать её словам о следе нечего. Поэтому сторожится не причина, а то, что круг
// вправду читает закрытые пункты работника и кладёт их в карточку: вырежи из `finish`
// довод `zakrytyePunkty` — здесь покраснеет.
zayavit('1');
planPunktov4([{ id: '1', title: 'Первый', tests: ['a.test.mjs'], files: ['app/A.php'], long: false }]);
await runSupervisor({
tickMs: 1000,
script: [c => { c.say({ type: 'result', is_error: false, result: 'сделал всё' }); c.die(0); }],
head: () => 'sha-iz-ugla',
runTests: ZELENO,
changedFiles: () => [],
});
const sled = readJson(join(wdir, 'worker.json')).sled_raboty;
expect(sled.punktov).toBe(1);
expect(sled.faylov).toBe(0);
expect(sled.est).toBe(true);
});
it('🪤 слова о следе НЕ приклеиваются к концам, которые называют себя сами', async () => {
// Погашенный по слову владельца кончился не «как-то», а по названной причине.
// Подмени мы и его слова словами о следе — владелец пошёл бы искать поломку там,
// где он сам нажал «стоп».
velet('w-1.stop.json');
const { out } = await runK2({
probeDisk: DISK_OK,
script: [() => {}],
changedFiles: () => [],
});
expect(out.reason).toMatch(/Команда владельца/i);
expect(out.reason).not.toMatch(/следов работы не осталось/);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// 🔴🔴🔴 ПУСТОЙ РАСХОД — В ЖИВОЙ ЦЕПИ НАДЗИРАТЕЛЯ, А НЕ В ОТДЕЛЬНОЙ ПРОВЕРКЕ.
//
// Решение владельца 29 (06.08): он платит за ПОДПИСКУ, не за запуск; «дорого» — не беда,
// беда — «жечь впустую: зациклило, сломался или ещё что». Решение 46/66 (29.07) назвало срок:
// гасим на четвёртом часу, в письме — «топталась, погашена на четвёртом часу».
//
// 🔴 ПОЧЕМУ ЗДЕСЬ, А НЕ У САМОГО ПРАВИЛА. У правила свои проверки (`pustoy-rashod.test.mjs`),
// и они зелены. Но зелень правила НЕ ОТВЕЧАЕТ на главный вопрос затеи — «а кто его зовёт?».
// Правило, которого никто не зовёт, не бывает проверенным: затея заплатила за это шесть раз.
// Здесь работник подделан ЦЕЛИКОМ и гасит его НАСТОЯЩИЙ круг надзирателя.
//
// 🪤 Ни одного платного запуска: работник подставной (`fakeWorker`), живая программа `claude`
// не зовётся, журнал замеров временный.
// ═══════════════════════════════════════════════════════════════════════════════
describe('🔴🔴 датчик пустого расхода в ЖИВОМ круге надзирателя', () => {
// Работник, который ТОПЧЕТСЯ: каждый круг переписывает запись о себе, и каждый раз
// говорит в ней ОДНО И ТО ЖЕ. Меняется только время записи.
// 🪤 `konchaetsyaNa` нужен там, где датчик по замыслу МОЛЧИТ (ждущий чужую службу):
// такой работник не гаснет ничем и упёрся бы в сторож ста двадцати кругов, а смена
// искала бы поломку не там. Топчущийся гаснет сам, ему это не нужно.
const topchetsya = (n, konchaetsyaNa = null) => Array.from({ length: n }, (_, i) => (c) => {
writeJsonAtomic(join(inbox, 'state.json'), {
item: '5', answers: { 'вопрос-1': 'ответа нет' }, assumption_spans: [], saved_at: T0 + i * 30_000,
});
if (konchaetsyaNa !== null && i === konchaetsyaNa) {
c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0);
}
});
// Работник ЗДОРОВЫЙ, но долгодумающий: коммитов нет, пункты не закрываются — а отчитывается
// он каждый раз о НОВОМ. Это тот самый, кого гасить нельзя: владелец платит за его работу.
const dolgodum = (n) => Array.from({ length: n }, (_, i) => (c) => {
writeJsonAtomic(join(inbox, 'state.json'), {
item: '5', answers: { hod: `разобрался с куском ${i}, перехожу к следующему` },
assumption_spans: [], saved_at: T0 + i * 30_000,
});
// На десятом круге он честно кончает работу — иначе проверка упёрлась бы в сторож кругов.
if (i === 9) { c.say({ type: 'result', is_error: false, result: 'сделал' }); c.die(0); }
});
it('🔴 БОЛЬНОЙ ГАСНЕТ настоящим кругом: пять одинаковых отчётов подряд', async () => {
const { out } = await runK2({
probeDisk: DISK_OK,
script: topchetsya(20),
head: () => 'sha-1', // вершина не двигается: коммитов нет
changedFiles: () => [],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/топчется на месте/i);
expect(out.reason).toMatch(/ОДНА И ТА ЖЕ/);
// 🔴 Сделанное сохраняется — слово владельца в решении 46, дословно.
expect(out.reason).toMatch(/Сделанное осталось на месте/);
});
it('🔴🔴 ВЛАДЕЛЬЦУ СКАЗАНО В СВОДКЕ: КОГО погасили и ЗА ЧТО', async () => {
await runK2({ probeDisk: DISK_OK, script: topchetsya(20), head: () => 'sha-1' });
const stroki = readLines(join(rdir, SUMMARY_LINES_FILE));
const moi = stroki.filter(r => /топчется на месте/i.test(r.line ?? ''));
expect(moi.length).toBeGreaterThanOrEqual(1);
expect(moi[0].worker_id).toBe('w-1'); // КОГО
expect(moi[0].line).toMatch(/Примета: топчется на месте/); // ЗА ЧТО
});
it('🔴 в ленте прогона осталась улика с приметой и числом', async () => {
await runK2({ probeDisk: DISK_OK, script: topchetsya(20), head: () => 'sha-1' });
const sob = readLines(join(rdir, 'events.jsonl')).filter(e => e.kind === 'pustoy_rashod');
expect(sob.length).toBe(1);
expect(sob[0].worker).toBe('w-1');
expect(sob[0].odinakovyh_podryad).toBeGreaterThanOrEqual(5);
});
it('🔴🔴 ЗДОРОВЫЙ ДОЛГОДУМ НЕ ТРОНУТ: отчёты разные — гасить не за что', async () => {
// 🔴 Половина пары, без которой первая ничего не доказывает. Датчик, гасящий всех подряд,
// не сторож, а отмена ночи: владелец платит за качество и скорость, и съеденная
// полезная работа дороже пропущенной пустой.
const { out } = await runK2({
probeDisk: DISK_OK,
script: dolgodum(20),
head: () => 'sha-1', // коммитов у него тоже нет — и всё равно не трогаем
changedFiles: () => [],
});
expect(out.reason ?? '').not.toMatch(/топчется|дело не двигается/i);
const sob = readLines(join(rdir, 'events.jsonl')).filter(e => e.kind === 'pustoy_rashod');
expect(sob).toEqual([]);
});
it('🔴🔴🔴 ЧЕТЫРЕ ЧАСА БЕЗ ЕДИНОГО СОХРАНЕНИЯ — гаснет, и отчёт о себе его НЕ спасает', async () => {
// 🔴 ЭТОТ СТОРОЖ ЗАВЕДЁН ПОТОМУ, ЧТО ВЫРЕЗАНИЕ НЕ ПОКРАСНЕЛО. Подмешал `state_write`
// в «дело сдвинулось» — то самое место, где врезка обессмысливается молча, — и ВСЕ пять
// проверок остались зелёными: больного ловила примета «топчется», а до четвёртого часа
// круги в них не доходили вовсе (сто двадцать кругов по полминуты — это час).
// Значит главная половина датчика не сторожилась НИЧЕМ, и зелень об этом молчала.
//
// 🔴 Круг здесь идёт по ПЯТЬ МИНУТ, а не по полминуты: иначе до четвёртого часа
// не добраться в пределах сторожа кругов. Работник при этом отчитывается о РАЗНОМ —
// примета «топчется» на нём молчит НАРОЧНО, судит его только срок.
// Решение владельца 46/66 (29.07): «четыре часа без единого сохранения».
const otchyotyRaznye = Array.from({ length: 80 }, (_, i) => () => {
writeJsonAtomic(join(inbox, 'state.json'), {
item: '5', answers: { hod: `круг ${i}` }, assumption_spans: [], saved_at: T0 + i * 300_000,
});
});
const { out } = await runK2({
probeDisk: DISK_OK,
script: otchyotyRaznye,
tickMs: 300_000, // пять минут на круг
head: () => 'sha-1', // вершина не двигается: сохранений нет ни одного
changedFiles: () => [],
});
expect(out.status).toBe('extinguished');
expect(out.reason).toMatch(/дело не двигается/i);
expect(out.reason).toMatch(/Отчёт о себе работой не считается/);
// 🪤 И это НЕ «топчется»: примета обязана быть названа та самая, иначе владелец прочтёт
// в сводке неправду о причине (решение 65 предостерегает ровно от этого).
expect(out.reason).not.toMatch(/топчется/i);
});
it('🔴🔴 ЖДУЩИЙ ЧУЖУЮ СЛУЖБУ не топчется — слово владельца, решение 66 пункт 5', async () => {
// «Чужая служба лежит → работник замирает с пометкой „жду чужую службу", топтанием
// НЕ СЧИТАЕТСЯ; в сводке видна настоящая причина, а не ложное „топтался"». Дословно.
writeJsonAtomic(join(inbox, 'status.json'), { status: 'waiting_service', item: '5' });
const { out } = await runK2({ probeDisk: DISK_OK, script: topchetsya(20, 12), head: () => 'sha-1' });
expect(out.reason ?? '').not.toMatch(/топчется/i);
expect(readLines(join(rdir, 'events.jsonl')).filter(e => e.kind === 'pustoy_rashod')).toEqual([]);
});
});