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:
parent
64852472de
commit
429d1c0897
15 changed files with 516 additions and 68 deletions
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
php/tests/Feature/Mod/Agent/BootFoundationTest.php
Normal file
21
php/tests/Feature/Mod/Agent/BootFoundationTest.php
Normal 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');
|
||||
});
|
||||
72
php/tests/Feature/Mod/Agent/CompleteTaskFoundationTest.php
Normal file
72
php/tests/Feature/Mod/Agent/CompleteTaskFoundationTest.php
Normal 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);
|
||||
});
|
||||
41
php/tests/Feature/Mod/Agent/EmbedMemoryFoundationTest.php
Normal file
41
php/tests/Feature/Mod/Agent/EmbedMemoryFoundationTest.php
Normal 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();
|
||||
});
|
||||
13
php/tests/Feature/Mod/Agent/FleetMigrationFoundationTest.php
Normal file
13
php/tests/Feature/Mod/Agent/FleetMigrationFoundationTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue