feat: implement orchestration and assignment (007) — routing, strategies, history

Phase 7 of the roadmap. On a ticket's automatic transition to
HUMAN_ESCALATION, routing resolves 006's hierarchy nodes and
capability-eligibility lookup directly (never a second matching
algorithm), and a pluggable strategy (ROUND_ROBIN, concurrency-safe via
atomic Redis INCR; LEAST_LOADED; SKILL_BASED) selects exactly one
eligible agent, persisted as a version-row-per-period Assignment plus an
append-only AssignmentHistory event log. MANUAL/DIRECT are never
auto-selected — only an explicit admin-supplied agentId reaches them.
On success the ticket moves HUMAN_ESCALATION -> IN_PROGRESS through
003's existing state machine. A ticket's "required skill" comes from
its most recent AI diagnosis's problemType (005) when one exists,
unioned with any matching hierarchy node's skills (006); when neither
exists, there's no skill constraint (every active agent eligible),
never zero.

Found and fixed two real, latent bugs in the shared event-bus
infrastructure while building this feature's own tests: (1)
EventBus.publish was built on EventEmitter.emit(), which never awaits
async listeners, so a caller had no guarantee any subscriber (005's
AI-session-ending hook, now also this feature's orchestration hook) had
actually finished — rewritten to track subscribers directly and await
them via Promise.all, same per-handler error isolation as before. (2)
registerDomainEventHandlers() was only called from server.ts's
production startup path, never from buildApp() — meaning every
integration test in this codebase had zero domain-event subscribers
registered at all. Now called (idempotently) from buildApp() itself,
since domain-event wiring is synchronous application behavior, not a
background-worker concern like the queue.

Adds 8 unit tests (each strategy's pure selection/tie-break logic), a
dedicated round-robin concurrency test verifying no two concurrent
selections collide under real parallel load, and 2 integration test
files covering all five user stories. Full regression (every
pre-existing 002-006 integration test plus every new 007 test) run
together against real Postgres/Redis/MinIO: 124 passed, 9 skipped
(005's AI-key-gated tests, unrelated), 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-03 11:58:17 +05:30
co-authored by Claude Sonnet 5
parent d3d57b9954
commit 77928c4878
51 changed files with 1500 additions and 118 deletions
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import { redisClient } from '@/infrastructure/cache';
import {
selectViaRoundRobin,
roundRobinSelectIndex,
} from '@/modules/orchestration/assignments/strategies/round-robin.strategy';
/**
* Constitution Principle VII / FR-006 / SC-002: round robin must be concurrency-safe under
* genuinely concurrent selection attempts, not just sequential calls — this is the first
* dedicated multi-writer-race test in this codebase since 003-ticketing's optimistic
* ticket-status concurrency (research.md).
*/
describe('ROUND_ROBIN concurrency safety', () => {
function fakeAgent(id: string) {
return {
id,
teamId: 't',
name: id,
active: true,
createdAt: new Date(),
updatedAt: new Date(),
skills: [],
};
}
it('never selects the same index twice for concurrent calls against the same key, and the final counter matches the call count', async () => {
const nodeId = `concurrency-test-node-${Date.now()}`;
const key = `ticketing:round_robin:${nodeId}`;
await redisClient.del(key);
const eligible = [
fakeAgent('a'),
fakeAgent('b'),
fakeAgent('c'),
fakeAgent('d'),
fakeAgent('e'),
];
const callCount = 25;
const results = await Promise.all(
Array.from({ length: callCount }, () =>
selectViaRoundRobin(eligible, { hierarchyNodeId: nodeId, requiredSkills: [] }),
),
);
// Every call must have selected somebody — never null for a non-empty eligible set.
expect(results.every((r) => r !== null)).toBe(true);
// Across every complete cycle through the 5 agents, each agent must be selected exactly
// callCount/5 times — if two concurrent INCRs had ever collided (both reading the same
// pre-increment value), some agent would be over- or under-selected relative to this exact
// count, since callCount (25) is a clean multiple of the eligible set size (5).
const counts = new Map<string, number>();
for (const r of results) {
if (!r) continue;
counts.set(r.id, (counts.get(r.id) ?? 0) + 1);
}
for (const agent of eligible) {
expect(counts.get(agent.id)).toBe(callCount / eligible.length);
}
const finalCount = await redisClient.get(key);
expect(Number(finalCount)).toBe(callCount);
await redisClient.del(key);
});
it('the pure index function never repeats an index within one full cycle', () => {
const length = 7;
const seen = new Set<number>();
for (let count = 1; count <= length; count++) {
const index = roundRobinSelectIndex(count, length);
expect(seen.has(index)).toBe(false);
seen.add(index);
}
expect(seen.size).toBe(length);
});
});
@@ -0,0 +1,210 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/007-orchestration-assignment/quickstart.md Scenarios 1, 3, 4, 5 against a real
* Postgres/Redis — one ticket's lifecycle through escalation, automatic assignment, history,
* manual reassignment, and re-escalation.
*/
describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', () => {
let app: FastifyInstance;
const externalProductId = `TEST_ORCH_PROD_${Date.now()}`;
const skillTag = `orch_skill_${Date.now()}`;
let productId: string;
let teamId: string;
let agentAId: string;
let agentBId: string;
let secret: string;
let ticketId: string;
beforeAll(async () => {
app = await buildApp();
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Orchestration Test Product', status: 'active' },
});
productId = product.id;
secret = generateCredentialSecret();
await prismaClient.productIntegration.create({
data: {
productId,
credentialRef: encryptCredential(secret),
authMechanism: 'signed_token',
allowedScope: { tenantIds: ['tenant-1'] },
status: 'active',
rateLimitPerMinute: 1000,
rateLimitPerUserPerMinute: 1000,
},
});
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
payload: { name: `Orch Team ${Date.now()}` },
});
teamId = team.json().data.id;
const agentA = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Orch Agent A' },
});
agentAId = agentA.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillTag}`,
payload: { level: 3 },
});
const agentB = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Orch Agent B' },
});
agentBId = agentB.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillTag}`,
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: 'Orch Node',
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const created = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: 'Needs a human, unrelated to AI diagnosis in this test.',
},
});
ticketId = created.json().data.ticketId;
});
afterAll(async () => {
await prismaClient.assignmentHistory.deleteMany({ where: { ticketId } });
await prismaClient.assignment.deleteMany({ where: { ticketId } });
await prismaClient.hierarchyNode.deleteMany({ where: { name: 'Orch Node' } });
await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentAId, agentBId] } } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.ticketMessage.deleteMany({ where: { ticketId } });
await prismaClient.ticket.deleteMany({ where: { id: ticketId } });
await prismaClient.problem.deleteMany({ where: { productId } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
await prismaClient.product.deleteMany({ where: { id: productId } });
await app.close();
});
it('Scenario 1: escalation triggers automatic resolution and assignment', async () => {
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
const escalate = await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
expect(escalate.statusCode).toBe(200);
// The event handler runs synchronously within the same process (in-memory EventEmitter),
// so by the time inject() resolves, publish's listeners have already been invoked — no
// polling needed, matching this codebase's existing event-bus behavior (005's own
// handleTicketStatusChanged is exercised the same way).
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
expect(current.statusCode).toBe(200);
expect([agentAId, agentBId]).toContain(current.json().data.agentId);
expect(current.json().data.strategy).toBe('ROUND_ROBIN');
const updatedTicket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
expect(updatedTicket.status).toBe('IN_PROGRESS');
});
it('Scenario 4: a manual reassignment overrides the automatic one', async () => {
const before = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
const originalAgentId = before.json().data.agentId;
const otherAgentId = originalAgentId === agentAId ? agentBId : agentAId;
const manual = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/assignment`,
payload: { agentId: otherAgentId, reason: 'Manual override for test' },
});
expect(manual.statusCode).toBe(200);
expect(manual.json().data.agentId).toBe(otherAgentId);
expect(manual.json().data.strategy).toBe('MANUAL');
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
expect(current.json().data.agentId).toBe(otherAgentId);
const notFound = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/assignment`,
payload: { agentId: 'nonexistent-agent-id' },
});
expect(notFound.statusCode).toBe(404);
});
it('Scenario 3: assignment history preserves every prior decision', async () => {
const history = await app.inject({
method: 'GET',
url: `/tickets/${ticketId}/assignment-history`,
});
expect(history.statusCode).toBe(200);
const rows: { action: string; strategy: string }[] = history.json().data;
expect(rows.length).toBeGreaterThanOrEqual(2); // the automatic assignment + the manual one
expect(rows.some((r) => r.action === 'assigned')).toBe(true);
expect(rows.some((r) => r.action === 'reassigned')).toBe(true);
});
it('Scenario 5: re-escalation resolves a fresh eligible set and reassigns', async () => {
const beforeReEscalate = await prismaClient.assignment.findFirst({
where: { ticketId, isCurrent: true },
});
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
const afterReEscalate = await app.inject({
method: 'GET',
url: `/tickets/${ticketId}/assignment`,
});
expect(afterReEscalate.statusCode).toBe(200);
const history = await app.inject({
method: 'GET',
url: `/tickets/${ticketId}/assignment-history`,
});
const rows: { agentId: string | null }[] = history.json().data;
// The pre-re-escalation assignment must still be present in history, whatever the new one is.
expect(rows.some((r) => r.agentId === beforeReEscalate?.agentId)).toBe(true);
});
});
@@ -0,0 +1,197 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/** Covers specs/007-orchestration-assignment/quickstart.md Scenario 2 (LEAST_LOADED,
* SKILL_BASED, and the empty-eligible-set outcome) against a real Postgres/Redis. */
describe('Orchestration and assignment — strategies (User Story 2)', () => {
let app: FastifyInstance;
let secret: string;
let teamId: string;
beforeAll(async () => {
app = await buildApp();
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
payload: { name: `Strategy Team ${Date.now()}` },
});
teamId = team.json().data.id;
});
afterAll(async () => {
await app.close();
});
async function createProductAndEscalate(problem = 'Needs a human.') {
const externalProductId = `TEST_STRAT_PROD_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Strategy Test Product', status: 'active' },
});
secret = generateCredentialSecret();
await prismaClient.productIntegration.create({
data: {
productId: product.id,
credentialRef: encryptCredential(secret),
authMechanism: 'signed_token',
allowedScope: { tenantIds: ['tenant-1'] },
status: 'active',
rateLimitPerMinute: 1000,
rateLimitPerUserPerMinute: 1000,
},
});
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const created = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem,
},
});
return { externalProductId, productId: product.id, ticketId: created.json().data.ticketId };
}
async function escalate(ticketId: string) {
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
return app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
it('LEAST_LOADED picks the eligible agent with the lowest current workload', async () => {
const skillTag = `least_loaded_skill_${Date.now()}`;
const agentLow = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Low Load Agent' },
});
const agentHigh = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'High Load Agent' },
});
for (const id of [agentLow.json().data.id, agentHigh.json().data.id]) {
await app.inject({
method: 'PUT',
url: `/admin/agents/${id}/skills/${skillTag}`,
payload: { level: 1 },
});
}
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentLow.json().data.id}/availability`,
payload: { status: 'available', workingHours: {}, currentLoad: 1 },
});
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentHigh.json().data.id}/availability`,
payload: { status: 'available', workingHours: {}, currentLoad: 9 },
});
const { externalProductId, ticketId } = await createProductAndEscalate();
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: `LL Node ${Date.now()}`,
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'LEAST_LOADED',
},
});
await escalate(ticketId);
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
expect(current.json().data.agentId).toBe(agentLow.json().data.id);
});
it('SKILL_BASED prefers the eligible agent with the higher proficiency level', async () => {
const skillTag = `skill_based_skill_${Date.now()}`;
const agentExpert = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Expert Agent' },
});
const agentNovice = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Novice Agent' },
});
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentExpert.json().data.id}/skills/${skillTag}`,
payload: { level: 9 },
});
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentNovice.json().data.id}/skills/${skillTag}`,
payload: { level: 1 },
});
const { externalProductId, ticketId } = await createProductAndEscalate();
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: `SB Node ${Date.now()}`,
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'SKILL_BASED',
},
});
await escalate(ticketId);
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
expect(current.json().data.agentId).toBe(agentExpert.json().data.id);
});
it('an empty eligible set assigns nobody, leaves the ticket escalated, and records the outcome', async () => {
const skillTag = `nobody_has_this_skill_${Date.now()}`;
const { externalProductId, ticketId } = await createProductAndEscalate();
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: `Empty Node ${Date.now()}`,
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
await escalate(ticketId);
const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` });
expect(current.statusCode).toBe(404);
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
expect(ticket.status).toBe('HUMAN_ESCALATION');
const history = await app.inject({
method: 'GET',
url: `/tickets/${ticketId}/assignment-history`,
});
const rows: { agentId: string | null; action: string }[] = history.json().data;
expect(rows.some((r) => r.agentId === null && r.action === 'unassigned')).toBe(true);
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import { roundRobinSelectIndex } from '@/modules/orchestration/assignments/strategies/round-robin.strategy';
import {
lowestLoadCandidates,
AgentWithLoad,
} from '@/modules/orchestration/assignments/calculators/workload.calculator';
import {
highestSkillScoreCandidates,
withSkillScores,
AgentWithSkillScore,
} from '@/modules/orchestration/assignments/calculators/skill-match.calculator';
function fakeAgent(id: string) {
return {
id,
teamId: 't',
name: id,
active: true,
createdAt: new Date(),
updatedAt: new Date(),
skills: [],
};
}
describe('roundRobinSelectIndex', () => {
it('cycles through indices 0..length-1 as the count increases', () => {
expect(roundRobinSelectIndex(1, 3)).toBe(0);
expect(roundRobinSelectIndex(2, 3)).toBe(1);
expect(roundRobinSelectIndex(3, 3)).toBe(2);
expect(roundRobinSelectIndex(4, 3)).toBe(0); // wraps back to the first agent
});
it('never produces the same index for two different, sequential counter values within one cycle', () => {
const seen = new Set<number>();
for (let count = 1; count <= 5; count++) {
seen.add(roundRobinSelectIndex(count, 5));
}
expect(seen.size).toBe(5);
});
});
describe('lowestLoadCandidates', () => {
it('picks the single lowest-load agent when there is no tie', () => {
const withLoads: AgentWithLoad[] = [
{ agent: fakeAgent('a'), currentLoad: 5 },
{ agent: fakeAgent('b'), currentLoad: 2 },
{ agent: fakeAgent('c'), currentLoad: 8 },
];
expect(lowestLoadCandidates(withLoads).map((a) => a.id)).toEqual(['b']);
});
it('returns every agent tied for the lowest load', () => {
const withLoads: AgentWithLoad[] = [
{ agent: fakeAgent('a'), currentLoad: 2 },
{ agent: fakeAgent('b'), currentLoad: 2 },
{ agent: fakeAgent('c'), currentLoad: 8 },
];
expect(
lowestLoadCandidates(withLoads)
.map((a) => a.id)
.sort(),
).toEqual(['a', 'b']);
});
it('returns an empty array for an empty input', () => {
expect(lowestLoadCandidates([])).toEqual([]);
});
});
describe('withSkillScores', () => {
it('sums proficiency level only across the required skills, ignoring extras', () => {
const eligible = [
{
...fakeAgent('a'),
skills: [
{ id: '1', agentId: 'a', skillTag: 'x', level: 3 },
{ id: '2', agentId: 'a', skillTag: 'y', level: 10 }, // not required — ignored
],
},
{
...fakeAgent('b'),
skills: [
{ id: '3', agentId: 'b', skillTag: 'x', level: 1 },
{ id: '4', agentId: 'b', skillTag: 'z', level: 1 },
],
},
];
const scored = withSkillScores(eligible, ['x', 'z']);
expect(scored.find((s) => s.agent.id === 'a')?.totalLevel).toBe(3); // only x counts
expect(scored.find((s) => s.agent.id === 'b')?.totalLevel).toBe(2); // x + z
});
});
describe('highestSkillScoreCandidates', () => {
it('picks the single highest-scoring agent when there is no tie', () => {
const scored: AgentWithSkillScore[] = [
{ agent: fakeAgent('a'), totalLevel: 3 },
{ agent: fakeAgent('b'), totalLevel: 7 },
];
expect(highestSkillScoreCandidates(scored).map((a) => a.id)).toEqual(['b']);
});
it('returns every agent tied for the highest score', () => {
const scored: AgentWithSkillScore[] = [
{ agent: fakeAgent('a'), totalLevel: 5 },
{ agent: fakeAgent('b'), totalLevel: 5 },
{ agent: fakeAgent('c'), totalLevel: 1 },
];
expect(
highestSkillScoreCandidates(scored)
.map((a) => a.id)
.sort(),
).toEqual(['a', 'b']);
});
});