diff --git a/php/Actions/Fleet/GetFleetStats.php b/php/Actions/Fleet/GetFleetStats.php index e8db1e4..59c36ca 100644 --- a/php/Actions/Fleet/GetFleetStats.php +++ b/php/Actions/Fleet/GetFleetStats.php @@ -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()), + ); + } } diff --git a/php/Actions/Fleet/GetNextTask.php b/php/Actions/Fleet/GetNextTask.php index 98296f3..e3db025 100644 --- a/php/Actions/Fleet/GetNextTask.php +++ b/php/Actions/Fleet/GetNextTask.php @@ -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 $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 $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 $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, + }; + } } diff --git a/php/Mcp/Tools/Agent/State/StateGet.php b/php/Mcp/Tools/Agent/State/StateGet.php index 590043f..d2ec07e 100644 --- a/php/Mcp/Tools/Agent/State/StateGet.php +++ b/php/Mcp/Tools/Agent/State/StateGet.php @@ -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(), ]); } diff --git a/php/Mcp/Tools/Agent/State/StateList.php b/php/Mcp/Tools/Agent/State/StateList.php index 694ab61..4e42d56 100644 --- a/php/Mcp/Tools/Agent/State/StateList.php +++ b/php/Mcp/Tools/Agent/State/StateList.php @@ -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(), ]); diff --git a/php/Mcp/Tools/Agent/State/StateSet.php b/php/Mcp/Tools/Agent/State/StateSet.php index f7c6b1d..6cc22de 100644 --- a/php/Mcp/Tools/Agent/State/StateSet.php +++ b/php/Mcp/Tools/Agent/State/StateSet.php @@ -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, ], ]); } diff --git a/php/Migrations/2026_03_31_000002_add_category_to_agent_workspace_states.php b/php/Migrations/2026_03_31_000002_add_category_to_agent_workspace_states.php new file mode 100644 index 0000000..ed42a97 --- /dev/null +++ b/php/Migrations/2026_03_31_000002_add_category_to_agent_workspace_states.php @@ -0,0 +1,34 @@ +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'); + }); + } +}; diff --git a/php/Models/WorkspaceState.php b/php/Models/WorkspaceState.php index 52fc547..5b60e38 100644 --- a/php/Models/WorkspaceState.php +++ b/php/Models/WorkspaceState.php @@ -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, diff --git a/php/tests/Feature/WorkspaceStateTest.php b/php/tests/Feature/WorkspaceStateTest.php index 6456498..1733294 100644 --- a/php/tests/Feature/WorkspaceStateTest.php +++ b/php/tests/Feature/WorkspaceStateTest.php @@ -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']); }