Files
portal/app/routes/console.php
T
Дмитрий 542b7d2d22 fix реклама за показы: очередь робота больше не встаёт колом, доклад об успехе не врёт, тихий ноль ловится
Р6 — задание, брошенное «в работе», держало очередь для всех клиентов навсегда.
Причина сбоя обрезается до 900 знаков, иначе портал отвергал отчёт о самом частом
виде поломки. Провал отчёта больше не глотается молча — едет в письмо человеку.
Приём отчёта ловит любую ошибку и закрывает задание сбойным вместо 500 роботу.
Новый сторож creative-jobs:reap каждые 10 минут разбирает пробку.

Р7 — обрыв на докладе «готово» объявлял провалом уже сделанную работу. Доклад
вынесен из-под обработчика сбоя, повторяется трижды, при неудаче только письмо.
Осечка ухода со страницы после «Создать» больше не считается провалом загрузки.

Р8 — пустой список размеров считался успехом, и робот заливал одни и те же файлы
по кругу, оставляя мусор в живом кабинете. Теперь громкий отказ до похода в Яндекс.

Портал 274/274, робот 41/41. Все защиты проверены вырезанием.
Выходов снятия заморозки денег по-прежнему четыре.
2026-07-27 20:42:23 +03:00

350 lines
24 KiB
PHP
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.
<?php
use App\Jobs\ChargeCampaignSpendJob;
use App\Jobs\External\RefreshExternalBalancesJob;
use App\Jobs\RecalcAdAudienceJob;
use App\Jobs\Sales\SalesProspectsAdvanceJob;
use App\Jobs\SendNewLeadsDigestJob;
use App\Jobs\SnapshotProjectRoutingJob;
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\FlushDeferredOnlineSyncJob;
use App\Jobs\Supplier\PruneSupplierExtraChannelsJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Jobs\SyncAdAudienceJob;
use App\Jobs\SyncCampaignAudienceJob;
use App\Jobs\SyncCampaignModerationJob;
use App\Jobs\SyncVkAudienceJob;
use App\Services\SchedulerHeartbeatTracker;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
// Hole #6: heartbeat-трекинг всех cron-задач.
// SchedulerHeartbeatTracker::recordRun() оборачивает каждую задачу через
// before/after/onFailure хуки Laravel Scheduler — минимально инвазивный подход.
/** @var SchedulerHeartbeatTracker $hb */
$hb = app(SchedulerHeartbeatTracker::class);
// Spec §6.1: ежедневный сброс projects.delivered_today=0 в 00:00 МСК.
// delivered_in_month НЕ трогаем — это месячный счётчик, отдельный cron Plan 4.
//
// NB: без `withoutOverlapping()` — операция идемпотентна (UPDATE WHERE delivered_today <> 0)
// и завершается за < 1 сек на любом ожидаемом объёме, overlap физически невозможен.
// Кроме того, `withoutOverlapping` требует таблицу `cache_locks`, которой в нашей
// schema.sql нет (Laravel-default-миграции удалены, см. project_state.md фаза 1).
Schedule::command('projects:reset-delivered-today')
->dailyAt('00:00')
->timezone('Europe/Moscow')
->before(fn () => $startTimes['projects:reset-delivered-today'] = microtime(true))
->onSuccess(function () use ($hb, &$startTimes): void {
$name = 'projects:reset-delivered-today';
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
$hb->recordRunResult($name, true, null, $ms);
})
->onFailure(function () use ($hb): void {
$hb->recordRunResult('projects:reset-delivered-today', false, 'Command failed', null);
});
// Task 4.2: досыл отложенной онлайн-очереди в 00:05 МСК (после сброса счётчиков в 00:00,
// вне окна 18:00→00:00 — отложенные правки уходят поставщику немедленно).
Schedule::job(new FlushDeferredOnlineSyncJob)
->dailyAt('00:05')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\FlushDeferredOnlineSyncJob', false, 'Job failed', null));
// Плитка балансов (28.06): ежедневный сбор баланса внешних сервисов (DaData/Поставщик/
// Yandex Cloud) в 06:30 МСК. Изоляция провайдеров внутри джобы; падение сети одного
// сервиса не роняет остальных и не валит расписание.
Schedule::job(new RefreshExternalBalancesJob)
->dailyAt('06:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\External\RefreshExternalBalancesJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\External\RefreshExternalBalancesJob', false, 'Job failed', null));
// Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService.
Schedule::command('projects:reset-monthly')
->monthlyOn(1, '00:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('projects:reset-monthly', true, null, null))
->onFailure(fn () => $hb->recordRunResult('projects:reset-monthly', false, 'Command failed', null));
// Audit #2 Phase 14 P2: partition maintenance — создаёт разделы на 3 месяца вперёд.
// Без этой записи partitions:create-months не запускается автоматически.
Schedule::command('partitions:create-months')
->daily()
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('partitions:create-months', true, null, null))
->onFailure(fn () => $hb->recordRunResult('partitions:create-months', false, 'Command failed', null));
// Hole #2 (23.05.2026): удаление устаревших месячных партиций согласно retention
// (system_settings: partition_retention_months_<table>).
// Запускается еженедельно в воскресенье в 03:00 МСК — вне пиковых часов,
// но раз в неделю достаточно (данные удаляются целыми месяцами).
Schedule::command('partitions:drop-expired')
->weeklyOn(0, '03:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null))
->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null));
// Учёт посетителей (spec 2026-07-13, 152-ФЗ): гости старше 180 дней удаляются.
// В site_visitors лежит сырой IP — держим срок хранения коротким.
Schedule::command('visitors:prune')
->weeklyOn(0, '03:15')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('visitors:prune', true, null, null))
->onFailure(fn () => $hb->recordRunResult('visitors:prune', false, 'Command failed', null));
// Task 5 (реклама на кандидатов v2): ночной пересчёт состояний рекламы по стадиям
// связанных карточек воронки — кто крутится, кто на паузе, кто гаснет совсем.
// Ничего не заливает в Яндекс, это следующий шаг (SyncAdAudienceJob, Task 6).
// 03:10 МСК — до pd:scrub (03:30), вне пиковых часов.
Schedule::job(new RecalcAdAudienceJob('pgsql_supplier'))
->dailyAt('03:10')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\RecalcAdAudienceJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\RecalcAdAudienceJob', false, 'Job failed', null));
// Task 6 (реклама на кандидатов v2): ночная заливка состава аудитории в Яндекс —
// следом за пересчётом состояний (03:10), чтобы уходил уже свежий список.
// Рубильник sales_ad_audience_platforms.enabled (yandex) = false — джоб не шлёт ничего.
// pgsql_supplier — джоб читает sales_prospects (стадия для решения по срокам),
// а на дефолтной роли crm_app_user к ним нет прав (боевая поломка 19.07).
Schedule::job(new SyncAdAudienceJob('pgsql_supplier'))
->dailyAt('03:20')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncAdAudienceJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncAdAudienceJob', false, 'Job failed', null));
// Task 8 (выбор площадки прогрева): заливка в ВК — следом за яндексовой. Ниже
// порога в 2000 номеров и без ключа доступа джоб не делает к ВК ни одного
// обращения, только проставляет статус (no_access / waiting_volume).
Schedule::job(new SyncVkAudienceJob('pgsql_supplier'))
->dailyAt('03:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncVkAudienceJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncVkAudienceJob', false, 'Job failed', null));
// F-P1 (152-ФЗ ретеншен): ежедневная анонимизация ПДн soft-deleted сделок старше
// retention-срока (system_settings.pd_scrub_soft_deleted_deals_days; no-op пока не задан).
// 03:30 МСК — вне пиковых часов, после partitions:drop-expired (03:00).
Schedule::command('pd:scrub-soft-deleted-deals')
->dailyAt('03:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('pd:scrub-soft-deleted-deals', true, null, null))
->onFailure(fn () => $hb->recordRunResult('pd:scrub-soft-deleted-deals', false, 'Command failed', null));
// Billing v2 Spec C §3.2: преfflight баланса в 18:00 MSK — заморозка/разморозка
// тенантов перед формированием заказа поставщику (без «бедных» клиентов).
// ВАЖНО: идёт ДО SyncSupplierProjectsJob (сдвинут на 18:05) — фильтр frozen-проектов
// должен примениться к расчёту заказа того же вечера.
Schedule::command('billing:preflight-sweep')
->dailyAt('18:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
// Этап 1 «оплата по счёту»: просроченные неоплаченные счета → overdue.
// 03:40 МСК — после ночных ретеншен-задач, вне пиковых часов.
Schedule::command('invoices:expire')
->dailyAt('03:40')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('invoices:expire', true, null, null))
->onFailure(fn () => $hb->recordRunResult('invoices:expire', false, 'Command failed', null));
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
Schedule::command('billing:frozen-reminder')
->dailyAt('18:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('billing:frozen-reminder', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:frozen-reminder', false, 'Command failed', null));
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
//
// NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет
// (см. project_state.md фаза 1). Операции идемпотентны: SyncSupplierProjectsJob
// делает diff'ы (skip-no-diff), CleanupJob — UPDATE WHERE conditions, RefreshSession
// — Cache::lock guard внутри handle, RetryFailedSupplierJobs — WHERE retried_at
// фильтр. На multi-server prod может потребовать cache_locks таблицу.
Schedule::job(new RefreshSupplierSessionJob)->hourly()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', false, 'Job failed', null));
// Spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.7:
// крон переехал с 20:30 на 18:00 МСК — даёт ~3 часа окно восстановления
// (эскалация на медленный ярус 2 / ручной ярус 3) в рабочее время до
// портального дедлайна 21:00. Session refresh — на 15 мин раньше sync (17:45).
Schedule::job(new RefreshSupplierSessionJob)
->dailyAt('17:45')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
// Spec 2026-05-26-slepok-routing-protection §4.2.2:
// SnapshotProjectRoutingJob создаёт slepok №NЛ для дня N+1 в 18:02 МСК.
// Запускается ПОСЛЕ billing:preflight-sweep (18:00) и ДО SyncSupplierProjectsJob (18:05).
Schedule::job(new SnapshotProjectRoutingJob)
->dailyAt('18:02')
->timezone('Europe/Moscow')
->before(fn () => $startTimes['SnapshotProjectRoutingJob'] = microtime(true))
->onSuccess(function () use ($hb, &$startTimes): void {
$name = 'SnapshotProjectRoutingJob';
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
$hb->recordRunResult($name, true, null, $ms);
})
->onFailure(fn () => $hb->recordRunResult('SnapshotProjectRoutingJob', false, 'Job failed', null));
// Billing v2 Spec C: сдвинут 18:00 → 18:05, чтобы billing:preflight-sweep (18:00)
// успел проставить frozen-флаги до формирования заказа поставщику.
Schedule::job(new SyncSupplierProjectsJob)
->dailyAt('18:05')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
// Сторож дедлайна поставщика (21:00 МСК). yellow=20:00, red=20:40 — письмо,
// если робот к порогу не закончил. Spec 2026-07-09-supplier-order-verification §5.
Schedule::command('supplier:deadline-watch yellow')
->dailyAt('20:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('supplier:deadline-watch yellow', true, null, null))
->onFailure(fn () => $hb->recordRunResult('supplier:deadline-watch yellow', false, 'Command failed', null));
Schedule::command('supplier:deadline-watch red')
->dailyAt('20:40')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('supplier:deadline-watch red', true, null, null))
->onFailure(fn () => $hb->recordRunResult('supplier:deadline-watch red', false, 'Command failed', null));
// Чистка доп-каналов авторасширения поставщика (B6=dop2/B8/…) в 20:00 МСК —
// до снимка поставщика (21:00), в разрешённом окне правок. Оставляет только B1/B2/B3.
// Spec: docs/superpowers/specs/2026-07-16-supplier-prune-extra-channels-design.md
Schedule::job(new PruneSupplierExtraChannelsJob)
->dailyAt('20:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\PruneSupplierExtraChannelsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\PruneSupplierExtraChannelsJob', false, 'Job failed', null));
Schedule::job(new CleanupInactiveSupplierProjectsJob)
->dailyAt('02:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', false, 'Job failed', null));
Schedule::command('supplier:retry-failed')->hourly()
->onSuccess(fn () => $hb->recordRunResult('supplier:retry-failed', true, null, null))
->onFailure(fn () => $hb->recordRunResult('supplier:retry-failed', false, 'Command failed', null));
// Резервный CSV-канал (Путь 2): сверка каждые 30 минут.
// Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.5
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null));
// G2-A: дайджест новых сделок клиентам — каждые 30 минут.
Schedule::job(new SendNewLeadsDigestJob)->everyThirtyMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\SendNewLeadsDigestJob', false, 'Job failed', null));
// Этап 3 «Автожизнь» воронки продаж: пересчёт авто-стадий по деньгам аккаунта каждые 15 мин.
// pgsql_admin (crm_admin_user) — кросс-тенант биллинг + запись sales_prospects.
Schedule::job(new SalesProspectsAdvanceJob('pgsql_admin'))->everyFifteenMinutes()
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Sales\SalesProspectsAdvanceJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Sales\SalesProspectsAdvanceJob', false, 'Job failed', null));
// Audit #2 Phase 14 P2: авто-детекция штормов упавших webhook-джобов.
// Сканирует за последние 10 мин, порог 200, дедуп 60 мин.
Schedule::command('incidents:watch-failures')
->everyTenMinutes()
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('incidents:watch-failures', true, null, null))
->onFailure(fn () => $hb->recordRunResult('incidents:watch-failures', false, 'Command failed', null));
// Hole #1: ежедневная проверка SHA-256 hash-chain в 6 audit-таблицах.
// Разрыв → incidents_log (severity high) + email kdv1@bk.ru.
// Ref: docs/superpowers/plans/2026-05-23-hole-1-hash-chain-validator.md
Schedule::command('audit:verify-chains')
->dailyAt('04:00')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('audit:verify-chains', true, null, null))
->onFailure(fn () => $hb->recordRunResult('audit:verify-chains', false, 'Command failed', null));
// Hole #6: проверка пульса планировщика — hourly МСК.
Schedule::command('scheduler:check-heartbeats')
->hourly()
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
// Задача 6 (пакетный сбор источников): страховка распорядителя шага 2. Джоба
// RunAutopodborStudyJob уже зовёт AutopodborStudyScheduler::tick() в finally после
// КАЖДОГО завершения прогона — это основной канал продвижения очереди. Этот крон —
// подстраховка на случай, если дорожка освободилась не через завершение джобы
// (например воркер шага 2 не поднят/упал молча) — раз в минуту проверяет и,
// если дорожка свободна, а очередь не пуста, стартует следующего.
//
// НЕ используем ->withoutOverlapping(): требует таблицу cache_locks, которой у нас
// нет в schema.sql (см. .env.example CACHE_STORE=database + комментарий выше в этом
// файле у Hole #2). Overlap и так не страшен — pickNextRunId()/tick() уже защищены
// собственным Cache::lock('autopodbor:study:tick', 10) внутри AutopodborStudyScheduler,
// а сам pickNextRunId() проверяет status='running' атомарно на момент чтения.
Schedule::command('autopodbor:study-tick')
->everyMinute()
->onSuccess(fn () => $hb->recordRunResult('autopodbor:study-tick', true, null, null))
->onFailure(fn () => $hb->recordRunResult('autopodbor:study-tick', false, 'Command failed', null));
// База знаний ИИ-бота: ночная переиндексация статей resources/help
// (спека 2026-07-02-jivo-ai-support-bot-design §3). Изменил статью — ночью бот знает.
// Без ->onOneServer(): требует cache_locks (см. NB выше у Hole #2/withoutOverlapping),
// а команда идемпотентна — полная перезаливка в транзакции, повтор безвреден.
Schedule::command('help:rebuild-knowledge')
->dailyAt('04:30')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('help:rebuild-knowledge', true, null, null))
->onFailure(fn () => $hb->recordRunResult('help:rebuild-knowledge', false, 'Command failed', null));
// Сверка зависших онлайн-пополнений (14.07.2026). Судьбу платежа мы узнаём из webhook'а
// но он может не дойти: тогда ОПЛАЧЕННЫЙ платёж не зачислится (потеря денег клиента), а
// отменённый навсегда повиснет «ожидает оплаты» (кабинет врёт клиенту). Каждые 5 минут
// сами спрашиваем шлюз про каждый pending старше 15 минут и закрываем его по правде.
Schedule::command('billing:reconcile-payments')
->everyFiveMinutes()
->onSuccess(fn () => $hb->recordRunResult('billing:reconcile-payments', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:reconcile-payments', false, 'Command failed', null));
// Рекламный модуль Директа (Часть B1). Джобы сами перечисляют кампании через
// pgsql_supplier (BYPASSRLS) — на дефолтной роли crm_app_user очередь без tenant-
// контекста RLS дала бы 0 строк. Времена МСК, вне пиковых часов и не пересекаются
// с уже занятыми слотами (03:20 SyncAd, 03:30 SyncVk, 04:00 audit).
Schedule::job(new SyncCampaignAudienceJob)
->dailyAt('03:50')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignAudienceJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignAudienceJob', false, 'Job failed', null));
Schedule::job(new ChargeCampaignSpendJob)
->dailyAt('04:20')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ChargeCampaignSpendJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\ChargeCampaignSpendJob', false, 'Job failed', null));
Schedule::job(new SyncCampaignModerationJob)
->everyTwoHours()
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignModerationJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\SyncCampaignModerationJob', false, 'Job failed', null));
// Сторож очереди робота-грузчика креативов. Задание, брошенное «в работе», держит очередь
// для ВСЕХ клиентов: пока хоть одно в работе, выдача отвечает «работы нет» и ни одна
// кампания не стартует. Робот может умереть молча — разбирать пробку руками некому.
// Каждые 10 минут при пороге в 30 минут: свежее задание сторож не отбирает.
Schedule::command('creative-jobs:reap')
->everyTenMinutes()
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('creative-jobs:reap', true, null, null))
->onFailure(fn () => $hb->recordRunResult('creative-jobs:reap', false, 'Command failed', null));