fix(agentic): align php state and fleet runtime

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 08:08:04 +00:00
parent 073938ca6f
commit 25ee288bd2
8 changed files with 209 additions and 7 deletions

View file

@ -19,6 +19,9 @@ class GetFleetStats
{
$nodes = FleetNode::query()->where('workspace_id', $workspaceId);
$tasks = FleetTask::query()->where('workspace_id', $workspaceId);
$taskSamples = (clone $tasks)
->whereNotNull('started_at')
->get();
return [
'nodes_online' => (clone $nodes)->online()->count(),
@ -26,7 +29,21 @@ class GetFleetStats
'tasks_week' => (clone $tasks)->where('created_at', '>=', now()->subDays(7))->count(),
'repos_touched' => (clone $tasks)->distinct('repo')->count('repo'),
'findings_total' => (clone $tasks)->get()->sum(static fn (FleetTask $task) => count($task->findings ?? [])),
'compute_hours' => 0,
'compute_hours' => (int) round(
$taskSamples->sum(fn (FleetTask $task) => self::taskDurationSeconds($task)) / 3600,
),
];
}
private static function taskDurationSeconds(FleetTask $fleetTask): int
{
if ($fleetTask->started_at === null) {
return 0;
}
return max(
0,
(int) $fleetTask->started_at->diffInSeconds($fleetTask->completed_at ?? now()),
);
}
}

View file

@ -28,22 +28,144 @@ class GetNextTask
throw new \InvalidArgumentException('Fleet node not found');
}
if (in_array($node->status, [FleetNode::STATUS_OFFLINE, FleetNode::STATUS_PAUSED], true)) {
return null;
}
$task = FleetTask::pendingForNode($node)->first();
if (! $task && ! $this->exceedsDailyBudget($node)) {
$task = $this->selectQueuedTask($workspaceId, $node, $capabilities);
}
if (! $task) {
return null;
}
$task->update([
$task->update(array_filter([
'fleet_node_id' => $task->fleet_node_id ?? $node->id,
'status' => FleetTask::STATUS_IN_PROGRESS,
'started_at' => $task->started_at ?? now(),
]);
], static fn (mixed $value): bool => $value !== null));
$node->update([
'status' => FleetNode::STATUS_BUSY,
'current_task_id' => $task->id,
'last_heartbeat_at' => now(),
]);
return $task->fresh();
}
/**
* @param array<string, mixed> $capabilities
*/
private function selectQueuedTask(int $workspaceId, FleetNode $node, array $capabilities): ?FleetTask
{
$preferredRepo = $this->lastTouchedRepo($node);
$nodeCapabilities = $this->normaliseCapabilities(array_merge(
$node->capabilities ?? [],
$capabilities,
));
$tasks = FleetTask::query()
->where('workspace_id', $workspaceId)
->whereNull('fleet_node_id')
->whereIn('status', [FleetTask::STATUS_ASSIGNED, FleetTask::STATUS_QUEUED])
->get()
->filter(fn (FleetTask $fleetTask): bool => $this->matchesCapabilities($fleetTask, $nodeCapabilities))
->sortBy(fn (FleetTask $fleetTask): string => sprintf(
'%d-%d-%010d-%010d',
$this->priorityWeight($fleetTask),
$preferredRepo !== null && $fleetTask->repo === $preferredRepo ? 0 : 1,
$fleetTask->created_at?->getTimestamp() ?? 0,
$fleetTask->id,
));
$task = $tasks->first();
return $task instanceof FleetTask ? $task : null;
}
private function exceedsDailyBudget(FleetNode $node): bool
{
$maxDailyHours = (float) ($node->compute_budget['max_daily_hours'] ?? 0);
if ($maxDailyHours <= 0) {
return false;
}
$usedSeconds = $node->tasks()
->whereDate('started_at', today())
->get()
->sum(fn (FleetTask $fleetTask): int => $this->taskDurationSeconds($fleetTask));
return $usedSeconds >= (int) round($maxDailyHours * 3600);
}
private function taskDurationSeconds(FleetTask $fleetTask): int
{
if ($fleetTask->started_at === null) {
return 0;
}
return max(
0,
(int) $fleetTask->started_at->diffInSeconds($fleetTask->completed_at ?? now()),
);
}
private function lastTouchedRepo(FleetNode $node): ?string
{
return $node->tasks()
->whereNotNull('repo')
->orderByDesc('completed_at')
->orderByDesc('updated_at')
->value('repo');
}
/**
* @param array<int, mixed> $capabilities
*/
private function normaliseCapabilities(array $capabilities): array
{
$normalised = [];
foreach ($capabilities as $key => $value) {
if (is_string($key) && $value) {
$normalised[] = $key;
}
if (is_string($value) && $value !== '') {
$normalised[] = $value;
}
}
return array_values(array_unique($normalised));
}
/**
* @param array<int, string> $nodeCapabilities
*/
private function matchesCapabilities(FleetTask $fleetTask, array $nodeCapabilities): bool
{
$report = is_array($fleetTask->report) ? $fleetTask->report : [];
$required = $this->normaliseCapabilities((array) ($report['required_capabilities'] ?? []));
if ($required === []) {
return true;
}
return array_diff($required, $nodeCapabilities) === [];
}
private function priorityWeight(FleetTask $fleetTask): int
{
$report = is_array($fleetTask->report) ? $fleetTask->report : [];
$priority = strtoupper((string) ($report['priority'] ?? 'P2'));
return match ($priority) {
'P0' => 0,
'P1' => 1,
'P2' => 2,
'P3' => 3,
default => 4,
};
}
}

View file

@ -7,6 +7,7 @@ namespace Core\Mod\Agentic\Mcp\Tools\Agent\State;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\WorkspaceState;
/**
* Get a workspace state value.
@ -92,7 +93,7 @@ class StateGet extends AgentTool
return $this->success([
'key' => $state->key,
'value' => $state->value,
'category' => $state->category,
'category' => $state->category ?? WorkspaceState::CATEGORY_GENERAL,
'updated_at' => $state->updated_at->toIso8601String(),
]);
}

View file

@ -7,6 +7,7 @@ namespace Core\Mod\Agentic\Mcp\Tools\Agent\State;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\WorkspaceState;
/**
* List all state values for a plan.
@ -95,7 +96,7 @@ class StateList extends AgentTool
'states' => $states->map(fn ($state) => [
'key' => $state->key,
'value' => $state->value,
'category' => $state->category,
'category' => $state->category ?? WorkspaceState::CATEGORY_GENERAL,
])->all(),
'total' => $states->count(),
]);

View file

@ -100,7 +100,7 @@ class StateSet extends AgentTool
],
[
'value' => $value,
'category' => $this->optional($args, 'category', 'general'),
'category' => $this->optional($args, 'category', WorkspaceState::CATEGORY_GENERAL),
]
);
@ -108,7 +108,7 @@ class StateSet extends AgentTool
'state' => [
'key' => $state->key,
'value' => $state->value,
'category' => $state->category,
'category' => $state->category ?? WorkspaceState::CATEGORY_GENERAL,
],
]);
}

View file

@ -0,0 +1,34 @@
<?php
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_workspace_states') || Schema::hasColumn('agent_workspace_states', 'category')) {
return;
}
Schema::table('agent_workspace_states', function (Blueprint $table) {
$table->string('category', 64)->default('general');
$table->index(['agent_plan_id', 'category'], 'agent_workspace_states_plan_category_idx');
});
}
public function down(): void
{
if (! Schema::hasTable('agent_workspace_states') || ! Schema::hasColumn('agent_workspace_states', 'category')) {
return;
}
Schema::table('agent_workspace_states', function (Blueprint $table) {
$table->dropIndex('agent_workspace_states_plan_category_idx');
$table->dropColumn('category');
});
}
};

View file

@ -15,6 +15,8 @@ class WorkspaceState extends Model
protected $table = 'agent_workspace_states';
public const CATEGORY_GENERAL = 'general';
public const TYPE_JSON = 'json';
public const TYPE_MARKDOWN = 'markdown';
@ -26,6 +28,7 @@ class WorkspaceState extends Model
protected $fillable = [
'agent_plan_id',
'key',
'category',
'value',
'type',
'description',
@ -52,6 +55,11 @@ class WorkspaceState extends Model
return $query->where('type', $type);
}
public function scopeInCategory(Builder $query, string $category): Builder
{
return $query->where('category', $category);
}
public function isJson(): bool
{
return $this->type === self::TYPE_JSON;
@ -128,6 +136,7 @@ class WorkspaceState extends Model
{
return [
'key' => $this->key,
'category' => $this->category ?? self::CATEGORY_GENERAL,
'type' => $this->type,
'description' => $this->description,
'value' => $this->value,

View file

@ -63,6 +63,21 @@ class WorkspaceStateTest extends TestCase
$this->assertEquals(42, $fresh->value['count']);
}
public function test_it_persists_category_metadata(): void
{
$state = WorkspaceState::create([
'agent_plan_id' => $this->plan->id,
'key' => 'categorised_key',
'category' => 'analysis',
'value' => ['foo' => 'bar'],
]);
$this->assertDatabaseHas('agent_workspace_states', [
'id' => $state->id,
'category' => 'analysis',
]);
}
// =========================================================================
// Type constants and helpers
// =========================================================================
@ -230,6 +245,7 @@ class WorkspaceStateTest extends TestCase
$state = WorkspaceState::create([
'agent_plan_id' => $this->plan->id,
'key' => 'mcp_key',
'category' => 'analysis',
'value' => ['x' => 99],
'type' => WorkspaceState::TYPE_JSON,
'description' => 'Test state entry',
@ -238,11 +254,13 @@ class WorkspaceStateTest extends TestCase
$context = $state->toMcpContext();
$this->assertArrayHasKey('key', $context);
$this->assertArrayHasKey('category', $context);
$this->assertArrayHasKey('type', $context);
$this->assertArrayHasKey('description', $context);
$this->assertArrayHasKey('value', $context);
$this->assertArrayHasKey('updated_at', $context);
$this->assertEquals('mcp_key', $context['key']);
$this->assertEquals('analysis', $context['category']);
$this->assertEquals('Test state entry', $context['description']);
}