From 82ffd420e0d91454da884dbc34d5d7a23df30700 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 26 Apr 2026 00:52:29 +0100 Subject: [PATCH] feat(agent/php): DispatchMantisTicketJob + HermesClient + agent_dispatches table (#827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- php/Jobs/DispatchMantisTicketJob.php | 163 ++++++++++++++++ ...5_000003_create_agent_dispatches_table.php | 35 ++++ php/Models/AgentDispatch.php | 23 +++ php/Services/HermesClient.php | 48 +++++ .../Feature/DispatchMantisTicketJobTest.php | 180 ++++++++++++++++++ 5 files changed, 449 insertions(+) create mode 100644 php/Jobs/DispatchMantisTicketJob.php create mode 100644 php/Migrations/2026_04_25_000003_create_agent_dispatches_table.php create mode 100644 php/Models/AgentDispatch.php create mode 100644 php/Services/HermesClient.php create mode 100644 php/tests/Feature/DispatchMantisTicketJobTest.php diff --git a/php/Jobs/DispatchMantisTicketJob.php b/php/Jobs/DispatchMantisTicketJob.php new file mode 100644 index 0000000..f74c33b --- /dev/null +++ b/php/Jobs/DispatchMantisTicketJob.php @@ -0,0 +1,163 @@ +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 + */ + 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; + } +} diff --git a/php/Migrations/2026_04_25_000003_create_agent_dispatches_table.php b/php/Migrations/2026_04_25_000003_create_agent_dispatches_table.php new file mode 100644 index 0000000..b1934b3 --- /dev/null +++ b/php/Migrations/2026_04_25_000003_create_agent_dispatches_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/php/Models/AgentDispatch.php b/php/Models/AgentDispatch.php new file mode 100644 index 0000000..15dbe2e --- /dev/null +++ b/php/Models/AgentDispatch.php @@ -0,0 +1,23 @@ + $metadata + * @return array + */ + 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'; + } +} diff --git a/php/tests/Feature/DispatchMantisTicketJobTest.php b/php/tests/Feature/DispatchMantisTicketJobTest.php new file mode 100644 index 0000000..602a2f6 --- /dev/null +++ b/php/tests/Feature/DispatchMantisTicketJobTest.php @@ -0,0 +1,180 @@ +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(); +});