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

882 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 🔴 `afterEach` и `existsSync` дописаны в УЖЕ СТОЯЩИЕ строки ввоза (кусок 3, задача 6),
// а не отдельными строками рядом: план предлагал завести вторые ввозы из тех же файлов
// с прозвищами (`rmSync as ubratSled`), а второе имя того же прибора — это ровно тот сорт
// мелочи, которая потом разъезжается молча.
import { describe, it, expect, afterEach, vi } from 'vitest';
import { rmSync, existsSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { planRun, startAll, MAX_WORKERS } from './run.mjs';
import { mayTouch } from './file-claims.mjs';
import { runDir, workerDir, summaryLinesPath } from './paths.mjs';
// 🔴 Путь к следам знает ОДИН файл на всю затею — берём его оттуда же, что и рабочий код,
// а не складываем свой второй такой же путь строкой.
import { deferredDir } from './deferred.mjs';
import { readJson, readLines } from './state.mjs';
// 🔴 Итог прогона пишет ОДНО место на всю затею — `writeRunOutcome` надзирателя. Проверки
// ниже подставляют его же, но с временным путём для `last-run.json`: настоящий лежит в общем
// `.night/` этого рабочего угла, и трогать его проверкой нельзя.
import { writeRunOutcome } from './supervisor.mjs';
// 🔴🔴 У КАЖДОГО ЗАПУСКА ПРИБОРА ПРОВЕРОК — СВОЙ КАТАЛОГ ПРОГОНА, И ЭТО НЕ УКРАШЕНИЕ.
// Проверки ниже пишут в НАСТОЯЩИЙ `.night/` хозяйства: каталог прогона считает сам
// `startAll` через `runDir`, и подменить его нечем — подпись задана договором о стыках.
// Поэтому уборка стоит в `finally`/`afterEach`, а имена прогонов заведомо пробные.
//
// 🪤 ЧЕГО ЭТОГО БЫЛО МАЛО. Имена прогонов были ПОСТОЯННЫМИ (`r-zanyatiya-fajlov`,
// `проба-головы` и прочие), а уборка сносит каталог ЦЕЛИКОМ. Два прибора проверок,
// запущенные разом в одном рабочем углу, брали ОДИН И ТОТ ЖЕ каталог: уборка одного
// сносила хозяйство другому прямо посреди опыта. Замерено 02.08.2026 — два прибора
// по шесть заходов: 10 красных из 12, и красные КАЖДЫЙ РАЗ РАЗНЫЕ, а поодиночке тот же
// файл зелен всегда. Это ровно тот сорт беды, из-за которого идут чинить исправное.
//
// 🔴 Неповторимый хвост берётся ТЕМ ЖЕ прибором, каким его берёт рабочий код
// (`run.mjs`: `r-<время>-<randomUUID>`), а не вторым, своим: два правила на одно дело
// в разных местах — известная беда хозяйства. Сносим при этом ТОЛЬКО СВОЁ.
// Сторож на этот класс — `nastoyashchee-hozyaystvo-v-proverkah.test.mjs`.
const okInput = {
base_branch: 'feat/night-dispatcher-part1',
items: [
{ id: '1', files: ['a.php'], tests: ['t.mjs'] },
{ id: '2', files: ['b.php'], tests: ['t.mjs'] },
],
// Эти поля кусок 1 принимает и не смотрит — их подключает кусок 2.
budget: { may_start: true, run_share_limit: 1 / 7 },
disk: { ok: true, free_gb: 40 },
owner: { last_summary_at: null },
previousWorkers: [],
capacity: { tasks_running: 0, max_tasks: 2 },
newTaskFiles: ['a.php'],
runningTasksFiles: [],
planSeal: { ok: true },
papers: { ok: true },
spec: { ok: true },
};
describe('planRun — расписание прогона', () => {
it('прогон расписан, работники получили по пункту', () => {
const r = planRun(okInput);
expect(r.ok).toBe(true);
expect(r.workers.length).toBe(2);
expect(r.workers[0].item.id).toBe('1');
expect(r.workers[0].worker_id).toBe('w-1');
expect(r.workers[1].worker_id).toBe('w-2');
});
it('каждому работнику своё имя базы — двое не пишут в одну', () => {
const r = planRun(okInput);
const names = new Set(r.workers.map(w => w.db_name));
expect(names.size).toBe(r.workers.length);
});
it('денег работникам не выдаётся вовсе — своего счёта в долларах у затеи нет', () => {
const r = planRun(okInput);
for (const w of r.workers) {
expect(w.max_usd).toBeUndefined();
expect(w.limit_usd).toBeUndefined();
}
expect(r.run_limit_usd).toBeUndefined();
});
it('доля недельного запаса пробрасывается как есть — считает её кусок 2, не мы', () => {
expect(planRun(okInput).run_share_limit).toBeCloseTo(1 / 7, 6);
expect(planRun({ ...okInput, budget: null }).run_share_limit).toBeNull();
});
it('точка ветвления уходит каждому работнику — угол ветвится от ветки затеи, не от главной', () => {
const r = planRun(okInput);
expect(r.workers[0].base_branch).toBe('feat/night-dispatcher-part1');
expect(r.base_branch).toBe('feat/night-dispatcher-part1');
});
it('точку ветвления не назвали — прогона нет: от главной в углу нет ничего из затеи', () => {
const r = planRun({ ...okInput, base_branch: null });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/точк[аи] ветвления/i);
expect(r.workers).toEqual([]);
expect(r.failed_gates[0].gate).toMatch(/точка ветвления/i);
});
it('делать нечего — прогона нет, и причина названа', () => {
const r = planRun({ ...okInput, items: [] });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/ни одного пункта/i);
expect(r.workers).toEqual([]);
});
it('работников не больше семи, даже если пунктов больше', () => {
const items = Array.from({ length: 12 }, (_, i) => ({ id: String(i + 1), files: [`f${i}.php`], tests: ['t.mjs'] }));
expect(MAX_WORKERS).toBe(7);
expect(planRun({ ...okInput, items }).workers.length).toBe(7);
});
it('пункты за потолком НЕ пропадают молча — сколько не взято, сказано числом и словами', () => {
// 🔴 Беда 8. Раньше лишние пункты просто срезались, и владелец не видел ни строки.
const items = Array.from({ length: 12 }, (_, i) => ({ id: String(i + 1), files: [`f${i}.php`], tests: ['t.mjs'] }));
const r = planRun({ ...okInput, items });
expect(r.ne_vzyato).toBe(5);
expect(r.ne_vzyato_slovami).toMatch(/не взято/i);
});
it('номер прогона содержит ВРЕМЯ, а не только дату — два прогона за сутки не затрут друг друга', () => {
// 🪤 Прежде номер был `r-<дата>`: второй прогон за те же сутки получил бы тот же номер,
// ту же ветку и тот же каталог — и затёр бы итог первого молча.
// 🆕 Обрезка до минуты снята (см. раздел «номер прогона вправду неповторим» ниже):
// теперь во имени есть и секунды, и случайный хвост. День и время в нём по-прежнему
// на первом месте — владелец обязан узнавать свой прогон в сводке глазами.
expect(planRun(okInput).run_id).toMatch(/^r-\d{4}-\d{2}-\d{2}-\d{6}-[0-9a-f]+$/);
});
});
// ============ БЕДА 8: потолок работников применялся ДВАЖДЫ и по-разному ============
//
// 🔴 ЗАМЕРЕНО СЧЁТОМ МЕСТ, а не памятью. На вопрос «сколько работников разом можно»
// в затее отвечали ДВА места, и отвечали они по-разному:
//
// · `gates.mjs` (`capacityFree`) — берёт число ИЗ НАСТРОЕК ВЛАДЕЛЬЦА
// (`max_workers_per_task`), запасное `MAX_WORKERS_DEFAULT = 7`, и при переборе
// ОТКАЗЫВАЕТ ВСЛУХ, назвав и своё число, и сколько просят;
// · `run.mjs` (`planRun`) — держал СВОЮ семёрку строкой в коде, настроек владельца
// не спрашивал вовсе и лишние пункты срезал МОЛЧА.
//
// Разъезд живой, не бумажный: напиши владелец в настройках «десять» — ворота пропустили
// бы десять, а срезка оставила бы семь. Три пункта не взял бы никто, и в сводке про них
// не стояло бы ни строки.
//
// Лечение: число живёт в ОДНОМ месте (ворота), `run.mjs` берёт его оттуда же, а всё,
// что за потолком, называется владельцу словами.
describe('беда 8: потолок работников — одно число, одно место, и остаток НАЗВАН владельцу', () => {
const punkty = n => Array.from({ length: n }, (_, i) => ({ id: String(i + 1), files: [`f${i}.php`], tests: ['t.mjs'] }));
it('владелец написал десять — заводится десять, а не семь из чужой головы', () => {
// 🔴 Эту проверку постоянная-семёрка в коде пройти не может физически.
const r = planRun({ ...okInput, items: punkty(10), capacity: { ...okInput.capacity, max_workers: 10 } });
expect(r.ok).toBe(true);
expect(r.workers.length).toBe(10);
expect(r.ne_vzyato).toBe(0);
});
it('владелец написал троих — заводится трое, а лишние шесть НАЗВАНЫ', () => {
const r = planRun({ ...okInput, items: punkty(9), capacity: { ...okInput.capacity, max_workers: 3 } });
expect(r.workers.length).toBe(3);
expect(r.ne_vzyato).toBe(6);
expect(r.ne_vzyato_slovami).toContain('6');
expect(r.ne_vzyato_slovami).toContain('3');
});
it('владелец не программист — в словах про непрочитанное нет ни одного имени из кода', () => {
const r = planRun({ ...okInput, items: punkty(9), capacity: { ...okInput.capacity, max_workers: 3 } });
const s = r.ne_vzyato_slovami;
// 🪤 «Лишь бы не пусто» тут не годится: строка обязана быть человеческой.
expect(s).not.toMatch(/\.mjs|\.json|planRun|MAX_WORKERS|max_workers|slice|items/);
expect(s).toMatch(/пункт/i);
});
it('пунктов не больше потолка — владельцу про «не взято» не говорят ничего', () => {
const r = planRun({ ...okInput, items: punkty(3), capacity: { ...okInput.capacity, max_workers: 7 } });
expect(r.ne_vzyato).toBe(0);
// 🪤 Пустая строка вместо молчания — это лишняя строка в сводке каждую ночь.
expect(r.ne_vzyato_slovami).toBeNull();
});
it('ровно по потолку — тоже молчание: семеро при потолке семь это разрешённые семеро', () => {
const r = planRun({ ...okInput, items: punkty(7), capacity: { ...okInput.capacity, max_workers: 7 } });
expect(r.workers.length).toBe(7);
expect(r.ne_vzyato).toBe(0);
expect(r.ne_vzyato_slovami).toBeNull();
});
// 🔴🔴 «А МОЙ ОТВЕТ КТО-НИБУДЬ СЛУШАЕТ?» — третий вопрос ко всякой защите. Число
// непрочитанных пунктов `planRun` возвращает полем, но ПЕЧАТАЕТ его чужой файл, и мог
// бы не печатать вовсе. Поэтому строка кладётся ещё и в сводку — тот канал, который
// до владельца доходит утром наверняка.
it('строка про непрочитанное вправду ДОХОДИТ до владельца — она в утренней сводке', () => {
const runId = `проба-потолка-${randomUUID().slice(0, 8)}`;
// Работников двое, а пунктов в очереди пять — трое сразу никому не достались.
const workers = [
{ worker_id: 'w-1', item: { id: '1', files: ['a.php'], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' },
{ worker_id: 'w-2', item: { id: '2', files: ['b.php'], tests: ['t.mjs'] }, branch: 'night/x/w-2', db_name: 'db2' },
];
const items = punkty(5);
try {
startAll({
runId, workers, items, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
workerModel: 'голова', createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
spawnSupervisorFn: () => ({ pid: 1 }),
});
const stroki = readLines(summaryLinesPath(runId)).map(s => s.line ?? '');
const pro = stroki.find(s => /не взято/i.test(s));
expect(pro).toBeTruthy();
expect(pro).toContain('3');
} finally {
rmSync(runDir(runId), { recursive: true, force: true });
}
});
it('пунктов ровно по работникам — лишней строки в сводке НЕТ', () => {
const runId = `проба-без-остатка-${randomUUID().slice(0, 8)}`;
const workers = [{ worker_id: 'w-1', item: { id: '1', files: ['a.php'], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' }];
try {
startAll({
runId, workers, items: punkty(1), planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
workerModel: 'голова', createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
spawnSupervisorFn: () => ({ pid: 1 }),
});
const stroki = readLines(summaryLinesPath(runId)).map(s => s.line ?? '');
expect(stroki.some(s => /не взято/i.test(s))).toBe(false);
} finally {
rmSync(runDir(runId), { recursive: true, force: true });
}
});
it('🔴 запасное число берётся ИЗ ВОРОТ, а не своей копией: поменяли там — прогон идёт за ним', async () => {
// 🔴🔴 ВЫРЕЗАНИЕ ВТОРОГО РОДА, вделанное в саму проверку. Сравнение
// `expect(MAX_WORKERS).toBe(MAX_WORKERS_DEFAULT)` было бы пустышкой: обе стороны
// равны семи и при ДВУХ отдельных семёрках. Поэтому число в воротах подменяется
// на четвёрку, и прогон обязан пойти за ним. Своя постоянная в коде дала бы семь.
vi.resetModules();
vi.doMock('./gates.mjs', async () => {
const nastoyashchie = await vi.importActual('./gates.mjs');
return { ...nastoyashchie, MAX_WORKERS_DEFAULT: 4 };
});
try {
const { planRun: planRunSPodmenoy } = await import('./run.mjs');
// Настроек владельца НЕТ — значит берётся запасное число, и оно теперь четыре.
const r = planRunSPodmenoy({ ...okInput, items: punkty(9) });
expect(r.workers.length).toBe(4);
expect(r.ne_vzyato).toBe(5);
} finally {
vi.doUnmock('./gates.mjs');
vi.resetModules();
}
});
});
// ============ беда 1: номер прогона мельчал только до минуты ============
//
// 🔴 Найдено воротами ЖИВЬЁМ, а не чтением: две разные задачи, заведённые в одну минуту,
// получили ОДИН номер. Карточка второй затёрла первую, строки сводки обоих смешались
// в одном каталоге, а ветки столкнулись живьём («ветка night/... уже существует»).
// Примечание рядом с кодом обещало обратное — «во имя прогона входит ВРЕМЯ», — а защита
// была сделана только до минуты. Ночью две задачи разом — это расписание, а не редкость.
describe('номер прогона вправду неповторим — два прогона в одну минуту не сливаются', () => {
// Часы замораживаем НАМЕРТВО: на живых часах две подряд идущие строки почти всегда
// попадут в разные секунды, и проверка зеленела бы на сломанном коде.
function vOdnuMinutu(delo) {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-08-02T23:17:04.000Z'));
try {
return delo();
} finally {
vi.useRealTimers();
}
}
it('🔴 два прогона в ОДНУ И ТУ ЖЕ секунду получают РАЗНЫЕ номера', () => {
const [a, b] = vOdnuMinutu(() => [planRun(okInput).run_id, planRun(okInput).run_id]);
expect(a).not.toBe(b);
});
it('🔴 у них разные каталоги, разные ветки и разные базы — затирать друг друга нечем', () => {
// Номер прогона входит в имена веток, каталогов и баз. Совпал номер — совпало ВСЁ:
// второй прогон пишет в чужой каталог, а `git worktree add -b` падает на занятой ветке.
const [p1, p2] = vOdnuMinutu(() => [planRun(okInput), planRun(okInput)]);
expect(runDir(p1.run_id)).not.toBe(runDir(p2.run_id));
expect(p1.workers[0].branch).not.toBe(p2.workers[0].branch);
expect(p1.workers[0].db_name).not.toBe(p2.workers[0].db_name);
});
it('номер остаётся понятным человеку: в нём видны день и время прогона', () => {
// 🔴 Неповторимость нельзя покупать нечитаемым именем: этот номер владелец видит
// в сводке и по нему же ищет каталог прогона.
const id = vOdnuMinutu(() => planRun(okInput).run_id);
expect(id.startsWith('r-2026-08-02-2317')).toBe(true);
});
it('имя базы работника из такого номера база ПРИМЕТ: только строчные, цифры и подчёркивание', () => {
// 🪤 `assertOwnDbName` (workspace.mjs) отвергает посторонние знаки, и отвергает уже
// посреди ночи. Длина имени базы у PostgreSQL — 63 знака.
const w = vOdnuMinutu(() => planRun(okInput).workers[0]);
expect(w.db_name).toMatch(/^[a-z0-9_]+$/);
expect(w.db_name.length).toBeLessThan(63);
});
it('имя ветки из такого номера git ПРИМЕТ: без пробелов и служебных знаков', () => {
const w = vOdnuMinutu(() => planRun(okInput).workers[0]);
expect(w.branch).toMatch(/^night\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/);
});
it('номер назвали снаружи — берётся он, а не выдуманный (не глухо)', () => {
expect(planRun({ ...okInput, run_id: 'r-своё-имя' }).run_id).toBe('r-своё-имя');
});
});
describe('startAll — превращение расписания в живых надзирателей', () => {
it('точку ветвления не назвали — углы не заводятся вовсе, и сказано почему', () => {
// Углы и надзиратели живьём проверяются в задаче 16: там заводится настоящий угол
// и настоящий отвязанный надзиратель. Здесь — единственное, что проверяется без них.
expect(() => startAll({ runId: 'r-1', workers: [], planPath: 'план.md', baseBranch: null }))
.toThrow(/точк[аи] ветвления/i);
});
it('голову работника не назвали — углы не заводятся вовсе, и сказано почему', () => {
// 🔴 Решение владельца: голова работника живёт в настройках (`worker_model`), а не в коде.
// Падать надо ЗДЕСЬ, до заведения углов: узнать об этом после десяти минут
// `composer install` в семи углах — значит выбросить всю работу впустую.
// Верни в код значение по умолчанию — покраснеет здесь.
expect(() => startAll({
runId: 'r-1', workers: [], planPath: 'план.md',
baseBranch: 'feat/night-dispatcher-part1', workerModel: null,
})).toThrow(/worker_model/i);
});
it('голова из настроек ложится в карточку работника — ею его и запустят', async () => {
// 🔴 Вторая половина того же решения: мало отказаться от зашитого слова, надо ещё
// донести настройку до места запуска. Карточка — то самое место: из неё надзиратель
// берёт голову и передаёт в `startWorker`.
// Впиши в карточку слово от себя вместо `workerModel` — покраснеет здесь.
const { readJson } = await import('./state.mjs');
const { runDir, workerDir } = await import('./paths.mjs');
const runId = `проба-головы-${randomUUID().slice(0, 8)}`;
const workers = [{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' }];
try {
startAll({
runId, workers, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
workerModel: 'голова-из-настроек',
createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
spawnSupervisorFn: () => ({ pid: 102 }),
});
expect(readJson(join(workerDir(runId, 'w-1'), 'worker.json')).model).toBe('голова-из-настроек');
} finally {
rmSync(runDir(runId), { recursive: true, force: true });
}
});
it('карточка заводит resumed_at и resumed_into пустыми — долг перед кусками 6 и 7 (позиция 10)', async () => {
// 🆕 Кусок 1 только ЗАВОДИТ поля; читает и пишет их при подъёме кусок 6. Без них
// второе нажатие «продолжить» не отличило бы уже поднятого работника от свежего.
const { readJson } = await import('./state.mjs');
const { runDir, workerDir } = await import('./paths.mjs');
const runId = `проба-resumed-полей-${randomUUID().slice(0, 8)}`;
const workers = [{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' }];
try {
startAll({
runId, workers, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
workerModel: 'голова-из-настроек',
createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
spawnSupervisorFn: () => ({ pid: 102 }),
});
const card = readJson(join(workerDir(runId, 'w-1'), 'worker.json'));
expect(card.resumed_at).toBeNull();
expect(card.resumed_into).toBeNull();
} finally {
rmSync(runDir(runId), { recursive: true, force: true });
}
});
it('карточка прогона со ВСЕМ составом написана ДО первого надзирателя', async () => {
// 🔴 Заведение угла идёт минутами. Напиши состав после цикла — первый же погасший
// работник увидел бы в каталоге одну карточку (свою), решил, что он последний,
// и закрыл бы весь прогон итогом из одного. Письмо ушло бы про одного из семи.
// Перенеси запись `run.json` обратно в конец — покраснеет здесь.
const { readJson } = await import('./state.mjs');
const { runDir } = await import('./paths.mjs');
const runId = `проба-порядка-${randomUUID().slice(0, 8)}`;
const seen = [];
// 🪤 У пунктов обязаны быть проверки: без них `buildWorkerPrompt` намеренно бросает
// («такой пункт закрыть нечем»), и проверка упала бы не на том, что мерит.
const workers = [
{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' },
{ worker_id: 'w-2', item: { id: '2', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-2', db_name: 'db2' },
];
try {
startAll({
runId, workers, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
workerModel: 'голова-из-настроек',
createWorkspaceFn: ({ workspaceRoot, dbName }) => ({ workspaceRoot, dbName, settingsPath: 'нет.json' }),
spawnSupervisorFn: ({ workerId }) => {
// Спрашиваем ровно в тот миг, когда поднимается надзиратель: объявлен ли состав?
seen.push({ workerId, run: readJson(join(runDir(runId), 'run.json')) });
return { pid: 100 };
},
});
expect(seen[0].run.workers).toEqual(['w-1', 'w-2']);
expect(seen[0].run.status).toBe('running');
} finally {
rmSync(runDir(runId), { recursive: true, force: true });
}
});
it('угол одного не завёлся — остальные заводятся, а провалившемуся написана карточка с причиной', async () => {
// 🪤 Прежде `startAll` бросал посреди цикла: прогон навсегда «идёт» (объявить себя
// последним некому), заведённые углы висят, а владелец не узнаёт ни слова о причине.
const { readJson } = await import('./state.mjs');
const { runDir, workerDir } = await import('./paths.mjs');
const runId = `проба-провала-${randomUUID().slice(0, 8)}`;
// 🪤 У пунктов обязаны быть проверки: без них `buildWorkerPrompt` намеренно бросает
// («такой пункт закрыть нечем»), и проверка упала бы не на том, что мерит.
const workers = [
{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-1', db_name: 'db1' },
{ worker_id: 'w-2', item: { id: '2', files: [], tests: ['t.mjs'] }, branch: 'night/x/w-2', db_name: 'db2' },
];
try {
const started = startAll({
runId, workers, planPath: 'план.md', baseBranch: 'feat/night-dispatcher-part1',
workerModel: 'голова-из-настроек',
createWorkspaceFn: ({ workerId, workspaceRoot, dbName }) => {
if (workerId === 'w-1') throw new Error('git worktree add: уже занято');
return { workspaceRoot, dbName, settingsPath: 'нет.json' };
},
spawnSupervisorFn: () => ({ pid: 101 }),
});
expect(started.map(s => s.worker_id)).toEqual(['w-2']);
const card = readJson(join(workerDir(runId, 'w-1'), 'worker.json'));
expect(card.status).toBe('extinguished');
expect(card.finish_reason).toMatch(/угол не завёлся/i);
// Состав прогона по-прежнему из двоих — иначе прогон некому будет закрыть.
expect(readJson(join(runDir(runId), 'run.json')).workers).toEqual(['w-1', 'w-2']);
} finally {
rmSync(runDir(runId), { recursive: true, force: true });
}
});
});
// ============ кусок 2: ворота перед прогоном ============
describe('planRun зовёт ворота ПЕРВОЙ строкой — иначе они правило без зовущего', () => {
it('запас дошёл до двух седьмых — прогона нет вовсе (проверка 16)', () => {
const r = planRun({
...okInput,
budget: { may_start: false, reason: 'Израсходовано 5/7 недельного запаса — прогоны не запускаются.', run_share_limit: 1 / 7 },
});
expect(r.ok).toBe(false);
expect(r.workers).toEqual([]);
expect(r.reason).toMatch(/запас/i);
});
it('замерить запас не удалось — прогона нет, и сказано ПОЧЕМУ (проверка 17)', () => {
// 🪤 Ноль почти всегда сбой, а не правда о мире. «Не знаем остаток» — это запрет,
// а не «наверное хватит»: решать о деньгах вслепую нельзя.
const r = planRun({
...okInput,
budget: { may_start: false, reason: 'Остаток недельного запаса замерить не удалось: события «rate_limit_event» вида seven_day от системы не приходило.', run_share_limit: null },
});
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/замерить не удалось/i);
expect(r.workers).toEqual([]);
});
it('в отказе назван ДЕНЬ, до которого прогоны не запускаются (проверка 23)', () => {
const r = planRun({
...okInput,
budget: { may_start: false, reason: 'Израсходовано 5/7 недельного запаса. Прогоны не запускаются до 2026-08-08 — в этот день запас обновится.', renews_on: '2026-08-08', run_share_limit: 1 / 7 },
});
expect(r.ok).toBe(false);
expect(r.reason).toContain('2026-08-08');
});
it('план правлен после печати — отказ (проверка 42)', () => {
const r = planRun({ ...okInput, planSeal: { ok: false, reason: 'Отпечаток плана не сошёлся: план правили после того, как его опечатали.' } });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/отпечаток/i);
expect(r.workers).toEqual([]);
});
it('замысел не проверен — отказ (проверка 44)', () => {
const r = planRun({ ...okInput, spec: { ok: false, reason: 'Замысел не проверен: рецензентов меньше двух.' } });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/замысел/i);
});
it('бумаги не сохранены в хранилище — отказ (проверка 45)', () => {
const r = planRun({ ...okInput, papers: { ok: false, reason: 'Не сохранены в хранилище: docs/superpowers/plans/пример.md' } });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/не сохранен/i);
});
it('на сервере нет места — отказ', () => {
const r = planRun({ ...okInput, disk: { ok: false, free_gb: 1.5, min_gb: 5, reason: 'На сервере кончается место: свободно 1.5 ГБ при пороге 5 ГБ.' } });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/место/i);
});
it('не прошли ДВОЕ ворот — названы оба, а не только первые', () => {
// 🔴 Владельцу нужен весь список: починив одно и упёршись во второе, он потерял бы ночь.
const r = planRun({
...okInput,
budget: { may_start: false, reason: 'Запас кончился.', run_share_limit: null },
papers: { ok: false, reason: 'Бумаги не сохранены.' },
});
expect(r.ok).toBe(false);
expect(r.failed_gates.length).toBeGreaterThanOrEqual(2);
const all = r.failed_gates.map(g => g.reason).join(' ');
expect(all).toMatch(/Запас кончился/);
expect(all).toMatch(/Бумаги не сохранены/);
});
it('у каждых непройденных ворот названо ИМЯ, а не «[object Object]»', () => {
// 🔴 Печать отказа устроена так: ` · ${g.gate}: ${g.reason}`. Смотри проверка один
// только `reason` — она зеленела бы и на выводе `[object Object]: запас: Запас кончился.`
// Ровно это и было в первой записи плана. Поэтому спрашиваем поле `gate` поимённо.
const r = planRun({
...okInput,
budget: { may_start: false, reason: 'Запас кончился.', run_share_limit: null },
papers: { ok: false, reason: 'Бумаги не сохранены.' },
});
const names = r.failed_gates.map(g => g.gate);
expect(names).toContain('запас');
expect(names).toContain('бумаги сохранены');
for (const n of names) {
expect(typeof n).toBe('string');
expect(n).not.toMatch(/object Object/);
}
// Имя ворот не задвоено: в `reason` его быть не должно — оно живёт в `gate`.
expect(r.failed_gates.find(g => g.gate === 'запас').reason).toBe('Запас кончился.');
});
it('все ворота пройдены — прогон расписан как прежде: ворота не глухие', () => {
// 🔴 Страховка. Ворота, которые не пропускают НИКОГДА, зеленят все семь проверок выше
// и убивают затею целиком.
const r = planRun(okInput);
expect(r.ok).toBe(true);
expect(r.workers.length).toBe(2);
});
});
// ============ кусок 3: занятие файлов за работниками ============
// 🔴 Ввозы этого куска дописаны в шапку файла (см. примечание там), а не здесь: вторых
// строк ввоза из тех же файлов не заводим.
// 🔴 Хвост неповторим У КАЖДОГО ЗАПУСКА ПРИБОРА: соседний прибор физически не может
// дотянуться до этого каталога, а уборка в `afterEach` сносит только его (см. шапку файла).
const RID = `r-zanyatiya-fajlov-${randomUUID().slice(0, 8)}`;
function dvoeRabotnikov(faylPervogo = 'app/A.php', faylVtorogo = 'app/B.php') {
return [
{ worker_id: 'w-1', item: { id: '1', files: [faylPervogo], tests: ['npm run test:tools'] }, branch: `night/${RID}/w-1`, base_branch: 'feat/night', db_name: 'db_1' },
{ worker_id: 'w-2', item: { id: '2', files: [faylVtorogo], tests: ['npm run test:tools'] }, branch: `night/${RID}/w-2`, base_branch: 'feat/night', db_name: 'db_2' },
];
}
// Подставной угол подаётся ЯВНО и только здесь: живой угол ставит зависимости минутами.
function ugolNaBumage({ workspaceRoot, dbName }) {
return { workspaceRoot, dbName, settingsPath: join(workspaceRoot, 'night-settings.json'), branch: null, creds: null };
}
function zavesti({ workers = dvoeRabotnikov(), createWorkspaceFn = ugolNaBumage, spawnSupervisorFn = () => ({ pid: 1 }) } = {}) {
return startAll({
runId: RID,
workers,
planPath: 'docs/superpowers/plans/пример.md',
baseBranch: 'feat/night',
workerModel: 'голова-из-настроек',
taskId: RID,
createWorkspaceFn,
spawnSupervisorFn,
});
}
describe('startAll занимает файлы пунктов за работниками (зовущий проверки 51)', () => {
afterEach(() => rmSync(runDir(RID), { recursive: true, force: true }));
it('файлы каждого пункта записаны за своим работником', () => {
zavesti();
const otkaz = mayTouch(runDir(RID), 'w-2', 'app/A.php');
expect(otkaz.ok).toBe(false);
expect(otkaz.clash_with).toBe('w-1');
expect(mayTouch(runDir(RID), 'w-1', 'app/A.php').ok).toBe(true);
});
it('занятия лежат в каталоге ПРОГОНА, а не в углу работника и не в общем корне', () => {
// Угол у каждого свой — в своём углу каждый занял бы всё. А в общем корне занятия
// пережили бы ночь, и назавтра ночь началась бы с чужих замков.
zavesti();
expect(existsSync(join(runDir(RID), 'file-claims.json'))).toBe(true);
});
it('файлы заняты ДО подъёма надзирателя, а не после', () => {
// 🔴 Опоздай занятие на минуту — двое заняли бы один файл: углы заводятся минутами,
// и первый надзиратель уже живёт, пока строится угол седьмого.
let vidnoVMigPodyoma = null;
zavesti({
spawnSupervisorFn: () => {
vidnoVMigPodyoma = mayTouch(runDir(RID), 'w-9', 'app/B.php').clash_with;
return { pid: 1 };
},
});
expect(vidnoVMigPodyoma).toBe('w-2');
});
it('два пункта на один файл — второй работник не заводится, первый работает', () => {
const started = zavesti({ workers: dvoeRabotnikov('app/A.php', 'app/A.php') });
expect(started.map(s => s.worker_id)).toEqual(['w-1']);
const karta = readJson(join(workerDir(RID, 'w-2'), 'worker.json'));
expect(karta.status).toBe('extinguished');
expect(karta.finish_reason).toContain('app/A.php');
const stroki = readLines(join(runDir(RID), 'summary-lines.jsonl')).map(r => r.line).join(' ');
expect(stroki).toContain('w-2');
});
it('🔴 не начавший работник ВЫЧЕРКНУТ из состава прогона', () => {
// 🔴 Состав из `run.json` поля `workers` — это тот самый список, по которому задача 7
// решает, все ли исполнили общую команду «стоп всё». Оставь мы здесь работника,
// которого не заводили вовсе, — отметку «исполнил» ставить некому, общий файл команды
// не уберётся НИКОГДА, и следующая ночь начнётся с чужого «стоп всё». То же и с решением
// «прогон окончен, когда погас последний».
zavesti({ workers: dvoeRabotnikov('app/A.php', 'app/A.php') });
const karta = readJson(join(runDir(RID), 'run.json'));
expect(karta.workers).toEqual(['w-1']);
});
it('🔴 СВЕРХ ПЛАНА: пункт БЕЗ файлов — работник заводится, и владельцу не врут про два пункта на один файл', () => {
// 🔴 Сторож на КЛАСС, а не на место: «замок принял „занимать нечего" за „занято чужим"».
// `claimFiles` отказывает двумя РАЗНЫМИ отказами, и свалить их в один — значит погасить
// работника с причиной, которой не было. План куска 3 ровно это и предлагал; три
// проверки куска 1 покраснели и оказались правы. Сведи эти два отказа обратно в один —
// покраснеет здесь, а не через месяц в живой ночи.
const bezFajlov = [
{ worker_id: 'w-1', item: { id: '1', files: [], tests: ['npm run test:tools'] }, branch: `night/${RID}/w-1`, base_branch: 'feat/night', db_name: 'db_1' },
];
const started = zavesti({ workers: bezFajlov });
expect(started.map(s => s.worker_id)).toEqual(['w-1']);
expect(readJson(join(runDir(RID), 'run.json')).workers).toEqual(['w-1']);
const stroki = readLines(join(runDir(RID), 'summary-lines.jsonl')).map(r => r.line).join(' ');
// Тихо пропустить такой пункт тоже нельзя: владелец обязан узнать, что этот работник
// ничем не заперт. Но сказано ему ПРАВДОЙ, а не выдуманным пересечением.
expect(stroki).toContain('не назван ни один файл');
expect(stroki).not.toContain('один и тот же файл');
});
it('угол не завёлся — занятые за ним файлы освобождены', () => {
// Иначе замок держал бы файл всю ночь за работника, которого нет вовсе, и никто
// другой не смог бы взять этот пункт.
zavesti({
createWorkspaceFn: ({ workspaceRoot, dbName, workerId }) => {
if (workerId === 'w-2') throw new Error('composer в углу не собрался');
return ugolNaBumage({ workspaceRoot, dbName });
},
});
expect(mayTouch(runDir(RID), 'w-9', 'app/B.php').ok).toBe(true);
// А файл живого работника остался занятым.
expect(mayTouch(runDir(RID), 'w-9', 'app/A.php').ok).toBe(false);
});
});
// ============ беда 3: прогон без единого работника держал место навсегда ============
//
// 🔴 Найдено воротами ЖИВЬЁМ: «Прогон заведён. Работников: 0» — и запись «идёт», навсегда
// занимающая одно из двух мест в потолке задач. Признак «идёт» ставится ДО того, как
// заведутся углы, а признак «кончился» ставит надзиратель ПОСЛЕДНЕГО погасшего работника
// (`amILast` отвечает «нет», пока карточек ноль). Работников ноль — ставить некому.
// Два таких призрака запирают всё хозяйство наглухо, и чинится это только руками.
// 🔴 Хвост неповторим у каждого запуска прибора — по той же причине, что и у `RID` выше.
const RID_PUSTO = `r-progon-bez-rabotnikov-${randomUUID().slice(0, 8)}`;
describe('прогон, в котором не завёлся НИ ОДИН работник, не остаётся «идущим» навсегда', () => {
let vremennyj;
afterEach(() => {
rmSync(runDir(RID_PUSTO), { recursive: true, force: true });
// 🔴🔴 УБОРКА СЛЕДА ОТЛОЖЕННОЙ ЗАДАЧИ — ДОПИСАНО КУСКОМ 4, беда была настоящая.
// Замерено: `writeRunOutcome` на пустом прогоне кладёт след отложенной задачи
// в ОБЩИЙ боевой `.night/deferred/`, а уборка сносила только каталог прогона. Следы
// копились от каждого запуска прибора (`r-progon-bez-rabotnikov-…json`, четыре штуки
// за вечер) и роняли ЧУЖУЮ проверку — сторож соседей «проверки не оставили в боевом
// каталоге следов». Поодиночке оба файла зелены, вместе — красный, и идут чинить
// исправное. Ровно тот класс, из-за которого заведён неповторимый хвост у номера прогона.
rmSync(join(deferredDir(), `${RID_PUSTO}.json`), { force: true });
if (vremennyj) rmSync(vremennyj, { recursive: true, force: true });
vremennyj = null;
});
// 🔴 Итог прогона пишет ОДНО место — `writeRunOutcome` надзирателя. Здесь подставляется
// ОНО ЖЕ, только `last-run.json` уводится во временный каталог: настоящий лежит в общем
// `.night/`, и проверка не имеет права его трогать. Второго способа закрыть прогон
// не заводим — иначе два правила на одно дело разъедутся молча.
function zavestiPusto({ workers, spawnSupervisorFn = () => ({ pid: 7 }) } = {}) {
vremennyj = mkdtempSync(join(tmpdir(), 'night-pusto-'));
const vidno = [];
const started = startAll({
runId: RID_PUSTO,
workers,
planPath: 'docs/superpowers/plans/пример.md',
baseBranch: 'feat/night',
workerModel: 'голова-из-настроек',
taskId: RID_PUSTO,
createWorkspaceFn: ({ workerId, workspaceRoot, dbName }) => {
if (workerId === 'w-9') return { workspaceRoot, dbName, settingsPath: 'нет.json' };
throw new Error('git worktree add: уже занято');
},
spawnSupervisorFn,
writeRunOutcomeFn: args => {
vidno.push(args);
return writeRunOutcome({ ...args, lastRunFile: join(vremennyj, 'last-run.json') });
},
});
return { started, vidno };
}
const dvoe = [
{ worker_id: 'w-1', item: { id: '1', files: ['app/P.php'], tests: ['t.mjs'] }, branch: `night/${RID_PUSTO}/w-1`, db_name: 'db_1' },
{ worker_id: 'w-2', item: { id: '2', files: ['app/Q.php'], tests: ['t.mjs'] }, branch: `night/${RID_PUSTO}/w-2`, db_name: 'db_2' },
];
it('🔴 не завёлся никто — прогон объявлен КОНЧИВШИМСЯ, а не «идёт»', () => {
const { started } = zavestiPusto({ workers: dvoe });
expect(started).toEqual([]);
const karta = readJson(join(runDir(RID_PUSTO), 'run.json'));
// Именно это поле читают ворота потолков: «идёт» здесь означает занятое место.
expect(karta.status).not.toBe('running');
expect(karta.status).toBe('finished');
expect(karta.finished_at).toBeTruthy();
});
it('🔴 закрывает прогон ТО ЖЕ место, что закрывает его после последнего погасшего', () => {
// Заведи мы здесь второе правило «прогон кончился» — оно разъехалось бы с первым молча.
const { vidno } = zavestiPusto({ workers: dvoe });
expect(vidno.length).toBe(1);
expect(vidno[0].runId).toBe(RID_PUSTO);
expect(vidno[0].runDirPath).toBe(runDir(RID_PUSTO));
});
it('владельцу сказано строкой сводки, что прогон кончился, не начавшись', () => {
zavestiPusto({ workers: dvoe });
const stroki = readLines(join(runDir(RID_PUSTO), 'summary-lines.jsonl')).map(r => r.line).join(' ');
expect(stroki).toMatch(/ни один работник|ни одного работника/i);
});
it('🔴 завёлся хоть один — прогон по-прежнему «идёт» (правило не глухое)', () => {
// Страховка. Закрывай мы прогон всегда — надзиратели живых работников нашли бы прогон
// уже кончившимся, и ночь умерла бы целиком при зелёных проверках выше.
const smeshanno = [
...dvoe,
{ worker_id: 'w-9', item: { id: '9', files: ['app/R.php'], tests: ['t.mjs'] }, branch: `night/${RID_PUSTO}/w-9`, db_name: 'db_9' },
];
const { started, vidno } = zavestiPusto({ workers: smeshanno });
expect(started.map(s => s.worker_id)).toEqual(['w-9']);
expect(readJson(join(runDir(RID_PUSTO), 'run.json')).status).toBe('running');
expect(vidno).toEqual([]);
});
});
// ============ кусок 4: пункты плана доходят до прогона и до карточек ============
//
// 🔴 ПРО ИМЕНА ЧЕСТНО, И ЭТО ОТСТУПЛЕНИЕ ОТ ПЛАНА ЗАДАЧИ 6, НАЗВАННОЕ ВСЛУХ.
// План писал эти проверки прозвищами (`ubratSled`, `prochest`, `katalogProgona`,
// `katalogRabotnika`, `mozhnoLi`) и уверял, что они «уже ввезены». Их здесь НЕТ и не было:
// кусок 3 прозвища сознательно отверг (см. шапку файла) и ввёз приборы своими именами.
// Пиши я по плану — файл не разобрался бы вовсе. Поэтому ниже настоящие имена:
// `rmSync`, `readJson`, `runDir`, `workerDir`, `mayTouch`. Вторых ввозов не заведено ни одного.
//
// 🔴 Хвост неповторим у каждого запуска прибора — по той же причине, что у `RID` выше:
// соседний прибор проверок физически не должен дотянуться до этого каталога.
const RID4 = `r-punkty-plana-${randomUUID().slice(0, 8)}`;
const PUNKT_PHP = { id: '1', title: 'Первый', tests: ['app/tests/Feature/PervyyTest.php'], files: ['app/A.php'], long: false };
const PUNKT_MJS = { id: '2', title: 'Второй', tests: ['tools/night/vtoroy.test.mjs'], files: ['app/B.php'], long: false };
const PUNKT_BEZ_PROVEROK = { id: '3', title: 'Третий', tests: [], files: ['app/C.php'], long: false };
function rabotnik4(item, n) {
return {
worker_id: `w-${n}`, item,
branch: `night/${RID4}/w-${n}`, base_branch: 'feat/night', db_name: `db_${n}`,
};
}
function zavesti4({ workers, items = null, runId = RID4 } = {}) {
return startAll({
runId,
workers,
items,
planPath: 'docs/superpowers/plans/пример.md',
baseBranch: 'feat/night',
workerModel: 'голова-из-настроек',
taskId: runId,
testCommandFor: w => `проверки-пункта-${w.item?.id}`,
createWorkspaceFn: ugolNaBumage,
spawnSupervisorFn: () => ({ pid: 1 }),
});
}
describe('пункты плана доходят до карточки прогона и до карточек работников', () => {
afterEach(() => rmSync(runDir(RID4), { recursive: true, force: true }));
it('🔴 в карточке прогона лежат ВСЕ пункты плана, а не только пункты заведённых работников', () => {
// 🔴🔴 Находка куска 4. Поле `items` в `run.json` — это ОЧЕРЕДЬ: из неё блок 5а берёт
// работнику следующий пункт. Клади туда только пункты семерых — пункты с восьмого
// не достались бы никому, и ночь кончилась бы на середине плана при зелёных проверках.
const vesPlan = [PUNKT_PHP, PUNKT_MJS, { id: '8', title: 'Восьмой', tests: ['t.test.mjs'], files: ['app/H.php'], long: false }];
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1), rabotnik4(PUNKT_MJS, 2)], items: vesPlan });
const karta = readJson(join(runDir(RID4), 'run.json'));
expect(karta.items.map(i => i.id)).toEqual(['1', '2', '8']);
});
it('пункт в карточке прогона несёт и заголовок, и проверки, и пометку «длинный»', () => {
// Без `tests` закрывать пункт нечем (проверка 39), без `long` — план не проверить (41).
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1)], items: [{ ...PUNKT_PHP, long: true }] });
const punkt = readJson(join(runDir(RID4), 'run.json')).items[0];
expect(punkt.title).toBe('Первый');
expect(punkt.tests).toEqual(['app/tests/Feature/PervyyTest.php']);
expect(punkt.files).toEqual(['app/A.php']);
expect(punkt.long).toBe(true);
});
it('пунктов плана не дали — в карточке прогона пункты работников, как прежде', () => {
// Умолчание честное: выдуманных пунктов здесь нет ни одного.
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1)] });
expect(readJson(join(runDir(RID4), 'run.json')).items.map(i => i.id)).toEqual(['1']);
});
it('🔴 в карточку работника лёг `test_command` — иначе замку нечем мерить', () => {
// 🔴🔴 Шов с задачей 5. Надзиратель берёт команду проверок ИЗ КАРТОЧКИ и подаёт замку.
// Не положи её здесь — замок каждую заявку отвечал бы «замерить не удалось», ни один
// пункт не засчитался бы ни одной ночью, а все проверки остались бы зелёными.
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1)] });
const karta = readJson(join(workerDir(RID4, 'w-1'), 'worker.json'));
expect(karta.test_command).toBe('проверки-пункта-1');
});
it('🔴 пункт без единой проверки — работник НЕ заводится и вычеркнут из состава (проверка 39)', () => {
// 🔴 Правило куска 1 на таком пункте бросает ошибку прямо в поручении — и убило бы
// ВЕСЬ прогон вместе с исправными работниками. Поэтому такой пункт отсеивается здесь,
// а владельцу про него говорят строкой.
const started = zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1), rabotnik4(PUNKT_BEZ_PROVEROK, 3)] });
expect(started.map(s => s.worker_id)).toEqual(['w-1']);
const karta = readJson(join(workerDir(RID4, 'w-3'), 'worker.json'));
expect(karta.status).toBe('extinguished');
expect(karta.finish_reason).toMatch(/ни одной проверки/i);
expect(readJson(join(runDir(RID4), 'run.json')).workers).toEqual(['w-1']);
});
it('пункт без проверок никого больше не задел — исправный работник заведён и файлы за ним', () => {
// 🔴 Страховка от отсева, который валит всех подряд: он зеленил бы проверку выше
// и убивал бы каждую ночь целиком.
zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1), rabotnik4(PUNKT_BEZ_PROVEROK, 3)] });
expect(mayTouch(runDir(RID4), 'w-9', 'app/A.php').ok).toBe(false);
// А файлы отсеянного никем не заняты — иначе его пункт не смог бы взять никто.
expect(mayTouch(runDir(RID4), 'w-9', 'app/C.php').ok).toBe(true);
});
it('🔴 вернувшуюся задачу делает НОВЫЙ работник — свой угол и своя сессия (проверка 29г)', () => {
// 🔴🔴 Дословное требование проверки 29: задача, не прошедшая приёмку, возвращается
// и делает её НОВЫЙ работник. Прежний погас вместе с концом задачи, и поднимать его
// тем же углом нельзя: в углу лежит ровно та работа, которую владелец забраковал.
// Сверяем номера и углы ДО и ПОСЛЕ — одинаковыми они быть не должны.
const pervyy = zavesti4({ workers: [rabotnik4(PUNKT_PHP, 1)] });
const RID5 = `r-vozvrat-zadachi-${randomUUID().slice(0, 8)}`;
try {
const vtoroy = zavesti4({
runId: RID5,
workers: [{ ...rabotnik4(PUNKT_PHP, 1), branch: `night/${RID5}/w-1` }],
items: [PUNKT_PHP],
});
expect(vtoroy[0].workspace).not.toBe(pervyy[0].workspace);
expect(vtoroy[0].session_id).not.toBe(pervyy[0].session_id);
expect(vtoroy[0].branch).not.toBe(pervyy[0].branch);
} finally {
rmSync(runDir(RID5), { recursive: true, force: true });
}
});
});