// Кусок 1 — единственная точка входа затеи, и до этой правки у неё не было ни одной // проверки. Главная причина, зачем этот файл вообще существует, названа в О-1: путь // к письму (`cmd === 'mail'`) раньше падал МОЛЧА при сбое ДО самой отправки — сводка // не строилась или транспорт не собирался, — и `mail-failures.jsonl` не получал строки. // Владелец утром видел тишину, а тишину он не отличит от «всё хорошо». // // 🔴 Настоящих писем эти проверки не шлют, настоящих баз не заводят, платного помощника // не рождают и в сеть не ходят — везде подставные `transport`/`sweep`/`readConfig` и // временный каталог вместо `.night/`. В этом рабочем углу `.night/` уже живёт (второй // правщик держит там свой прогон) — проверки его не трогают ни разу. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { main, runMailCommand, collectSummary, runningRuns, idushchieProgony, komandaProdolzheniya, kartochkiRabotnikov } from './cli.mjs'; import { readLines } from './state.mjs'; import { razborDovodov, zamerDovody, vhodVorot, strokiOtkaza, odinVid, vyborProdolzhaemogo } from './cli-parts.mjs'; // 🔴 Список продолжаемых в затее ОДИН и живёт в `resume.mjs`. Проверка ввозит его оттуда же, // откуда его берёт правило: сверяйся я со своей копией слов — обе могли бы разъехаться // с настоящей и остаться зелёными. import { PRODOLZHAEMYE } from './resume.mjs'; // 🔴 Настоящие приборы запаса — для проверки шва между кусками 2 и 6. Подставка тут // доказала бы только моё представление о том, что отдаёт `budgetState`. import { recordMeasure as zapisatZamer, budgetState as sostoyanieZapasa } from './budget.mjs'; import { execFileSync } from 'node:child_process'; import { resolveBin } from './paths.mjs'; // 🔴 Настоящий откатчик и настоящий смотритель замка: подставки доказали бы только себя. import { otkatDoKommita } from './supervisor.mjs'; import { mayTouch } from './file-claims.mjs'; // 🔴 ПРЕДЕЛ ВРЕМЕНИ ВСЕМУ ФАЙЛУ. Здесь вправду заводится `git`, а при пределе по умолчанию // (5000 мс) это краснеет от СОСЕДЕЙ по машине, а не от поломки. Число не набирается своё — // берётся общее; откуда оно взято, написано в самом `predel-zhivogo-zapuska.mjs`. import { PREDEL_ZHIVOGO_ZAPUSKA } from './predel-zhivogo-zapuska.mjs'; vi.setConfig({ testTimeout: PREDEL_ZHIVOGO_ZAPUSKA }); const GIT = resolveBin('git'); // 🔴 Кусок 3, задача 6. `startGates` — НАСТОЯЩИЕ ворота: числа идут в них не рукой, // а через `vhodVorot`. `odinVidPuti` — общая мелочь про пути (задача 2, `paths.mjs`), // ввозится ради одной проверки: что она и `odinVid` куска 2 отвечают одинаково. import { startGates } from './gates.mjs'; import { odinVidPuti, runDir, workerDir, summaryLinesPath } from './paths.mjs'; // 🔴 Кусок 6, задача 7 — ПОДЪЁМ ПРОДОЛЖЕНИЯ. Живой `zavestiProdolzhenie` из `run.mjs`. import { zavestiProdolzhenie } from './run.mjs'; import { readJson } from './state.mjs'; // 🔴 «Съеденного времени ноль» меряется ТЕМ ЖЕ прибором, что и ночью (`limits.mjs`), // а не своим счётом рядом: два счёта одного и того же разъехались бы молча. import { billableMs } from './limits.mjs'; import { randomUUID } from 'node:crypto'; let dir, failPath, sentPath; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'night-cli-')); failPath = join(dir, 'mail-failures.jsonl'); sentPath = join(dir, 'last-summary.json'); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); const FAKE_CFG = { mail: { host: 'h', port: 587, user: 'u', pass: 'p', from: 'robot@liderra.ru', to: 'owner@example.com' } }; const OK_TRANSPORT = { sendMail: async () => ({ messageId: '1' }) }; const OK_SUMMARY = { subject: 'с', text: 'т' }; const noopSweep = () => []; const okBuild = () => OK_SUMMARY; describe('main — общий разбор команды', () => { it('О-2: настроек нет — понятный отказ и код возврата, отличный от успеха', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const code = await main(['node', 'cli.mjs', 'summary'], { readConfig: () => null }); expect(code).not.toBe(0); expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/настроек нет/i)); errSpy.mockRestore(); }); it('О-2: неизвестная команда — понятный отказ и код возврата, отличный от успеха', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const code = await main(['node', 'cli.mjs', 'полетели-в-космос'], { readConfig: () => FAKE_CFG }); expect(code).not.toBe(0); expect(errSpy).toHaveBeenCalledWith(expect.stringMatching(/неизвестная команда/i)); errSpy.mockRestore(); }); it('команду не назвали вовсе — тот же честный отказ, а не падение', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const code = await main(['node', 'cli.mjs'], { readConfig: () => FAKE_CFG }); expect(code).not.toBe(0); errSpy.mockRestore(); }); it('summary — печатает то, что отдала сводка, кодом успеха', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const code = await main(['node', 'cli.mjs', 'summary'], { readConfig: () => FAKE_CFG, summaryOf: () => ({ text: 'Сводка за сегодня' }), sweep: () => [], }); expect(code).toBe(0); expect(logSpy).toHaveBeenCalledWith('Сводка за сегодня'); logSpy.mockRestore(); }); it('mail — успех передаётся наружу кодом 0 и словом об успехе', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const code = await main(['node', 'cli.mjs', 'mail'], { readConfig: () => FAKE_CFG, runMail: async () => ({ ok: true }), }); expect(code).toBe(0); expect(logSpy).toHaveBeenCalledWith('Сводка отправлена.'); logSpy.mockRestore(); }); it('mail — неудача передаётся наружу кодом, отличным от успеха, и словом о неудаче', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const code = await main(['node', 'cli.mjs', 'mail'], { readConfig: () => FAKE_CFG, runMail: async () => ({ ok: false, error: 'транспорт не собрался' }), }); expect(code).not.toBe(0); expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('транспорт не собрался')); logSpy.mockRestore(); }); }); describe('collectSummary — О-2: сводка на пустом месте не молчит', () => { it('прогона не было (нет last-run) — сводка всё равно есть и так и говорит', () => { const s = collectSummary(FAKE_CFG, [], { readLastRun: () => null, readSummaryLines: () => [] }); expect(s.text).toMatch(/прогона не было/i); }); it('строки сводки читаются, только когда есть run_id', () => { const readSummaryLines = vi.fn(() => ['строка']); collectSummary(FAKE_CFG, [], { readLastRun: () => null, readSummaryLines }); expect(readSummaryLines).not.toHaveBeenCalled(); }); }); describe('runMailCommand — О-1: любой сбой на пути к письму записывается', () => { it('дошли до отправки, транспорт настоящий — письмо уходит, неудачи нет', async () => { const r = await runMailCommand({ cfg: FAKE_CFG, failPath, sentPath, sweep: noopSweep, buildSummaryLines: okBuild, makeTransport: async () => OK_TRANSPORT, }); expect(r.ok).toBe(true); expect(readLines(failPath)).toEqual([]); }); it('О-1 главная: сборка сводки упала ДО отправки — неудача всё равно легла в mail-failures.jsonl', async () => { const r = await runMailCommand({ cfg: FAKE_CFG, failPath, sentPath, sweep: noopSweep, buildSummaryLines: () => { throw new Error('сводка развалилась'); }, makeTransport: async () => OK_TRANSPORT, }); expect(r.ok).toBe(false); const rows = readLines(failPath); expect(rows.length).toBe(1); expect(rows[0].error).toContain('сводка развалилась'); }); it('О-1 главная: транспорт не собрался — неудача всё равно легла в mail-failures.jsonl', async () => { const r = await runMailCommand({ cfg: FAKE_CFG, failPath, sentPath, sweep: noopSweep, buildSummaryLines: okBuild, makeTransport: async () => { throw new Error('SMTP не отвечает на соединение'); }, }); expect(r.ok).toBe(false); const rows = readLines(failPath); expect(rows.length).toBe(1); expect(rows[0].error).toContain('SMTP не отвечает на соединение'); }); it('О-1 главная: сама уборка старых углов упала — неудача всё равно легла в mail-failures.jsonl', async () => { const r = await runMailCommand({ cfg: FAKE_CFG, failPath, sentPath, sweep: () => { throw new Error('угол не читается'); }, buildSummaryLines: okBuild, makeTransport: async () => OK_TRANSPORT, }); expect(r.ok).toBe(false); expect(readLines(failPath)[0].error).toContain('угол не читается'); }); it('функция не бросает наружу — она возвращает отказ, даже когда всё внутри рухнуло', async () => { await expect(runMailCommand({ cfg: FAKE_CFG, failPath, sentPath, sweep: noopSweep, buildSummaryLines: () => { throw new Error('x'); }, makeTransport: async () => OK_TRANSPORT, })).resolves.toBeTruthy(); }); it('писать свою неудачу тоже не вышло — всё равно не бросает и сказано об этом (unrecorded)', async () => { const badPath = join(dir, 'занято-каталогом'); mkdirSync(badPath, { recursive: true }); const r = await runMailCommand({ cfg: FAKE_CFG, failPath: badPath, sentPath, sweep: noopSweep, buildSummaryLines: () => { throw new Error('сводка развалилась'); }, makeTransport: async () => OK_TRANSPORT, }); expect(r.ok).toBe(false); expect(r.error).toContain('сводка развалилась'); expect(r.unrecorded).toBeTruthy(); }); it('сама отправка (sendMail) упала — это по-прежнему записывает mailer.mjs, здесь ничего не задвоилось', async () => { const r = await runMailCommand({ cfg: FAKE_CFG, failPath, sentPath, sweep: noopSweep, buildSummaryLines: okBuild, makeTransport: async () => ({ sendMail: async () => { throw new Error('SMTP лёг'); } }), }); expect(r.ok).toBe(false); const rows = readLines(failPath); expect(rows.length).toBe(1); // ровно одна строка, а не две от двух ловушек подряд expect(rows[0].error).toContain('SMTP лёг'); }); }); // 🪤 `sweepNow` (обёртка над `sweepStaleWorkspaces` из `workspace.mjs`) здесь НЕ проверяется // напрямую: она зовёт настоящую уборку по настоящему REPO_ROOT этого рабочего угла — а там // уже живёт реальный `.night/` второго правщика. Прогнать её вживую в проверке — риск // тронуть чужие файлы. Проверки самой уборки — в workspace.test.mjs (чужой файл, не мой). // В `main`/`runMailCommand` она всегда вызывается через внедряемый `sweep`, что и проверено выше. // ============ кусок 2: три тихие части точки входа ============ // // 🔴 Правило владельца: решая, писать ли проверку, спрашивать не «важно ли это», а «если // сломается — заметим ли мы сами». Живой запуск ломается ГРОМКО (не запустится ничего) — // он принимается глазами. А эти три вещи ломаются ТИХО и тихо делают не то: // разбор доводов команды, раскладка ответов правил по кормушкам ворот и печать отказа. // // Чужие части подставляем, живьём не зовём: здесь проверяется НЕ то, что правила правы, // а то, что их ответы дошли до ворот целыми и что владельцу сказали правду. const ZAPAS = { may_start: true, renews_on: '2026-08-08', run_share_limit: 1 / 7 }; const MESTO = { ok: true, free_gb: 42.5 }; const PECHAT = { ok: true, seal: { fingerprint: 'aaa' } }; const BUMAGI = { ok: true }; const ZAMYSEL = { ok: true }; const CFG = { base_branch: 'feat/night', probe_model: 'haiku', rate_limit_type: 'seven_day', max_tasks_at_once: 2, max_workers_per_task: 7, }; function argvFor(...rest) { return ['node', 'tools/night/cli.mjs', ...rest]; } describe('часть 1: доводы команды доходят до неё теми же, какими их набрали', () => { it('run: три пути ложатся каждый в своё поле, а не путаются местами', () => { const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/priyomka.md', '--spec', 'docs/spec.md')); expect(d.planPath).toBe('docs/plan.md'); expect(d.priyomkaPath).toBe('docs/priyomka.md'); expect(d.specPath).toBe('docs/spec.md'); expect(d.error).toBeUndefined(); }); it('run: ключи набраны в другом порядке — поля всё равно свои', () => { const d = razborDovodov(argvFor('run', 'docs/plan.md', '--spec', 'docs/spec.md', '--priyomka', 'docs/priyomka.md')); expect(d.priyomkaPath).toBe('docs/priyomka.md'); expect(d.specPath).toBe('docs/spec.md'); }); it('run: обратные косые Windows приводятся к одному виду', () => { // 🪤 Владелец копирует путь из окна Windows. Подай его как набран — хранилище ответит // «нет такой бумаги», и владелец получит отказ по причине, которой нет. const d = razborDovodov(argvFor('run', 'docs\\superpowers\\plans\\plan.md', '--priyomka', '.\\docs\\p.md', '--spec', 'docs\\s.md')); expect(d.planPath).toBe('docs/superpowers/plans/plan.md'); expect(d.priyomkaPath).toBe('docs/p.md'); expect(d.specPath).toBe('docs/s.md'); }); it('run: не назван приёмочный лист — понятный отказ с образцом команды, а не молчание', () => { const d = razborDovodov(argvFor('run', 'docs/plan.md', '--spec', 'docs/spec.md')); expect(d.error).toContain('--priyomka'); expect(d.error).toContain('--spec'); expect(d.planPath).toBeUndefined(); }); it('run: не назван план — понятный отказ, и ключ --spec планом НЕ становится', () => { const d = razborDovodov(argvFor('run', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md')); expect(d.error).toContain('node tools/night/cli.mjs run'); }); it('seal: путь плана берётся из довода', () => { expect(razborDovodov(argvFor('seal', 'docs/plan.md')).planPath).toBe('docs/plan.md'); }); it('seal: путь не назван — понятный отказ с образцом, а не молчание', () => { const d = razborDovodov(argvFor('seal')); expect(d.error).toContain('seal <путь-к-плану>'); expect(d.planPath).toBeUndefined(); }); it('approve-spec: бумага и ВСЕ названные рецензенты доходят до отметки', () => { // 🔴 Рецензентов называют дважды. Возьми разбор только последнего — отметка выйдет // с одним именем, ворота замысла откажут, а причина будет непонятной. const d = razborDovodov(argvFor('approve-spec', 'docs/spec.md', '--reviewer', 'Пётр', '--reviewer', 'Анна')); expect(d.specPath).toBe('docs/spec.md'); expect(d.reviewers).toEqual(['Пётр', 'Анна']); }); it('approve-spec: бумага не названа — понятный отказ с образцом', () => { expect(razborDovodov(argvFor('approve-spec', '--reviewer', 'Пётр')).error).toContain('--reviewer'); }); it('budget: голова замера берётся из настройки probe_model, зашитого слова нет', () => { expect(zamerDovody({ ...CFG, probe_model: 'самая-дешёвая' }).model).toBe('самая-дешёвая'); }); it('budget: настройки probe_model нет — головы нет, и подставлять её нечем', () => { // Умолчания нет намеренно: пусть замер откажется понятной строкой с именем настройки, // а не сходит за деньги владельца чужой головой. expect(zamerDovody({ ...CFG, probe_model: undefined }).model).toBeUndefined(); }); it('🔴 budget: вид предела берётся ИЗ НАСТРОЙКИ, а нет строки — отказ с её именем', () => { // 🔴🔴 ЭТА ПРОВЕРКА СТОРОЖИЛА МИНУ. Прежде вторая её строка требовала обратного: // «нет строки в настройках — ВСЁ РАВНО недельный». Замерено живьём 03.08.2026: этому // счёту приходит `five_hour`, недельного события не приходило ни разу — то есть молчаливое // умолчание заставляло ворота ждать запас, которого не бывает, и они не пропустили // ни одного прогона за всю жизнь затеи. Владелец при этом читал «события от системы // не пришло», хотя оно приходило каждый раз. // Теперь умолчания нет нигде: потеряна настройка — отказ, и в нём её имя. expect(zamerDovody({ ...CFG, rate_limit_type: 'five_hour' }).kind).toBe('five_hour'); expect(() => zamerDovody({ ...CFG, rate_limit_type: undefined })).toThrow(/rate_limit_type/); }); }); describe('часть 2: воротам поданы ВСЕ девять ответов, а не часть', () => { const running = [ { run_id: 'r-1', status: 'running', workers: ['w-1', 'w-2'], items: [{ id: '1', files: ['app/A.php'] }] }, { run_id: 'r-2', status: 'running', workers: ['w-3'], items: [{ id: '2', files: ['app/B.php'] }] }, ]; const vhod = () => vhodVorot({ cfg: CFG, items: [{ id: '1' }], budget: ZAPAS, disk: MESTO, planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL, running, }); it('во входе есть все девять кормушек ворот — поимённо', () => { // 🔴 Порядок ворот: запас · отклик владельца · место · замысел · бумаги · отпечаток · // от прошлого прогона никого · потолки · пересечение файлов. const v = vhod(); for (const pole of ['budget', 'owner', 'disk', 'spec', 'papers', 'planSeal', 'previousWorkers', 'capacity', 'newTaskFiles', 'runningTasksFiles']) { expect(v).toHaveProperty(pole); } }); it('ответы правил доходят до ворот ТЕМИ ЖЕ, какими пришли', () => { // 🔴 Тихая беда: положи ответ запаса в поле места — ворота места прочтут `may_start`, // не найдут `ok`, откажут, и владелец пойдёт чистить диск вместо ожидания запаса. const v = vhod(); expect(v.budget).toBe(ZAPAS); expect(v.disk).toBe(MESTO); expect(v.planSeal).toBe(PECHAT); expect(v.papers).toBe(BUMAGI); expect(v.spec).toBe(ZAMYSEL); }); it('ветка отсчёта и пункты берутся из настроек и из разбора плана', () => { const v = vhod(); expect(v.base_branch).toBe('feat/night'); expect(v.items).toEqual([{ id: '1' }]); }); it('число идущих прогонов настоящее, а не ноль', () => { expect(vhod().capacity.tasks_running).toBe(2); }); it('число работников в работе берётся из карточек прогонов, а не выдумывается', () => { // 🔴 Состав прогона пишет кусок 1 (`startAll` кладёт `workers` в `run.json`) — заводить // это поле заново не надо, его надо сложить. Ноль здесь означал бы «никого нет», // и ворота потолков пропустили бы восьмого работника. // // 🔴 ПОПРАВЛЕНО КУСКОМ 3 (задача 6) И НАЗВАНО ВСЛУХ. Проверка не выброшена и число // в ней то же — 3, — но вход стал честнее. Кусок 2 складывал работников ВСЕХ идущих // прогонов и на этом входе получал 3 (2+1). Проверка 27 говорит «внутри ЗАДАЧИ», // поэтому счёт теперь идёт по своей задаче плюс те, кого просят завести: у задачи `r-1` // своих двое, просят одного — трое. Не назови мы задачу, вышла бы единица, и это было // бы верно: чужих работников в свой потолок не берут. Место, где кусок 3 имел право // тронуть чужую проверку, план назвал только одно (`gates.test.mjs`) — вот второе, // и его пропустили: старый ответ сторожил ровно то правило, которое кусок 3 заменяет. const v = vhodVorot({ cfg: CFG, items: [{ id: '1' }], budget: ZAPAS, disk: MESTO, planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL, running, taskId: 'r-1', }); expect(v.capacity.workers_in_task).toBe(3); }); it('карточка прогона без состава — считается ноль работников, а не обвал', () => { const v = vhodVorot({ cfg: CFG, budget: ZAPAS, disk: MESTO, running: [{ run_id: 'r-1' }] }); expect(v.capacity.workers_in_task).toBe(0); expect(v.capacity.tasks_running).toBe(1); }); it('потолки берутся из настроек владельца', () => { const v = vhodVorot({ cfg: { ...CFG, max_tasks_at_once: 1, max_workers_per_task: 3 }, running: [] }); expect(v.capacity.max_tasks).toBe(1); expect(v.capacity.max_workers).toBe(3); }); it('занятые файлы идущих прогонов подаются воротам пересечения', () => { expect(vhod().runningTasksFiles).toEqual([['app/A.php'], ['app/B.php']]); }); it('четверо ненаписанных ворот получают ПУСТОЕ, а не выдуманное', () => { // Их правила пишут куски 3 и 7. Пустое значение — это честное «сказать нечего»; // выдуманное «всё хорошо» было бы враньём, которое никто потом не найдёт. const v = vhod(); expect(v.owner).toEqual({ last_summary_at: null }); expect(v.previousWorkers).toEqual([]); expect(v.newTaskFiles).toEqual([]); }); }); describe('часть 3: в отказе владельцу названы ворота и человеческая причина', () => { it('названы ИМЕНА непройденных ворот и причины словами', () => { const stroki = strokiOtkaza({ ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }], }); expect(stroki.join('\n')).toContain('запас'); expect(stroki.join('\n')).toContain('Запас кончился.'); }); it('непройденных двое — названы ОБА, а не только первые', () => { // Починив одно и упёршись во второе, владелец потерял бы ночь. const stroki = strokiOtkaza({ ok: false, reason: 'Запас кончился.', failed_gates: [ { gate: 'запас', reason: 'Запас кончился.' }, { gate: 'бумаги сохранены', reason: 'Не сохранено: docs/plan.md' }, ], }); const t = stroki.join('\n'); expect(t).toContain('запас'); expect(t).toContain('бумаги сохранены'); expect(t).toContain('docs/plan.md'); }); it('в отказе НЕТ месива: ни [object Object], ни склеенной строки all_reasons', () => { // 🔴 Ровно на этом шве уже ломалось: `failed` — это ПАРЫ, а не строки. Разбери // кто-нибудь `all_reasons` — владелец получит «запас: запас: Запас кончился.». const t = strokiOtkaza({ ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }], all_reasons: ['запас: Запас кончился.'], }).join('\n'); expect(t).not.toContain('[object Object]'); expect(t).not.toContain('запас: запас'); }); it('отказ есть, а имён ворот не дали — печатается общая причина, а не пустота', () => { const t = strokiOtkaza({ ok: false, reason: 'Что-то пошло не так.', failed_gates: [] }).join('\n'); expect(t).toContain('Что-то пошло не так.'); }); it('запас кончился — сказано, КОГДА станет можно', () => { const t = strokiOtkaza( { ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] }, { renews_on: '2026-08-08' }, ).join('\n'); expect(t).toContain('2026-08-08'); }); it('🔴 дня обновления назвать не смогли — печатается ПРИЧИНА, а не пустое место', () => { // Прежде строка про запас печаталась только при `renews_on`. Сбой прибора (день пуст, // день в прошлом, число не в миллисекундах) не давал НИ ОДНОЙ строки, и владелец читал // это как «ещё не считали». Слова сбоя готовит budget.mjs — здесь их обязаны донести. const t = strokiOtkaza( { ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] }, { renews_on: null, renews_on_problem: 'прибор не назвал день обновления запаса вовсе (resets_at пуст)' }, ).join('\n'); expect(t).toContain('сказать не удалось'); expect(t).toContain('resets_at пуст'); }); it('день назвали — печатается день, а причина сбоя не примешивается', () => { const t = strokiOtkaza( { ok: false, reason: 'Запас кончился.', failed_gates: [{ gate: 'запас', reason: 'Запас кончился.' }] }, { renews_on: '2026-08-08', renews_on_problem: null }, ).join('\n'); expect(t).toContain('2026-08-08'); expect(t).not.toContain('сказать не удалось'); }); it('про запас не сказано ничего вовсе — лишней строки не появляется', () => { // Отказ бывает не по запасу: тогда молчать про запас правильно. const t = strokiOtkaza( { ok: false, reason: 'Место кончилось.', failed_gates: [{ gate: 'место', reason: 'Место кончилось.' }] }, { renews_on: null, renews_on_problem: null }, ).join('\n'); expect(t).not.toContain('недельный запас'); expect(t).not.toContain('Недельный запас'); }); it('ворота пройдены — печатать нечего вовсе', () => { // Без этой проверки печать могла бы работать всегда, и владелец получал бы // «Прогон НЕ запущен» в ночь, когда прогон как раз запустился. expect(strokiOtkaza({ ok: true, failed_gates: [] })).toEqual([]); }); }); // ============ блок 4: точка входа не оставлена звать удалённые имена ============ // 🔴 Зачем он. `cli.mjs` в проверки ввозится только тремя своими функциями, а сам разбор // команд ими не покрыт — это решение владельца: живой запуск ломается громко. Но один // класс поломки здесь как раз тихий: правка убирает из файла объявление, а зов остаётся. // Такой файл падает не при загрузке, а позже — внутри той команды, куда в тот вечер никто // не заглянул. Сторож не запускает точку входа и не проверяет её поведение: он читает её // как текст и смотрит, что каждое зовомое имя откуда-то взято. // // 🔴 Список известных имён намеренно ЩЕДРЫЙ — в него идут и доводы функций. Пусть сторож // пропустит спорное, зато не покраснеет зря: сторож, который врёт красным, отключают первым. // Совсем оторванное имя вроде `odinVid` он ловит всё равно — это доказано вырезанием. const OT_SISTEMY = new Set([ 'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof', 'else', 'do', 'await', 'async', 'new', 'delete', 'void', 'throw', 'case', 'try', 'in', 'of', 'instanceof', 'String', 'Number', 'Boolean', 'Object', 'Array', 'JSON', 'Date', 'Math', 'Set', 'Map', 'Promise', 'Error', 'RegExp', 'console', 'process', 'parseInt', 'parseFloat', 'isNaN', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'require', 'import', 'super', ]); // Убрать примечания и тексты для владельца: слова из них — не зовы. function tolkoKod(text) { return text .replace(/\/\*[\s\S]*?\*\//g, ' ') .replace(/\/\/[^\n]*/g, ' ') .replace(/`(?:\\[\s\S]|[^`\\])*`/g, '``') .replace(/'(?:\\[\s\S]|[^'\\])*'/g, "''") .replace(/"(?:\\[\s\S]|[^"\\])*"/g, '""'); } function imenaKotorykhFayluHvataet(kod) { const est = new Set(OT_SISTEMY); // 🔴 ПОПРАВЛЕНО ПРОТИВ ПЛАНА, вслух. В плане `dobav` стирал из куска ВСЕ не-буквы // и добавлял остаток целиком. На куске `readConfig = () => readJson(configPath())` // это давало слипшийся мусор `readConfigreadJsonconfigPath`, а само имя `readConfig` // так и оставалось «ниоткуда» — сторож краснел бы на здоровом файле. Берём ПЕРВОЕ имя // куска: оно и есть объявляемое. У ввоза с переименованием (`x as y`) имя даёт правая часть. const dobav = s => { const chistoe = String(s).replace(/^[\s{[(]+/, '').split(/\s+as\s+/).pop(); const m = String(chistoe).trim().match(/^[\w$]+/); if (m) est.add(m[0]); }; for (const m of kod.matchAll(/import\s*\{([^}]*)\}\s*from/g)) m[1].split(',').forEach(dobav); for (const m of kod.matchAll(/import\s+([\w$]+)\s*(?:,|from)/g)) dobav(m[1]); for (const m of kod.matchAll(/(?:function|class)\s+([\w$]+)/g)) dobav(m[1]); for (const m of kod.matchAll(/(?:const|let|var)\s+([\w$]+)/g)) dobav(m[1]); for (const m of kod.matchAll(/(?:const|let|var)\s*\{([^}]*)\}/g)) m[1].split(',').forEach(dobav); // Доводы функций — тоже имена, которые у файла есть. for (const m of kod.matchAll(/function\s*[\w$]*\s*\(([^)]*)\)/g)) m[1].split(',').forEach(dobav); // 🔴 ВТОРАЯ ПОПРАВКА ПРОТИВ ПЛАНА. Доводы, разобранные прямо в скобках с пустым // умолчанием — `function f({ a, b = c() } = {}) {` — прежней строкой не ловились вовсе: // она обрывалась на первой же закрывающей скобке внутри (`mailFailuresPath()`), и семь // доводов `runMailCommand` оставались «ниоткуда». // 🪤 Один вложенный слой фигурных скобок внутри доводов разрешён намеренно: умолчание // вида `x = async e => f(e, { disk: await g() })` иначе обрывает разбор на середине, // и остаток доводов снова становится «ниоткуда». На этом сторож уже краснел живьём. for (const m of kod.matchAll(/\{((?:[^{}]|\{[^{}]*\})*)\}\s*=\s*\{\s*\}\s*\)/g)) m[1].split(',').forEach(dobav); for (const m of kod.matchAll(/\(([^()]*)\)\s*=>/g)) m[1].split(',').forEach(dobav); for (const m of kod.matchAll(/([\w$]+)\s*=>/g)) dobav(m[1]); return est; } describe('часть 4: точка входа не зовёт имён, которых у неё нет', () => { it('каждое имя, которое зовёт `cli.mjs`, либо ввезено, либо объявлено в нём же', () => { const kod = tolkoKod(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli.mjs'), 'utf8')); const est = imenaKotorykhFayluHvataet(kod); const bezymyannye = []; for (const m of kod.matchAll(/(? { it('newTaskFiles — настоящие файлы пунктов нового прогона, а не пустой список', () => { // 🔴 Место, размеченное куском 2 словами «пусто, и это твоё». Пустой список означал бы, // что ворота пересечения не держат никогда: правило без пищи пропускает всё. const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: null, items: [{ id: '1', files: ['app/A.php'] }, { id: '2', files: ['app/C.php'] }], }); expect(v.newTaskFiles).toEqual(['app/A.php', 'app/C.php']); }); it('пунктов без файлов — список пустой, а не выдуманный', () => { const v = vhodVorot({ cfg: NASTROYKI, running: [], items: [{ id: '1' }] }); expect(v.newTaskFiles).toEqual([]); // 🔴 УСИЛЕНО ПРОТИВ ПЛАНА, вслух. В плане у этой проверки было только утверждение выше, // и в красной поре она была ЗЕЛЁНОЙ: пустоты ждали, пустота там и стояла. Зелёная // в красной поре не сторожит ничего. Вторая половина краснеет честно: пункт без файлов // обязан пропасть из списка, а пункт с файлами — остаться. Свались они в одно // (`items.map(i => i.files)`), в списке оказалось бы `undefined` — и ворота пересечения // спотыкались бы о него на ровном месте. const smeshanno = vhodVorot({ cfg: NASTROYKI, running: [], items: [{ id: '1' }, { id: '2', files: ['app/A.php'] }], }); expect(smeshanno.newTaskFiles).toEqual(['app/A.php']); }); it('workers_in_task — идущие работники СВОЕЙ задачи плюс те, кого просят завести', () => { // 🔴 Считай мы только идущих — потолок стерёг бы уже начатое и пропустил бы восьмого. // Своих у задачи `z-1` двое, просят завести ещё двоих — станет четверо, а не трое // (трое — это ответ куска 2, который складывал работников ВСЕХ идущих прогонов). const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-1', items: [{ id: '9', files: ['app/D.php'] }, { id: '10', files: ['app/E.php'] }], }); expect(v.capacity.workers_in_task).toBe(4); }); it('работники ЧУЖОЙ задачи в этот счёт не идут', () => { // Иначе потолок «семь работников внутри задачи» превратился бы в «семь на весь сервер», // и вторая задача не запустилась бы никогда. const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-2', items: [{ id: '9' }] }); expect(v.capacity.workers_in_task).toBe(2); }); it('tasks_running считает ЗАДАЧИ, а не прогоны', () => { const dvaProgonaOdnoyZadachi = [ { run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1'], items: [] }, { run_id: 'r-2', status: 'running', task_id: 'z-1', workers: ['w-2'], items: [] }, ]; const v = vhodVorot({ cfg: NASTROYKI, running: dvaProgonaOdnoyZadachi, taskId: null, items: [] }); expect(v.capacity.tasks_running).toBe(1); }); it('своя задача третьей не считается', () => { // Продолжение уже идущей задачи новую задачу не заводит — иначе потолок «две задачи» // запретил бы доводить до конца то, что уже начато. const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-1', items: [] }); expect(v.capacity.tasks_running).toBe(1); }); it('runningTasksFiles не включает файлы СВОЕЙ задачи', () => { // Иначе задача пересекалась бы сама с собой и не запускалась бы никогда. const v = vhodVorot({ cfg: NASTROYKI, running: IDUT, taskId: 'z-1', items: [] }); expect(v.runningTasksFiles).toEqual([['app/B.php']]); }); }); describe('часть 6: НАСТОЯЩИЕ ворота на этих числах отказывают и пропускают', () => { const ostalnoe = { budget: ZAPAS, disk: MESTO, planSeal: PECHAT, papers: BUMAGI, spec: ZAMYSEL, }; it('восьмой работник внутри задачи — отказ (проверка 27)', () => { const semero = [{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6', 'w-7'], items: [] }]; const r = startGates(vhodVorot({ cfg: NASTROYKI, ...ostalnoe, running: semero, taskId: 'z-1', items: [{ id: '8', files: ['app/Z.php'] }], })); expect(r.ok).toBe(false); expect(r.failed.map(f => f.gate)).toContain('потолки задач и работников'); }); it('🔴 ГРАНИЦА: шестеро идут, просят седьмого — ПУСКАЮТ (проверка 46)', () => { // 🔴🔴 Шов между задачей 2 и этой. `vhodVorot` подаёт «сколько СТАНЕТ»: шестеро идут // плюс один просимый — семь. Правило задачи 2 сравнивает строго (`> 7`), поэтому // семеро проходят. Стоял бы там `>=`, как в первом наброске, — эта проверка // покраснела бы, а семеро одной задачи не собрались бы никогда, и проверку 46 // бумаги («семеро работников одной задачи шли разом») показать было бы нечем. // Проверка ЖИВОГО пути: числа идут не рукой, а через `vhodVorot`. const shestero = [{ run_id: 'r-1', status: 'running', task_id: 'z-1', workers: ['w-1', 'w-2', 'w-3', 'w-4', 'w-5', 'w-6'], items: [] }]; const r = startGates(vhodVorot({ cfg: NASTROYKI, ...ostalnoe, running: shestero, taskId: 'z-1', items: [{ id: '7', files: ['app/Z.php'] }], })); expect(r.ok).toBe(true); expect(r.failed).toEqual([]); }); it('третья задача — отказ, а продолжение уже идущей — пропуск (проверка 26)', () => { const r = startGates(vhodVorot({ cfg: NASTROYKI, ...ostalnoe, running: IDUT, taskId: 'z-3', items: [{ id: '1', files: ['app/Z.php'] }], })); expect(r.ok).toBe(false); expect(r.failed.map(f => f.gate)).toContain('потолки задач и работников'); // 🔴 УСИЛЕНО ПРОТИВ ПЛАНА, вслух. План сам признал, что в красной поре эта проверка // ЗЕЛЁНАЯ: кусок 2 складывал прогоны и на этом входе случайно давал тот же ответ. // Зелёная в красной поре не сторожит ничего, поэтому здесь же стоит её зеркало — // тот же вход, но задача СВОЯ. Отличить «третья» от «продолжение начатого» кусок 2 // не умел вовсе, и зеркало краснеет честно. const svoya = startGates(vhodVorot({ cfg: NASTROYKI, ...ostalnoe, running: IDUT, taskId: 'z-1', items: [{ id: '1', files: ['app/Z.php'] }], })); expect(svoya.ok).toBe(true); }); it('новая задача задевает файл идущей — отказ, и файл назван (проверка 50)', () => { const r = startGates(vhodVorot({ cfg: { ...NASTROYKI, max_tasks_at_once: 9 }, ...ostalnoe, running: IDUT, taskId: 'z-3', items: [{ id: '1', files: ['app/A.php'] }], })); expect(r.ok).toBe(false); expect(r.failed.map(f => f.gate)).toContain('задачи не пересекаются по файлам'); expect(r.all_reasons.join(' ')).toContain('app/A.php'); }); it('вторая задача своими файлами — ворота пропускают: они не глухие', () => { // 🔴 Страховка. Ворота, которые не пропускают НИКОГДА, зеленят три проверки выше // и убивают затею целиком: ночь не запустится ни разу, а причина будет выглядеть верной. const r = startGates(vhodVorot({ cfg: NASTROYKI, ...ostalnoe, running: [IDUT[0]], taskId: 'z-3', items: [{ id: '1', files: ['app/Z.php'] }], })); expect(r.ok).toBe(true); expect(r.failed).toEqual([]); }); }); describe('часть 7: два новых ключа команды run и правда про несторожащие ворота', () => { it('--task и --files доходят до разбора доводов', () => { const d = razborDovodov(argvFor( 'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--task', 'z-1', '--files', 'app/A.php, app\\B.php', )); expect(d.taskId).toBe('z-1'); expect(d.files).toEqual(['app/A.php', 'app/B.php']); }); it('🔴 `odinVid` куска 2 и `odinVidPuti` куска 3 отвечают ОДИНАКОВО', () => { // 🔴🔴 Две мелочи делают одну работу — сведение имени файла к одному виду. Разойдись // они хоть в одном знаке, и ключ `--files` дал бы одно имя, а замок и ворота ждали бы // другое: пересечения не находились бы, и все проверки остались бы зелёными. // Свести их в одну совсем нельзя: `odinVid` куска 2 зовут и другие его места, а кусок 3 // чужое не переписывает. Значит их СВЕРЯЕМ — вот здесь. for (const obrazets of ['app\\A.php', './app/A.php', 'app//A.php', ' app/A.php ', 'docs\\a\\b.md', '']) { expect(odinVid(obrazets)).toBe(odinVidPuti(obrazets)); } }); it('ключей не назвали — файлов нет и задача новая, а не выдуманные значения', () => { const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md')); expect(d.files).toEqual([]); expect(d.taskId).toBeNull(); }); it('строка «эти ворота пока НЕ сторожат» потеряла двоих', () => { // 🪤 Тихая поломка: ворота начали сторожить, а владельцу в сводке по-прежнему пишут, // что они пропускают всё. Он читает неправду и не узнает об этом никогда. const spisok = TEKST_CLI.match(/VOROTA_POKA_NE_STOROZHAT = \[([\s\S]*?)\]/)[1]; expect(spisok).toContain('отклик владельца'); expect(spisok).toContain('от прошлого прогона никого'); expect(spisok).not.toContain('потолки задач и работников'); expect(spisok).not.toContain('задачи не пересекаются по файлам'); }); it('🔴 СВЕРХ ПЛАНА: несторожащих ворот названо владельцу РОВНО столько, сколько пустышек в самих воротах', () => { // 🔴 Сторож на КЛАСС, а не на место: «ворота начали сторожить, а владельцу про них // по-прежнему пишут „пропускают всё"». Проверка выше сторожит ровно сегодняшних двоих // поимённо — то есть ровно сегодняшний день. Эта переживёт кусок 7: подставит он // `ownerResponded` вместо пустышки и забудет вычеркнуть строку у владельца — // покраснеет здесь. const pustyshek = (TEKST_VOROT.match(/notYetWritten\(\),/g) ?? []).length; const nazvano = TEKST_CLI.match(/VOROTA_POKA_NE_STOROZHAT = \[([\s\S]*?)\]/)[1] .split('\n').filter(s => /['«][а-яё]/i.test(s)).length; expect(nazvano).toBe(pustyshek); }); it('🔴 СВЕРХ ПЛАНА: точка входа вправду ПОДАЁТ воротам номер задачи и настоящие файлы', () => { // 🔴 Сторож на КЛАСС «правило написано, а зовущего нет» — ровно тот, ради которого // весь этот кусок и затеян. Разбор доводов и `vhodVorot` проверены выше поодиночке, // но между ними стоит `cli.mjs`, чьи команды проверками не покрыты намеренно (живой // запуск ломается громко). Убери кто-нибудь `taskId` из зова ворот или верни // `files: []` единственному пункту — обе половины останутся зелёными, а ночь молча // вернётся к «ворота пересечения не держат никогда». Поэтому смотрим текстом. const zovVorot = TEKST_CLI.match(/planRun\(vhodVorot\(\{([\s\S]*?)\}\)\)/)[1]; expect(zovVorot).toContain('taskId'); // 🔴 ПЕРЕНАЦЕЛЕНО КУСКОМ 4, называю вслух. Прежде здесь стояло `files: razbor.files` // у единственного слепленного пункта «весь-план»: до разбора плана файлы называл // владелец рукой ключом `--files`. Куска этого больше нет — файлы каждого пункта // берутся из самого плана. Сторожимое СВОЙСТВО не ослаблено, а усилено: раньше // сторожили «список файлов не пуст», теперь — что воротам идут вправду разобранные // пункты плана. Верни кто-нибудь сюда выдуманный пункт — покраснеет. expect(zovVorot).toMatch(/items:\s*punkty/); expect(TEKST_CLI).toMatch(/punkty\s*=\s*vhodVorot\(\{\s*planPath\s*\}\)\.items/); expect(TEKST_CLI).not.toMatch(/id:\s*'весь-план'/); }); }); // ============ беда 2: порванная карточка прогона снимала ОБА замка разом ============ // // 🔴 Найдено воротами ЖИВЬЁМ: одну запись порвали — и тут же завелась третья задача // при потолке в две, а следом задача на файл, который порванный прогон держит. Оба отказа // исчезли МОЛЧА: список идущих прогонов читался плоским `readJson`, а тот на порванном файле // отдаёт «ничего», и прогон просто выпадал из счёта. // // 🔴 Правило на этот класс в хозяйстве УЖЕ ЕСТЬ и уже применено дважды: `file-claims.mjs` // (замок на файлы) и `plan-lock-hook.mjs` (замок на правку плана) читают через общий прибор // `readJsonOrBroken` и прямо пишут почему — испорченную запись нельзя считать за «никого нет». // Здесь закрывается третья дверь тем же способом, а не выдумывается четвёртый. describe('часть 8: порванная карточка прогона не превращается в «прогона нет»', () => { function razlozhit(kartochki) { const koren = join(dir, 'runs'); for (const [id, tekst] of Object.entries(kartochki)) { mkdirSync(join(koren, id), { recursive: true }); writeFileSync(join(koren, id, 'run.json'), tekst, 'utf8'); } return koren; } const idet = id => JSON.stringify({ run_id: id, status: 'running', task_id: id, workers: ['w-1'], items: [{ id: '1', files: ['app/A.php'] }] }); it('🔴 карточка порвана — счёт идущих прогонов падает ГРОМКО, а не теряет прогон молча', () => { const koren = razlozhit({ 'r-1': idet('r-1'), 'r-2': '{"run_id": "r-2", "status": "runn' }); expect(() => runningRuns(koren)).toThrow(/испорчен|прочесть не удалось|не читается/i); }); it('🔴 в отказе назван ФАЙЛ, который надо посмотреть глазами', () => { const koren = razlozhit({ 'r-2': 'это не JSON вовсе' }); expect(() => runningRuns(koren)).toThrow(/r-2/); }); it('всё цело — идущие считаются, а кончившиеся не считаются (правило не глухое)', () => { const koren = razlozhit({ 'r-1': idet('r-1'), 'r-3': JSON.stringify({ run_id: 'r-3', status: 'finished', workers: ['w-1'] }), }); const idut = runningRuns(koren); expect(idut.map(r => r.run_id)).toEqual(['r-1']); }); it('каталога прогонов нет вовсе — пусто, а не обвал', () => { expect(runningRuns(join(dir, 'такого-каталога-нет'))).toEqual([]); }); it('каталог прогона есть, а карточки в нём нет — это НЕ прогон, и не обвал', () => { mkdirSync(join(dir, 'runs2', 'r-9'), { recursive: true }); expect(runningRuns(join(dir, 'runs2'))).toEqual([]); }); it('🔴 счёт идущих прогонов читает карточки ОБЩИМ прибором, а не плоским readJson', () => { // 🔴 Сторож на КЛАСС, а не на место: «одну дверь закрыли, вторую забыли». Плоский // `readJson` на порванном файле отдаёт `null`, и прогон молча выпадает из счёта. const telo = TEKST_CLI.match(/export function runningRuns\([\s\S]*?\n\}/)[0]; expect(telo).toContain('readRuns'); expect(telo).not.toMatch(/readJson\(/); }); it('🔴 обёртка одна на ОБОИХ зовущих: и `seal`, и `run` спрашивают счёт через неё', () => { // Два try/catch по месту разъехались бы молча: поправят один, забудут другой. expect((TEKST_CLI.match(/idushchieProgony\(\)/g) ?? []).length).toBe(2); expect((TEKST_CLI.match(/runningRuns\(\)/g) ?? []).length).toBe(0); }); it('🔴 обёртка отдаёт владельцу СТРОКУ, а не роняет программу следом стека', () => { const r = idushchieProgony(() => { throw new Error('Карточка прогона r-2 испорчена.'); }); expect(r.ok).toBe(false); expect(r.reason).toContain('r-2'); expect(r.running).toEqual([]); }); it('всё цело — обёртка отдаёт список как есть (не глухая)', () => { const spisok = [{ run_id: 'r-1', status: 'running' }]; const r = idushchieProgony(() => spisok); expect(r.ok).toBe(true); expect(r.running).toBe(spisok); expect(r.reason).toBeNull(); }); }); // ============ кусок 4: план разбирается на пункты, отказы печатаются до замера ============ // // 🔴 ТРИ ОТСТУПЛЕНИЯ ОТ ПЛАНА ЗАДАЧИ 6, НАЗВАННЫЕ ВСЛУХ. План описывал `cli.mjs` таким, // каким тот был два куска назад, и списанные с него проверки сторожили бы пустоту: // 1. План искал в тексте `probeUtilization(` — этого имени в `cli.mjs` НЕТ вовсе, замер // давно идёт через `zamerSoSchyotom` (сторож `platnyy-zamer-tolko-cherez-schyot`). // Проверка «отказ раньше замера» искала бы имя, которого нет, и была бы зелена всегда // и при любом порядке — то есть не сторожила бы ровно того, ради чего написана. // 2. План звал разобранные доводы `dovody`; в файле они зовутся `razbor`. // 3. План велел сторожить «`const running = runningRuns()` объявлен один раз». Такой // строки в `cli.mjs` нет: список идущих берётся через `idushchieProgony()` — обёртку, // которая отличает «никого нет» от «карточка порвана». Переносить её вверх незачем: // разбору плана она не нужна вовсе. Вместо этого сторожим то, что вправду опасно, — // что план читается РОВНО ОДИН раз за запуск. const PLAN_OBRAZETS = [ '# Пример плана', '', '### Task 1: Первый пункт', '', 'Текст пункта.', '', '### Task 2: Второй пункт', '', 'Текст пункта.', ].join('\n'); describe('часть 7: план разбирается на пункты на ВХОДЕ ворот', () => { it('🔴 `vhodVorot` читает файл плана и отдаёт пункты с их проверками и файлами', () => { // 🔴🔴 Здесь снимается долг куска 3, названный им вслух: через `cli.mjs run` больше // одного работника за раз завести было нельзя — ключ `--files` клал все имена // в один пункт. Теперь пункты берутся из плана, и их столько, сколько написал автор. const v = vhodVorot({ cfg: NASTROYKI, running: [], taskId: null, planPath: 'docs/пример.md', readPlan: () => PLAN_OBRAZETS, }); expect(v.items.map(i => i.id)).toEqual(['1', '2']); expect(v.items[0].tests).toEqual(['app/tests/Feature/PervyyTest.php']); expect(v.newTaskFiles).toEqual(['app/A.php', 'app/B.php']); expect(v.items[1].long).toBe(true); }); it('пути плана не дали — пункты берутся из довода, как прежде', () => { // Умолчание честное: своих пунктов `vhodVorot` не выдумывает ни одного. const v = vhodVorot({ cfg: NASTROYKI, running: [], items: [{ id: '9', files: ['app/Z.php'] }] }); expect(v.items.map(i => i.id)).toEqual(['9']); }); it('🔴 пункты из плана идут и в счёт потолка работников, а не только в ответ', () => { // 🪤 СВЕРХ ПЛАНА. В теле `vhodVorot` слово `items` стояло в ТРЁХ местах, и заменить // надо все три. Пропусти третье — `workers_in_task` считал бы пункты, поданные доводом // (то есть ноль), потолок в семь работников не сработал бы НИ РАЗУ, и проверка 27 // отменилась бы молча. Первые два места стерегут проверки выше, третье — эта. const v = vhodVorot({ cfg: NASTROYKI, running: [], taskId: null, planPath: 'docs/пример.md', readPlan: () => PLAN_OBRAZETS, }); expect(v.capacity.workers_in_task).toBe(2); }); }); describe('часть 8: ключи «продолжай» и «список брака»', () => { it('`--continue` разобран как слово владельца', () => { const d = razborDovodov(argvFor( 'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--continue', )); expect(d.ownerSaidContinue).toBe(true); }); it('`--defects <файл>` разобран в список брака по строкам', () => { const d = razborDovodov( argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--defects', 'docs/brak.md'), { readDefects: () => 'кнопка не там\n\nсумма не сходится\n' }, ); expect(d.defectList).toEqual(['кнопка не там', 'сумма не сходится']); }); it('ключей не дали — «продолжай» нет и список брака пуст, а не выдуманы', () => { const d = razborDovodov(argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md')); expect(d.ownerSaidContinue).toBe(false); expect(d.defectList).toEqual([]); }); it('🔴 файла со списком брака нет — список ПУСТ, а не выдуман непустым', () => { // 🪤 СВЕРХ ПЛАНА. Непустой список на нечитаемом файле открыл бы ворота задаче, брак // которой никто не назвал: работник пошёл бы переделывать неизвестно что. const d = razborDovodov( argvFor('run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--defects', 'нет-такого-файла.md'), { readDefects: () => { throw new Error('нет такого файла'); } }, ); expect(d.defectList).toEqual([]); expect(d.defectsPath).toBe('нет-такого-файла.md'); }); it('🔴 `--continue` не съедает следующее слово командной строки', () => { // 🪤 СВЕРХ ПЛАНА. Спроси мы `--continue` тем же помощником, что и ключи со значением, // он забрал бы следующее слово — то есть чужой ключ, и `--task` потерялся бы молча. const d = razborDovodov(argvFor( 'run', 'docs/plan.md', '--priyomka', 'docs/p.md', '--spec', 'docs/s.md', '--continue', '--task', 'z-7', )); expect(d.ownerSaidContinue).toBe(true); expect(d.taskId).toBe('z-7'); }); }); // 🔴🔴 СМОТРИМ ТОЛЬКО ТЕЛО КОМАНДЫ `run`, И ЭТО НЕ ПРИДИРКА. Первая моя попытка искала // имена по ВСЕМУ файлу — и сторож молча смотрел не туда: у команды `budget` есть СВОЙ зов // замера, и он стоит в файле выше всей команды `run`. «Отказ раньше замера» сравнивал // отказ из `run` с замером из `budget`, то есть был красным при любом верном порядке // и позеленел бы от перестановки чужой команды. Замерено, а не предположено. const BLOK_RUN = TEKST_CLI.slice( TEKST_CLI.indexOf("if (cmd === 'run') {"), TEKST_CLI.indexOf("if (cmd === 'smoke-longrun') {"), ); describe('часть 9: отказы стоят РАНЬШЕ замера запаса и раньше ворот', () => { it('🔴 кусок текста, по которому судят проверки ниже, вправду вырезан', () => { // 🪤 Без этой строки все проверки порядка ниже стали бы зелёными на ПУСТОЙ строке: // переименуй кто-нибудь команду — и `indexOf` дал бы −1, срез вышел бы пустым, // а `indexOf` в пустой строке равен −1 у всех имён сразу, то есть «порядок сошёлся». expect(BLOK_RUN.length).toBeGreaterThan(2000); expect(BLOK_RUN).toContain("if (cmd === 'run') {"); }); it('🔴 отказ по плану печатается ДО крошечного замера остатка', () => { // 🔴🔴 Замер идёт платной головой из подписки владельца, то есть стоит денег. План, который // 🪤 Имя той программы здесь НЕ НАБИРАЕТСЯ словом нарочно: сторож живого запуска считает // всякое её называние заявкой на платный зов и требует вписать её в разрешение — а этот // файл её не заводит НИ РАЗУ и заводить не должен. // заведомо не пройдёт, не стоит ни одного платного зова. И только при этом порядке // отказ можно посмотреть живьём глазами: запуск не рождает ни замера, ни угла, // ни живого работника. const iPlan = BLOK_RUN.indexOf('validatePlan('); const iZamer = BLOK_RUN.indexOf('zamerSoSchyotom('); expect(iPlan).toBeGreaterThan(-1); expect(iZamer).toBeGreaterThan(-1); expect(iPlan).toBeLessThan(iZamer); }); it('🔴 ворота отложенной задачи стоят ДО замера и ДО `planRun`', () => { const iSled = BLOK_RUN.indexOf('deferredTaskGate('); const iZamer = BLOK_RUN.indexOf('zamerSoSchyotom('); const iPlanRun = BLOK_RUN.indexOf('planRun('); expect(iSled).toBeGreaterThan(-1); expect(iZamer).toBeGreaterThan(-1); expect(iPlanRun).toBeGreaterThan(-1); expect(iSled).toBeLessThan(iZamer); expect(iSled).toBeLessThan(iPlanRun); }); it('🔴 воротам отложенной задачи подаётся перепроверка плана ИЗ ЭТОГО запуска (проверка 30)', () => { // 🪤 Тихая поломка: подай сюда `true` постоянной — правило зеленело бы, а план // отложенной задачи не перепроверялся бы ни разу, и работник шёл бы по бумаге, // которая за неделю разошлась с продуктом. expect(TEKST_CLI).toMatch(/plan_revalidated:\s*planOk\.ok/); }); it('🔴 `startAll` получает ВСЕ пункты плана, а не только пункты семерых', () => { expect(TEKST_CLI).toMatch(/items:\s*punkty/); }); it('🔴🔴 воротам подан путь плана ИЗ СЛЕДА и путь плана ЭТОГО запуска (проверка 29б)', () => { // 🔴🔴 Правило «тот же план» живёт в воротах, но без обоих путей оно стоит и молчит: // задачу можно было бы «доделать» по совсем другому плану, и заметить это нечем. expect(TEKST_CLI).toMatch(/plan_path:\s*sled\.plan_path/); expect(TEKST_CLI).toMatch(/plan_path_now:\s*razbor\.planPath/); }); it('🔴 команда проверок работника строится ИЗ ЕГО пункта, а не общим набором', () => { // 🔴🔴 Кусок 1 клал сюда `() => "npm run test:tools"` — общий набор. Оставь его — // работник читал бы в поручении, что его пункт меряется всем прогоном продукта. // 🪤 Сама надёжность проверки 38 держится НЕ на этой строке: замок команду из карточки // не берёт вовсе, он считает её заново на каждом судимом пункте (задача 5). // Здесь сторожится только то, что говорят РАБОТНИКУ. expect(TEKST_CLI).toMatch(/testCommandFor:\s*w\s*=>\s*komandaProverokPunkta\(w\.item\)/); }); it('🔴 план читается РОВНО ОДИН раз за запуск', () => { // 🔴🔴 Читай мы файл дважды, между двумя чтениями он мог бы измениться, и ворота // судили бы не тот план, который пошёл в работу. Поэтому `planPath` подан // единственному зову — тому, который его разбирает. const chteniya = BLOK_RUN.match(/vhodVorot\(\{\s*planPath\s*\}\)/g) ?? []; expect(chteniya.length).toBe(1); // 🪤 И полный зов ворот путь плана НЕ получает: получи он его — файл прочли бы второй раз. // 🔴 Примечания из зова вырезаются. Найдено на себе: я написал в самом зове словами, // что путь плана сюда НЕ подаётся, — и эта проверка покраснела на СЛОВЕ «planPath» // в примечании, хотя довода там нет. Сторож, красный от собственного объяснения, // толкает вычеркнуть объяснение, а не починить дело. const zovVorot = BLOK_RUN.match(/planRun\(vhodVorot\(\{([\s\S]*?)\}\)\)/)[1] .split('\n').filter(s => !s.trim().startsWith('//')).join('\n'); expect(zovVorot).not.toMatch(/planPath/); // 🪤 И вторая половина: своего, второго чтения файла плана в `cli.mjs` не осталось. // Кусок 1 читал первую строку плана сам (`readFileSync(planPath`), чтобы слепить // единственный пункт «весь-план». Этот путь заменён разбором, и возвращать его нельзя: // два места, читающих один файл, — это два разных плана в одном запуске. expect(TEKST_CLI).not.toMatch(/readFileSync\(planPath/); }); it('🔴 отказ по плану вправду ОСТАНАВЛИВАЕТ запуск, а не только произносится', async () => { // 🔴🔴🔴 НАЙДЕНО ВЫРЕЗАНИЕМ ВТОРОГО РОДА, планом не предусмотрено — дыра была настоящая. // Все проверки выше смотрят ТЕКСТ и доказывают, что ворота плана ЗОВУТСЯ. Но зов — // это ещё не защита: я обвёл их умно (`if (false && !planOk.ok)`), оставив зов на месте, // и ВСЕ 139 проверок остались зелёными, а негодный план пошёл бы в ночь. Это ровно тот // класс, ради которого весь кусок и затеян, только этажом выше: не «правило написано, // а зовущего нет», а «зовущий есть, а ответа он не слушает». // // 🔴 ПЛАТНОГО ЗОВА ЗДЕСЬ НЕ РОДИТСЯ НИ ПРИ КАКОЙ ПОЛОМКЕ, и это не надежда. В настройках // намеренно НЕТ строки `probe_model`, а замер спрашивает её ПЕРВЫМ делом и падает // до единого запуска чего бы то ни было. Убери кто-нибудь отказ по плану — проверка // покраснеет на СЛОВАХ, а не потратит деньги владельца. const vremenno = mkdtempSync(join(tmpdir(), 'night-plan-gate-')); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { const planFile = join(vremenno, 'plan.md'); const priyomkaFile = join(vremenno, 'priyomka.md'); const specFile = join(vremenno, 'spec.md'); writeFileSync(planFile, '# Пробный план\n\nЗаголовков пунктов здесь нет ни одного.\n'); writeFileSync(priyomkaFile, '# приёмочный лист\n'); writeFileSync(specFile, '# замысел\n'); const code = await main( argvFor('run', planFile, '--priyomka', priyomkaFile, '--spec', specFile), { readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7 }) }, ); const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n'); expect(code).toBe(1); // 🪤 Судим ПО НАПЕЧАТАННЫМ СЛОВАМ, а не по коду возврата: единица приходит и когда // ворота честно отказали, и когда до них не дошло вовсе. Код возврата тут не улика. expect(skazano).toMatch(/план проверку не прошёл/i); expect(skazano).toMatch(/нет ни одного пункта/i); } finally { errSpy.mockRestore(); rmSync(vremenno, { recursive: true, force: true }); } }); it('🔴 отказ по плану с повторяющимися номерами тоже ОСТАНАВЛИВАЕТ запуск', async () => { // Второй вкус той же беды: номер пункта — ключ, по которому ведут очередь и запирают // файлы. Двое под одним номером закроют друг друга, и оба отчитаются правдой. const vremenno = mkdtempSync(join(tmpdir(), 'night-plan-gate-')); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { const planFile = join(vremenno, 'plan.md'); const priyomkaFile = join(vremenno, 'priyomka.md'); const specFile = join(vremenno, 'spec.md'); writeFileSync(planFile, [ '# Пробный план', '', '### Task 1: Первый', '', '', '### Task 1: Второй под тем же номером', '', ].join('\n')); writeFileSync(priyomkaFile, '# приёмочный лист\n'); writeFileSync(specFile, '# замысел\n'); const code = await main( argvFor('run', planFile, '--priyomka', priyomkaFile, '--spec', specFile), { readConfig: () => ({ base_branch: 'feat/night', max_tasks_at_once: 2, max_workers_per_task: 7 }) }, ); const skazano = errSpy.mock.calls.map(c => String(c[0])).join('\n'); expect(code).toBe(1); expect(skazano).toMatch(/повторяются номера пунктов/i); // 🪤 И виновник назван поимённо: без имени владелец не поймёт, который пункт чинить. expect(skazano).toMatch(/Task 1/); } finally { errSpy.mockRestore(); rmSync(vremenno, { recursive: true, force: true }); } }); it('🔴 след отложенной задачи снимается ПОСЛЕ удачного запуска, а не до ворот', () => { // 🪤 СВЕРХ ПЛАНА. Сними след раньше отказа — вторая попытка запуска прошла бы уже // без ворот вовсе: следа нет, значит «задача не откладывалась», и проверки 28–30 // отменились бы молча. Сними позже некуда: не сними вовсе — владельцу пришлось бы // говорить «продолжай» каждую ночь заново. const iVorota = BLOK_RUN.indexOf('deferredTaskGate('); const iStartAll = BLOK_RUN.indexOf('const started = startAll('); const iSnyatie = BLOK_RUN.indexOf('clearDeferred('); expect(iSnyatie).toBeGreaterThan(-1); expect(iSnyatie).toBeGreaterThan(iVorota); expect(iSnyatie).toBeGreaterThan(iStartAll); }); }); // ═══════════════════════════════════════════════════════════════════════════════ // КУСОК 6, ЗАДАЧА 7 — ПОДЪЁМ ПРОДОЛЖЕНИЯ. // // 🔴 Зачем эти проверки вообще есть. Датчик зовущих (`schyot-zovushchih.mjs`) показал: // у СЕМИ из семи правил куска 6 зовущих НОЛЬ. Построено, проверено, зелено — и не подключено // ни к чему. Правило метода номер 2: защита, которую никто не зовёт, не бывает проверенной; // затея заплатила за это шесть раз. Здесь строится тот, кто зовёт. // // 🪤 Карточки пишутся в НАСТОЯЩИЙ `.night/runs/`, потому что `runDir` считает от постоянной // `NIGHT_ROOT` и подмены не принимает. Поэтому номер прогона у каждой проверки СВОЙ // (случайный) и каталог сносится в `finally` — тот же приём, что у проверок `startAll`. // В этом углу `.night/` живой, там лежит чужой прогон: своими номерами мы его не задеваем. // ═══════════════════════════════════════════════════════════════════════════════ describe('🔴 кусок 6, задача 7 — подъём продолжения (zavestiProdolzhenie)', () => { // Восемь отрезков и три пометки «в расчёте на ответ» — ровно то, что П-25 велит подать. const VOSEM_OTREZKOV = Array.from({ length: 8 }, (_, i) => ({ kind: 'working', ms: 3600_000, n: i })); function staryj(over = {}) { return { worker_id: 'w-1', run_id: `вчера-${randomUUID().slice(0, 8)}`, item: { id: '3', title: 'третий', tests: ['npm run t'], files: ['app/A.php'] }, branch: 'night/вчера/w-1', base_branch: 'feat/night-dispatcher-part1', workspace: join(tmpdir(), 'угол-вчера-w-1'), settings_path: join(tmpdir(), 'угол-вчера-w-1', '.night-worker', 'settings.json'), db_name: 'night_w1', model: 'старая-голова', session_id: 'старая-сессия', status: 'extinguished', finish_reason: 'упёрся в восемь часов', spans: VOSEM_OTREZKOV, started_at: 1000, resumed_at: null, resumed_into: null, ...over, }; } // Кладём карточку старого работника на диск — её `zavestiProdolzhenie` обязан пометить. function polozhitStarogo(w) { const d = workerDir(w.run_id, w.worker_id); mkdirSync(d, { recursive: true }); writeFileSync(join(d, 'worker.json'), JSON.stringify(w), 'utf8'); return d; } function ubrat(...runIds) { for (const r of runIds) rmSync(runDir(r), { recursive: true, force: true }); } it('🔴 П-13: у продолжения СВОЙ прогон, и обе карточки помнят, кого продолжают', () => { const w = staryj(); const novyj = `продолж-${randomUUID().slice(0, 8)}`; polozhitStarogo(w); try { const r = zavestiProdolzhenie({ runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова-из-настроек', spawnSupervisorFn: () => ({ pid: 4242 }), }); expect(r.ok).toBe(true); expect(r.run_id).toBe(novyj); expect(r.run_id).not.toBe(w.run_id); // свой номер, не вчерашний expect(r.supervisor_pid).toBe(4242); // Карточка НОВОГО прогона помнит, какой прогон и какого работника продолжаем. const runCard = readJson(join(runDir(novyj), 'run.json')); expect(runCard.resumed_from).toBe(w.run_id); expect(runCard.resumed_worker).toBe('w-1'); expect(runCard.status).toBe('running'); // Карточка СТАРОГО работника получила обе отметки. const staraya = readJson(join(workerDir(w.run_id, 'w-1'), 'worker.json')); expect(Number.isFinite(staraya.resumed_at)).toBe(true); expect(staraya.resumed_into).toBe(novyj); } finally { ubrat(novyj, w.run_id); } }); it('🔴 П-25: у продолжающего учётчик отрезков ПУСТ — восемь вчерашних не переносятся', () => { const w = staryj(); const novyj = `продолж-${randomUUID().slice(0, 8)}`; polozhitStarogo(w); try { const r = zavestiProdolzhenie({ runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова', spawnSupervisorFn: () => ({ pid: 1 }), }); const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json')); expect(nov.spans).toEqual([]); expect(nov.spans).not.toHaveLength(8); } finally { ubrat(novyj, w.run_id); } }); it('🔴 П-26: съеденного времени ноль — восемь часов пошли ЗАНОВО', () => { const w = staryj(); const novyj = `продолж-${randomUUID().slice(0, 8)}`; polozhitStarogo(w); try { const r = zavestiProdolzhenie({ runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова', spawnSupervisorFn: () => ({ pid: 1 }), now: () => 9_000_000, }); const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json')); expect(billableMs(nov.spans ?? [])).toBe(0); expect(nov.started_at).toBe(9_000_000); // отсчёт от подъёма, а не от вчерашней 1000 expect(nov.started_at).not.toBe(w.started_at); } finally { ubrat(novyj, w.run_id); } }); it('угол, ветка, точка ветвления, база и настройки — ВЗЯТЫ У СТАРОГО, сессия НОВАЯ', () => { const w = staryj(); const novyj = `продолж-${randomUUID().slice(0, 8)}`; polozhitStarogo(w); try { const r = zavestiProdolzhenie({ runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова', spawnSupervisorFn: () => ({ pid: 1 }), }); const nov = readJson(join(workerDir(novyj, r.worker_id), 'worker.json')); expect(nov.workspace).toBe(w.workspace); expect(nov.branch).toBe(w.branch); expect(nov.base_branch).toBe(w.base_branch); expect(nov.db_name).toBe(w.db_name); expect(nov.settings_path).toBe(w.settings_path); // 🔴 Сессия обязана быть НОВОЙ: та же вернула бы работника в оборванный разговор. expect(nov.session_id).not.toBe(w.session_id); expect(nov.session_id).toBeTruthy(); // Голова — из настроек владельца, а не вчерашняя из карточки. expect(nov.model).toBe('голова'); expect(nov.model).not.toBe(w.model); } finally { ubrat(novyj, w.run_id); } }); it('🔴 П-45: порождатель надзирателя УПАЛ — карточка прогона не осталась идущей', () => { // 🔴 Кусок про «пережить падение» обязан пережить и своё. Упади он между заведением // карточки и подъёмом — прогон навсегда числился бы идущим, и закрыть его было бы некому. const w = staryj(); const novyj = `продолж-${randomUUID().slice(0, 8)}`; polozhitStarogo(w); try { const r = zavestiProdolzhenie({ runId: novyj, worker: w, planPath: 'план.md', workerModel: 'голова', spawnSupervisorFn: () => { throw new Error('надзиратель не поднялся'); }, }); expect(r.ok).toBe(false); expect(r.reason).toMatch(/надзиратель не поднялся/); const runCard = readJson(join(runDir(novyj), 'run.json')); expect(runCard.status).not.toBe('running'); expect(runCard.status).toBe('finished'); expect(runCard.finish_reason).toBeTruthy(); } finally { ubrat(novyj, w.run_id); } }); it('🔴 головы не назвали — падаем ДО порождения надзирателя, а не после', () => { const w = staryj(); const novyj = `продолж-${randomUUID().slice(0, 8)}`; let zovov = 0; try { expect(() => zavestiProdolzhenie({ runId: novyj, worker: w, planPath: 'план.md', workerModel: null, spawnSupervisorFn: () => { zovov += 1; return { pid: 1 }; }, })).toThrow(/worker_model/); // 🔴 Ноль — не украшение: надзиратель родил бы живого работника, то есть ДЕНЬГИ. expect(zovov).toBe(0); } finally { ubrat(novyj, w.run_id); } }); it('🔴 угол НЕ заводится заново: `createWorkspace` из этой дороги не зовётся ни разу', () => { // 🔴 Решение 1: продолжение идёт в ТОМ ЖЕ углу. Заведись угол заново — работник // потерял бы всё наработанное, а `composer install` съел бы десять минут ночи. // 🪤 Сторож смотрит на КОД, а не на поведение: подать сюда подставной `createWorkspace` // нечем — его в подписи нет вовсе, и это ровно то, что проверяется. const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'run.mjs'), 'utf8'); const nachalo = src.indexOf('export function zavestiProdolzhenie'); expect(nachalo).toBeGreaterThan(-1); const telo = src.slice(nachalo, src.indexOf('\n}', nachalo)); expect(telo).not.toMatch(/createWorkspace\w*\s*\(/); }); }); // ═══════════════════════════════════════════════════════════════════════════════ // КУСОК 6, ЗАДАЧА 6 — ТИХИЕ ЧАСТИ ТОЧКИ ВХОДА. Снятие запора шагов 1 и 2. // // 🔴 Это те самые два шага, которые в прошлом круге заперли всю дорогу продолжения: // у `razborDovodov` не было полей `--resume`/`--worker`, а `vyborProdolzhaemogo` // не существовало вовсе. Тихое живёт здесь; в `cli.mjs` — только громкое. // ═══════════════════════════════════════════════════════════════════════════════ describe('🔴 кусок 6, задача 6 — разбор доводов продолжения', () => { const bazovye = ['node', 'cli.mjs', 'run', 'план.md', '--priyomka', 'п.md', '--spec', 'з.md']; it('ключи `--resume` и `--worker` доезжают до команды теми же, какими их набрали', () => { const d = razborDovodov([...bazovye, '--resume', 'прогон-7', '--worker', 'w-3']); expect(d.resumeRunId).toBe('прогон-7'); expect(d.resumeWorkerId).toBe('w-3'); }); it('🔴 не набрали — НЕ выдумываем: оба поля пусты, а не «первый попавшийся»', () => { const d = razborDovodov([...bazovye]); expect(d.resumeRunId).toBeNull(); expect(d.resumeWorkerId).toBeNull(); }); it('🔴 `--resume` без значения не съедает следующий ключ', () => { // 🪤 Ровно та беда, от которой в этом файле уже стоит `estLiKlyuch`: ключ, спрошенный // как ключ-со-значением, забирает следующее слово командной строки — то есть ЧУЖОЙ ключ. const d = razborDovodov(['node', 'cli.mjs', 'run', 'план.md', '--priyomka', 'п.md', '--spec', 'з.md', '--resume']); expect(d.resumeRunId).toBeNull(); const e = razborDovodov([...bazovye, '--resume', '--worker', 'w-3']); expect(e.resumeRunId).not.toBe('--worker'); expect(e.resumeRunId).toBeNull(); }); it('прежний разбор не тронут: план, приёмка, замысел и `--files` на месте', () => { const d = razborDovodov([...bazovye, '--resume', 'r-1', '--files', 'a.php,b.php']); expect(d.planPath).toBe('план.md'); expect(d.priyomkaPath).toBe('п.md'); expect(d.specPath).toBe('з.md'); expect(d.files).toEqual(['a.php', 'b.php']); }); }); describe('🔴 кусок 6, задача 6 — выбор продолжаемого работника', () => { const rab = (over = {}) => ({ worker_id: 'w-1', run_id: 'вчера', status: 'extinguished', resumed_into: null, ...over, }); it('🔴 П-36: продолжаемых нет вовсе — никого не берём и говорим почему', () => { const r = vyborProdolzhaemogo({ workers: [rab({ status: 'working' }), rab({ worker_id: 'w-2', status: 'paused' })] }); expect(r.worker).toBeNull(); expect(r.reason).toBeTruthy(); expect(r.candidates).toEqual([]); }); it('🔴 П-36: продолжаемых несколько, имени не назвали — НЕ берём первого попавшегося', () => { const r = vyborProdolzhaemogo({ workers: [rab({ worker_id: 'w-1' }), rab({ worker_id: 'w-2', status: 'stalled' })], }); expect(r.worker).toBeNull(); expect(r.candidates).toEqual(['w-1', 'w-2']); expect(r.reason).toMatch(/w-1/); expect(r.reason).toMatch(/w-2/); }); it('продолжаемый ровно один и имени не назвали — берём его', () => { const r = vyborProdolzhaemogo({ workers: [rab({ worker_id: 'w-9' }), rab({ worker_id: 'w-2', status: 'working' })] }); expect(r.worker?.worker_id).toBe('w-9'); expect(r.reason).toBeNull(); }); it('назвали имя — берём именно его, даже когда продолжаемых несколько', () => { const r = vyborProdolzhaemogo({ workers: [rab({ worker_id: 'w-1' }), rab({ worker_id: 'w-2', status: 'stalled' })], workerId: 'w-2', }); expect(r.worker?.worker_id).toBe('w-2'); }); it('🔴 П-39: назвали ЖИВОГО — отказ, и про паузу сказано, чем она снимается', () => { for (const zhivoy of ['working', 'paused', 'waiting_service', 'waiting_answer_idle', 'stopped']) { const r = vyborProdolzhaemogo({ workers: [rab({ status: zhivoy })], workerId: 'w-1' }); expect(r.worker).toBeNull(); expect(r.reason).toMatch(/жив/i); } // Про паузу — своё слово: владелец обязан узнать, ЧЕМ она снимается. const p = vyborProdolzhaemogo({ workers: [rab({ status: 'paused' })], workerId: 'w-1' }); expect(p.reason).toMatch(/resume/); }); it('🔴 П-42: назвали УЖЕ ПРОДОЛЖЕННОГО — отказ, и назван прогон, куда его продолжили', () => { const r = vyborProdolzhaemogo({ workers: [rab({ resumed_into: 'прогон-завтра' })], workerId: 'w-1', }); expect(r.worker).toBeNull(); expect(r.reason).toMatch(/прогон-завтра/); }); it('🔴 уже продолженный не считается продолжаемым и БЕЗ имени — иначе поднимут второго', () => { // 🪤 Второй живой работник в том же углу и той же базе поверх первого — худшее, // что эта кнопка может сделать. const r = vyborProdolzhaemogo({ workers: [rab({ resumed_into: 'прогон-завтра' })] }); expect(r.worker).toBeNull(); expect(r.candidates).toEqual([]); }); it('назвали того, кого в прогоне нет вовсе — своя причина, а не «он жив»', () => { const r = vyborProdolzhaemogo({ workers: [rab()], workerId: 'w-404' }); expect(r.worker).toBeNull(); expect(r.reason).toMatch(/w-404/); expect(r.reason).not.toMatch(/жив/i); }); it('🔴🔴 ТРЕТЬЕ: у карточки НЕТ слова состояния — это не «жив» и не «продолжаем»', () => { // 🔴 ВЫРЕЗАНИЕ ВТОРОГО РОДА. Допущение правила: «состояние — это одно из известных слов». // Годный по форме случай, в него не попавший: карточка, у которой слова нет вовсе // (запись оборвалась, файл неполон). Плоское «не продолжаемый ⇒ жив» сказало бы владельцу // «он жив, пауза снимается командой resume» про работника, которого, может быть, нет. // Замерено: живых слов у работника ВОСЕМЬ (working · paused · stalled · extinguished · // waiting_service · waiting_answer_idle · waiting_answer_busy · stopped), а состояний // у этого входа девять — девятое и есть «слова нет». for (const net of [undefined, null, '', 7]) { const r = vyborProdolzhaemogo({ workers: [rab({ status: net })], workerId: 'w-1' }); expect(r.worker).toBeNull(); expect(r.reason).toMatch(/состояни/i); expect(r.reason).not.toMatch(/жив/i); } }); it('карточек не дали вовсе — отказ, а не обвал', () => { for (const pusto of [undefined, null, []]) { const r = vyborProdolzhaemogo({ workers: pusto }); expect(r.worker).toBeNull(); expect(r.reason).toBeTruthy(); } expect(() => vyborProdolzhaemogo()).not.toThrow(); }); it('🛡️ СТОРОЖ: список продолжаемых ВВЕЗЁН из resume.mjs, а не набран здесь второй раз', () => { // 🔴 Два списка слов на один вопрос разъехались бы молча: `resumePlan` судил бы по одному, // кнопка — по другому, и обе стороны остались бы зелёными. const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli-parts.mjs'), 'utf8'); expect(src).toMatch(/PRODOLZHAEMYE/); expect(src).toMatch(/from '\.\/resume\.mjs'/); // Своей копии слов в файле нет. expect(src).not.toMatch(/\[\s*'stalled'\s*,\s*'extinguished'\s*\]/); // И правило вправду судит по ввезённому списку, а не по своему представлению: // оба слова из него — продолжаемые. for (const slovo of PRODOLZHAEMYE) { expect(vyborProdolzhaemogo({ workers: [rab({ status: slovo })] }).worker?.worker_id).toBe('w-1'); } }); it('🛡️ СТОРОЖ: сборщик карточек даёт ТРЕТИЙ ответ, а не пустоту', () => { // 🔴 Долг М-181, который я же и назвал. Плоский `readJson` + `.filter(Boolean)` выбросил // бы порванную карточку МОЛЧА — и продолжаемым оказался бы другой работник, а отказы // «его уже продолжили» и «продолжаемых несколько» промолчали бы, оставаясь зелёными. const d = mkdtempSync(join(tmpdir(), 'night-kart-')); try { const wdir = join(d, 'workers'); mkdirSync(join(wdir, 'w-1'), { recursive: true }); mkdirSync(join(wdir, 'w-2'), { recursive: true }); writeFileSync(join(wdir, 'w-1', 'worker.json'), JSON.stringify({ worker_id: 'w-1', status: 'stalled' }), 'utf8'); // 🪤 Порванная карточка: файл ЕСТЬ, разобрать нельзя. Это не «работника нет». writeFileSync(join(wdir, 'w-2', 'worker.json'), '{ порвано', 'utf8'); const r = kartochkiRabotnikov('всё-равно', d); expect(r.ok).toBe(false); expect(r.reason).toMatch(/w-2/); expect(r.reason).toMatch(/прочесть не удалось|разобрать/i); } finally { rmSync(d, { recursive: true, force: true }); } }); it('сборщик: карточек нет вовсе — отказ с номером прогона, а не обвал', () => { const d = mkdtempSync(join(tmpdir(), 'night-kart2-')); try { const r = kartochkiRabotnikov('прогон-777', d); expect(r.ok).toBe(false); expect(r.reason).toMatch(/прогон-777/); } finally { rmSync(d, { recursive: true, force: true }); } }); it('🛡️ СТОРОЖ решения 11: в `vhodVorot` кусок 6 не встаёт ни одним доводом', () => { // 🔴 Развилка «если продолжение — подавай пустышки» была снята диспетчером 01.08: // это вторая дорога к правилу, открывающаяся молча при первой ошибке в признаке. const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'cli-parts.mjs'), 'utf8'); const i = src.indexOf('export function vhodVorot'); const telo = src.slice(i, src.indexOf('\n}', i)); expect(telo).not.toMatch(/resume/i); expect(telo).not.toMatch(/prodolzh/i); }); }); // ═══════════════════════════════════════════════════════════════════════════════ // 🔴🔴 ЖИВАЯ ДОРОГА ПРОДОЛЖЕНИЯ — ТОТ САМЫЙ ЗОВУЩИЙ. // До неё у ДЕСЯТИ правил куска 6 зовущих было ноль. // ═══════════════════════════════════════════════════════════════════════════════ describe('🔴 кнопка «продолжить» — живая дорога из двенадцати шагов', () => { let ugol; let vcheraId; let kommitZachtennogo; let rechi; let novyjId; // 🔴 Угол — НАСТОЯЩЕЕ хранилище, и прогон — настоящий каталог: дорога зовёт настоящие // читалки (`closedItemsOfRun`, `kommitZachtennogoPunkta`, `rabotaPosleZachtennogo`), // и подставить их нечем. Подставка доказала бы только моё представление о них — ровно // тот класс, на котором в этой затее уже обжигались. beforeEach(() => { rechi = []; novyjId = null; ugol = mkdtempSync(join(tmpdir(), 'night-ugol-')); execFileSync(GIT, ['init', '-q', '-b', 'baza'], { cwd: ugol }); execFileSync(GIT, ['config', 'user.email', 'r@liderra.local'], { cwd: ugol }); execFileSync(GIT, ['config', 'user.name', 'работник'], { cwd: ugol }); writeFileSync(join(ugol, 'A.php'), 'засчитанное\n', 'utf8'); execFileSync(GIT, ['add', '.'], { cwd: ugol }); execFileSync(GIT, ['commit', '-qm', 'пункт 1 засчитан'], { cwd: ugol }); kommitZachtennogo = String(execFileSync(GIT, ['rev-parse', 'HEAD'], { cwd: ugol, encoding: 'utf8' })).trim(); // Поверх засчитанного — работа, которую продолжение обязано снять откатом. writeFileSync(join(ugol, 'B.php'), 'работа после засчитанного\n', 'utf8'); execFileSync(GIT, ['add', '.'], { cwd: ugol }); execFileSync(GIT, ['commit', '-qm', 'после засчитанного'], { cwd: ugol }); // Настоящий каталог поднимаемого прогона с приговором по пункту 1. vcheraId = `вчера-${randomUUID().slice(0, 8)}`; const wdir = workerDir(vcheraId, 'w-1'); mkdirSync(wdir, { recursive: true }); writeFileSync(join(runDir(vcheraId), 'run.json'), JSON.stringify({ run_id: vcheraId, items: [{ id: '1' }, { id: '2' }] }), 'utf8'); writeFileSync(join(wdir, 'items-verified.jsonl'), `${JSON.stringify({ item: '1', at: 1000, commit_sha: kommitZachtennogo, tests_green: true, line: 'пункт 1 закрыт' })}\n`, 'utf8'); }); afterEach(() => { rmSync(ugol, { recursive: true, force: true }); rmSync(runDir(vcheraId), { recursive: true, force: true }); // Новый прогон дорога заводит сама — за собой убираем тоже, чтобы не копить в хозяйстве. if (novyjId) rmSync(runDir(novyjId), { recursive: true, force: true }); }); const CFG = { worker_model: 'голова-из-настроек', disk_free_min_gb: 5 }; const HOROSHO = { ok: true, reason: null }; const rabotnik = (over = {}) => ({ worker_id: 'w-1', run_id: vcheraId, status: 'extinguished', resumed_into: null, workspace: ugol, branch: 'night/вчера/w-1', base_branch: 'baza', db_name: 'night_w1', settings_path: 'нет.json', session_id: 'старая', spans: [], ...over, }); // Всё вокруг дороги — подставное; предмет проверки сама дорога. function dovody(over = {}) { return { cfg: CFG, planPath: 'план.md', resumeRunId: vcheraId, readCards: () => ({ ok: true, cards: [rabotnik()], reason: null }), pechat: () => HOROSHO, measureDisk: async () => HOROSHO, zamer: async () => ({ ok: true, measure: { utilization: 0.1, status: 'allowed', kind: 'five_hour', resets_at: Date.now() + 3600_000 }, problem: null }), // 🪤 Настоящий остаток писал бы в ОБЩИЙ журнал замеров `.night/` — своя строка там // сдвинула бы чужой прирост и погасила бы соседний прогон ни за что. // 🪤 Заодно ловим номер НОВОГО прогона: он случайный, а замки живут в его каталоге. // Шаг замера — самое раннее место, где номер уже известен. ostatok: ({ runId }) => { novyjId = runId; return { may_start: true, reason: null, dolya_ne_prishla: false, slova_vladeltsu: null }; }, podnyat: () => ({ ok: true, reason: null, run_id: 'новый', worker_id: 'w-1', supervisor_pid: 42 }), // 🔴 Откатчик — НАСТОЯЩИЙ. Подставной, отвечающий «получилось» без коммита, дорога // законно отвергает («на слово не верим») — и это поймано живьём при первом прогоне: // моя удобная подставка провалила happy-path, потому что защита работает. // Угол здесь настоящее хранилище, так что настоящему откатчику есть с чем работать. gryaz: () => false, // Всё сказанное владельцу копится: по нему и судим, что он вправду прочтёт. out: s => rechi.push(String(s)), err: s => rechi.push(String(s)), ...over, }; } it('🔴 П-24: `planRun` при продолжении не зовётся НИ РАЗУ', async () => { // 🔴 Сторож решения 8. Приведи кнопку обратно на общую дорогу — краснеет. // 🪤 Считаем зовы настоящего модуля: заглушка внутри чужой функции доказала бы себя. vi.resetModules(); let zovov = 0; vi.doMock('./run.mjs', async () => { const nast = await vi.importActual('./run.mjs'); return { ...nast, planRun: (...a) => { zovov += 1; return nast.planRun(...a); } }; }); const { komandaProdolzheniya: kp } = await import('./cli.mjs'); await kp(dovody()); expect(zovov).toBe(0); vi.doUnmock('./run.mjs'); vi.resetModules(); }); it('🔴 П-41: отпечаток плана негоден — ПЛАТНЫЙ ЗАМЕР НЕ ЗВАЛСЯ НИ РАЗУ', async () => { // 🔴 Замер — платная программа, деньги владельца. Жечь его на попытку, которая заведомо // провалится по дешёвым воротам, нельзя. Переставь замер вперёд — краснеет. let zamerov = 0; const d = mkdtempSync(join(tmpdir(), 'night-pechat-')); try { const kod = await komandaProdolzheniya(dovody({ // Отпечаток негоден — дешёвые ворота не сошлись. pechat: () => ({ ok: false, reason: 'отпечаток плана не сошёлся' }), zamer: async () => { zamerov += 1; return { ok: true, measure: null, problem: null }; }, })); expect(kod).toBe(1); expect(zamerov).toBe(0); } finally { rmSync(d, { recursive: true, force: true }); } }); it('🔴 П-17: живой ЧУЖОЙ работник и молчание владельца кнопку НЕ держат', async () => { // 🔴 Проверка 9 дословно: «больше кнопка не проверяет ничего… иначе она не сработала бы // никогда». Подать это в `resumeChecks` нечем — у неё таких кормушек нет и не будет. const kod = await komandaProdolzheniya(dovody({ readCards: () => ({ ok: true, cards: [rabotnik(), rabotnik({ worker_id: 'w-9', status: 'working', workspace: ugol })], reason: null, }), resumeWorkerId: 'w-1', })); expect(rechi.join(' | ')).toMatch(/продолжен прогоном/); // видно, ЧТО именно сказали expect(kod).toBe(0); // дошли до подъёма }); it('🔴 П-48: угла работника нет вовсе — понятный отказ, а не обвал глубже', async () => { const kod = await komandaProdolzheniya(dovody({ readCards: () => ({ ok: true, cards: [rabotnik({ workspace: join(tmpdir(), 'такого-угла-нет-совсем') })], reason: null }), })); expect(kod).toBe(1); }); it('🔴 П-47: КАЖДАЯ причина отказа печатается владельцу В ОКНО', async () => { // 🔴 Решение владельца 125: окно — единственная дорога до него, письма нет. // Убери печать — краснеет; появись зов отправлялки — краснеет тоже. const skazano = []; const kod = await komandaProdolzheniya(dovody({ readCards: () => ({ ok: true, cards: [], reason: 'продолжать некого' }), err: s => skazano.push(String(s)), out: s => skazano.push(String(s)), })); expect(kod).toBe(1); // 🔴 Предмет — сам факт печати владельцу, а не точные слова: причину сочиняет правило // `vyborProdolzhaemogo`, и приколачивать её текст здесь значило бы сторожить не то. expect(skazano.length).toBeGreaterThan(0); expect(skazano.join(' ')).toMatch(/Продолжение не начато/); expect(skazano.join(' ')).toMatch(/карточки работника|продолжать/i); }); it('🔴 П-29: встал, не закрыв НИ ОДНОГО пункта — не поднят, наработки показаны', async () => { // 🔴 Настоящее условие проверки 117: продолжать не с чего. Убираем приговор по пункту — // засчитанных не остаётся вовсе, и `resumePlan` отказывает САМ, в правиле. rmSync(join(workerDir(vcheraId, 'w-1'), 'items-verified.jsonl'), { force: true }); let podnyatoRaz = 0; const kod = await komandaProdolzheniya(dovody({ podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; }, })); expect(kod).toBe(1); // 🔴 Работник НЕ поднят — владелец решает сам, что делать с наработками. expect(podnyatoRaz).toBe(0); expect(rechi.join(' ')).toMatch(/Отложено/); }); it('🔴 грязный угол — отказа хватает, до отката и подъёма не доходим', async () => { let otkatov = 0; let podnyatoRaz = 0; const kod = await komandaProdolzheniya(dovody({ gryaz: () => true, otkatchik: () => { otkatov += 1; return { ok: true }; }, podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; }, })); expect(kod).toBe(1); expect(otkatov).toBe(0); expect(podnyatoRaz).toBe(0); }); it('🔴 «спросить про грязь не удалось» — тоже отказ: вслепую не откатываем', async () => { // 🔴 ТРЕТЬЕ состояние прибора грязи. Слить его с «чисто» значило бы откатывать вслепую. const kod = await komandaProdolzheniya(dovody({ gryaz: () => null })); expect(kod).toBe(1); }); it('🔴🔴 ШОВ: НАСТОЯЩИЙ ответ budgetState проходит ворота продолжения', async () => { // 🔴 ЗАМЕРЕНО ЖИВЬЁМ: `budgetState` отвечает полем `may_start`, а `otvetVorot` внутри // `resumeChecks` смотрит только на `ok` — и на заведомо хорошем запасе (израсходовано 5%) // ворота отвечали «не мерили — остаток неизвестен». Кнопка не сработала бы НИ РАЗУ, // а оба куска зелены, потому что ни один не видит другого. // 🪤 Проверка нарочно подаёт НАСТОЯЩИЙ ответ настоящего `budgetState`, а не удобную // выдумку: удобная выдумка и была причиной, по которой шов не находился. const d = mkdtempSync(join(tmpdir(), 'night-shov-')); try { const p = join(d, 'm.jsonl'); zapisatZamer(p, { utilization: 0.05, status: 'allowed', kind: 'five_hour', resets_at: Date.now() + 3600_000, at: Date.now() }); const nastoyashchiy = sostoyanieZapasa(p, { rate_limit_type: 'five_hour' }, Date.now()); expect(nastoyashchiy.may_start).toBe(true); const kod = await komandaProdolzheniya(dovody({ ostatok: () => nastoyashchiy })); expect(kod).toBe(0); // дошли до подъёма, а не упёрлись в «не мерили» } finally { rmSync(d, { recursive: true, force: true }); } }); // ═════════ ЗАМКИ НА ФАЙЛЫ: самое опасное место затеи ═════════ // 🔴 Продолжение не просто пишет — оно ОТКАТЫВАЕТ (`read-tree --reset -u` переписывает // дерево целиком). Без замка откат сносит правку соседа в общей ветке и общей базе, // МОЛЧА. Владелец обнаружит это не утром, а когда чего-то недосчитается. it('🔴🔴 файл держит ДРУГОЙ идущий прогон — не откатываем и не поднимаем', async () => { let otkatov = 0; let podnyatoRaz = 0; const kod = await komandaProdolzheniya(dovody({ // Соседний прогон занял тот же файл, что задел наш работник после засчитанного пункта. idushchie: () => ({ ok: true, reason: null, running: [ { run_id: 'сосед', status: 'running', items: [{ id: '9', files: ['B.php'] }] }, ] }), otkatchik: () => { otkatov += 1; return { ok: true }; }, podnyat: () => { podnyatoRaz += 1; return { ok: true, run_id: 'н', worker_id: 'w-1', supervisor_pid: 1 }; }, })); expect(kod).toBe(1); // 🔴 Главное: до отката НЕ дошли. Дойди — чужая правка была бы снесена. expect(otkatov).toBe(0); expect(podnyatoRaz).toBe(0); // И владельцу сказано, КАКОЙ файл и что откат снёс бы чужое. expect(rechi.join(' ')).toMatch(/B\.php/); expect(rechi.join(' ')).toMatch(/чуж/i); }); it('🔴 счёт идущих прогонов не сошёлся — тоже не откатываем (порванная карточка)', async () => { // 🪤 «Сколько идёт сейчас — неизвестно» нельзя считать за «никого нет»: именно так // замок открылся бы всем ровно в ту ночь, когда запись порвалась. let otkatov = 0; const kod = await komandaProdolzheniya(dovody({ idushchie: () => ({ ok: false, running: [], reason: 'карточку прогона прочесть не удалось' }), otkatchik: () => { otkatov += 1; return { ok: true }; }, })); expect(kod).toBe(1); expect(otkatov).toBe(0); }); it('🔴 поднимаемый прогон САМ СЕБЕ не соперник — иначе продолжение невозможно никогда', async () => { // 🪤 Вырезание второго рода: «файл занят идущим прогоном» — а поднимаемый прогон // тоже есть в списке, если его карточку ещё не закрыли. Считай мы его соперником, // кнопка отказывала бы ВСЕГДА и по причине, которой нет. const kod = await komandaProdolzheniya(dovody({ idushchie: () => ({ ok: true, reason: null, running: [ { run_id: vcheraId, status: 'running', items: [{ id: '1', files: ['A.php', 'B.php'] }] }, ] }), })); expect(kod).toBe(0); }); it('🔴 файлы ВПРАВДУ заняты за продолжающим — и заняты ДО отката', async () => { let zanyatyVMomentOtkata = null; const kod = await komandaProdolzheniya(dovody({ otkatchik: (a) => { // Смотрим замок ИМЕННО в минуту отката: занять после было бы поздно. // 🔴🔴 СПРАШИВАЕМ ЗА ЧУЖОГО, А НЕ ЗА СВОЕГО — и это не придирка, это вырезание // второго рода, поймавшее мою же слабую проверку. `mayTouch` за СВОЕГО отвечает // «можно» и когда файл занят им, и когда не занят НИКЕМ: пустой список занятий // читается как «всё свободно». То есть прежняя форма этой проверки оставалась // зелёной даже с полностью вырезанным занятием. Отказ ЧУЖОМУ — единственное, // что доказывает, что замок вправду взят. zanyatyVMomentOtkata = mayTouch(runDir(novyjId), 'кто-то-другой', 'B.php'); return otkatDoKommita(a); }, })); expect(kod).toBe(0); expect(zanyatyVMomentOtkata?.ok).toBe(false); }); it('🔴 отказ ПОСЛЕ занятия освобождает замок — мертвец файлы не держит', async () => { // 🔴 Замок, переживший неудачную попытку, держал бы файлы за работником, которого нет, // и следующая попытка упёрлась бы в него же. const kod = await komandaProdolzheniya(dovody({ podnyat: () => ({ ok: false, reason: 'надзиратель не поднялся' }), })); expect(kod).toBe(1); // Файл снова свободен для КОГО УГОДНО. expect(mayTouch(runDir(novyjId), 'кто-то-другой', 'B.php').ok).toBe(true); }); it('🔴 голову не назвали — подъём отказывает, и это видно владельцу', async () => { const skazano = []; const kod = await komandaProdolzheniya(dovody({ cfg: { disk_free_min_gb: 5 }, podnyat: () => { throw new Error('в night.config.json нет worker_model'); }, err: s => skazano.push(String(s)), out: s => skazano.push(String(s)), })); expect(kod).toBe(1); expect(skazano.join(' ')).toMatch(/worker_model/); }); });