feat(agent/php): DispatchMantisTicketJob + HermesClient + agent_dispatches table (#827)

Phase 3 lane: queueable Job that resolves a profile via ProfileSelector,
posts POST {gateway}/v1/responses to the chosen Hermes gateway, persists
ticket_id/profile_id/response_id/run_id/status in agent_dispatches, and
chains CaptureDispatchResultJob.

Migration 2026_04_25_000003 creates agent_dispatches table (FK-free,
Postgres-compatible).

HermesClient: thin Laravel HTTP wrapper around the Hermes /v1/responses
endpoint with Authorization header + JSON body.

DispatchMantisTicketJob behaviour:
- Resolves profile via ProfileSelector::pickFor()
- Null-profile → log warn + ->release(60) requeue
- Otherwise POSTs to gateway, persists AgentDispatch row, queues
  CaptureDispatchResultJob

AgentDispatch Eloquent model with minimal $fillable.

Pest Feature test (Http::fake): verifies request shape, persisted row,
downstream capture-job queueing, and the no-profile requeue path.
Test file conditionally aliases minimal stubs for sibling-lane services
so this file remains runnable before #826/#828 fully land in dev.

Codex note: php -l clean; pest skipped (no vendor/).

Closes tasks.lthn.sh/view.php?id=827

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Snider 2026-04-26 00:52:29 +01:00
parent fc59aa02eb
commit 82ffd420e0
5 changed files with 449 additions and 0 deletions

View file

@ -0,0 +1,163 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Jobs;
use Core\Mod\Agentic\Models\AgentDispatch;
use Core\Mod\Agentic\Models\AgentProfile;
use Core\Mod\Agentic\Services\HermesClient;
use Core\Mod\Agentic\Services\MantisClient;
use Core\Mod\Agentic\Services\ProfileSelector;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class DispatchMantisTicketJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $backoff = 60;
public int $timeout = 120;
public function __construct(
public int $ticketId,
) {
$this->onQueue('ai');
}
public function handle(
MantisClient $mantisClient,
HermesClient $hermesClient,
?ProfileSelector $profileSelector = null,
): void {
$profileSelector ??= $this->resolveProfileSelector();
$ticket = $mantisClient->get($this->ticketId);
$profile = $this->pickProfile($profileSelector, $ticket);
if ($profile === null) {
Log::warning('DispatchMantisTicketJob: no agent profile available for Mantis ticket', [
'ticket_id' => $this->ticketId,
]);
$this->release(60);
return;
}
$response = $hermesClient->createResponse(
$profile,
$this->buildPrompt($ticket),
[
'conversation' => "mantis-{$this->ticketId}",
'ticket_id' => $this->ticketId,
],
);
$dispatch = $this->createDispatch($profile, $response);
CaptureDispatchResultJob::dispatch($this->ticketId, $dispatch->response_id);
}
/**
* @return array<int, string>
*/
public function tags(): array
{
return [
'mantis-dispatch',
"ticket:{$this->ticketId}",
];
}
private function resolveProfileSelector(): ?ProfileSelector
{
if (! class_exists(ProfileSelector::class)) {
return null;
}
return app(ProfileSelector::class);
}
private function pickProfile(?ProfileSelector $profileSelector, mixed $ticket): ?AgentProfile
{
if ($profileSelector === null) {
return null;
}
$profile = $profileSelector->pickFor($ticket);
return $profile instanceof AgentProfile ? $profile : null;
}
private function createDispatch(AgentProfile $profile, array $response): AgentDispatch
{
return AgentDispatch::query()->create([
'ticket_id' => $this->ticketId,
'profile_id' => $profile->getKey() === null ? null : (int) $profile->getKey(),
'response_id' => $this->extractResponseId($response),
'run_id' => $this->extractRunId($response),
'status' => $this->extractStatus($response),
]);
}
private function buildPrompt(mixed $ticket): string
{
foreach (['body', 'description', 'text', 'content', 'summary', 'title'] as $key) {
$value = data_get($ticket, $key);
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
if (is_string($ticket) && trim($ticket) !== '') {
return trim($ticket);
}
$fallback = json_encode($ticket, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (is_string($fallback) && $fallback !== '') {
return $fallback;
}
return "Mantis ticket {$this->ticketId}";
}
private function extractResponseId(array $response): string
{
$responseId = data_get($response, 'id');
if (is_scalar($responseId) && $responseId !== '') {
return (string) $responseId;
}
throw new RuntimeException('Hermes response did not include an id');
}
private function extractRunId(array $response): ?string
{
$runId = data_get($response, 'run_id') ?? data_get($response, 'run.id');
return is_scalar($runId) && $runId !== '' ? (string) $runId : null;
}
private function extractStatus(array $response): string
{
$status = data_get($response, 'status');
return is_string($status) && $status !== ''
? $status
: AgentDispatch::STATUS_QUEUED;
}
}

View file

@ -0,0 +1,35 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('agent_dispatches')) {
return;
}
Schema::create('agent_dispatches', function (Blueprint $table): void {
$table->id();
$table->integer('ticket_id')->index();
$table->unsignedBigInteger('profile_id')->nullable();
$table->string('response_id');
$table->string('run_id')->nullable();
$table->string('status')->default('queued');
$table->text('error')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('agent_dispatches');
}
};

View file

@ -0,0 +1,23 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Model;
class AgentDispatch extends Model
{
public const STATUS_QUEUED = 'queued';
protected $fillable = [
'ticket_id',
'profile_id',
'response_id',
'run_id',
'status',
'error',
];
}

View file

@ -0,0 +1,48 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\AgentProfile;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class HermesClient
{
/**
* Create a Hermes response run for a Mantis ticket prompt.
*
* @param array<string, mixed> $metadata
* @return array<string, mixed>
*/
public function createResponse(AgentProfile $profile, string $input, array $metadata = []): array
{
$response = $this->request($profile)->post($this->url($profile), [
'model' => 'default',
'input' => $input,
'metadata' => $metadata,
]);
$response->throw();
$payload = $response->json();
return is_array($payload) ? $payload : [];
}
private function request(AgentProfile $profile): PendingRequest
{
return Http::withToken((string) $profile->api_key_cipher)
->acceptJson()
->asJson()
->timeout(60);
}
private function url(AgentProfile $profile): string
{
return rtrim($profile->gateway_url, '/').'/v1/responses';
}
}

View file

@ -0,0 +1,180 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Jobs\CaptureDispatchResultJob;
use Core\Mod\Agentic\Jobs\DispatchMantisTicketJob;
use Core\Mod\Agentic\Models\AgentDispatch;
use Core\Mod\Agentic\Models\AgentProfile;
use Core\Mod\Agentic\Services\HermesClient;
use Core\Mod\Agentic\Services\MantisClient;
use Core\Mod\Agentic\Services\ProfileSelector;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\Job as QueueJobContract;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
if (! class_exists(CaptureDispatchResultJob::class)) {
class DispatchMantisTicketJobTestCaptureDispatchResultJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $ticketId,
public string $responseId,
) {}
}
class_alias(
DispatchMantisTicketJobTestCaptureDispatchResultJob::class,
CaptureDispatchResultJob::class,
);
}
if (! class_exists(MantisClient::class)) {
class DispatchMantisTicketJobTestMantisClient {}
class_alias(
DispatchMantisTicketJobTestMantisClient::class,
MantisClient::class,
);
}
if (! class_exists(ProfileSelector::class)) {
class DispatchMantisTicketJobTestProfileSelector
{
public function pickFor(mixed $ticket): ?AgentProfile
{
return null;
}
}
class_alias(
DispatchMantisTicketJobTestProfileSelector::class,
ProfileSelector::class,
);
}
it('dispatches with the ticket identifier payload', function () {
Queue::fake();
DispatchMantisTicketJob::dispatch(827);
Queue::assertPushedOn('ai', DispatchMantisTicketJob::class);
Queue::assertPushed(DispatchMantisTicketJob::class, function ($queuedJob) {
return $queuedJob->ticketId === 827;
});
});
it('posts the prompt to Hermes, records the dispatch, and queues result capture', function () {
Queue::fake();
Http::fake([
'https://hermes.example.test/v1/responses' => Http::response([
'id' => 'resp_123',
'run_id' => 'run_456',
'status' => 'queued',
], 200),
]);
$profile = AgentProfile::query()->create([
'name' => 'Hermes Default',
'gateway_url' => 'https://hermes.example.test',
'api_key_cipher' => 'hermes-test-key',
'cost_class' => 'm',
'capability_tags' => ['mantis'],
'quota_headroom_pct' => 100,
'enabled' => true,
]);
$ticket = [
'body' => 'Investigate the failing regression test in the dispatch pipeline.',
];
$mantisClient = Mockery::mock(MantisClient::class);
$mantisClient->shouldReceive('get')
->once()
->with(827)
->andReturn($ticket);
$profileSelector = Mockery::mock(ProfileSelector::class);
$profileSelector->shouldReceive('pickFor')
->once()
->with($ticket)
->andReturn($profile);
$this->app->instance(MantisClient::class, $mantisClient);
$this->app->instance(ProfileSelector::class, $profileSelector);
$job = new DispatchMantisTicketJob(827);
$this->app->call([$job, 'handle'], [
'hermesClient' => new HermesClient,
]);
Http::assertSent(function ($request) {
return $request->method() === 'POST'
&& $request->url() === 'https://hermes.example.test/v1/responses'
&& $request->hasHeader('Authorization', 'Bearer hermes-test-key')
&& $request['model'] === 'default'
&& $request['input'] === 'Investigate the failing regression test in the dispatch pipeline.'
&& $request['metadata']['conversation'] === 'mantis-827'
&& $request['metadata']['ticket_id'] === 827;
});
$dispatch = AgentDispatch::query()->sole();
expect($dispatch->ticket_id)->toBe(827)
->and($dispatch->profile_id)->toBe($profile->id)
->and($dispatch->response_id)->toBe('resp_123')
->and($dispatch->run_id)->toBe('run_456')
->and($dispatch->status)->toBe('queued');
Queue::assertPushed(CaptureDispatchResultJob::class, 1);
});
it('logs and releases the job when no profile is available', function () {
Http::fake();
$ticket = [
'body' => 'Retry this ticket once a profile becomes available.',
];
$mantisClient = Mockery::mock(MantisClient::class);
$mantisClient->shouldReceive('get')
->once()
->with(912)
->andReturn($ticket);
$profileSelector = Mockery::mock(ProfileSelector::class);
$profileSelector->shouldReceive('pickFor')
->once()
->with($ticket)
->andReturn(null);
Log::shouldReceive('warning')
->once()
->with('DispatchMantisTicketJob: no agent profile available for Mantis ticket', [
'ticket_id' => 912,
]);
$queuedJob = Mockery::mock(QueueJobContract::class);
$queuedJob->shouldReceive('release')
->once()
->with(60);
$job = new DispatchMantisTicketJob(912);
$job->setJob($queuedJob);
$job->handle($mantisClient, new HermesClient, $profileSelector);
expect(AgentDispatch::query()->count())->toBe(0);
Http::assertNothingSent();
});