Files
Teamup/src/Modules/TeamUp.Modules.OrgBoard/Runtime/QaHandoffTrigger.cs
T
soroush.asadi fe7a5c481e M6: working memory + the PO→QA trigger + analytics — V1 complete
Working memory (Memory module's first real code):
- MemoryEntry (schema "memory", vector(384), InitialMemory migration); TeamMemory implements
  the SharedKernel ITeamMemory seam (embed-and-store on write, cosine recall on read);
  GET /api/memory/search. HashingTextEmbedder promoted to SharedKernel (pure, deterministic;
  swapped for ONNX/BYOK embedders later behind ITextEmbedder).
- Written on approval: Governance's approve stores an Approval/Correction entry per decision.
- Read at assembly: the executor recalls the team's top-3 relevant entries; the prompt gains
  a "# Team memory" section (treated as data, not instructions).

The single V1 event trigger:
- IAgentDispatcher (SharedKernel) implemented by Assembler's AgentRunDispatcher (shared by
  the API and triggers). OrgBoard's QaHandoffTrigger: a task hitting done creates a QA task
  (provenance parent, assigned to the QA agent) and dispatches a run for the team's QA AI
  seat. Guardrails: Test/Review tasks never re-trigger (no self-cascade) and a task hands
  off at most once. Audited as handoff.triggered.

Analytics — the V1 verdict view:
- IBoardStats (SharedKernel) implemented by OrgBoard; GET /api/governance/analytics returns
  approval rate, avg edit distance, per-agent metrics + edit-distance trend, tasks done.
- UI: /analytics — stat cards, per-agent table, recharts edit-distance trend per agent.

Verified: build green; ArchitectureTests 8/8; IntegrationTests 42/42 incl. the M6 acceptance
end to end — a dev marks a story done → Quill wakes via the handoff (QA task with provenance,
assigned to the agent) → drafts a test plan that waits in review → approve records the second
agent's edit distance → analytics show approval rate 100%, avg edit distance > 0, and trends
for BOTH Aria and Quill; memory written on Aria's corrected approval is recalled into her next
prompt; the guardrails hold. Client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:07:35 +03:30

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);
}
}