using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using TeamUp.Modules.OrgBoard.Domain; using TeamUp.Modules.OrgBoard.Persistence; using TeamUp.SharedKernel.Ai; using TeamUp.SharedKernel.Auditing; namespace TeamUp.Modules.OrgBoard.Runtime; /// /// The single V1 event trigger: a task hitting done emits a handoff that creates a QA task /// (with provenance) for the team's QA AI seat and dispatches a run — the boundary is a pipe, not /// a gate; the QA agent then acts per its OWN autonomy. Guardrails: QA/Review tasks never /// re-trigger (no self-cascade), and a task hands off at most once (the duplicate check is the /// V1 rate limit). The richer event mesh is Phase 1+. /// internal sealed class QaHandoffTrigger( OrgBoardDbContext db, IAgentDispatcher dispatcher, IAuditLog audit, TimeProvider clock, ILogger logger) { private const string QaSkillKey = "test-plan-generation"; public async Task OnTaskDoneAsync(WorkItem item, Guid actorMemberId, CancellationToken cancellationToken = default) { // No self-cascade: QA's own output never wakes QA again. if (item.Type is WorkItemType.Test or WorkItemType.Review) { return; } // At most one handoff per task. if (await db.WorkItems.AnyAsync(w => w.ParentId == item.Id && w.Type == WorkItemType.Test, cancellationToken)) { return; } // The receiving seat: an AI seat on this team equipped with the QA skill. var seat = await ( from s in db.Seats join a in db.Agents on s.Id equals a.SeatId where s.TeamId == item.TeamId && s.State == SeatState.Ai && a.SkillKeys.Contains(QaSkillKey) orderby s.CreatedAtUtc select s).FirstOrDefaultAsync(cancellationToken); if (seat is null) { return; // no QA AI seat — nothing to hand off to } var now = clock.GetUtcNow(); var qaTask = new WorkItem( item.TeamId, "QA: " + item.Title, "Handoff: \"" + item.Title + "\" hit done. Draft the test plan.", WorkItemType.Test, actorMemberId, now, parentId: item.Id); if (seat.AgentId is { } agentId) { qaTask.AssignToAgent(agentId, now); } db.WorkItems.Add(qaTask); await db.SaveChangesAsync(cancellationToken); var runId = await dispatcher.DispatchAsync(seat.Id, qaTask.Id, cancellationToken); await audit.WriteAsync( new AuditEvent("handoff.triggered", "WorkItem", qaTask.Id, actorMemberId, $"\"{item.Title}\" done → QA run {runId}"), cancellationToken); logger.LogInformation( "PO→QA handoff: task {TaskId} done → QA task {QaTaskId}, run {RunId}.", item.Id, qaTask.Id, runId); } }