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:
parent
fc59aa02eb
commit
82ffd420e0
5 changed files with 449 additions and 0 deletions
163
php/Jobs/DispatchMantisTicketJob.php
Normal file
163
php/Jobs/DispatchMantisTicketJob.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
23
php/Models/AgentDispatch.php
Normal file
23
php/Models/AgentDispatch.php
Normal 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',
|
||||
];
|
||||
}
|
||||
48
php/Services/HermesClient.php
Normal file
48
php/Services/HermesClient.php
Normal 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';
|
||||
}
|
||||
}
|
||||
180
php/tests/Feature/DispatchMantisTicketJobTest.php
Normal file
180
php/tests/Feature/DispatchMantisTicketJobTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue