feat(agent/agentic): RFC foundation — atomic CompleteTask + credit ledger reconcile

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 <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=841
This commit is contained in:
Snider 2026-04-25 20:59:38 +01:00
parent 64852472de
commit 429d1c0897
15 changed files with 516 additions and 68 deletions

View file

@ -1,5 +1,7 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Credits;
@ -7,6 +9,8 @@ namespace Core\Mod\Agentic\Actions\Credits;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
use Illuminate\Support\Facades\DB;
class AwardCredits
{
@ -21,33 +25,74 @@ class AwardCredits
string $taskType,
int $amount,
?int $fleetNodeId = null,
?string $description = null
?string $description = null,
?int $fleetTaskId = null,
): CreditEntry {
if ($agentId === '' || $taskType === '' || $amount === 0) {
throw new \InvalidArgumentException('agent_id, task_type, and non-zero amount are required');
}
$node = $fleetNodeId !== null
? FleetNode::query()->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,
]);
});
}
}

View file

@ -1,5 +1,7 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Credits;
@ -7,6 +9,7 @@ namespace Core\Mod\Agentic\Actions\Credits;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Models\FleetNode;
use Illuminate\Database\Eloquent\Builder;
class GetBalance
{
@ -19,28 +22,41 @@ class GetBalance
*/
public function handle(int $workspaceId, string $agentId): array
{
$node = FleetNode::query()
$nodeId = FleetNode::query()
->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,
];
}
}

View file

@ -1,5 +1,7 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Credits;
@ -7,6 +9,7 @@ namespace Core\Mod\Agentic\Actions\Credits;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Models\FleetNode;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class GetCreditHistory
@ -18,18 +21,29 @@ class GetCreditHistory
*/
public function handle(int $workspaceId, string $agentId, int $limit = 50): Collection
{
$node = FleetNode::query()
$nodeId = FleetNode::query()
->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();

View file

@ -1,5 +1,7 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Fleet;
@ -8,6 +10,7 @@ use Core\Actions\Action;
use Core\Mod\Agentic\Actions\Credits\AwardCredits;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
use Illuminate\Support\Facades\DB;
/**
* Fleet tasks intentionally do not create AgentSession records. AgentSession tracks interactive,
@ -37,41 +40,70 @@ class CompleteTask
array $changes = [],
array $report = []
): FleetTask {
$node = FleetNode::query()
->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();
});
}
}

View file

@ -1,5 +1,7 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic;
@ -177,11 +179,13 @@ class Boot extends ServiceProvider
$event->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);

View file

@ -1,5 +1,7 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
@ -12,7 +14,7 @@ use Mod\Content\Services\AIGatewayService;
class GenerateCommand extends Command
{
protected $signature = 'generate
protected $signature = 'agentic:generate
{action=status : Action: status, brief, batch, plan, queue-stats}
{--id= : Brief or Plan ID}
{--type=help_article : Content type: help_article, blog_post, landing_page, social_post}

View file

@ -33,7 +33,7 @@ class EmbedMemory implements ShouldQueue
{
$memory = BrainMemory::find($this->memoryId);
if (! $memory instanceof BrainMemory) {
if (! $memory instanceof BrainMemory || $memory->indexed_at !== null) {
return;
}

View file

@ -0,0 +1,34 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
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('fleet_nodes') || Schema::hasColumn('fleet_nodes', 'current_task_id')) {
return;
}
Schema::table('fleet_nodes', function (Blueprint $table): void {
$table->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');
});
}
};

View file

@ -0,0 +1,74 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('credit_entries')) {
return;
}
if (! Schema::hasColumn('credit_entries', 'agent_id')) {
Schema::table('credit_entries', function (Blueprint $table): void {
$table->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');
});
}
}
};

View file

@ -0,0 +1,70 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
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('credit_entries') && Schema::hasColumn('credit_entries', 'fleet_task_id')) {
Schema::table('credit_entries', function (Blueprint $table): void {
$table->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']);
});
}
}
};

View file

@ -1,5 +1,7 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
@ -16,6 +18,8 @@ class CreditEntry extends Model
protected $fillable = [
'workspace_id',
'fleet_node_id',
'fleet_task_id',
'agent_id',
'task_type',
'amount',
'balance_after',
@ -23,6 +27,7 @@ class CreditEntry extends Model
];
protected $casts = [
'fleet_task_id' => '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);
}
}

View file

@ -0,0 +1,21 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Console\Commands\AgenticGenerateCommand;
use Core\Mod\Agentic\Console\Commands\GenerateCommand;
use Illuminate\Contracts\Console\Kernel;
test('agent foundation console wiring registers the canonical commands', function (): void {
$commands = app(Kernel::class)->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');
});

View file

@ -0,0 +1,72 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Actions\Fleet\CompleteTask;
use Core\Mod\Agentic\Models\CreditEntry;
use Core\Mod\Agentic\Models\FleetNode;
use Core\Mod\Agentic\Models\FleetTask;
test('agent foundation complete task is atomic and idempotent for credits', function (): void {
$workspace = createWorkspace();
$node = FleetNode::query()->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);
});

View file

@ -0,0 +1,41 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Jobs\EmbedMemory;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Http;
function foundationEmbedMemoryService(): BrainService
{
return new BrainService(
ollamaUrl: 'https://ollama.test',
qdrantUrl: 'https://qdrant.test',
collection: 'openbrain',
embeddingModel: 'embeddinggemma',
verifySsl: false,
);
}
test('agent foundation embed memory skips rows that are already indexed', function (): void {
$workspace = createWorkspace();
$memory = BrainMemory::query()->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();
});

View file

@ -0,0 +1,13 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Illuminate\Support\Facades\Schema;
test('agent foundation migrations expose the fleet task pointer and credit ledger columns', function (): void {
expect(Schema::hasColumn('fleet_nodes', 'current_task_id'))->toBeTrue()
->and(Schema::hasColumn('credit_entries', 'agent_id'))->toBeTrue()
->and(Schema::hasColumn('credit_entries', 'fleet_task_id'))->toBeTrue();
});