Files
fotospiel-app/app/Console/Commands/AiEditsRecoverStuckCommand.php
Codex Agent 1d2242fb4d
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
feat(ai): finalize AI magic edits epic rollout and operations
2026-02-06 22:41:51 +01:00

180 lines
6.2 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Jobs\PollAiEditRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class AiEditsRecoverStuckCommand extends Command
{
protected $signature = 'ai-edits:recover-stuck
{--minutes=30 : Minimum age in minutes for queued/processing requests}
{--requeue : Re-dispatch stuck requests back to the queue}
{--fail : Mark stuck requests as failed}';
protected $description = 'Inspect stuck AI edit requests and optionally recover them by requeueing or failing.';
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig)
{
parent::__construct();
}
public function handle(): int
{
$minutes = max(1, (int) $this->option('minutes'));
$shouldRequeue = (bool) $this->option('requeue');
$shouldFail = (bool) $this->option('fail');
if ($shouldRequeue && $shouldFail) {
$this->error('Use either --requeue or --fail, not both.');
return self::FAILURE;
}
$cutoff = now()->subMinutes($minutes);
$requests = AiEditRequest::query()
->with([
'event:id,slug,name',
'providerRuns' => function (HasMany $query): void {
$query->select(['id', 'request_id', 'provider_task_id', 'attempt'])
->orderByDesc('attempt');
},
])
->whereIn('status', [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING])
->where(function (Builder $query) use ($cutoff): void {
$query
->where(function (Builder $queuedQuery) use ($cutoff): void {
$queuedQuery->whereNull('started_at')
->whereNotNull('queued_at')
->where('queued_at', '<=', $cutoff);
})
->orWhere(function (Builder $processingQuery) use ($cutoff): void {
$processingQuery->whereNotNull('started_at')
->where('started_at', '<=', $cutoff);
})
->orWhere(function (Builder $fallbackQuery) use ($cutoff): void {
$fallbackQuery->whereNull('queued_at')
->whereNull('started_at')
->where('updated_at', '<=', $cutoff);
});
})
->orderBy('updated_at')
->get();
if ($requests->isEmpty()) {
$this->info(sprintf('No stuck AI edit requests older than %d minute(s).', $minutes));
return self::SUCCESS;
}
$this->table(
['ID', 'Event', 'Status', 'Queued/Started', 'Latest task'],
$requests->map(function (AiEditRequest $request): array {
$latestTaskId = $this->latestProviderTaskId($request) ?? '-';
$eventLabel = (string) ($request->event?->name ?: $request->event?->slug ?: $request->event_id);
$ageSource = $request->started_at ?: $request->queued_at ?: $request->updated_at;
return [
(string) $request->id,
$eventLabel,
$request->status,
$ageSource?->toIso8601String() ?? '-',
$latestTaskId,
];
})->all()
);
if (! $shouldRequeue && ! $shouldFail) {
$this->info('Dry-run only. Use --requeue to dispatch recovery jobs or --fail to terminate stuck requests.');
return self::SUCCESS;
}
if ($shouldFail) {
$count = $this->markAsFailed($requests);
$this->info(sprintf('Marked %d AI edit request(s) as failed.', $count));
return self::SUCCESS;
}
[$processDispatches, $pollDispatches] = $this->requeueRequests($requests);
$this->info(sprintf(
'Recovered %d stuck AI edit request(s): %d process dispatch(es), %d poll dispatch(es).',
$processDispatches + $pollDispatches,
$processDispatches,
$pollDispatches
));
return self::SUCCESS;
}
/**
* @return array{0:int,1:int}
*/
private function requeueRequests(Collection $requests): array
{
$queueName = $this->runtimeConfig->queueName();
$processDispatches = 0;
$pollDispatches = 0;
foreach ($requests as $request) {
if ($request->status === AiEditRequest::STATUS_QUEUED) {
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
$processDispatches++;
continue;
}
$providerTaskId = $this->latestProviderTaskId($request);
if ($providerTaskId !== null) {
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)->onQueue($queueName);
$pollDispatches++;
continue;
}
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
$processDispatches++;
}
return [$processDispatches, $pollDispatches];
}
private function markAsFailed(Collection $requests): int
{
$updated = 0;
$now = now();
foreach ($requests as $request) {
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'operator_recovery_marked_failed',
'failure_message' => 'Marked as failed by ai-edits:recover-stuck.',
'completed_at' => $now,
])->save();
$updated++;
}
return $updated;
}
private function latestProviderTaskId(AiEditRequest $request): ?string
{
foreach ($request->providerRuns as $run) {
$taskId = trim((string) ($run->provider_task_id ?? ''));
if ($taskId !== '') {
return $taskId;
}
}
return null;
}
}