3732 lines
264 KiB
JavaScript
3732 lines
264 KiB
JavaScript
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([]);
|
||
});
|
||
});
|