fix(agentic): align php state and fleet runtime
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
073938ca6f
commit
25ee288bd2
8 changed files with 209 additions and 7 deletions
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue