diff --git a/php/Console/Commands/AgenticDispatchQueueCommand.php b/php/Console/Commands/AgenticDispatchQueueCommand.php new file mode 100644 index 0000000..65ef50e --- /dev/null +++ b/php/Console/Commands/AgenticDispatchQueueCommand.php @@ -0,0 +1,281 @@ +option('limit'); + + if ($limit < 1) { + $this->error('--limit must be greater than zero.'); + + return self::FAILURE; + } + + $tickets = $mantisClient->listOpen(); + + if ($tickets === []) { + $this->info('No open Mantis tickets awaiting dispatch.'); + Log::info('agentic:dispatch-queue found no open tickets', [ + 'limit' => $limit, + ]); + + return self::SUCCESS; + } + + Log::info('agentic:dispatch-queue starting', [ + 'limit' => $limit, + 'open_tickets' => count($tickets), + ]); + + $queued = 0; + $skippedAssigned = 0; + $skippedSuppressed = 0; + $skippedUnavailable = 0; + $failed = 0; + + foreach ($tickets as $ticket) { + if ($queued >= $limit) { + break; + } + + $ticketId = $this->ticketId($ticket); + + if ($ticketId === null) { + $failed++; + + Log::warning('agentic:dispatch-queue skipped ticket with invalid identifier', [ + 'ticket' => $ticket, + ]); + + continue; + } + + if (! $this->isUnassigned($ticket)) { + $skippedAssigned++; + + Log::info('agentic:dispatch-queue skipped assigned ticket', [ + 'ticket_id' => $ticketId, + ]); + + continue; + } + + if ($this->hasRecentDispatchMarker($ticket)) { + $skippedSuppressed++; + + Log::info('agentic:dispatch-queue skipped in-flight ticket', [ + 'ticket_id' => $ticketId, + ]); + + continue; + } + + $profile = $profileSelector->pickFor($ticket); + + if ($profile === null) { + $skippedUnavailable++; + + Log::info('agentic:dispatch-queue found no eligible profile for ticket', [ + 'ticket_id' => $ticketId, + ]); + + continue; + } + + try { + $mantisClient->note($ticketId, $this->inFlightNote($profile->name)); + DispatchMantisTicketJob::dispatch($ticketId); + + $queued++; + + $this->line("Queued Mantis ticket #{$ticketId} with profile {$profile->name}."); + Log::info('agentic:dispatch-queue queued ticket', [ + 'ticket_id' => $ticketId, + 'profile_id' => $profile->id, + 'profile_name' => $profile->name, + ]); + } catch (Throwable $throwable) { + $failed++; + + $this->warn("Failed to queue Mantis ticket #{$ticketId}: {$throwable->getMessage()}"); + Log::warning('agentic:dispatch-queue failed to queue ticket', [ + 'ticket_id' => $ticketId, + 'profile_id' => $profile->id, + 'profile_name' => $profile->name, + 'error' => $throwable->getMessage(), + ]); + } + } + + $this->info( + "Queued {$queued} Mantis ticket(s); " + ."skipped {$skippedAssigned} assigned, {$skippedSuppressed} in-flight, " + ."{$skippedUnavailable} without matching profile; {$failed} failed.", + ); + + Log::info('agentic:dispatch-queue completed', [ + 'queued' => $queued, + 'skipped_assigned' => $skippedAssigned, + 'skipped_suppressed' => $skippedSuppressed, + 'skipped_unavailable' => $skippedUnavailable, + 'failed' => $failed, + ]); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + /** + * @param array $ticket + */ + private function ticketId(array $ticket): ?int + { + $ticketId = data_get($ticket, 'id'); + + if (is_int($ticketId)) { + return $ticketId; + } + + if (is_string($ticketId) && ctype_digit($ticketId)) { + return (int) $ticketId; + } + + return null; + } + + /** + * @param array $ticket + */ + private function isUnassigned(array $ticket): bool + { + foreach ([ + data_get($ticket, 'handler.id'), + data_get($ticket, 'handler.name'), + data_get($ticket, 'handler.username'), + data_get($ticket, 'assigned_to.id'), + data_get($ticket, 'assigned_to.name'), + data_get($ticket, 'assigned_to.username'), + ] as $candidate) { + if (is_scalar($candidate) && trim((string) $candidate) !== '') { + return false; + } + } + + return blank(data_get($ticket, 'handler')) && blank(data_get($ticket, 'assigned_to')); + } + + /** + * @param array $ticket + */ + private function hasRecentDispatchMarker(array $ticket): bool + { + $notes = data_get($ticket, 'notes'); + + if (! is_array($notes)) { + return false; + } + + $threshold = now()->subMinutes(self::IN_FLIGHT_WINDOW_MINUTES); + + foreach ($notes as $note) { + $noteText = $this->noteText($note); + $createdAt = $this->noteTimestamp($note); + + if ($noteText === null || $createdAt === null) { + continue; + } + + if (str_contains($noteText, self::IN_FLIGHT_MARKER_PREFIX) && $createdAt->greaterThanOrEqualTo($threshold)) { + return true; + } + } + + return false; + } + + private function inFlightNote(string $profileName): string + { + return self::IN_FLIGHT_MARKER_PREFIX + ." (profile: {$profileName}). Suppressing concurrent dispatch for 5min."; + } + + private function noteText(mixed $note): ?string + { + $text = data_get($note, 'text'); + + if (! is_string($text)) { + return null; + } + + $text = trim($text); + + return $text === '' ? null : $text; + } + + private function noteTimestamp(mixed $note): ?Carbon + { + foreach (['created_at', 'date_submitted', 'updated_at', 'last_modified'] as $key) { + $timestamp = $this->parseTimestamp(data_get($note, $key)); + + if ($timestamp instanceof Carbon) { + return $timestamp; + } + } + + return null; + } + + private function parseTimestamp(mixed $value): ?Carbon + { + if (is_int($value) || (is_string($value) && ctype_digit($value))) { + return Carbon::createFromTimestamp((int) $value); + } + + if (is_string($value) && trim($value) !== '') { + try { + return Carbon::parse($value); + } catch (Throwable) { + return null; + } + } + + foreach (['date', 'value', 'timestamp'] as $key) { + $nestedValue = data_get($value, $key); + + if ($nestedValue === null) { + continue; + } + + $timestamp = $this->parseTimestamp($nestedValue); + + if ($timestamp instanceof Carbon) { + return $timestamp; + } + } + + return null; + } +} diff --git a/php/Console/Commands/AgenticSyncProfilesCommand.php b/php/Console/Commands/AgenticSyncProfilesCommand.php new file mode 100644 index 0000000..cf4c4e9 --- /dev/null +++ b/php/Console/Commands/AgenticSyncProfilesCommand.php @@ -0,0 +1,214 @@ +orderBy('id') + ->get(); + + if ($profiles->isEmpty()) { + $this->info('No agent profiles found to synchronise.'); + Log::info('agentic:sync-profiles found no profiles'); + + return self::SUCCESS; + } + + Log::info('agentic:sync-profiles starting', [ + 'profiles' => $profiles->count(), + ]); + + $synchronised = 0; + $failed = 0; + + foreach ($profiles as $profile) { + try { + $modelIdentifiers = $this->fetchModelIdentifiers($profile); + $capabilityTags = $this->inferCapabilityTags($modelIdentifiers); + + $profile->forceFill([ + 'capability_tags' => $capabilityTags, + ])->save(); + + $synchronised++; + + $this->line( + sprintf( + 'Synchronised %s: %s', + $profile->name, + $capabilityTags === [] ? '[none]' : '['.implode(', ', $capabilityTags).']', + ), + ); + + Log::info('agentic:sync-profiles synchronised profile', [ + 'profile_id' => $profile->id, + 'profile_name' => $profile->name, + 'model_count' => count($modelIdentifiers), + 'capability_tags' => $capabilityTags, + ]); + } catch (Throwable $throwable) { + $failed++; + + $message = "Failed to synchronise {$profile->name}: {$throwable->getMessage()}"; + + $this->warn($message); + Log::warning('agentic:sync-profiles failed to synchronise profile', [ + 'profile_id' => $profile->id, + 'profile_name' => $profile->name, + 'error' => $throwable->getMessage(), + ]); + } + } + + $this->info("Synchronised {$synchronised} profile(s); {$failed} failed."); + + Log::info('agentic:sync-profiles completed', [ + 'synchronised' => $synchronised, + 'failed' => $failed, + ]); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } + + /** + * @return list + */ + private function fetchModelIdentifiers(AgentProfile $profile): array + { + $response = Http::withToken((string) $profile->api_key_cipher) + ->acceptJson() + ->timeout(15) + ->get($this->modelsUrl($profile)); + + if (! $response->successful()) { + throw new RuntimeException("Gateway models request failed: {$response->status()}"); + } + + $payload = $response->json(); + $records = is_array($payload) + ? ($payload['data'] ?? $payload['models'] ?? $payload) + : []; + + if (! is_array($records)) { + return []; + } + + $identifiers = []; + + foreach ($records as $record) { + $identifier = $this->extractModelIdentifier($record); + + if ($identifier === null) { + continue; + } + + $identifiers[] = $identifier; + } + + sort($identifiers, SORT_NATURAL | SORT_FLAG_CASE); + + return array_values(array_unique($identifiers)); + } + + /** + * @return list + */ + private function inferCapabilityTags(array $modelIdentifiers): array + { + $capabilityTags = []; + + foreach ($modelIdentifiers as $modelIdentifier) { + foreach ($this->inferTagsForModel($modelIdentifier) as $tag) { + $capabilityTags[$tag] = $tag; + } + } + + ksort($capabilityTags); + + return array_values($capabilityTags); + } + + /** + * @return list + */ + private function inferTagsForModel(string $modelIdentifier): array + { + $tags = []; + $isSmallReasoner = ( + (str_contains($modelIdentifier, 'gpt') || str_contains($modelIdentifier, 'o3') || str_contains($modelIdentifier, 'o4')) + && (str_contains($modelIdentifier, 'mini') || str_contains($modelIdentifier, 'nano')) + ); + + if (str_contains($modelIdentifier, 'embedding')) { + $tags[] = 'embedding'; + } + + if (str_contains($modelIdentifier, 'claude-opus') || str_contains($modelIdentifier, 'opus')) { + array_push($tags, 'analysis', 'core', 'handoff'); + } elseif (! $isSmallReasoner && ( + str_contains($modelIdentifier, 'claude') + || str_contains($modelIdentifier, 'sonnet') + || str_contains($modelIdentifier, 'haiku') + || str_contains($modelIdentifier, 'gpt') + || str_contains($modelIdentifier, 'o3') + || str_contains($modelIdentifier, 'o4') + )) { + $tags[] = 'analysis'; + } + + if ($isSmallReasoner) { + array_push($tags, 'cheap', 'dispatch'); + } + + return array_values(array_unique($tags)); + } + + private function modelsUrl(AgentProfile $profile): string + { + return rtrim($profile->gateway_url, '/').'/v1/models'; + } + + private function extractModelIdentifier(mixed $record): ?string + { + if (is_string($record)) { + return $this->normaliseModelIdentifier($record); + } + + foreach (['id', 'name', 'model'] as $key) { + $value = data_get($record, $key); + + if (! is_string($value)) { + continue; + } + + return $this->normaliseModelIdentifier($value); + } + + return null; + } + + private function normaliseModelIdentifier(string $modelIdentifier): ?string + { + $normalised = strtolower(trim($modelIdentifier)); + + return $normalised === '' ? null : $normalised; + } +} diff --git a/php/Services/MantisClient.php b/php/Services/MantisClient.php index 86cb7b7..5ae3305 100644 --- a/php/Services/MantisClient.php +++ b/php/Services/MantisClient.php @@ -17,6 +17,24 @@ class MantisClient private ?string $token = null, ) {} + /** + * @return list> + */ + public function listOpen(): array + { + $response = $this->request()->get('/api/rest/issues', [ + 'status' => 'new', + ]); + + if (! $response->successful()) { + throw new RuntimeException("Mantis listOpen failed: {$response->status()}"); + } + + $issues = $response->json('issues'); + + return is_array($issues) ? array_values(array_filter($issues, 'is_array')) : []; + } + public function note(int $ticketId, string $text): void { $response = $this->request()->post("/api/rest/issues/{$ticketId}/notes", [ diff --git a/php/tests/Feature/Console/AgenticDispatchQueueCommandTest.php b/php/tests/Feature/Console/AgenticDispatchQueueCommandTest.php new file mode 100644 index 0000000..d05ed4e --- /dev/null +++ b/php/tests/Feature/Console/AgenticDispatchQueueCommandTest.php @@ -0,0 +1,215 @@ + 'base64:'.base64_encode(random_bytes(32))]); + } + + config([ + 'agentic.mantis.base_url' => 'https://tasks.example.test', + 'agentic.mantis.token' => 'mantis-token-123', + ]); + + Carbon::setTestNow('2026-04-26 12:00:00'); + + $this->app->make(Kernel::class)->registerCommand( + $this->app->make(AgenticDispatchQueueCommand::class), + ); +}); + +afterEach(function (): void { + Carbon::setTestNow(); +}); + +function dispatchQueueProfile(array $attributes = []): AgentProfile +{ + return AgentProfile::create(array_merge([ + 'name' => $attributes['name'] ?? 'dispatch-profile', + 'gateway_url' => $attributes['gateway_url'] ?? 'https://gateway.example.test', + 'api_key_cipher' => $attributes['api_key_cipher'] ?? 'plain-secret', + 'cost_class' => 'C', + 'capability_tags' => ['dispatch'], + 'quota_headroom_pct' => 100, + 'enabled' => true, + 'last_dispatched_at' => null, + ], $attributes)); +} + +test('AgenticDispatchQueueCommand_handle_Good_queues_up_to_the_default_limit_and_marks_tickets_in_flight', function (): void { + Queue::fake(); + + dispatchQueueProfile([ + 'name' => 'core-profile', + 'cost_class' => 'A', + 'capability_tags' => ['core'], + ]); + + dispatchQueueProfile([ + 'name' => 'review-profile', + 'cost_class' => 'B', + 'capability_tags' => ['review'], + 'quota_headroom_pct' => 50, + ]); + + dispatchQueueProfile([ + 'name' => 'general-profile', + 'cost_class' => 'C', + 'capability_tags' => ['dispatch'], + ]); + + Http::fake([ + 'https://tasks.example.test/api/rest/issues?status=new' => Http::response([ + 'issues' => [ + [ + 'id' => 900, + 'handler' => [ + 'id' => 7, + 'name' => 'assigned-user', + ], + 'summary' => 'Already assigned ticket', + 'tags' => ['core'], + ], + [ + 'id' => 901, + 'notes' => [ + [ + 'text' => 'Picked up by agentic dispatch queue (profile: stale-profile). Suppressing concurrent dispatch for 5min.', + 'created_at' => now()->subMinutes(2)->toIso8601String(), + ], + ], + 'summary' => 'Recently claimed ticket', + 'tags' => ['review'], + ], + [ + 'id' => 902, + 'severity' => 'critical', + 'summary' => 'Core escalation', + 'tags' => ['core'], + ], + [ + 'id' => 903, + 'severity' => 'major', + 'summary' => 'Review queue item', + 'tags' => ['review'], + ], + [ + 'id' => 904, + 'severity' => 'minor', + 'summary' => 'General maintenance', + 'tags' => [], + ], + ], + ], 200), + 'https://tasks.example.test/api/rest/issues/*/notes' => Http::response([], 201), + ]); + + $this->artisan('agentic:dispatch-queue') + ->expectsOutputToContain('Queued 3 Mantis ticket(s); skipped 1 assigned, 1 in-flight, 0 without matching profile; 0 failed.') + ->assertSuccessful(); + + Queue::assertPushedOn('ai', DispatchMantisTicketJob::class); + Queue::assertPushed(DispatchMantisTicketJob::class, 3); + Queue::assertPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 902); + Queue::assertPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 903); + Queue::assertPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 904); + Queue::assertNotPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => in_array($job->ticketId, [900, 901], true)); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' + && $request->url() === 'https://tasks.example.test/api/rest/issues?status=new' + && $request->hasHeader('Authorization', 'mantis-token-123')); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'https://tasks.example.test/api/rest/issues/902/notes' + && $request['text'] === 'Picked up by agentic dispatch queue (profile: core-profile). Suppressing concurrent dispatch for 5min.'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'https://tasks.example.test/api/rest/issues/903/notes' + && $request['text'] === 'Picked up by agentic dispatch queue (profile: review-profile). Suppressing concurrent dispatch for 5min.'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'https://tasks.example.test/api/rest/issues/904/notes' + && $request['text'] === 'Picked up by agentic dispatch queue (profile: general-profile). Suppressing concurrent dispatch for 5min.'); + + Http::assertSentCount(4); +}); + +test('AgenticDispatchQueueCommand_handle_Bad_rejects_non_positive_limits', function (): void { + Queue::fake(); + Http::fake(); + + $this->artisan('agentic:dispatch-queue', ['--limit' => 0]) + ->expectsOutput('--limit must be greater than zero.') + ->assertExitCode(Command::FAILURE); + + Queue::assertNothingPushed(); + Http::assertNothingSent(); +}); + +test('AgenticDispatchQueueCommand_handle_Ugly_honours_a_custom_limit_after_skips', function (): void { + Queue::fake(); + + dispatchQueueProfile([ + 'name' => 'core-profile', + 'cost_class' => 'A', + 'capability_tags' => ['core'], + ]); + + dispatchQueueProfile([ + 'name' => 'general-profile', + 'cost_class' => 'C', + 'capability_tags' => ['dispatch'], + ]); + + Http::fake([ + 'https://tasks.example.test/api/rest/issues?status=new' => Http::response([ + 'issues' => [ + [ + 'id' => 910, + 'handler' => [ + 'id' => 3, + 'name' => 'assigned-user', + ], + 'summary' => 'Assigned first', + 'tags' => ['core'], + ], + [ + 'id' => 911, + 'severity' => 'critical', + 'summary' => 'First queueable ticket', + 'tags' => ['core'], + ], + [ + 'id' => 912, + 'severity' => 'minor', + 'summary' => 'Would queue next if limit allowed', + 'tags' => [], + ], + ], + ], 200), + 'https://tasks.example.test/api/rest/issues/*/notes' => Http::response([], 201), + ]); + + $this->artisan('agentic:dispatch-queue', ['--limit' => 1]) + ->expectsOutputToContain('Queued 1 Mantis ticket(s); skipped 1 assigned, 0 in-flight, 0 without matching profile; 0 failed.') + ->assertSuccessful(); + + Queue::assertPushed(DispatchMantisTicketJob::class, 1); + Queue::assertPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 911); + Queue::assertNotPushed(DispatchMantisTicketJob::class, fn (DispatchMantisTicketJob $job): bool => $job->ticketId === 912); + + Http::assertSentCount(2); +}); diff --git a/php/tests/Feature/Console/AgenticSyncProfilesCommandTest.php b/php/tests/Feature/Console/AgenticSyncProfilesCommandTest.php new file mode 100644 index 0000000..6622a37 --- /dev/null +++ b/php/tests/Feature/Console/AgenticSyncProfilesCommandTest.php @@ -0,0 +1,130 @@ + 'base64:'.base64_encode(random_bytes(32))]); + } + + $this->app->make(Kernel::class)->registerCommand( + $this->app->make(AgenticSyncProfilesCommand::class), + ); +}); + +function syncProfilesProfile(array $attributes = []): AgentProfile +{ + return AgentProfile::create(array_merge([ + 'name' => $attributes['name'] ?? 'dispatch-profile', + 'gateway_url' => $attributes['gateway_url'] ?? 'https://gateway.example.test', + 'api_key_cipher' => $attributes['api_key_cipher'] ?? 'plain-secret', + 'cost_class' => 'C', + 'capability_tags' => ['dispatch'], + 'quota_headroom_pct' => 100, + 'enabled' => true, + 'last_dispatched_at' => null, + ], $attributes)); +} + +test('AgenticSyncProfilesCommand_handle_Good_synchronises_gateway_models_without_touching_dispatch_usage', function (): void { + $lastDispatchedAt = Carbon::parse('2026-04-26 09:15:00'); + + $opusProfile = syncProfilesProfile([ + 'name' => 'claude-opus-profile', + 'gateway_url' => 'https://gateway-opus.example.test', + 'api_key_cipher' => 'opus-token', + 'capability_tags' => ['stale'], + 'last_dispatched_at' => $lastDispatchedAt, + ]); + + $miniProfile = syncProfilesProfile([ + 'name' => 'gpt-mini-profile', + 'gateway_url' => 'https://gateway-mini.example.test', + 'api_key_cipher' => 'mini-token', + 'capability_tags' => ['old-tag'], + 'enabled' => false, + ]); + + Http::fake([ + 'https://gateway-opus.example.test/v1/models' => Http::response([ + 'data' => [ + ['id' => 'claude-opus-4-1'], + ['id' => 'embedding-3-large'], + ], + ], 200), + 'https://gateway-mini.example.test/v1/models' => Http::response([ + 'data' => [ + ['id' => 'gpt-5.4-mini'], + ], + ], 200), + ]); + + $this->artisan('agentic:sync-profiles') + ->expectsOutputToContain('Synchronised 2 profile(s); 0 failed.') + ->assertSuccessful(); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' + && $request->url() === 'https://gateway-opus.example.test/v1/models' + && $request->hasHeader('Authorization', 'Bearer opus-token')); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' + && $request->url() === 'https://gateway-mini.example.test/v1/models' + && $request->hasHeader('Authorization', 'Bearer mini-token')); + + expect($opusProfile->fresh()->capability_tags)->toBe([ + 'analysis', + 'core', + 'embedding', + 'handoff', + ])->and($opusProfile->fresh()->last_dispatched_at?->equalTo($lastDispatchedAt))->toBeTrue() + ->and($miniProfile->fresh()->capability_tags)->toBe([ + 'cheap', + 'dispatch', + ]); +}); + +test('AgenticSyncProfilesCommand_handle_Bad_continues_past_gateway_failures_and_returns_failure', function (): void { + $healthyProfile = syncProfilesProfile([ + 'name' => 'healthy-profile', + 'gateway_url' => 'https://gateway-healthy.example.test', + 'api_key_cipher' => 'healthy-token', + ]); + + $brokenProfile = syncProfilesProfile([ + 'name' => 'broken-profile', + 'gateway_url' => 'https://gateway-broken.example.test', + 'api_key_cipher' => 'broken-token', + 'capability_tags' => ['keep-me'], + ]); + + Http::fake([ + 'https://gateway-healthy.example.test/v1/models' => Http::response([ + 'data' => [ + ['id' => 'gpt-5.4-mini'], + ], + ], 200), + 'https://gateway-broken.example.test/v1/models' => Http::response([], 500), + ]); + + $this->artisan('agentic:sync-profiles') + ->expectsOutputToContain('Failed to synchronise broken-profile: Gateway models request failed: 500') + ->expectsOutputToContain('Synchronised 1 profile(s); 1 failed.') + ->assertExitCode(Command::FAILURE); + + expect($healthyProfile->fresh()->capability_tags)->toBe([ + 'cheap', + 'dispatch', + ])->and($brokenProfile->fresh()->capability_tags)->toBe([ + 'keep-me', + ]); +});