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>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user