From 429d1c0897383225c0723e653d67cfcbe93dba8e Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 20:59:38 +0100 Subject: [PATCH] =?UTF-8?q?feat(agent/agentic):=20RFC=20foundation=20?= =?UTF-8?q?=E2=80=94=20atomic=20CompleteTask=20+=20credit=20ledger=20recon?= =?UTF-8?q?cile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation slice for Mantis #841 php/Mod/Agent RFC implementation: * CompleteTask now wraps in DB::transaction with idempotent credit awards and safe current_task_id clearing * Credits/{Award,GetBalance,GetCreditHistory} updated for agent_id + fleet_task_id ledger support and richer balance totals * GenerateCommand canonical agentic:generate wiring; legacy duplicate no longer registered * Boot wires brain:clean / brain:prune / brain:reindex * EmbedMemory exits early when memory already indexed * 3 follow-on fleet migrations reconcile fleet_nodes pointer column, fleet_tasks/credit_entries fk/index hygiene, fleet+credit constraints * 4 foundation tests under php/tests/Feature/Mod/Agent/ php -l clean on all modified files. pest unrunnable in sandbox (no vendor/). Foundation slice only: remaining model/action parity, full MCP tool/ service sweep, fleet controller auth-context, and 41-tool/45-action surface left for follow-up tickets. Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=841 --- php/Actions/Credits/AwardCredits.php | 85 +++++++++++++----- php/Actions/Credits/GetBalance.php | 36 +++++--- php/Actions/Credits/GetCreditHistory.php | 26 ++++-- php/Actions/Fleet/CompleteTask.php | 90 +++++++++++++------ php/Boot.php | 6 +- php/Console/Commands/GenerateCommand.php | 4 +- php/Jobs/EmbedMemory.php | 2 +- ..._25_000002_reconcile_fleet_nodes_table.php | 34 +++++++ ...concile_fleet_tasks_and_credit_entries.php | 74 +++++++++++++++ ...et_current_task_and_credit_constraints.php | 70 +++++++++++++++ php/Models/CreditEntry.php | 10 +++ .../Feature/Mod/Agent/BootFoundationTest.php | 21 +++++ .../Mod/Agent/CompleteTaskFoundationTest.php | 72 +++++++++++++++ .../Mod/Agent/EmbedMemoryFoundationTest.php | 41 +++++++++ .../Agent/FleetMigrationFoundationTest.php | 13 +++ 15 files changed, 516 insertions(+), 68 deletions(-) create mode 100644 php/Migrations/2026_04_25_000002_reconcile_fleet_nodes_table.php create mode 100644 php/Migrations/2026_04_25_000003_reconcile_fleet_tasks_and_credit_entries.php create mode 100644 php/Migrations/2026_04_25_000004_add_fleet_current_task_and_credit_constraints.php create mode 100644 php/tests/Feature/Mod/Agent/BootFoundationTest.php create mode 100644 php/tests/Feature/Mod/Agent/CompleteTaskFoundationTest.php create mode 100644 php/tests/Feature/Mod/Agent/EmbedMemoryFoundationTest.php create mode 100644 php/tests/Feature/Mod/Agent/FleetMigrationFoundationTest.php 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(); +});