78 lines
2.9 KiB
C#
78 lines
2.9 KiB
C#
|
|
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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// The single V1 event trigger: a task hitting <c>done</c> 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+.
|
||
|
|
/// </summary>
|
||
|
|
internal sealed class QaHandoffTrigger(
|
||
|
|
OrgBoardDbContext db,
|
||
|
|
IAgentDispatcher dispatcher,
|
||
|
|
IAuditLog audit,
|
||
|
|
TimeProvider clock,
|
||
|
|
ILogger<QaHandoffTrigger> 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);
|
||
|
|
}
|
||
|
|
}
|