From cb9ce34309b18e499b288b8b39573899c00dae6b Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 16 Jun 2026 21:47:04 +0330 Subject: [PATCH] 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 --- .../Runtime/AgentRunDispatcher.cs | 13 +++++++++++++ .../TeamUp.Modules.Governance/Gate/ActionGate.cs | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunDispatcher.cs b/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunDispatcher.cs index 9b0c231..ac4091b 100644 --- a/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunDispatcher.cs +++ b/src/Modules/TeamUp.Modules.Assembler/Runtime/AgentRunDispatcher.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.EntityFrameworkCore; using TeamUp.Modules.Assembler.Domain; using TeamUp.Modules.Assembler.Persistence; using TeamUp.Modules.Assembler.Queue; @@ -12,6 +13,18 @@ internal sealed class AgentRunDispatcher(AssemblerDbContext db, JobQueue queue, { public async Task 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()); db.AgentRuns.Add(run); await db.SaveChangesAsync(cancellationToken); diff --git a/src/Modules/TeamUp.Modules.Governance/Gate/ActionGate.cs b/src/Modules/TeamUp.Modules.Governance/Gate/ActionGate.cs index f5351de..6b0405a 100644 --- a/src/Modules/TeamUp.Modules.Governance/Gate/ActionGate.cs +++ b/src/Modules/TeamUp.Modules.Governance/Gate/ActionGate.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using TeamUp.Modules.Governance.Domain; using TeamUp.Modules.Governance.Persistence; using TeamUp.SharedKernel.Access; @@ -25,6 +26,20 @@ internal sealed class ActionGate( 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()); db.ReviewItems.Add(item); await db.SaveChangesAsync(cancellationToken);