agent/php/Jobs/CaptureDispatchResultJob.php
Snider 83df8ad71a fix(agent): address CodeRabbit + SonarCloud findings on PR #6
20+ CHANGES_REQUESTED dispositions across PHP MCP services, Go pkg/agentic,
hermes_runner_mcp Python server, plugin shell scripts.

Highlights:
- DatabaseSchema.php: identifier quoting
- AwardCredits.php: task row locking order
- CreditTransaction.php: fail-fast row decoding
- OpenApiGenerator.php: YAML parse handling + uri query params
- CaptureDispatchResultJob.php: AgentProfile namespace fix
- CreditsController.php: missing workspace_id fail-closed
- QueryAuditService.php: prose query false positives + unbounded aggregation
- McpHealthService.php: proc_close after timeout + env var resolution
- CreditLedger.php + FleetOverview.php: workspace agent + dispatch target validation
- McpAgentServerCommand.php: quota burn on failed tool calls
- McpMetricsService.php: N-day window consistency
- hermes_runner_mcp: API key off command line + invalid method+id + run_id encoding
- CircuitBreaker.php: extracted CircuitOpenException class with autoload-correct placement
- pkg/agentic + brain + flow: SonarCloud sendMessage/fetchLoopRepoRefs/commitWorkspace/Connect annotations
- shell scripts: removed [[ usage for portability

43 files modified, 1 new (CircuitOpenException.php).

Verification: gofmt -w + php -l + python3 -m py_compile + bash -n all clean.
Touched-package go test passes (pkg/lib/flow, pkg/lib).
Full go test ./... blocked by pre-existing dappco.re module graph drift, out of scope.

Parked for separate work:
- Mantis #1062: go.mod local replace removal (cross-repo architectural)
- Mantis #1063: Sonar residual line-length / duplication quality-gate cluster

Closes findings on https://github.com/dAppCore/agent/pull/6

Co-authored-by: Codex <noreply@openai.com>
2026-04-27 13:39:24 +01:00

262 lines
6.6 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Jobs;
use Core\Mod\Agentic\Models\AgentProfile;
use Core\Mod\Agentic\Services\MantisClient;
use Core\Mod\Agentic\Services\ShaExtractor;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class CaptureDispatchResultJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const SUMMARY_PATHS = [
'output.summary',
'data.output.summary',
'result.output.summary',
'summary',
];
private const OUTPUT_PATHS = [
'output.content',
'output.text',
'data.output.content',
'data.output.text',
'result.output.content',
'result.output.text',
'content',
'text',
'message',
'output',
'data.output',
'result.output',
];
private const EVENT_PATHS = [
'events',
'data.events',
'result.events',
'log',
'data.log',
'result.log',
'tail',
'data.tail',
'result.tail',
'last_event',
'data.last_event',
'result.last_event',
];
public int $tries = 3;
public int $backoff = 60;
public int $timeout = 120;
/**
* @param array<string, mixed> $response
*/
public function __construct(
public int $ticketId,
public array $response,
public ?string $repo = null,
public ?AgentProfile $profile = null,
) {
$this->onQueue('ai');
}
public function handle(MantisClient $mantis, ShaExtractor $extractor): void
{
$transcript = $this->transcriptText();
if ($transcript === '') {
throw new RuntimeException('Hermes response did not contain model output.');
}
$match = $extractor->extract($transcript);
$sha = $match['sha'];
if ($sha === null) {
throw new RuntimeException('Unable to extract commit SHA from model output.');
}
$repo = $match['repo'] ?? $this->normaliseRepo($this->repo);
if ($repo === null) {
throw new RuntimeException('Unable to determine repository for close note.');
}
$forgeUrl = $match['forge_url'] ?? $this->buildForgeUrl($repo, $sha);
$mantis->note($this->ticketId, $this->buildCloseNote(
sha: $sha,
repo: $repo,
forgeUrl: $forgeUrl,
summaryLine: $this->firstLineOfSummary(),
profile: $this->profile,
));
$mantis->close($this->ticketId);
}
private function buildCloseNote(
string $sha,
string $repo,
string $forgeUrl,
string $summaryLine,
?AgentProfile $profile = null,
): string {
$profileName = $this->profileName($profile);
return "Implemented by codex exec supervised by CorePHP Agentic dispatch (profile: {$profileName}).\n"
."Commit: {$sha} on {$repo} dev ({$forgeUrl}).\n"
."{$summaryLine}\n\n"
.'Filed-by: agentic';
}
private function firstLineOfSummary(): string
{
$summary = $this->firstText(self::SUMMARY_PATHS);
if ($summary === '') {
$summary = $this->firstText(self::OUTPUT_PATHS);
}
if ($summary === '') {
$summary = $this->transcriptText();
}
foreach (preg_split('/\R/', $summary) ?: [] as $line) {
$trimmed = trim($line);
if ($trimmed !== '') {
return $trimmed;
}
}
return 'No summary available.';
}
private function transcriptText(): string
{
$segments = [];
foreach (array_merge(self::SUMMARY_PATHS, self::OUTPUT_PATHS, self::EVENT_PATHS) as $path) {
$text = $this->stringifyText(data_get($this->response, $path));
if ($text !== '') {
$segments[] = $text;
}
}
if ($segments === []) {
$fallback = $this->stringifyText($this->response);
if ($fallback !== '') {
$segments[] = $fallback;
}
}
return implode("\n", array_values(array_unique($segments)));
}
/**
* @param array<int, string> $paths
*/
private function firstText(array $paths): string
{
foreach ($paths as $path) {
$text = $this->stringifyText(data_get($this->response, $path));
if ($text !== '') {
return $text;
}
}
return '';
}
private function stringifyText(mixed $value): string
{
$segments = $this->collectStrings($value);
$segments = array_values(array_filter(array_map(
static fn (string $segment): string => trim($segment),
$segments,
), static fn (string $segment): bool => $segment !== ''));
return implode("\n", array_values(array_unique($segments)));
}
/**
* @return array<int, string>
*/
private function collectStrings(mixed $value): array
{
if (is_string($value)) {
return [$value];
}
if (is_int($value) || is_float($value)) {
return [(string) $value];
}
if (! is_array($value)) {
return [];
}
$preferredKeys = ['summary', 'content', 'text', 'message', 'body', 'log', 'tail'];
$segments = [];
foreach ($preferredKeys as $key) {
if (array_key_exists($key, $value)) {
$segments = array_merge($segments, $this->collectStrings($value[$key]));
}
}
foreach ($value as $key => $item) {
if (in_array($key, $preferredKeys, true)) {
continue;
}
$segments = array_merge($segments, $this->collectStrings($item));
}
return $segments;
}
private function normaliseRepo(?string $repo): ?string
{
if ($repo === null) {
return null;
}
$trimmed = trim($repo, " \t\n\r\0\x0B/");
return $trimmed !== '' ? $trimmed : null;
}
private function buildForgeUrl(string $repo, string $sha): string
{
return 'https://forge.lthn.sh/'.$repo.'/commit/'.$sha;
}
private function profileName(?AgentProfile $profile = null): string
{
$name = $profile?->name;
if (is_string($name) && trim($name) !== '') {
return trim($name);
}
return 'unknown';
}
}