Files
portal/tools/night/workspace.mjs
T

844 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Рабочий угол работника. Замок — отсутствие возможности, а не программа:
// боевых ключей в углу просто нет, имя своей базы подставлено сразу.
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { workerSettingsPath } from './paths.mjs';
import { execFileSync } from 'node:child_process';
import { readdirSync, symlinkSync, linkSync, copyFileSync } from 'node:fs';
import { resolveBin, needsShell, workerInboxDir, runsDir } from './paths.mjs';
import { readJson, writeJsonAtomic } from './state.mjs';
import { FINAL_STATUSES } from './supervisor.mjs';
const LIVE_MARKERS = [
/mdb\.yandexcloud\.net/i,
/ANTHROPIC_API_KEY\s*=\s*\S/,
/YOOKASSA_SECRET_KEY\s*=\s*\S/,
/DADATA_TOKEN\s*=\s*\S/,
/SMSC_PASSWORD\s*=\s*\S/,
/MAIL_PASSWORD\s*=\s*\S/,
/"pass"\s*:\s*"[^"]+"/, // пароль почтового ящика из night.config.json
];
export function workerDbName(runId, workerId) {
const safe = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '_');
return `night_${safe(runId)}_${safe(workerId)}`;
}
// Доступы к местной базе БЕРУТСЯ ИЗ РАБОЧИХ НАСТРОЕК, а не выдумываются: проверено живьём —
// вход под postgres/postgres местная база отвергает. Имя базы отсюда НЕ берётся: у работника своя.
export function dbCredsFromAppEnv(repoRoot) {
const path = join(repoRoot, 'app', '.env');
if (!existsSync(path)) {
throw new Error(`Нет рабочих настроек ${path} — доступы к местной базе взять негде, а выдумывать их нельзя: база отвергнет вход посреди ночи.`);
}
const text = readFileSync(path, 'utf8');
const pick = key => {
const m = new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, 'm').exec(text);
if (!m) return null;
return m[1].trim().replace(/^["']|["']$/g, '');
};
const username = pick('DB_USERNAME');
if (!username) {
throw new Error(`В ${path} нет DB_USERNAME — доступов к базе нет. Выдуманных доступов у затеи не бывает.`);
}
return {
host: pick('DB_HOST') ?? '127.0.0.1',
port: pick('DB_PORT') ?? '5432',
username,
password: pick('DB_PASSWORD') ?? '',
};
}
export function workerEnvFile({ dbName, creds, appUrl = 'http://127.0.0.1:8000' }) {
if (!creds?.username) {
throw new Error('Не даны доступы к местной базе (из app/.env) — угол не заводится: выдуманные доступы дают отказ базы посреди ночи.');
}
// Ровно то, что нужно для проверок, и ни одного боевого ключа.
//
// 🔴 Пустая строка `APP_KEY=` тут ОБЯЗАТЕЛЬНА, и вот почему. Ключ заводит
// `php artisan key:generate` в самом углу (задача 7), но эта команда не ДОПИСЫВАЕТ строку,
// а ЗАМЕНЯЕТ уже существующую. Нет строки — заменять нечего: ключ не запишется, команда
// напечатает ошибку и вернёт при этом НУЛЕВОЙ код (проверено по живому коду Laravel
// в этом хранилище, `KeyGenerateCommand.php`: `return;` без кода возврата). Заведение угла
// сочло бы это успехом, а работник всю ночь бился бы в «No application encryption key
// has been specified» — ровно та беда, от которой шаг и поставлен.
return [
'APP_ENV=testing',
'APP_DEBUG=true',
'APP_KEY=',
`APP_URL=${appUrl}`,
'DB_CONNECTION=pgsql',
`DB_HOST=${creds.host}`,
`DB_PORT=${creds.port}`,
`DB_DATABASE=${dbName}`,
`DB_USERNAME=${creds.username}`,
`DB_PASSWORD=${creds.password}`,
'CACHE_STORE=array',
'QUEUE_CONNECTION=sync',
'SESSION_DRIVER=array',
'MAIL_MAILER=array',
'',
].join('\n');
}
// Угол ветвится от ВЕТКИ ЗАТЕИ. Точка ветвления передаётся явно: без неё git берёт текущую
// ветку хозяйства, а это может быть что угодно, вплоть до главной, где затеи нет вовсе.
export function worktreeArgs({ branch, workspaceRoot, baseBranch }) {
if (!baseBranch) {
throw new Error('Не названа точка ветвления рабочего угла (base_branch). От главной ветки в углу нет ничего из затеи — ни модулей, ни поручения.');
}
return ['worktree', 'add', '-b', branch, workspaceRoot, baseBranch];
}
// Пароль базы уходит psql переменной окружения. Ключа «пароль» в командной строке у psql нет,
// а без пароля он останавливается и спрашивает его у процесса, у которого нет клавиатуры.
export function psqlEnv(creds, from = process.env) {
return { ...from, PGPASSWORD: creds?.password ?? '' };
}
export function assertNoLiveSecrets(workspaceRoot) {
// 🪤 Смотреть только в тот файл, который мы сами и написали, — значит всегда отвечать
// «годен». Поэтому мест несколько: боевые настройки продукта, настройки самой затеи
// с паролем почты и всё, что могло приехать в угол вместе с веткой.
const suspects = [
join(workspaceRoot, 'app', '.env'),
join(workspaceRoot, 'app', '.env.production'),
join(workspaceRoot, '.env'),
join(workspaceRoot, '.env.production'),
join(workspaceRoot, 'night.config.json'),
];
for (const path of suspects) {
if (!existsSync(path)) continue;
const text = readFileSync(path, 'utf8');
for (const re of LIVE_MARKERS) {
if (re.test(text)) {
return {
ok: false,
reason: `В рабочем углу нашлись боевые доступы (${path}, признак ${re}). Работник не стартует: замок держится на том, что их там нет.`,
};
}
}
}
return { ok: true, reason: null };
}
// 🔴 Сторож ключа приложения. `php artisan key:generate` ЗАМЕНЯЕТ строку `APP_KEY=`,
// а не дописывает её, и при неудаче печатает ошибку с НУЛЕВЫМ кодом возврата — то есть
// заведение угла сочло бы провал успехом, а работник всю ночь бился бы в «No application
// encryption key has been specified». Поэтому после `key:generate` файл читается глазами
// программы: ключ либо есть непустой, либо угла нет.
export function assertAppKey(workspaceRoot) {
const path = join(workspaceRoot, 'app', '.env');
const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
const m = /^\s*APP_KEY\s*=\s*(\S+)\s*$/m.exec(text);
if (!m) {
throw new Error(`В ${path} нет непустого APP_KEY после key:generate. Команда печатает успех и при неудаче — верить ей нельзя. Работник в таком углу всю ночь бился бы в «No application encryption key has been specified».`);
}
return m[1];
}
export function buildWorkerSettings() {
// Настройки запуска работника. На хуки НЕ опираемся: проверено живьём —
// в отдельном углу они не запускаются вовсе. Здесь запрет живёт в самом запуске.
//
// 🪤 И одних запретов МАЛО. Работник запускается безголово: согласия человека
// спросить не у кого. Без явного разрешающего списка `git commit` и прогон проверок
// упёрлись бы в отказ, и ни один пункт не закрылся бы за все восемь часов —
// при зелёных проверках на «запреты держат». Поэтому разрешено ровно то, что нужно
// для закрытия пункта: правка файлов, свой коммит, свои проверки. Запрет сильнее
// разрешения, поэтому push, слияние и «наружу» остаются закрытыми.
return {
permissions: {
defaultMode: 'acceptEdits',
allow: [
// 🔴 Правка, запись и чтение — ТОЛЬКО внутри угла. Голые `Read`/`Write`/`Edit`
// без границы по пути — это право записать любой файл на машине: и главный рабочий
// каталог, откуда идёт выкат, и `.git` главной ветки, и настройки помощника,
// и ключи в домашнем каталоге. Образцы считаются от каталога, где лежит сам этот
// файл настроек, а он лежит в корне угла — значит `./**` это ровно угол и ничего сверх.
'Read(./**)',
'Write(./**)',
'Edit(./**)',
'Glob',
'Grep',
'TodoWrite',
'Bash(git add:*)',
'Bash(git commit:*)',
'Bash(git status:*)',
'Bash(git diff:*)',
'Bash(git log:*)',
'Bash(git rev-parse:*)',
'Bash(git branch --show-current)',
'Bash(npm run test:tools:*)',
'Bash(npm run lint:vue:*)',
'Bash(npm run type-check:*)',
'Bash(npm run test:vue:*)',
'Bash(composer test:*)',
'Bash(composer pint:*)',
'Bash(composer stan:*)',
'Bash(php artisan test:*)',
'Bash(php artisan test --testsuite=Browser:*)',
'Bash(php artisan migrate:*)',
'Bash(npx vitest:*)',
// Браузерные проверки и сборка фронта: без них не закрыть ни один пункт про то,
// что видит клиент. Playwright и Pa11y — уже стоящие в хозяйстве инструменты.
'Bash(npx playwright test:*)',
'Bash(npm run a11y:*)',
'Bash(npm run build:*)',
// 🔴 Выдержать паузу. Без этого права работнику физически нечем ждать: его дело
// в пробном долгом прогоне — раз в десять минут дописать строку следа, а на этих
// отметках держится ВСЁ доказательство проверки 1 (жив через три часа). Не дать
// права — он упрётся в отказ на первой же паузе, замолчит, и надзиратель назовёт
// его вставшим. Проверка 1 провалилась бы не потому, что затея не работает,
// а потому, что работнику нечем было переждать десять минут.
'Bash(sleep:*)',
],
deny: [
'Bash(git push:*)',
'Bash(git merge:*)',
'Bash(git checkout main:*)',
'Bash(git checkout master:*)',
'Bash(git branch -f:*)',
'Bash(git remote:*)',
// 🪤 Запрет ловит НАЧАЛО строки команды. Значит одна и та же работа, написанная
// другой формой, проходит мимо запрета не таясь: `git -C <чужой каталог> push`,
// `git -c ... push`, `git switch main`, `git worktree add ... main`,
// `git update-ref refs/heads/main`, `git config remote.origin.url`. Все эти формы
// закрыты поимённо — иначе список запретов держит только честного.
'Bash(git -C:*)',
'Bash(git -c:*)',
'Bash(git --git-dir:*)',
'Bash(git --work-tree:*)',
'Bash(git switch:*)',
'Bash(git worktree:*)',
'Bash(git update-ref:*)',
'Bash(git config:*)',
'Bash(git reset --hard:*)',
'Bash(git clean:*)',
'Bash(gh :*)',
'Bash(scp:*)',
'Bash(rsync:*)',
'Bash(ssh:*)',
'Bash(curl:*)',
'Bash(wget:*)',
// Наружу из угла — ни читать, ни писать. Угол — это git worktree, он делит
// один и тот же `.git` с хозяйством, поэтому «выйти вверх» тут не отвлечённая
// опасность, а один шаг `..`.
'Read(../**)',
'Write(../**)',
'Edit(../**)',
'Read(~/**)',
'Write(~/**)',
'Edit(~/**)',
'WebFetch',
'WebSearch',
],
},
env: { NIGHT_WORKER: '1' },
};
}
// 🔴 Замки, которые никто не положил в угол, — это обещание на бумаге и ничего больше.
// Здесь они кладутся файлом, и сразу закрывается вторая дыра: рабочий угол — это worktree
// того же хранилища, и в нём лежит ОТСЛЕЖИВАЕМЫЙ хранилищем `.claude/settings.json`
// со своими разрешениями и хуками. Чей список победит в углу — вопрос, на который мы
// отвечать не хотим вовсе: поэтому проектный список угла перезаписывается ТЕМИ ЖЕ замками,
// а личный (`settings.local.json`) убирается. Тогда ответ один при любом порядке чтения.
export function writeWorkerSettings(workspaceRoot) {
const settings = buildWorkerSettings();
const text = JSON.stringify(settings, null, 2);
const own = workerSettingsPath(workspaceRoot);
writeFileSync(own, text, 'utf8');
const claudeDir = join(workspaceRoot, '.claude');
mkdirSync(claudeDir, { recursive: true });
writeFileSync(join(claudeDir, 'settings.json'), text, 'utf8');
rmSync(join(claudeDir, 'settings.local.json'), { force: true });
return { settingsPath: own, projectSettingsPath: join(claudeDir, 'settings.json') };
}
// 🪤 Запуск внешней программы в одном месте, а не в шести. `composer` и `npm` на этой
// машине — `.bat`/`.cmd`, а Node с 18.20/20.12 отказывается запускать такие файлы
// без оболочки (CVE-2024-27980, ответ EINVAL). Забудь оболочку в одном из шести вызовов —
// и угол не заведётся, а узнается это ночью.
function runTool(bin, args, opts = {}) {
return execFileSync(bin, args, { stdio: 'pipe', shell: needsShell(bin), ...opts });
}
// 🆕 ДОЛГ 13 (найдено живьём 01.08.2026, врезка в шапке задачи 7). Пре-коммитный сторож
// секретов зовётся строкой `./bin/gitleaks.exe` — путём от ТЕКУЩЕГО каталога, а сам бинарник
// (22 МБ) лежит только в главном хозяйстве и в хранилище не хранится. Без этой функции угол
// заводится «успешно», а первый же коммит работника в нём падает целиком с «нет такого файла» —
// ровно тот класс беды, который сама затея обязана ловить, а не создавать.
export function installSecretsGuard({ repoRoot, workspaceRoot, run = runTool }) {
const guardName = process.platform === 'win32' ? 'gitleaks.exe' : 'gitleaks';
const srcDir = join(repoRoot, 'bin');
const destDir = join(workspaceRoot, 'bin');
// Сторожа нет даже в главном хозяйстве — врать про «угол готов» нельзя, останавливаемся
// здесь, а не после долгой установки composer/npm.
if (!existsSync(join(srcDir, guardName))) {
throw new Error(
`Сторож секретов не найден в главном хозяйстве (${join(srcDir, guardName)}). Угол не заводим: `
+ 'без него коммит работника в углу будет падать целиком, и работа за ночь не сохранится.',
);
}
// Стрелка (junction), не копия: это ГОТОВАЯ ПРОГРАММА, а не код проекта. Запрет на ссылку
// для vendor/node_modules (абзац в шапке задачи) — про код, который Laravel/Node САМИ
// резолвят своим резолвером путей и с которым потом сверяют поведение; ссылка подсовывает
// им чужой код при зелёном результате. gitleaks.exe никто так не резолвит — программу здесь
// просто исполняют по полному пути, подменять там нечего. Копировать 22 МБ семи работникам
// каждую ночь — дорого и незачем; стрелка бесплатна.
//
// 🔴🔴 ПОПРАВЛЕНО ПО ЖИВОМУ ОБРЫВУ 02.08.2026. Прежде здесь стояло только «нет каталога —
// поставь стрелку», и заведение угла падало ВСЕГДА, у каждого работника, каждую ночь.
// Причина: каталог `bin` в углу есть ВСЕГДА — хранилище само хранит `bin/.gitkeep`
// и `bin/zap-active-scan.ps1`, и они приезжают в угол вместе с веткой. Стрелка не ставилась
// никогда, сторожа в углу не было никогда. Замерено настоящим пробным углом, а не чтением.
//
// 🪤 Каталог сносить и заменять стрелкой НЕЛЬЗЯ: в нём лежат отслеживаемые хранилищем
// файлы работника. Поэтому в готовый каталог кладётся ОДИН файл сторожа — жёсткой
// ссылкой: места она не занимает так же, как стрелка, и 22 МБ никто не копирует.
const guardPath = join(destDir, guardName);
if (!existsSync(destDir)) {
symlinkSync(srcDir, destDir, 'junction');
} else if (!existsSync(guardPath)) {
// 🔴 Запасной путь — настоящая копия: жёсткая ссылка живёт только в пределах одного
// тома, а угол может лежать и на другом диске. Молча остаться без сторожа тут нельзя.
try {
linkSync(join(srcDir, guardName), guardPath);
} catch {
copyFileSync(join(srcDir, guardName), guardPath);
}
}
if (!existsSync(guardPath)) {
throw new Error(`Сторож секретов не попал в угол: ${guardPath} не виден после стрелки на ${srcDir}.`);
}
// Мало того, что файл лежит на месте, — он обязан ЗАПУСКАТЬСЯ. Не проверить это здесь —
// и получится ровно та беда, которую вся затея ловит: угол «завёлся», а сторож в нём мёртв.
try {
run(guardPath, ['version'], { cwd: workspaceRoot });
} catch (err) {
throw new Error(
`Сторож секретов в угле не запускается (${guardPath}): ${err?.message ?? err}. `
+ 'Угол не заводим — доверять коммитам в нём нельзя.',
);
}
return { guardPath };
}
// 🔴 Проверка 46, часть «базы не досталось — работник не стартовал вовсе».
// Имя общей базы проверок названо здесь словом намеренно: это единственное место затеи,
// где оно вообще упоминается, и упоминается оно затем, чтобы ЗАПРЕТИТЬ его.
const OBSHCHAYA_BAZA_PROVEROK = 'liderra_testing';
function assertOwnDbName(name) {
const s = String(name ?? '').trim();
if (!s) {
throw new Error('Работнику не досталось своей базы, поэтому угол не заводится и работник не стартует. Общая база на семерых даёт ЗЕЛЁНЫЕ ВРУЩИЕ проверки — это хуже, чем не запуститься вовсе.');
}
if (/(^|_)(undefined|null)(_|$)/.test(s)) {
throw new Error(`Имя своей базы вышло «${s}» — значит номер прогона или номер работника не передали. Такое имя одинаково у всех, кто ошибся так же, и это та же общая база, только с правдоподобным названием.`);
}
if (s === OBSHCHAYA_BAZA_PROVEROK) {
throw new Error(`Работнику подсунули общую базу проверок «${s}». В неё пишут все проверки хозяйства сразу — семеро затрут друг другу таблицы, а проверки при этом останутся зелёными.`);
}
if (!/^[a-z0-9_]+$/.test(s)) {
throw new Error(`В имени базы «${s}» есть посторонние знаки. В имени базы допустимы только строчные буквы, цифры и подчёркивание — иначе база откажет уже посреди ночи, когда чинить будет некому.`);
}
return s;
}
// 🆕 БЕДА 7 (найдена ЖИВЫМ ОБРЫВОМ 02.08.2026, не чтением). Уборка за упавшей на полпути
// установкой угла. Убирает ТОЛЬКО то, что заведено своей рукой в этом самом вызове, —
// метки `nashe` ставятся сразу после удачи каждого заводящего шага и ни секундой раньше.
//
// 🪤 Порядок здесь не украшение, и переставлять его нельзя:
// 1) база — её снос ни от чего не зависит;
// 2) каталог угла — пока он выгружен на ветку, git ветку удалить НЕ ДАСТ;
// 3) ветка — последней.
//
// 🔴 Ни один сбой уборки не имеет права заслонить ПРИЧИНУ падения: здесь ничего не бросается
// вовсе, возвращается список того, что осталось лежать, а собирает слова сам `createWorkspace`.
// 🔴🔴 НЕНАДЁЖНЫЙ ШАГ (замерено живьём 03.08.2026). `composer install` в свежем углу упал
// ОДИН РАЗ ИЗ ТРЁХ на одном и том же коде и одних и тех же бумагах:
//
// In ClassMapGenerator.php line 169:
// realpath of …/pest-plugin-mutate/src\Subscribers\TrackMutationSuiteFinish.php
// failed to resolve, got false
//
// 🔴 ПРИЧИНА НЕ НАЙДЕНА, И Я ЕЁ НЕ ВЫДУМЫВАЮ. Стройное объяснение через предел длины пути
// Windows я построил и сам же опроверг прямым опытом (PHP берёт пути и в 270 знаков, а
// спорный файл лежал на диске). Поэтому лечим НЕНАДЁЖНОСТЬ, а не выдуманную причину.
//
// 🪤 Ненадёжный шаг хуже стены. Стена падает всегда и потому видна; этот падает через раз
// и выглядит случайностью. Работников семеро, заведение каждого ~15 минут — половина
// углов не завелась бы, и владелец получил бы утром «работники не завелись» без причины.
export const POPYTOK_SETEVOGO_SHAGA = 3;
// Отказы, которые повторять БЕССМЫСЛЕННО: они не про сеть, а про сами бумаги проекта.
//
// 🔴 ЭТОТ СПИСОК — МИНИМУМ, А НЕ ПЕРЕЧЕНЬ. В него попадает только то, чью повторяемость
// я ЗАМЕРИЛ. `ERESOLVE` замерен в круге 1 (стена 2): он падает ВСЕГДА, на каждой попытке,
// потому что это спор версий в `package.json`. Повторять его — сжечь три раза по минуте
// и написать владельцу неправду про «сеть моргнула».
//
// 🟡 Чего в списке НЕТ и почему: у отказа composer выше приметы нет ВООБЩЕ — я не знаю,
// чем он отличается от сетевого. Значит он попадает в «повторяем», и это осознанно:
// цена ошибки в эту сторону — потраченное время, в другую — несделанная ночь.
const BESSMYSLENNO_POVTORYAT = [
{ primeta: /ERESOLVE/i, chto: 'не сошлись версии в бумагах проекта — это не сеть, повтор ничего не изменит' },
];
// 🪤 ПАУЗЫ МЕЖДУ ПОПЫТКАМИ ЗДЕСЬ НЕТ, И ЭТО РЕШЕНИЕ, А НЕ ЗАБЫВЧИВОСТЬ. Пауза — обычный
// приём, но я НЕ ЗАМЕРЯЛ, помогает ли она именно этому отказу. Добавить её значило бы
// лечить выдуманную причину и потом объяснять владельцу лишние минуты. Понадобится —
// добавится по замеру.
export function sPovtorom({ run, bin, args, opts, imya, popytok = POPYTOK_SETEVOGO_SHAGA, zhurnal = null }) {
const upali = [];
const nachalo = Date.now();
const sekund = () => Math.round((Date.now() - nachalo) / 1000);
for (let n = 1; n <= popytok; n += 1) {
try {
const out = run(bin, args, opts);
// 🔴 Записываем ТОЛЬКО когда вправду пришлось повторять. Пиши мы каждую удачу —
// владелец читал бы «повторов 0» семь раз за ночь и перестал бы это замечать,
// а строка нужна ровно затем, чтобы её заметили.
if (n > 1) zhurnal?.push({ shag: imya, popytok: n, sekund: sekund(), upali: [...upali] });
return out;
} catch (err) {
const slova = `${err?.message ?? err}\n${err?.stdout ?? ''}\n${err?.stderr ?? ''}`;
upali.push(String(err?.message ?? err).split('\n')[0]);
const bes = BESSMYSLENNO_POVTORYAT.find(b => b.primeta.test(slova));
if (bes) {
zhurnal?.push({ shag: imya, popytok: n, sekund: sekund(), upali: [...upali], ne_povtoryali: bes.chto });
// 🔴 Причина идёт ЦЕЛИКОМ и первой: владелец чинит по ней, а не по нашей приписке.
throw new Error(`${err?.message ?? err}\n\nПовторять этот шаг («${imya}») не стали: ${bes.chto}.`);
}
if (n === popytok) {
zhurnal?.push({ shag: imya, popytok: n, sekund: sekund(), upali: [...upali], sdalis: true });
throw new Error(`${err?.message ?? err}\n\nШаг «${imya}» отказал ${popytok} раза подряд за ${sekund()} с — это уже не случайность, и повторять дальше нечего.`);
}
}
}
// Сюда попасть нельзя: цикл либо вернул, либо бросил. Строка нужна статанализу.
throw new Error(`Шаг «${imya}»: повторы кончились непонятным образом.`);
}
// 🆕 `snesi`/`est` подставляются по той же причине, что и `run`: запасную дорогу уборки
// надо уметь УРОНИТЬ в проверке. Живой случай, ради которого она заведена, — «каталог
// держит чужая программа», и тогда снос руками падает ТОЖЕ. Без подстановки этот случай
// не воспроизводится, и проверка сторожила бы только удачную половину.
function uberiZaSoboy({ run, git, psql, repoRoot, workspaceRoot, branch, dbName, creds, nashe, snesi = rmSync, est = existsSync }) {
const ostalos = [];
if (nashe.baza) {
try {
run(psql, ['-h', creds.host, '-p', String(creds.port), '-U', creds.username, '-d', 'postgres', '-c', `DROP DATABASE IF EXISTS ${dbName}`], {
env: psqlEnv(creds),
});
} catch (err) { ostalos.push(`база ${dbName} (${err?.message ?? err})`); }
}
if (nashe.ugol) {
let ugolUbran = true;
try {
run(git, ['worktree', 'remove', '--force', workspaceRoot], { cwd: repoRoot });
} catch (err) {
// 🔴🔴 ЗАПАСНАЯ ДОРОГА УБОРКИ — НАЙДЕНА ЖИВЬЁМ 03.08.2026. Установка упала сама,
// уборка пошла штатной дорогой и ТОЖЕ упала:
//
// git worktree remove --force … → код 128
// fatal: validation failed, cannot remove working tree: '…/.git' does not exist
//
// Угол и ветка остались в хранилище, снимал руками. То есть «беда 7» была закрыта
// не полностью: закрыт случай «упал шаг установки», не закрыт «упала сама уборка».
//
// 🪤 И отказала она ровно там, где угол ПОВРЕЖДЁН, — то есть в самом частом случае,
// ради которого уборка и заведена. Проверка «уборка работает на целом угле» была бы
// зелена всегда и не сторожила бы ничего.
//
// Запасная дорога делает то же самое руками: сносит каталог, чистит запись о нём
// в хранилище (`worktree prune` — без неё git считает угол живым и не отдаёт ветку),
// и только после этого выше снимается ветка.
ugolUbran = false;
const pervaya = String(err?.message ?? err);
try {
snesi(workspaceRoot, { recursive: true, force: true });
// 🔴 Верим не своей команде, а диску: `rmSync` с `force` молчит и когда не снёс.
if (est(workspaceRoot)) throw new Error('каталог остался на диске после сноса');
run(git, ['worktree', 'prune'], { cwd: repoRoot });
ugolUbran = true;
} catch (err2) {
ostalos.push(`каталог угла ${workspaceRoot} (штатно: ${pervaya}; запасной дорогой: ${err2?.message ?? err2})`);
}
}
// 🔴 Ветку трогаем только если каталог вправду ушёл. Иначе `branch -D` откажет сам
// («используется рабочим каталогом»), и в остатках стояла бы вторая строка про то же
// самое — владелец решил бы, что бед две.
if (ugolUbran) {
try {
run(git, ['branch', '-D', branch], { cwd: repoRoot });
} catch (err) { ostalos.push(`ветка ${branch} (${err?.message ?? err})`); }
} else {
ostalos.push(`ветка ${branch} (её нельзя снять, пока лежит каталог угла)`);
}
}
return ostalos;
}
// 🔴🔴 СТЕНА 4, НАЙДЕНА ЖИВЬЁМ 03.08.2026 В СОБРАННОМ УГЛУ — и найдена ТОЛЬКО потому, что
// в углу вправду позвали проверку продукта. Все прежние приборы смотрели внутрь и молчали.
//
// ЧТО СЛУЧИЛОСЬ. `php artisan test --filter=ExampleTest` в свежем углу отвечает:
// Expected response status code [200] but received 500.
// InvalidArgumentException: Please provide a valid cache path.
// in vendor/laravel/framework/src/Illuminate/View/Compilers/Compiler.php:75
//
// ПРИЧИНА ЗАМЕРЕНА, А НЕ ВЫВЕДЕНА. Хранилище знает про `app/storage` ровно восемь файлов
// (`git ls-files app/storage`), и это подпорки `.gitignore` для `app`, `framework`,
// `framework/cache`, `framework/cache/data` и `logs`. Подпорок для `framework/views`,
// `framework/sessions` и `framework/testing` в хранилище НЕТ. Пустых каталогов git не
// хранит — значит в любом свежем углу этих трёх каталогов не существует, и первый же
// шаблон Blade роняет запрос пятисоткой.
//
// 🔴 ЭТО ШИРЕ НОЧИ. Тем же местом ломается ЛЮБАЯ свежая выписка проекта, не только угол
// работника. Настоящее лечение — вернуть подпорки в хранилище, но `app/**` это бумаги
// основного портала, и правит их владелец. Здесь мы чиним ровно свой угол.
//
// 🪤 ПОЧЕМУ НЕ «СОЗДАТЬ ТОЛЬКО VIEWS». Пятисотку дал `views`, но недостающих каталогов
// три, и остальные два выстрелят на первом же тесте с сессией или с подставным диском —
// то есть ночью, когда чинить некому. Заводим весь набор, какой Laravel держит сам.
export const RABOCHIE_KATALOGI = [
['storage', 'framework', 'views'],
['storage', 'framework', 'sessions'],
['storage', 'framework', 'testing'],
['storage', 'framework', 'cache', 'data'],
['storage', 'logs'],
['bootstrap', 'cache'],
];
// Возвращает то, чего НЕ БЫЛО и что завели мы. Пустой ответ — значит всё уже стояло.
// Отвечать списком, а не молчанием, нужно проверке: иначе она не отличит «завели» от
// «ничего не делали» и была бы зелена в обоих случаях.
export function sozdayRabochieKatalogi(workspaceRoot, est = existsSync, sozday = mkdirSync) {
const zaveli = [];
for (const chasti of RABOCHIE_KATALOGI) {
const put = join(workspaceRoot, 'app', ...chasti);
if (est(put)) continue;
sozday(put, { recursive: true });
zaveli.push(chasti.join('/'));
}
return zaveli;
}
// 🆕 Подставной запуск внешних программ (`run`) — та же точка впрыска, что у `installSecretsGuard`
// и у `destroy` в уборке старых углов. Живой ход берёт настоящий `runTool`; проверкам нужно
// уронить установку на названном шаге и посмотреть, что позвано после падения, — а настоящий
// `git worktree add` во временном каталоге, хранилищем не являющимся, падает сам и не по делу.
export function createWorkspace({ repoRoot, runId, workerId, branch, workspaceRoot, baseBranch, dbName = null, run = runTool, snesi = rmSync }) {
// 🔴 ПЕРВОЙ строкой, до всего остального. Ниже заводится угол и минутами ставятся
// зависимости; отказ после этого означал бы, что мы уже потратили место и время,
// а проверка 46 требует «не стартовал ВОВСЕ».
const db = assertOwnDbName(dbName ?? workerDbName(runId, workerId));
// 🪤 Windows + Laravel: vendor и node_modules НЕЛЬЗЯ подтягивать ссылкой на главный
// каталог — Laravel резолвит ссылку обратно и гоняет проверки против ЧУЖОГО кода
// при зелёном результате. Ставим зависимости в углу заново.
//
// 🪤 И ни одну внешнюю программу не зовём голым именем: `composer` и `npm` в PATH —
// скрипты без расширения, из программы это ENOENT (класс, убивший 334 прогона).
const git = resolveBin('git');
const composer = resolveBin('composer');
const npm = resolveBin('npm');
const php = resolveBin('php');
const psql = resolveBin('psql');
mkdirSync(workspaceRoot, { recursive: true });
// 🔴 БЕДА 7. Что заведено СВОЕЙ рукой в этом самом вызове — и, значит, что нам можно
// убирать при падении. Метка ставится ТОЛЬКО после удачи заводящего шага.
const nashe = { ugol: false, baza: false };
// 🔴 Сюда ложатся ТОЛЬКО те шаги, которые пришлось повторять. Пустой список = ночь
// прошла гладко; непустой обязан дойти до сводки владельца, иначе мы уберём КРИК,
// а не БЕДУ, и ненадёжность спрячется навсегда.
const povtory = [];
// Доступы нужны уборке базы, а взяты они будут ниже — объявляем заранее, иначе уборка
// их не увидит. Пока их нет, метка `nashe.baza` стоять не может физически.
let creds = null;
try {
// Угол ветвится от ВЕТКИ ЗАТЕИ, а не от главной: точка ветвления передана явно.
//
// 🔴🔴 ЭТА СТРОКА СТОИТ ВНУТРИ УБОРКИ, И ЭТО ПОПРАВЛЕНО ВЫРЕЗАНИЕМ ВТОРОГО РОДА
// (02.08.2026). Сперва она стояла СНАРУЖИ — «упало заведение ветки, значит убирать
// нечего». Выглядело верно, но метка `nashe.ugol` при этом становилась МЁРТВОЙ: до
// уборки доходили только те падения, где ветка уже заведена, то есть проверка
// `if (nashe.ugol)` отвечала «да» ВСЕГДА и не сторожила ничего. Замерено: перенос
// метки на строку выше не покраснил НИ ОДНОЙ из шестидесяти проверок.
// Теперь запрет держит сама метка, и её вправду спрашивают.
run(git, worktreeArgs({ branch, workspaceRoot, baseBranch }), { cwd: repoRoot });
// 🔴 ДОКАЗАТЕЛЬСТВО, ЧТО ВЕТКА НАША, — в самой команде, а не в нашей вере. `worktree add -b`
// на уже существующей ветке ПАДАЕТ (замерено живьём 02.08.2026: код возврата 255,
// «fatal: a branch named ... already exists», каталога при этом не появляется). Значит
// удачный `add` означает ровно одно: и ветку, и каталог завели мы. Упал `add` — метка
// не встанет, и уборка не тронет ветку, которая может оказаться чужой.
nashe.ugol = true;
// ДОЛГ 13: сторож секретов коммита обязан попасть в угол СРАЗУ, до первой же записи файлов
// работником, — иначе первый его коммит в этом углу упадёт целиком с «нет такого файла»,
// а узнается это только ночью, когда чинить уже некому.
installSecretsGuard({ repoRoot, workspaceRoot, run });
// Сторож ДО нашей записи: смотрим, что приехало в угол вместе с веткой. После записи
// он смотрел бы на файл, написанный тремя строками выше нашей же рукой, и отвечал бы
// «годен» всегда — то есть не сторожил бы ничего.
const before = assertNoLiveSecrets(workspaceRoot);
if (!before.ok) throw new Error(before.reason);
creds = dbCredsFromAppEnv(repoRoot);
writeFileSync(join(workspaceRoot, 'app', '.env'), workerEnvFile({ dbName: db, creds }), 'utf8');
// Замки кладём ФАЙЛОМ, здесь. Объявить их в задаче 6 и не положить в угол — значит
// запустить работника без единого запрета при зелёных проверках на «запреты написаны».
// Заодно перебивается проектный `.claude/settings.json`, приехавший в угол с веткой.
const { settingsPath } = writeWorkerSettings(workspaceRoot);
// И ещё раз сторож — уже поверх написанного нами.
const guard = assertNoLiveSecrets(workspaceRoot);
if (!guard.ok) throw new Error(guard.reason);
// Почтовый ящик работника: модулей затеи он не видит, разговаривать с надзирателем
// может только файлами. Ящик заводим заранее, чтобы работнику не пришлось его создавать.
// Имя ящика строит `paths.mjs` — второе место, знающее его по-своему, разошлось бы
// с первым, и надзиратель искал бы ящик не там, где его завели.
mkdirSync(workerInboxDir(workspaceRoot), { recursive: true });
// 🔴 СТЕНА 4: рабочие каталоги Laravel, которых нет в хранилище. Довод целиком в шапке
// `RABOCHIE_KATALOGI`. Ставится ДО первой php-команды: `key:generate` и `migrate` ниже
// поднимают приложение, а без `bootstrap/cache` его поднять негде.
sozdayRabochieKatalogi(workspaceRoot);
// Зависимости: свои, а не ссылкой. `composer` — для PHP, `npm ci` дважды —
// в корне угла (наши проверки, pa11y) и в app/ (фронт, Vue, браузерные проверки).
// 🪤 Именно здесь и рвётся живая установка чаще всего: `npm ci` — это сеть, а сеть
// за одни сутки 02.08.2026 обрывалась трижды. Раньше такой обрыв оставлял ветку
// и полный каталог навсегда.
// 🔴 Три установки идут ЧЕРЕЗ ПОВТОР (довод — в шапке `POPYTOK_SETEVOGO_SHAGA`).
// Остальные шаги дороги — нет, и это не забывчивость:
// · `git worktree add -b` — повтор на уже заведённой ветке падает по ДРУГОЙ причине
// и стёр бы доказательство «ветка наша» (см. довод у `nashe.ugol`);
// · `CREATE DATABASE` — повтор упёрся бы в «база уже есть», то есть в свой же успех;
// · `key:generate`, `migrate`, сборка фронта — местные, сети не трогают, и ни одного
// их падения я не замерил. Повторять то, что ни разу не падало, — лечить выдумку.
sPovtorom({ run, bin: composer, args: ['install', '--no-interaction', '--quiet'], opts: { cwd: join(workspaceRoot, 'app') }, imya: 'библиотеки PHP (composer install)', zhurnal: povtory });
sPovtorom({ run, bin: npm, args: ['ci', '--no-audit', '--no-fund'], opts: { cwd: workspaceRoot }, imya: 'библиотеки JS в корне угла (npm ci)', zhurnal: povtory });
// 🔴 СТЕНА 2, ЗАМЕРЕНА ЖИВЬЁМ 03.08.2026. Установка в `app/` без `--legacy-peer-deps`
// падает ВСЕГДА и у КАЖДОГО работника, то есть живой работник не рождался ни разу:
//
// npm error code ERESOLVE
// npm error While resolving: histoire@1.0.0-beta.1
// npm error Found: vite@8.0.11 (dev vite@"^8.0.0" from the root project)
// npm error Could not resolve dependency: peer vite@"^7.3.0" from histoire@1.0.0-beta.1
//
// Бумаги основного портала (`app/package.json`) правит владелец, не мы, — поэтому спор
// о версиях мы не решаем, а ГЛУШИМ, и ровно его: `--legacy-peer-deps` пропускает только
// разбор ссылок между зависимостями, дерево же ставится строго по замку.
//
// 🔴 ПОЧЕМУ ЭТО НЕ ВРАНЬЁ ПРО СОСТАВ. Замерено тремя списками: `--legacy-peer-deps` даёт
// 409 папок верхнего уровня, `--force` даёт те же 409 (`diff` списков пуст), и ровно
// столько же лежит в главной рабочей копии портала. То есть главная копия сама собрана
// с тем же допущением — мы повторяем живущее, а не выдумываем новое.
//
// 🔴 ПОЧЕМУ НЕ `--force`. Он глушит спор о версиях И ЕЩЁ несколько защит npm разом.
// Состав вышел тот же, значит лишняя широта ничего не покупает.
//
// 🪤 Виновник спора — `histoire` (каталог картинок для страниц). Работнику он не нужен
// ни разу: среди разрешённых ему команд выше `npm run story` нет. Но `npm ci` ставит
// все девелоперские зависимости скопом, выбрать «только нужное» он не умеет.
//
// 🟢 Корневая установка (строкой выше) этого ключа НЕ получает: она замерена отдельно
// и проходит с нулём. Ключ ставится ровно там, где замерена стена.
sPovtorom({ run, bin: npm, args: ['ci', '--no-audit', '--no-fund', '--legacy-peer-deps'], opts: { cwd: join(workspaceRoot, 'app') }, imya: 'библиотеки JS в app/ (npm ci)', zhurnal: povtory });
// 🔴🔴 СТЕНА 5, НАЙДЕНА ЖИВЬЁМ 03.08.2026 — и найдена ТОЛЬКО потому, что стену 4 сняли
// и пошли дальше. Стены стоят ЦЕПОЧКОЙ: каждая следующая видна лишь из-за снятой прежней.
//
// Illuminate\Foundation\ViteManifestNotFoundException: Vite manifest not found at:
// <угол>/app/public/build/manifest.json
//
// ПРИЧИНА ЗАМЕРЕНА: `app/.gitignore:20` прячет `/public/build`, и `git ls-files
// app/public/build` не знает о нём НИ ОДНОГО файла. Значит сборки фронта в свежем углу
// нет никогда, и любая проверка, рисующая страницу через `@vite(...)`, отвечает
// пятисоткой — то есть снова «КРАСНЫЕ проверки» про исправный код.
//
// 🟢 ЦЕНА ЗАМЕРЕНА, А НЕ ПРИКИНУТА: **9 секунд** (сам vite — 4,45 с). Против 14 минут
// всей дороги это ничто, поэтому шаг и добавлен, а не вынесен владельцу на решение.
//
// 🪤 ПАПКА — `app/`, И ЭТО НЕ МЕЛОЧЬ. Наряда `build` в корневом `package.json` НЕТ:
// из корня угла `npm run build` отвечает «нет такого наряда» за одну секунду (замерено).
// Тот же класс, что стена 3, — команда сама по себе верная, а зовут её не оттуда.
run(npm, ['run', 'build'], { cwd: join(workspaceRoot, 'app') });
// Свой ключ приложения: без APP_KEY часть команд Laravel в углу встанет с «No application
// encryption key has been specified», и работник будет биться в это всю ночь.
run(php, ['artisan', 'key:generate', '--force'], { cwd: join(workspaceRoot, 'app') });
// 🔴 И ПРОВЕРИТЬ, что ключ действительно записан. `key:generate` при неудаче печатает
// ошибку и возвращает НУЛЕВОЙ код — верить её молчанию нельзя, а `stdio: 'pipe'`
// и саму ошибку прячет. Пустой строкой `APP_KEY=` мы дали ей что заменять; здесь
// проверяем, что замена случилась.
assertAppKey(workspaceRoot);
// Своя база. Пароль — переменной окружения: иначе psql повиснет, спрашивая его.
run(psql, ['-h', creds.host, '-p', String(creds.port), '-U', creds.username, '-d', 'postgres', '-c', `CREATE DATABASE ${db}`], {
env: psqlEnv(creds),
});
// 🔴 Метка СРАЗУ после удачи, и ни строкой раньше. Поставь её до команды — и упавший
// на самой команде вызов пошёл бы сносить базу, которую не заводил, то есть ЧУЖУЮ
// базу с тем же именем.
nashe.baza = true;
// Проверки пойдут в СВОЮ базу только если её имя дойдёт до самого прогона, а не только
// до .env: app/phpunit.xml:33 задаёт DB_DATABASE жёстко. Поэтому и здесь имя базы уходит
// окружением — так же, как оно уходит работнику (workerEnv, задача 5).
run(php, ['artisan', 'migrate', '--force'], {
cwd: join(workspaceRoot, 'app'),
env: { ...process.env, DB_DATABASE: db },
});
// 🟡 ЧЕГО ЭТА ПОЧИНКА НЕ ПОКРЫВАЕТ, СКАЗАНО ВСЛУХ: `povtory` я отсюда ОТДАЮ, но до
// сводки владельца их доносит `run.mjs`, а он не мой файл. Пока туда не врезано —
// повторы видны только тому, кто зовёт `createWorkspace` напрямую. Это долг, не
// готовая дорога, и выдавать его за готовую нельзя.
return { workspaceRoot, dbName: db, branch, creds, settingsPath, povtory };
} catch (err) {
// 🔴 БЕДА 7. Установка упала на полпути — убираем ровно своё и говорим об этом словами.
const ostalos = uberiZaSoboy({ run, git, psql, repoRoot, workspaceRoot, branch, dbName: db, creds, nashe, snesi });
// 🔴 ПРИЧИНА ПАДЕНИЯ ИДЁТ ПЕРВОЙ И ЦЕЛИКОМ. Заслони её отчётом уборки — и владелец
// читал бы про уборку вместо того, что вправду сломалось.
const chistota = ostalos.length
? ` Убрать за собой удалось не всё, в хранилище осталось: ${ostalos.join('; ')}. Это снимается руками.`
: ' За собой убрано: ни ветки, ни каталога угла, ни базы после этой попытки не осталось — тот же прогон можно повторить.';
throw new Error(`${err?.message ?? err}${chistota}`);
}
}
// Уборка угла. Зовётся ОДИН раз — когда задача прошла приёмку владельца (`cli.mjs accept`,
// задача 15). Не после смерти работника: его наработки могут ещё понадобиться владельцу.
export function destroyWorkspace({ repoRoot, workspaceRoot, dbName, creds = null }) {
const git = resolveBin('git');
const psql = resolveBin('psql');
const c = creds ?? dbCredsFromAppEnv(repoRoot);
const removed = { worktree: false, database: false, errors: [] };
try {
runTool(git, ['worktree', 'remove', '--force', workspaceRoot], { cwd: repoRoot });
removed.worktree = true;
} catch (err) { removed.errors.push(`угол: ${err?.message ?? err}`); }
try {
runTool(psql, ['-h', c.host, '-p', String(c.port), '-U', c.username, '-d', 'postgres', '-c', `DROP DATABASE IF EXISTS ${dbName}`], {
env: psqlEnv(c),
});
removed.database = true;
} catch (err) { removed.errors.push(`база: ${err?.message ?? err}`); }
return removed;
}
// --- принудительная уборка старых углов ---------------------------------------
//
// 🔴 Решение владельца: угол старше срока убирается ПРИНУДИТЕЛЬНО, даже если задача ещё
// не принята. Без этого каждый непринятый прогон навсегда оставлял бы до семи углов с полной
// установкой зависимостей и семь баз — и затея встала бы сама, по месту на диске.
// Тихо это делать нельзя: про каждый убранный угол в сводку идёт ОТДЕЛЬНАЯ строка.
export const DAY_MS = 86_400_000;
// 🆕 Долг перед куском 6 (решение владельца 126 от 01.08.2026). Пока владелец не решил
// судьбу работника кнопкой «продолжить» (кусок 6) или командой `accept` (кусок 7), уборка
// старше срока его угол не трогает вовсе.
//
// 🔴 Список ЗАВЕДЕН ОТ `FINAL_STATUSES` (задача 11, supervisor.mjs) — единственного места,
// где перечислены все итоговые состояния работника, — а не переписан своими словами рядом.
// Заведи надзиратель четвёртое итоговое состояние, а этот список останься прежним —
// уборка снесла бы угол работника, чью судьбу владелец ещё не решил, и он бы об этом
// не узнал вовсе.
//
// `paused` исключён из защиты НАМЕРЕННО (решение владельца, дословно из бумаги задачи 8):
// пауза — команда самого владельца, он и так знает про этот угол, второй раз спрашивать
// его не нужно. Исключение названо СПИСКОМ, а не молчаливым вычитанием из головы —
// проверка `isProtectedFromSweep` ниже держит его поимённо на два статуса.
const SWEEP_EXCLUDED_FINAL_STATUSES = new Set(['paused']);
const AWAITING_OWNER_STATUSES = new Set(
FINAL_STATUSES.filter(status => !SWEEP_EXCLUDED_FINAL_STATUSES.has(status)),
);
export function isProtectedFromSweep(card) {
return AWAITING_OWNER_STATUSES.has(card?.status) && !card?.resumed_at;
}
export function isWorkspaceStale(card, { now, maxAgeDays }) {
if (!card?.workspace) return false; // угла нет — убирать нечего
if (card.workspace_removed_at) return false; // уже убран, второй раз не трогаем
// 🆕 Долг перед куском 6: работник ждёт слова владельца — принудительная уборка
// его не трогает, сколько бы дней ни прошло. Убирается такой угол только по слову
// владельца: командой `accept` (кусок 7) либо явной уборкой. Цена принята владельцем
// вслух — углы копятся на диске, пока он не скажет «можно».
if (isProtectedFromSweep(card)) return false;
// Считаем от конца работника, а если его нет — от начала. Нет ни того, ни другого —
// не трогаем: угадывать возраст угла нельзя, а лишняя уборка уносит чужую работу.
const since = Number.isFinite(card.finished_at) ? card.finished_at : card.started_at;
if (!Number.isFinite(since)) return false;
return now - since >= maxAgeDays * DAY_MS;
}
export function sweepStaleWorkspaces({
repoRoot,
runsRoot = runsDir(),
maxAgeDays = 7,
now = Date.now(),
destroy = destroyWorkspace,
}) {
const swept = [];
if (!existsSync(runsRoot)) return swept;
for (const runId of readdirSync(runsRoot)) {
const run = readJson(join(runsRoot, runId, 'run.json'));
if (!run) continue;
if (run.status === 'accepted') continue; // принятое уже убрал `cli.mjs accept`
for (const workerId of run.workers ?? []) {
const cardPath = join(runsRoot, runId, 'workers', workerId, 'worker.json');
const card = readJson(cardPath);
if (!isWorkspaceStale(card, { now, maxAgeDays })) continue;
let r;
try {
r = destroy({ repoRoot, workspaceRoot: card.workspace, dbName: card.db_name });
} catch (err) {
// 🔴 Уборка не имеет права уронить утреннюю сводку: письмо обязано уйти ВСЕГДА.
// Отметку при неудаче не ставим — завтра попробуем снова, а владелец увидит строку.
swept.push({
run_id: runId,
worker_id: workerId,
workspace: card.workspace,
worktree: false,
database: false,
errors: [String(err?.message ?? err)],
line: `Рабочий угол работника ${workerId} (прогон ${runId}) старше ${maxAgeDays} дней, но убрать его не вышло: ${err?.message ?? err}. Место на диске не освободилось.`,
});
continue;
}
// Отметка в карточке обязательна: без неё на следующее утро тот же угол убирали бы
// снова и снова, и в сводке каждый день стояла бы одна и та же строка.
writeJsonAtomic(cardPath, { ...card, workspace_removed_at: now, workspace_removed_reason: 'старше срока, задача не принята' });
swept.push({
run_id: runId,
worker_id: workerId,
workspace: card.workspace,
...r,
line: `Убран рабочий угол работника ${workerId} (прогон ${runId}): задача не принята ${maxAgeDays} дней. Угол — ${r.worktree ? 'убран' : 'НЕ убран'}, база — ${r.database ? 'убрана' : 'НЕ убрана'}${r.errors?.length ? ` (${r.errors.join('; ')})` : ''}.`,
});
}
}
return swept;
}