diff --git a/app/app/Jobs/External/RefreshExternalBalancesJob.php b/app/app/Jobs/External/RefreshExternalBalancesJob.php index 1f72a421..92da3a04 100644 --- a/app/app/Jobs/External/RefreshExternalBalancesJob.php +++ b/app/app/Jobs/External/RefreshExternalBalancesJob.php @@ -42,14 +42,17 @@ class RefreshExternalBalancesJob implements ShouldQueue public function handle(): void { - $table = DB::connection(self::DB_CONNECTION)->table('external_service_balances'); - foreach ($this->providers() as $cls) { /** @var BalanceProvider $p */ $p = app($cls); $key = $p->serviceKey(); $reading = $p->fetch(); // не бросает + // Свежий query-builder на КАЖДУЮ итерацию: переиспользование одного билдера + // накапливает where-клаузы (service_key=A AND service_key=B…) → updateOrInsert + // ошибочно идёт в INSERT существующей строки → нарушение PK. + $table = DB::connection(self::DB_CONNECTION)->table('external_service_balances'); + if (! $reading->ok) { // Оставляем прошлый баланс, помечаем ok=false + ошибку. $table->updateOrInsert( diff --git a/app/tests/Feature/External/RefreshExternalBalancesJobTest.php b/app/tests/Feature/External/RefreshExternalBalancesJobTest.php index b0b0753b..4b46cc65 100644 --- a/app/tests/Feature/External/RefreshExternalBalancesJobTest.php +++ b/app/tests/Feature/External/RefreshExternalBalancesJobTest.php @@ -51,6 +51,18 @@ it('пишет балансы трёх сервисов + считает све expect($rows['dadata']->ok)->toBeTruthy(); }); +it('повторный запуск обновляет строки, а не падает на PK (свежий builder/итерация)', function () { + app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::ok('dadata', 4500, 'RUB', 100))); + app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::fail('supplier', 'таймаут'))); + app()->instance(YandexCloudBalanceProvider::class, fakeProvider('yandex_cloud', BalanceReading::ok('yandex_cloud', 42000, 'RUB', 600))); + + (new RefreshExternalBalancesJob)->handle(); + (new RefreshExternalBalancesJob)->handle(); // второй прогон не должен бросить UniqueConstraint + + $rows = DB::connection('pgsql_supplier')->table('external_service_balances')->get(); + expect($rows)->toHaveCount(3); // строк по-прежнему 3, без дублей +}); + it('упавший провайдер не роняет джобу и сохраняет ошибку, остальные пишутся', function () { app()->instance(DadataBalanceProvider::class, fakeProvider('dadata', BalanceReading::fail('dadata', 'HTTP 403'))); app()->instance(SupplierBalanceProvider::class, fakeProvider('supplier', BalanceReading::ok('supplier', 50000, 'RUB', null))); diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index b9e5f80e..2315e2db 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-28T04:13:25.256Z +Last updated: 2026-06-28T04:26:08.585Z | Контролёр | Состояние | Детали | |---|---|---| @@ -33,7 +33,7 @@ Last updated: 2026-06-28T04:13:25.256Z | enforce-coverage-verify.mjs | `enforce-coverage-verify.mjs` | 🔴 | | enforce-todowrite-skill-verifier.mjs | `enforce-todowrite-skill-verifier.mjs` | 🔴 | -Недавние escape владельца: 0 · Недавние блоки: 7 +Недавние escape владельца: 0 · Недавние блоки: 5 **Недавние блоки (детали):** @@ -44,8 +44,6 @@ Last updated: 2026-06-28T04:13:25.256Z | 2026-06-27T09:25:54.127Z | bash:node -e "for (const d of ['протокол-наставника','проблема-закрытия-вопросов-протокола','содержит']) { try { const p | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "for (const d | | 2026-06-27T07:03:56.852Z | bash:node -e "1" 2>/dev/null; for f in docs/secretary/*/protocol.md; do printf '%6s %s\n' "$(wc -l < "$f")" "$f"; done | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:node -e "1" 2>/dev/nul | | 2026-06-27T05:45:19.915Z | bash:rm ~/.claude/runtime/secretary-mode-*.json | floor: опасная по содержанию команда без аварийного выхода — блок (правило 8); FLOOR-ESCAPE: bash:rm ~/.claude/runtime/s | -| 2026-06-27T04:18:53.490Z | powershell:Stop-Process -Id 4072 -Force | floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: powershell:Stop-Process | -| 2026-06-27T04:18:44.603Z | powershell:Stop-Process -Id 4072 -Force; if ($?) { "killed 4072" }; Start-Sleep -Milliseconds 300; Get-CimInstance Win32 | floor: опасная PowerShell-команда без аварийного выхода — блок (правило 8, V1-PS); FLOOR-ESCAPE: powershell:Stop-Process | ## Метрики (информационные, не алерты) @@ -129,9 +127,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 17.01ч | 220544.2ч | -| 21928 | Code | 7.32ч | 0.0ч | -| 1212 | svchost | 4.38ч | 10093734.7ч | +| 3440 | MsMpEng | 17.07ч | 0.0ч | +| 21928 | Code | 7.36ч | 451351.4ч | +| 1212 | svchost | 4.38ч | NaNч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.