diff --git a/php/Actions/Credits/AwardCredits.php b/php/Actions/Credits/AwardCredits.php index e16bda0..7cc3651 100644 --- a/php/Actions/Credits/AwardCredits.php +++ b/php/Actions/Credits/AwardCredits.php @@ -1,5 +1,7 @@ where('workspace_id', $workspaceId)->find($fleetNodeId) - : FleetNode::query()->where('workspace_id', $workspaceId)->where('agent_id', $agentId)->first(); + return DB::transaction(function () use ( + $workspaceId, + $agentId, + $taskType, + $amount, + $fleetNodeId, + $description, + $fleetTaskId, + ): CreditEntry { + $node = $fleetNodeId !== null + ? FleetNode::query()->where('workspace_id', $workspaceId)->lockForUpdate()->find($fleetNodeId) + : FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->lockForUpdate() + ->first(); - if (! $node) { - throw new \InvalidArgumentException('Fleet node not found'); - } + if (! $node instanceof FleetNode) { + throw new \InvalidArgumentException('Fleet node not found'); + } - $previousBalance = (int) CreditEntry::query() - ->where('workspace_id', $workspaceId) - ->where('fleet_node_id', $node->id) - ->latest('id') - ->value('balance_after'); + if ($fleetTaskId !== null) { + $fleetTask = FleetTask::query() + ->where('workspace_id', $workspaceId) + ->find($fleetTaskId); - return CreditEntry::create([ - 'workspace_id' => $workspaceId, - 'fleet_node_id' => $node->id, - 'task_type' => $taskType, - 'amount' => $amount, - 'balance_after' => $previousBalance + $amount, - 'description' => $description, - ]); + if (! $fleetTask instanceof FleetTask) { + throw new \InvalidArgumentException('Fleet task not found'); + } + + if ($fleetTask->fleet_node_id !== null && $fleetTask->fleet_node_id !== $node->id) { + throw new \InvalidArgumentException('Fleet task does not belong to the node being credited'); + } + + $existing = CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->where('fleet_node_id', $node->id) + ->where('fleet_task_id', $fleetTaskId) + ->first(); + + if ($existing instanceof CreditEntry) { + return $existing; + } + } + + $previousBalance = (int) CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $node->agent_id) + ->latest('id') + ->value('balance_after'); + + return CreditEntry::query()->create([ + 'workspace_id' => $workspaceId, + 'fleet_node_id' => $node->id, + 'fleet_task_id' => $fleetTaskId, + 'agent_id' => $node->agent_id, + 'task_type' => $taskType, + 'amount' => $amount, + 'balance_after' => $previousBalance + $amount, + 'description' => $description, + ]); + }); } } diff --git a/php/Actions/Credits/GetBalance.php b/php/Actions/Credits/GetBalance.php index 6dee1d5..0276432 100644 --- a/php/Actions/Credits/GetBalance.php +++ b/php/Actions/Credits/GetBalance.php @@ -1,5 +1,7 @@ where('workspace_id', $workspaceId) ->where('agent_id', $agentId) - ->first(); + ->value('id'); - if (! $node) { + $query = CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->where(function (Builder $builder) use ($agentId, $nodeId): void { + $builder->where('agent_id', $agentId); + + if ($nodeId !== null) { + $builder->orWhere(function (Builder $legacy) use ($nodeId): void { + $legacy->whereNull('agent_id') + ->where('fleet_node_id', $nodeId); + }); + } + }); + + if ($nodeId === null && ! $query->exists()) { throw new \InvalidArgumentException('Fleet node not found'); } - $balance = (int) CreditEntry::query() - ->where('workspace_id', $workspaceId) - ->where('fleet_node_id', $node->id) + $balance = (int) (clone $query) ->latest('id') ->value('balance_after'); + $totalEarned = (int) (clone $query)->where('amount', '>', 0)->sum('amount'); + $totalSpent = (int) abs((int) (clone $query)->where('amount', '<', 0)->sum('amount')); + $entries = (clone $query)->count(); return [ 'agent_id' => $agentId, 'balance' => $balance, - 'entries' => CreditEntry::query() - ->where('workspace_id', $workspaceId) - ->where('fleet_node_id', $node->id) - ->count(), + 'total_earned' => $totalEarned, + 'total_spent' => $totalSpent, + 'entries' => $entries, ]; } } diff --git a/php/Actions/Credits/GetCreditHistory.php b/php/Actions/Credits/GetCreditHistory.php index b98a0f4..4ee45c5 100644 --- a/php/Actions/Credits/GetCreditHistory.php +++ b/php/Actions/Credits/GetCreditHistory.php @@ -1,5 +1,7 @@ where('workspace_id', $workspaceId) ->where('agent_id', $agentId) - ->first(); + ->value('id'); - if (! $node) { + $query = CreditEntry::query() + ->where('workspace_id', $workspaceId) + ->where(function (Builder $builder) use ($agentId, $nodeId): void { + $builder->where('agent_id', $agentId); + + if ($nodeId !== null) { + $builder->orWhere(function (Builder $legacy) use ($nodeId): void { + $legacy->whereNull('agent_id') + ->where('fleet_node_id', $nodeId); + }); + } + }); + + if ($nodeId === null && ! $query->exists()) { throw new \InvalidArgumentException('Fleet node not found'); } - return CreditEntry::query() - ->where('workspace_id', $workspaceId) - ->where('fleet_node_id', $node->id) + return $query ->latest() ->limit($limit) ->get(); diff --git a/php/Actions/Fleet/CompleteTask.php b/php/Actions/Fleet/CompleteTask.php index 2f67848..168eeee 100644 --- a/php/Actions/Fleet/CompleteTask.php +++ b/php/Actions/Fleet/CompleteTask.php @@ -1,5 +1,7 @@ where('workspace_id', $workspaceId) - ->where('agent_id', $agentId) - ->first(); + return DB::transaction(function () use ( + $workspaceId, + $agentId, + $taskId, + $result, + $findings, + $changes, + $report, + ): FleetTask { + $node = FleetNode::query() + ->where('workspace_id', $workspaceId) + ->where('agent_id', $agentId) + ->lockForUpdate() + ->first(); - $fleetTask = FleetTask::query() - ->where('workspace_id', $workspaceId) - ->find($taskId); + $fleetTask = FleetTask::query() + ->where('workspace_id', $workspaceId) + ->lockForUpdate() + ->find($taskId); - if (! $node || ! $fleetTask) { - throw new \InvalidArgumentException('Fleet task not found'); - } + if (! $node instanceof FleetNode || ! $fleetTask instanceof FleetTask) { + throw new \InvalidArgumentException('Fleet task not found'); + } - $status = ($result['status'] ?? '') === 'failed' - ? FleetTask::STATUS_FAILED - : FleetTask::STATUS_COMPLETED; + if ($fleetTask->fleet_node_id !== null && $fleetTask->fleet_node_id !== $node->id) { + throw new \InvalidArgumentException('Fleet task does not belong to this node'); + } - $fleetTask->update([ - 'status' => $status, - 'result' => $result, - 'findings' => $findings, - 'changes' => $changes, - 'report' => $report, - 'completed_at' => now(), - ]); + $status = ($result['status'] ?? '') === 'failed' + ? FleetTask::STATUS_FAILED + : FleetTask::STATUS_COMPLETED; - $node->update([ - 'status' => FleetNode::STATUS_ONLINE, - 'current_task_id' => null, - 'last_heartbeat_at' => now(), - ]); + $fleetTask->update([ + 'status' => $status, + 'result' => $result, + 'findings' => $findings, + 'changes' => $changes, + 'report' => $report, + 'completed_at' => now(), + ]); - $creditAmount = max(1, count($findings) + 1); - AwardCredits::run($workspaceId, $agentId, 'fleet-task', $creditAmount, $node->id, 'Fleet task completed'); + $creditAmount = max(1, count($findings) + 1); + AwardCredits::run( + $workspaceId, + $agentId, + 'fleet-task', + $creditAmount, + $node->id, + 'Fleet task completed', + $fleetTask->id, + ); - return $fleetTask->fresh(); + $nodeUpdate = [ + 'last_heartbeat_at' => now(), + ]; + + if ($node->current_task_id === null || $node->current_task_id === $fleetTask->id) { + $nodeUpdate['status'] = FleetNode::STATUS_ONLINE; + $nodeUpdate['current_task_id'] = null; + } + + $node->update($nodeUpdate); + + return $fleetTask->fresh(); + }); } } diff --git a/php/Boot.php b/php/Boot.php index b3be3e3..a009b06 100644 --- a/php/Boot.php +++ b/php/Boot.php @@ -1,5 +1,7 @@ command(Console\Commands\TaskCommand::class); $event->command(Console\Commands\PlanCommand::class); - $event->command(Console\Commands\AgenticGenerateCommand::class); $event->command(Console\Commands\GenerateCommand::class); $event->command(Console\Commands\PlanRetentionCommand::class); $event->command(Console\Commands\BrainSeedMemoryCommand::class); $event->command(Console\Commands\BrainIngestCommand::class); + $event->command(Console\Commands\BrainCleanCommand::class); + $event->command(Console\Commands\BrainPruneCommand::class); + $event->command(Console\Commands\BrainReindexCommand::class); $event->command(Console\Commands\ScanCommand::class); $event->command(Console\Commands\DispatchCommand::class); $event->command(Console\Commands\PrManageCommand::class); diff --git a/php/Console/Commands/GenerateCommand.php b/php/Console/Commands/GenerateCommand.php index 070033d..0e64e75 100644 --- a/php/Console/Commands/GenerateCommand.php +++ b/php/Console/Commands/GenerateCommand.php @@ -1,5 +1,7 @@ memoryId); - if (! $memory instanceof BrainMemory) { + if (! $memory instanceof BrainMemory || $memory->indexed_at !== null) { return; } diff --git a/php/Migrations/2026_04_25_000002_reconcile_fleet_nodes_table.php b/php/Migrations/2026_04_25_000002_reconcile_fleet_nodes_table.php new file mode 100644 index 0000000..8eac2c3 --- /dev/null +++ b/php/Migrations/2026_04_25_000002_reconcile_fleet_nodes_table.php @@ -0,0 +1,34 @@ +unsignedBigInteger('current_task_id')->nullable(); + }); + } + + public function down(): void + { + if (! Schema::hasTable('fleet_nodes') || ! Schema::hasColumn('fleet_nodes', 'current_task_id')) { + return; + } + + Schema::table('fleet_nodes', function (Blueprint $table): void { + $table->dropColumn('current_task_id'); + }); + } +}; diff --git a/php/Migrations/2026_04_25_000003_reconcile_fleet_tasks_and_credit_entries.php b/php/Migrations/2026_04_25_000003_reconcile_fleet_tasks_and_credit_entries.php new file mode 100644 index 0000000..f6a356b --- /dev/null +++ b/php/Migrations/2026_04_25_000003_reconcile_fleet_tasks_and_credit_entries.php @@ -0,0 +1,74 @@ +string('agent_id')->nullable(); + $table->index('agent_id'); + }); + } + + if (! Schema::hasColumn('credit_entries', 'fleet_task_id')) { + Schema::table('credit_entries', function (Blueprint $table): void { + $table->unsignedBigInteger('fleet_task_id')->nullable(); + $table->index('fleet_task_id'); + }); + } + + DB::table('credit_entries') + ->select(['id', 'fleet_node_id']) + ->whereNull('agent_id') + ->whereNotNull('fleet_node_id') + ->orderBy('id') + ->chunkById(100, function ($entries): void { + foreach ($entries as $entry) { + $agentId = DB::table('fleet_nodes') + ->where('id', $entry->fleet_node_id) + ->value('agent_id'); + + if (is_string($agentId) && $agentId !== '') { + DB::table('credit_entries') + ->where('id', $entry->id) + ->update(['agent_id' => $agentId]); + } + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('credit_entries')) { + return; + } + + if (Schema::hasColumn('credit_entries', 'fleet_task_id')) { + Schema::table('credit_entries', function (Blueprint $table): void { + $table->dropIndex(['fleet_task_id']); + $table->dropColumn('fleet_task_id'); + }); + } + + if (Schema::hasColumn('credit_entries', 'agent_id')) { + Schema::table('credit_entries', function (Blueprint $table): void { + $table->dropIndex(['agent_id']); + $table->dropColumn('agent_id'); + }); + } + } +}; diff --git a/php/Migrations/2026_04_25_000004_add_fleet_current_task_and_credit_constraints.php b/php/Migrations/2026_04_25_000004_add_fleet_current_task_and_credit_constraints.php new file mode 100644 index 0000000..982d27d --- /dev/null +++ b/php/Migrations/2026_04_25_000004_add_fleet_current_task_and_credit_constraints.php @@ -0,0 +1,70 @@ +unique(['fleet_node_id', 'fleet_task_id']); + }); + } + + $driver = Schema::getConnection()->getDriverName(); + if ($driver === 'sqlite') { + return; + } + + if (Schema::hasTable('fleet_nodes') && Schema::hasTable('fleet_tasks') && Schema::hasColumn('fleet_nodes', 'current_task_id')) { + Schema::table('fleet_nodes', function (Blueprint $table): void { + $table->foreign('current_task_id') + ->references('id') + ->on('fleet_tasks') + ->nullOnDelete(); + }); + } + + if (Schema::hasTable('credit_entries') && Schema::hasTable('fleet_tasks') && Schema::hasColumn('credit_entries', 'fleet_task_id')) { + Schema::table('credit_entries', function (Blueprint $table): void { + $table->foreign('fleet_task_id') + ->references('id') + ->on('fleet_tasks') + ->nullOnDelete(); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('credit_entries')) { + Schema::table('credit_entries', function (Blueprint $table): void { + $table->dropUnique(['fleet_node_id', 'fleet_task_id']); + }); + } + + $driver = Schema::getConnection()->getDriverName(); + if ($driver === 'sqlite') { + return; + } + + if (Schema::hasTable('fleet_nodes')) { + Schema::table('fleet_nodes', function (Blueprint $table): void { + $table->dropForeign(['current_task_id']); + }); + } + + if (Schema::hasTable('credit_entries') && Schema::hasColumn('credit_entries', 'fleet_task_id')) { + Schema::table('credit_entries', function (Blueprint $table): void { + $table->dropForeign(['fleet_task_id']); + }); + } + } +}; diff --git a/php/Models/CreditEntry.php b/php/Models/CreditEntry.php index 21bb1f3..7f01616 100644 --- a/php/Models/CreditEntry.php +++ b/php/Models/CreditEntry.php @@ -1,5 +1,7 @@ 'integer', 'amount' => 'integer', 'balance_after' => 'integer', ]; @@ -36,4 +41,9 @@ class CreditEntry extends Model { return $this->belongsTo(FleetNode::class); } + + public function fleetTask(): BelongsTo + { + return $this->belongsTo(FleetTask::class); + } } diff --git a/php/tests/Feature/Mod/Agent/BootFoundationTest.php b/php/tests/Feature/Mod/Agent/BootFoundationTest.php new file mode 100644 index 0000000..4c9456c --- /dev/null +++ b/php/tests/Feature/Mod/Agent/BootFoundationTest.php @@ -0,0 +1,21 @@ +all(); + + expect($commands)->toHaveKey('agentic:generate') + ->and($commands['agentic:generate'])->toBeInstanceOf(GenerateCommand::class) + ->and(collect($commands)->contains(static fn (object $command): bool => $command instanceof AgenticGenerateCommand)) + ->toBeFalse() + ->and(array_keys($commands))->toContain('brain:clean') + ->toContain('brain:prune') + ->toContain('brain:reindex'); +}); diff --git a/php/tests/Feature/Mod/Agent/CompleteTaskFoundationTest.php b/php/tests/Feature/Mod/Agent/CompleteTaskFoundationTest.php new file mode 100644 index 0000000..dafd512 --- /dev/null +++ b/php/tests/Feature/Mod/Agent/CompleteTaskFoundationTest.php @@ -0,0 +1,72 @@ +create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'charon', + 'platform' => 'linux', + 'status' => FleetNode::STATUS_BUSY, + 'registered_at' => now()->subMinutes(10), + 'last_heartbeat_at' => now()->subMinute(), + ]); + + $task = FleetTask::query()->create([ + 'workspace_id' => $workspace->id, + 'fleet_node_id' => $node->id, + 'repo' => 'dappco.re/go/agent', + 'branch' => 'dev', + 'task' => 'Complete the foundation slice', + 'status' => FleetTask::STATUS_IN_PROGRESS, + 'started_at' => now()->subMinutes(5), + ]); + + $node->update(['current_task_id' => $task->id]); + + $completed = CompleteTask::run( + $workspace->id, + 'charon', + $task->id, + ['status' => 'completed'], + [['severity' => 'medium']], + ['files_changed' => 3], + ['summary' => 'Foundation delivered'], + ); + + CompleteTask::run( + $workspace->id, + 'charon', + $task->id, + ['status' => 'completed'], + [['severity' => 'medium']], + ['files_changed' => 3], + ['summary' => 'Foundation delivered'], + ); + + $creditEntry = CreditEntry::query() + ->where('workspace_id', $workspace->id) + ->where('fleet_node_id', $node->id) + ->where('fleet_task_id', $task->id) + ->first(); + + expect($completed->status)->toBe(FleetTask::STATUS_COMPLETED) + ->and($node->fresh()->status)->toBe(FleetNode::STATUS_ONLINE) + ->and($node->fresh()->current_task_id)->toBeNull() + ->and($creditEntry)->not->toBeNull() + ->and($creditEntry?->agent_id)->toBe('charon') + ->and(CreditEntry::query() + ->where('workspace_id', $workspace->id) + ->where('fleet_node_id', $node->id) + ->where('fleet_task_id', $task->id) + ->count())->toBe(1); +}); diff --git a/php/tests/Feature/Mod/Agent/EmbedMemoryFoundationTest.php b/php/tests/Feature/Mod/Agent/EmbedMemoryFoundationTest.php new file mode 100644 index 0000000..8027420 --- /dev/null +++ b/php/tests/Feature/Mod/Agent/EmbedMemoryFoundationTest.php @@ -0,0 +1,41 @@ +create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'architecture', + 'content' => 'Indexed memories should not be embedded again.', + 'confidence' => 0.9, + 'indexed_at' => now()->subMinute(), + ]); + + Http::fake(); + + (new EmbedMemory($memory->id))->handle(foundationEmbedMemoryService()); + + expect($memory->fresh()?->indexed_at)->not->toBeNull(); + Http::assertNothingSent(); +}); diff --git a/php/tests/Feature/Mod/Agent/FleetMigrationFoundationTest.php b/php/tests/Feature/Mod/Agent/FleetMigrationFoundationTest.php new file mode 100644 index 0000000..ecfc20a --- /dev/null +++ b/php/tests/Feature/Mod/Agent/FleetMigrationFoundationTest.php @@ -0,0 +1,13 @@ +toBeTrue() + ->and(Schema::hasColumn('credit_entries', 'agent_id'))->toBeTrue() + ->and(Schema::hasColumn('credit_entries', 'fleet_task_id'))->toBeTrue(); +});