Defensive: prevent duplicate agent runs and stacked review items
Two server-side guards so repeated "Run" clicks can't pile up duplicates: - Dispatcher: returns the in-flight run if one is already Queued/Running for the same (seat, task) instead of dispatching another model call. - Action gate: if an identical action is already Pending for the same (task, agent), it keeps that review item rather than stacking another copy in the inbox. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeamUp.Modules.Assembler.Domain;
|
using TeamUp.Modules.Assembler.Domain;
|
||||||
using TeamUp.Modules.Assembler.Persistence;
|
using TeamUp.Modules.Assembler.Persistence;
|
||||||
using TeamUp.Modules.Assembler.Queue;
|
using TeamUp.Modules.Assembler.Queue;
|
||||||
@@ -12,6 +13,18 @@ internal sealed class AgentRunDispatcher(AssemblerDbContext db, JobQueue queue,
|
|||||||
{
|
{
|
||||||
public async Task<Guid> DispatchAsync(Guid seatId, Guid workItemId, CancellationToken cancellationToken = default)
|
public async Task<Guid> DispatchAsync(Guid seatId, Guid workItemId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// Defensive: never stack a duplicate run while one is already in flight for this (seat, task).
|
||||||
|
// Repeated "Run" clicks return the in-flight run instead of dispatching another model call.
|
||||||
|
var inFlight = await db.AgentRuns
|
||||||
|
.Where(r => r.SeatId == seatId && r.WorkItemId == workItemId
|
||||||
|
&& (r.Status == AgentRunStatus.Queued || r.Status == AgentRunStatus.Running))
|
||||||
|
.Select(r => (Guid?)r.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (inFlight is { } existing)
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
var run = new AgentRun(seatId, workItemId, clock.GetUtcNow());
|
var run = new AgentRun(seatId, workItemId, clock.GetUtcNow());
|
||||||
db.AgentRuns.Add(run);
|
db.AgentRuns.Add(run);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeamUp.Modules.Governance.Domain;
|
using TeamUp.Modules.Governance.Domain;
|
||||||
using TeamUp.Modules.Governance.Persistence;
|
using TeamUp.Modules.Governance.Persistence;
|
||||||
using TeamUp.SharedKernel.Access;
|
using TeamUp.SharedKernel.Access;
|
||||||
@@ -25,6 +26,20 @@ internal sealed class ActionGate(
|
|||||||
|
|
||||||
if (GatePolicy.ShouldHold(proposal.Autonomy, risk))
|
if (GatePolicy.ShouldHold(proposal.Autonomy, risk))
|
||||||
{
|
{
|
||||||
|
// Defensive: collapse duplicates. If an identical action is already pending for this
|
||||||
|
// (task, agent), keep that one rather than stacking another copy in the review inbox.
|
||||||
|
var existing = await db.ReviewItems
|
||||||
|
.Where(r => r.WorkItemId == proposal.WorkItemId
|
||||||
|
&& r.AgentId == proposal.AgentId
|
||||||
|
&& r.ActionKind == proposal.ActionKind
|
||||||
|
&& r.Status == ReviewStatus.Pending)
|
||||||
|
.Select(r => (Guid?)r.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (existing is { } existingId)
|
||||||
|
{
|
||||||
|
return new GateResult(GateOutcome.Held, existingId);
|
||||||
|
}
|
||||||
|
|
||||||
var item = new ReviewItem(proposal, clock.GetUtcNow());
|
var item = new ReviewItem(proposal, clock.GetUtcNow());
|
||||||
db.ReviewItems.Add(item);
|
db.ReviewItems.Add(item);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
Reference in New Issue
Block a user