M4: agent-run job queue + worker drain (Increment 1)

SharedKernel: IWorkerModule seam (RegisterWorker runs in the worker host only).
Bootstrap: AddTeamUpWorkerServices; the worker host now wires it.

Assembler module (schema "assembler", InitialAssembler migration):
- Job (Pending→Processing→Done/Failed) + AgentRun (Queued→Running→Completed/Failed) entities.
- JobQueue: enqueue + ClaimNextAsync using `FOR UPDATE SKIP LOCKED` in a transaction.
- AgentRunExecutor (Increment-1 placeholder — real assemble/model/parse lands in Increment 2).
- JobProcessor BackgroundService drains the queue on the worker host (web off the model path).
- POST /api/assembler/runs enqueues a run; GET /api/assembler/runs/{id} reads it.

Verified: build green; ArchitectureTests 8/8 (Assembler references only SharedKernel);
IntegrationTests 28/28 incl. enqueue→claim(SKIP LOCKED)→process→Completed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 01:16:37 +03:30
parent 34ea407e86
commit 09eaf360a3
18 changed files with 906 additions and 17 deletions
@@ -0,0 +1,50 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TeamUp.Modules.Assembler.Queue;
using TeamUp.Modules.Assembler.Runtime;
namespace TeamUp.Modules.Assembler.Worker;
/// <summary>Drains the agent-run queue on the worker host: claim (SKIP LOCKED) → process, repeat.</summary>
internal sealed class JobProcessor(IServiceScopeFactory scopeFactory, ILogger<JobProcessor> logger) : BackgroundService
{
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
private readonly string _worker = $"{Environment.MachineName}:{Environment.ProcessId}";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Agent-run job processor started ({Worker}).", _worker);
using var timer = new PeriodicTimer(PollInterval);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await DrainAsync(stoppingToken);
await timer.WaitForNextTickAsync(stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await using var scope = scopeFactory.CreateAsyncScope();
var queue = scope.ServiceProvider.GetRequiredService<JobQueue>();
var job = await queue.ClaimNextAsync(_worker, cancellationToken);
if (job is null)
{
break;
}
var executor = scope.ServiceProvider.GetRequiredService<AgentRunExecutor>();
await executor.ProcessAsync(job, cancellationToken);
}
}
}