Skip to main content
Laravel, thinking fast.

Publishing keeps the user informed. Checkpointing is what saves the work when something goes wrong:

// Save a checkpoint so failed() can recover partial state if the job is killed
Cache::put($this->checkpointKey(), array_merge($state, ['conversationHistory' => $history]), now()->addMinutes(5));

And then the part most people leave empty:

public function failed(\Throwable $exception): void
{
    report($exception);

    $checkpoint = Cache::pull($this->checkpointKey());

    $base = $checkpoint ?? [ /* the state the job started with */ ];

    $base['messages'][] = [
        'role' => 'bot',
        'content' => __('messages.errors.ai_error'),
        'is_error' => true,
    ];
    $base['hasError'] = true;
    $base['done'] = true;

    Cache::forget(self::cancelKey($this->cacheKey));
    Cache::put($this->cacheKey, $base, now()->addMinutes(5));
}

Three things earn their place here.

It recovers partial state. Two useful tool results and then a crash should not discard the two results. Cache::pull reads and removes in one step.

It sets done. The failure path must stop the client polling. A feature that fails but never says so is worse than one that fails loudly — the user waits indefinitely for something that is never coming.

It cleans up its own keys. Cancel flags and checkpoints are removed, so the next turn starts from nothing rather than inheriting a stale instruction.

failed() is not error handling bolted on the side. It is the second implementation of your feature — the one that runs on the worst day — and it deserves the same attention as handle().

Let the User Stop

Long-running means a user will want to interrupt it. Once the job is running there is nothing left to cancel except the round trips it has not made yet, so cancellation is cooperative:

// The visitor pressed stop. Checked before the call rather than after, so an
// in-flight round-trip is the most that can still be paid for; whatever has
// already landed stays on screen.
if (Cache::has(self::cancelKey($this->cacheKey))) {
    $state['stopped'] = true;
    break;
}

Two details worth copying.

Checked at the top of the loop, not the bottom. Check after the call and you always pay for one more round trip than the user asked for. At the top, an in-flight request is the maximum waste.

A cache flag, not a queue signal. The job is already running on a worker somewhere; there is no channel to it. A key the front end sets and the job reads is the simplest thing that works, and stopping is not urgent to the millisecond.

Note also that stopping is not failing. stopped is its own state — nothing on screen is discarded, no error is shown. The user asked it to stop and it stopped.