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
+39
View File
@@ -159,3 +159,42 @@ never require a code change"). See
generic `User`/`UserRole` model unrelated to this system's real architecture (nothing else uses
it — `CustomerReference`, built in 002, is the real customer-identity mechanism).
`identity/customers` and `identity/auth` are untouched — out of scope here.
# Orchestration and Assignment
When a ticket reaches `HUMAN_ESCALATION`, this system automatically resolves 006's hierarchy/
capability-eligibility lookup for it and assigns exactly one eligible agent through a pluggable
strategy — no caller has to trigger this. See
`specs/007-orchestration-assignment/contracts/orchestration-contract.md` for the full route list
and `specs/007-orchestration-assignment/quickstart.md` for runnable scenarios.
- **Trigger**: entirely event-driven — `ticketsService.updateStatus` (003) publishes a domain
event on every status change, and this feature's subscriber (registered alongside 005's own
AI-session-ending one) reacts when `newStatus === 'HUMAN_ESCALATION'`. Neither `ticketing` nor
`ai-support` has any import of `orchestration` — the event bus is what keeps that direction
one-way. **The event bus itself was fixed while building this feature**: `EventBus.publish` now
actually awaits its subscribers (it previously fired them via `EventEmitter.emit`, which never
waits for an async listener) — without that fix, a status-update HTTP call could return before
orchestration (or 005's own hook) had actually finished. `registerDomainEventHandlers()` is
called from `buildApp()` itself now (idempotently — see its own code comment), not only from
`server.ts`'s production startup path, so this is true in tests too, not just production.
- **Strategies**: `ROUND_ROBIN` (concurrency-safe via an atomic Redis `INCR`, verified under real
concurrent load in `tests/concurrency/round-robin.test.ts`), `LEAST_LOADED`, `SKILL_BASED` are
auto-selected from the matched hierarchy node's own `assignmentStrategy` (or a configurable
system default, `ORCHESTRATION_DEFAULT_STRATEGY`, when no node matched). `MANUAL`/`DIRECT` are
never auto-selected — they only ever come from `POST /admin/tickets/:ticketId/assignment`'s
explicit `agentId`. `LEAST_LOADED`/`SKILL_BASED` ties fall through to the same concurrency-safe
cursor `ROUND_ROBIN` uses, never an arbitrary/unstable ordering.
- **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 own `skills` (006) — when neither
exists, there's no skill constraint at all (every active agent is eligible), never zero eligible
agents; see `specs/007-orchestration-assignment/research.md`.
- **History**: `Assignment` is a version-row-per-period model (reassigning supersedes the current
row, never overwrites it); `AssignmentHistory` is a separate, purely-additive event log —
`GET /tickets/:ticketId/assignment` (current) and `GET /tickets/:ticketId/assignment-history`
(everything, including "no eligible agent" outcomes with `agentId: null`).
- `currentLoad` (006) is read by `LEAST_LOADED`, never written by this feature — no
increment-on-assign/decrement-on-resolve lifecycle exists yet (that's a later phase's job once
ticket resolution itself is built).
- SLA policy execution and rule-driven escalation are intentionally out of scope here — doc 05
documents them alongside orchestration, but the roadmap places them in Phase 8. See
`specs/007-orchestration-assignment/spec.md` Assumptions.
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "assignments" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"strategy" TEXT NOT NULL,
"reason" TEXT,
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"unassignedAt" TIMESTAMP(3),
CONSTRAINT "assignments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "assignment_history" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"agentId" TEXT,
"action" TEXT NOT NULL,
"strategy" TEXT NOT NULL,
"reason" TEXT,
"actor" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "assignment_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "assignments_ticketId_isCurrent_idx" ON "assignments"("ticketId", "isCurrent");
-- CreateIndex
CREATE INDEX "assignment_history_ticketId_createdAt_idx" ON "assignment_history"("ticketId", "createdAt");
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignment_history" ADD CONSTRAINT "assignment_history_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+43 -7
View File
@@ -136,13 +136,15 @@ model Ticket {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id])
problem Problem @relation(fields: [problemId], references: [id])
customer CustomerReference @relation(fields: [customerId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
messages TicketMessage[]
attachments TicketAttachment[]
aiSessions AISupportSession[]
product Product @relation(fields: [productId], references: [id])
problem Problem @relation(fields: [problemId], references: [id])
customer CustomerReference @relation(fields: [customerId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
messages TicketMessage[]
attachments TicketAttachment[]
aiSessions AISupportSession[]
assignments Assignment[]
assignmentHistory AssignmentHistory[]
@@unique([productId, idempotencyKey])
@@index([productId, status])
@@ -407,6 +409,7 @@ model Agent {
skills AgentSkill[]
availability AgentAvailability?
assignments Assignment[]
@@index([teamId, active])
@@map("agents")
@@ -466,3 +469,36 @@ model HierarchyNode {
@@index([active])
@@map("hierarchy_nodes")
}
model Assignment {
id String @id @default(cuid())
ticketId String // not unique — one row per assignment period, see
// specs/007-orchestration-assignment/research.md
ticket Ticket @relation(fields: [ticketId], references: [id])
agentId String
agent Agent @relation(fields: [agentId], references: [id])
strategy String // ROUND_ROBIN | LEAST_LOADED | SKILL_BASED | MANUAL | DIRECT
reason String?
isCurrent Boolean @default(true)
assignedAt DateTime @default(now())
unassignedAt DateTime?
@@index([ticketId, isCurrent])
@@map("assignments")
}
model AssignmentHistory {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
agentId String? // null for a "no eligible agent" outcome — FR-008
action String // assigned | reassigned | unassigned
strategy String
reason String?
actor String // system | agentId | adminId
createdAt DateTime @default(now())
@@index([ticketId, createdAt])
@@map("assignment_history")
}
@@ -57,3 +57,32 @@
constraint" (every active agent eligible) rather than inventing a new mapping table or treating
"no signal" as "no eligible agents" — the latter would silently contradict FR-004's own
explicit "rather than failing."
## Implementation notes (added during /speckit-implement)
- **Found and fixed a real, latent bug in 005's own event-bus infrastructure**: `EventBus.publish`
was built on Node's `EventEmitter.emit()`, which invokes async listeners without awaiting them
— so a caller of `publish` (i.e. `ticketsService.updateStatus`) had no guarantee that any
subscriber (005's AI-session-ending hook, and now 007's automatic-orchestration hook) had
actually finished by the time it returned. This was invisible in 005 because nothing tested
FR-023's hook through a real HTTP status-update call with immediate-consistency assertions —
007's own integration tests would have been flaky (occasionally reading "no assignment yet")
without fixing it first. `EventBus` was rewritten to track subscribers itself and have `publish`
`await Promise.all(...)` over every matching handler (same per-handler try/catch isolation the
old implementation had), and `tickets.service.ts` now `await`s the publish call.
- **Found and fixed a second, related gap**: `registerDomainEventHandlers()` was only ever called
from `server.ts`'s production startup path, never from `buildApp()` — meaning every integration
test in this codebase (which calls `buildApp()` directly, the same "no worker process in this
test" convention 003's attachment tests already established for the queue) had **zero**
subscribers registered for any domain event, silently no-op-ing both 005's and 007's hooks.
Fixed by calling `registerDomainEventHandlers()` from `buildApp()` itself (made idempotent,
since `buildApp()` runs once per test file across a shared, module-level `eventBus` singleton)
— domain-event wiring is synchronous application behavior, not a long-running background
process like the queue's workers, so unlike `bootstrapQueue` it belongs in `buildApp()`, not
only in `server.ts`.
- Both fixes were necessary before *any* of this feature's own integration tests could pass
reliably — found while writing Quickstart Scenario 1's test, not discovered later.
- All five user stories' integration tests, the dedicated concurrency test, and the full
regression suite (every pre-existing 002-006 integration test plus every new 007 test) were run
together against real Docker-provisioned Postgres/Redis/MinIO: 124 passed, 9 skipped (005's
AI-key-gated tests, unrelated to this feature), 0 failed.
+46 -42
View File
@@ -26,14 +26,14 @@ All file paths are relative to `supporthub-api/` (repo root).
## Phase 1: Setup
- [ ] T001 [P] Populate `src/modules/orchestration/routing/` with `service/`, `types/`,
- [x] T001 [P] Populate `src/modules/orchestration/routing/` with `service/`, `types/`,
`index.ts` (no controller/routes — internal only), replacing the existing
`RoutingService.routeTicket` stub
- [ ] T002 [P] Extend `src/modules/orchestration/assignments/` with the standard module shape
- [x] T002 [P] Extend `src/modules/orchestration/assignments/` with the standard module shape
(`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`,
`constants/`, `index.ts`) around its existing `engine/`/`strategies/`/`rules/`/
`calculators/` directories, replacing every stub file's content
- [ ] T003 [P] Populate `src/modules/orchestration/orchestration/` with `service/`, `types/`,
- [x] T003 [P] Populate `src/modules/orchestration/orchestration/` with `service/`, `types/`,
`index.ts`, replacing the existing `OrchestrationService.orchestrateWorkflow` stub
---
@@ -44,10 +44,10 @@ All file paths are relative to `supporthub-api/` (repo root).
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [ ] T004 Add `Assignment` (with `isCurrent`, `unassignedAt`) and `AssignmentHistory` models to
- [x] T004 Add `Assignment` (with `isCurrent`, `unassignedAt`) and `AssignmentHistory` models to
`prisma/schema.prisma` per data-model.md, plus `Ticket.assignments`/
`Ticket.assignmentHistory`/`Agent.assignments` back-relations (depends on T001-T003)
- [ ] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T004 (depends on T004)
**Checkpoint**: Schema migrated. User stories can now be built.
@@ -63,25 +63,27 @@ capability-eligible agent set — reusing 006 directly, never a second matching
### Tests for User Story 1
- [ ] T006 [US1] Integration test covering Quickstart Scenario 1 (escalation triggers
- [x] T006 [US1] Integration test covering Quickstart Scenario 1 (escalation triggers
resolution automatically; matching-node case scopes correctly; no-match case falls back to
skill-only) against a real Postgres in `tests/integration/orchestration-resolution.test.ts`
(depends on T005)
skill-only) against a real Postgres — implemented in
`tests/integration/orchestration-flow.test.ts` (combined with US3/US4/US5's own tests,
T020/T024/T028, since they form one continuous ticket lifecycle) rather than the
originally-planned `orchestration-resolution.test.ts` (depends on T005)
### Implementation for User Story 1
- [ ] T007 [US1] Add `RoutingService.resolveEligibleAgents(ticketId)`: reads the ticket's
- [x] T007 [US1] Add `RoutingService.resolveEligibleAgents(ticketId)`: reads the ticket's
product/category/priority context, calls 006's `capabilityLookupService.findEligibleAgents`
directly (research.md — through `orchestration/hierarchy`'s public `index.ts`) with the
ticket's own required-skill context, and also returns which hierarchy node(s), if any,
matched (needed later for the node's own `assignmentStrategy`, T013) — in
`src/modules/orchestration/routing/service/routing.service.ts` (depends on T005)
- [ ] T008 [US1] Add the domain-event subscriber skeleton: register a
- [x] T008 [US1] Add the domain-event subscriber skeleton: register a
`DomainEventName.TICKET_UPDATED` handler in `src/events/handlers/index.ts` (alongside
005's existing subscriber) that calls into `orchestration`'s engine (T017) when
`payload.newStatus === 'HUMAN_ESCALATION'` — wired now so T007's resolution is reachable
end-to-end even before US2's strategy execution exists (depends on T007)
- [ ] T009 [US1] Run Quickstart Scenario 1 locally and confirm all 4 steps pass
- [x] T009 [US1] Run Quickstart Scenario 1 locally and confirm all 4 steps pass
**Checkpoint**: Every ticket reaching human escalation has its hierarchy node and eligible-agent
set resolved automatically. Nothing is assigned yet — that's User Story 2.
@@ -99,43 +101,43 @@ constitution's concurrency-testing gate for assignment.
### Tests for User Story 2
- [ ] T010 [P] [US2] Unit tests for each strategy's pure selection function — `ROUND_ROBIN`
- [x] T010 [P] [US2] Unit tests for each strategy's pure selection function — `ROUND_ROBIN`
given a fixed counter value picks the expected index; `LEAST_LOADED` picks the lowest-load
agent; `SKILL_BASED` picks the highest-matching-level agent; an empty eligible array
returns `null` for every strategy — in `tests/unit/orchestration/strategies.test.ts`
- [ ] T011 [US2] Concurrency test: fire many simultaneous `ROUND_ROBIN` selections against the
- [x] T011 [US2] Concurrency test: fire many simultaneous `ROUND_ROBIN` selections against the
same eligible set and a real Redis instance, and confirm every selected index is exactly
what the sequence of atomic `INCR` results implies — no two concurrent calls ever compute
the same index from the same counter value, and the final counter value equals the number
of calls — in `tests/concurrency/round-robin.test.ts` (depends on T005)
- [ ] T012 [US2] Integration test covering Quickstart Scenario 2 steps 3-5 (`LEAST_LOADED`,
- [x] T012 [US2] Integration test covering Quickstart Scenario 2 steps 3-5 (`LEAST_LOADED`,
`SKILL_BASED`, empty-eligible-set outcome recorded) against a real Postgres in
`tests/integration/orchestration-strategies.test.ts` (depends on T005)
### Implementation for User Story 2
- [ ] T013 [US2] Add the round-robin selection function — `INCR` against
- [x] T013 [US2] Add the round-robin selection function — `INCR` against
`ticketing:round_robin:<hierarchyNodeId ?? 'unscoped'>` via the existing shared Redis
client, then `(count - 1) % eligibleAgents.length` into the eligible array sorted by
`agent.id` (research.md) — in
`src/modules/orchestration/assignments/strategies/round-robin.strategy.ts` (replacing the
existing stub) (depends on T005)
- [ ] T014 [P] [US2] Add `LEAST_LOADED` (reads each candidate's `AgentAvailability.currentLoad`,
- [x] T014 [P] [US2] Add `LEAST_LOADED` (reads each candidate's `AgentAvailability.currentLoad`,
lowest wins, ties fall through to T013's cursor — research.md) in
`src/modules/orchestration/assignments/strategies/least-loaded.strategy.ts` +
`calculators/workload.calculator.ts` (replacing the existing stub)
- [ ] T015 [P] [US2] Add `SKILL_BASED` (reads each candidate's `AgentSkill.level` for the
- [x] T015 [P] [US2] Add `SKILL_BASED` (reads each candidate's `AgentSkill.level` for the
required skills, highest total wins, ties fall through to T013's cursor) in
`src/modules/orchestration/assignments/strategies/skill-based.strategy.ts` +
`calculators/skill-match.calculator.ts`
- [ ] T016 [US2] Add a strategy registry (`STRATEGY_REGISTRY: Record<string, (eligible, context)
- [x] T016 [US2] Add a strategy registry (`STRATEGY_REGISTRY: Record<string, (eligible, context)
=> Promise<Agent | null>>`) covering `ROUND_ROBIN`/`LEAST_LOADED`/`SKILL_BASED`, with
`MANUAL`/`DIRECT` deliberately absent (those are never auto-selected — they only ever come
from an explicit caller-supplied `agentId`, T024) — an unregistered strategy name resolves
to `null` (no agent), matching FR-008 — in
`src/modules/orchestration/assignments/service/strategy-registry.ts` (depends on T013,
T014, T015)
- [ ] T017 [US2] Add `AssignmentEngine.evaluateAndAssign(ticketId)` (replacing the existing
- [x] T017 [US2] Add `AssignmentEngine.evaluateAndAssign(ticketId)` (replacing the existing
stub): calls T007's resolution, then T016's registry using the resolved node's
`assignmentStrategy` (or a configurable system default when no node matched), persists the
result via T019/T020's repository (approved agent → new `Assignment` + `assigned`
@@ -143,11 +145,11 @@ constitution's concurrency-testing gate for assignment.
success transitions the ticket via `ticketsService.updateStatus(ticketId, 'IN_PROGRESS',
ticket.version, 'system')` (research.md) — in
`src/modules/orchestration/assignments/engine/assignment.engine.ts` (depends on T016)
- [ ] T018 [US2] Wire T008's event-subscriber skeleton to actually call T017's
- [x] T018 [US2] Wire T008's event-subscriber skeleton to actually call T017's
`evaluateAndAssign` — completing the automatic escalation→assignment flow — in
`src/modules/orchestration/orchestration/service/orchestration.service.ts` (depends on
T017)
- [ ] T019 [US2] Run Quickstart Scenario 2 locally and confirm all 5 steps pass
- [x] T019 [US2] Run Quickstart Scenario 2 locally and confirm all 5 steps pass
**Checkpoint**: Escalated tickets are now actually assigned — the full automatic flow works end
to end, concurrency-safely.
@@ -163,14 +165,15 @@ superseded, never lost.
### Tests for User Story 3
- [ ] T020 [US3] Integration test covering Quickstart Scenario 3 (reassignment supersedes the
- [x] T020 [US3] Integration test covering Quickstart Scenario 3 (reassignment supersedes the
current `Assignment` row, both remain in `AssignmentHistory`, `GET .../assignment` reflects
only the latest) against a real Postgres in `tests/integration/orchestration-history.test.ts`
(depends on T005)
only the latest) against a real Postgres — implemented in
`tests/integration/orchestration-flow.test.ts` (see T006's note) rather than
`orchestration-history.test.ts` (depends on T005)
### Implementation for User Story 3
- [ ] T021 [US3] Add `AssignmentRepository` (`createAssignment` — in one transaction, sets any
- [x] T021 [US3] Add `AssignmentRepository` (`createAssignment` — in one transaction, sets any
existing current row's `isCurrent: false`/`unassignedAt: now()` then inserts the new
current row, matching research.md's version-row-per-period refinement; `findCurrent`;
`AssignmentHistoryRepository.record` — always additive, never updates) in
@@ -178,10 +181,10 @@ superseded, never lost.
`repository/assignment-history.repository.ts` (depends on T005) — this is the repository
T017 (US2) already depends on; implemented here since US3's own story is what specifies its
correctness guarantees in detail
- [ ] T022 [US3] Add Zod schema + `GET /tickets/:ticketId/assignment`,
- [x] T022 [US3] Add Zod schema + `GET /tickets/:ticketId/assignment`,
`GET /tickets/:ticketId/assignment-history` routes in `assignments/schema/` + `routes/` +
`controller/`, registered from `src/api/routes.ts` (depends on T021)
- [ ] T023 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass
- [x] T023 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass
**Checkpoint**: Assignment history is durable and correctly superseded, independently of whether
a reassignment came from automatic re-escalation or a manual override.
@@ -197,21 +200,21 @@ assignment.
### Tests for User Story 4
- [ ] T024 [US4] Integration test covering Quickstart Scenario 4 (manual assignment overrides;
- [x] T024 [US4] Integration test covering Quickstart Scenario 4 (manual assignment overrides;
nonexistent agent rejected with `404`; a second manual assignment supersedes the first)
against a real Postgres in `tests/integration/orchestration-manual-assignment.test.ts`
(depends on T021)
against a real Postgres — implemented in `tests/integration/orchestration-flow.test.ts`
(see T006's note) rather than `orchestration-manual-assignment.test.ts` (depends on T021)
### Implementation for User Story 4
- [ ] T025 [US4] Add `AssignmentsService.assignManually(ticketId, agentId, actor, reason?,
- [x] T025 [US4] Add `AssignmentsService.assignManually(ticketId, agentId, actor, reason?,
strategy = 'MANUAL')`: validates the agent exists (resolve-or-404, research.md), then
reuses T021's repository the same way T017's automatic path does — in
`src/modules/orchestration/assignments/service/assignments.service.ts` (depends on T021)
- [ ] T026 [US4] Add Zod schema + `POST /admin/tickets/:ticketId/assignment` route (gated by
- [x] T026 [US4] Add Zod schema + `POST /admin/tickets/:ticketId/assignment` route (gated by
`fastify.authenticate`) in `assignments/schema/` + `routes/` + `controller/` (depends on
T025)
- [ ] T027 [US4] Run Quickstart Scenario 4 locally and confirm all 3 steps pass
- [x] T027 [US4] Run Quickstart Scenario 4 locally and confirm all 3 steps pass
**Checkpoint**: Both automatic and manual assignment paths exist, fully interchangeable from
history's point of view.
@@ -227,18 +230,19 @@ eligible set.
### Tests for User Story 5
- [ ] T028 [US5] Integration test covering Quickstart Scenario 5 (an already-assigned ticket
- [x] T028 [US5] Integration test covering Quickstart Scenario 5 (an already-assigned ticket
re-escalated gets a freshly computed eligible set and a new assignment; the prior one
remains in history) against a real Postgres in
`tests/integration/orchestration-re-escalation.test.ts` (depends on T018, T021)
remains in history) against a real Postgres — implemented in
`tests/integration/orchestration-flow.test.ts` (see T006's note) rather than
`orchestration-re-escalation.test.ts` (depends on T018, T021)
### Implementation for User Story 5
- [ ] T029 [US5] Confirm (no new production code expected — this is a verification task): T008's
- [x] T029 [US5] Confirm (no new production code expected — this is a verification task): T008's
event subscriber and T017's engine already re-run resolution from scratch on every
`HUMAN_ESCALATION` event with no caching of a prior eligible set — if T028's test reveals
any staleness, fix it in `orchestration/routing`'s service (depends on T028)
- [ ] T030 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass
- [x] T030 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass
**Checkpoint**: All five user stories work independently and together — escalation, resolution,
strategy selection, persistence, manual override, and re-escalation form one coherent,
@@ -248,14 +252,14 @@ concurrency-safe flow.
## Phase 8: Polish & Cross-Cutting Concerns
- [ ] T031 [P] Add an "Orchestration and Assignment" section to `README.md` describing the
- [x] T031 [P] Add an "Orchestration and Assignment" section to `README.md` describing the
automatic escalation trigger, the five strategies (and which ones are auto-selectable vs.
manual-only), the concurrency-safety mechanism, and what's explicitly deferred (SLA/
escalation-policy execution, workload lifecycle mutation)
- [ ] T032 [P] Update `specs/007-orchestration-assignment/checklists/requirements.md` Notes with
- [x] T032 [P] Update `specs/007-orchestration-assignment/checklists/requirements.md` Notes with
any implementation-time findings
- [ ] T033 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [ ] T034 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
- [x] T033 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T034 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
elsewhere, then the full integration + concurrency suite against real Docker-provisioned
infra
+2
View File
@@ -14,6 +14,7 @@ import { sessionsRoutes } from '@/modules/ai-support/sessions';
import { teamsRoutes } from '@/modules/identity/teams';
import { agentsRoutes } from '@/modules/identity/agents';
import { hierarchyRoutes } from '@/modules/orchestration/hierarchy';
import { assignmentsRoutes } from '@/modules/orchestration/assignments';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
@@ -29,5 +30,6 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
await app.register(teamsRoutes);
await app.register(agentsRoutes);
await app.register(hierarchyRoutes);
await app.register(assignmentsRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
+8
View File
@@ -4,6 +4,7 @@ import { env } from '@/config';
import { logger } from '@/infrastructure/observability';
import { AppError } from '@/common/errors';
import { bootstrapPlugins, bootstrapRoutes } from '@/bootstrap';
import { registerDomainEventHandlers } from '@/events';
export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({
@@ -15,6 +16,13 @@ export async function buildApp(): Promise<FastifyInstance> {
// Register Bootstrapped Plugins
await bootstrapPlugins(app);
// Domain event handlers (005's AI-session-ending hook, 007's automatic-orchestration hook) —
// registered here, not only in server.ts's production startup path, because they're
// synchronous application wiring (a status change reacting to another module), not a
// long-running background process like the queue's workers (bootstrapQueue stays server.ts-
// only for that reason). registerDomainEventHandlers() is idempotent — see its own comment.
registerDomainEventHandlers();
// Global Error Handler — MUST be registered before any route module is (bootstrapRoutes
// below), because Fastify resolves each encapsulated child context's error handler at the
// time that context is registered. A handler set after a child module has already been
+5
View File
@@ -50,6 +50,11 @@ const envSchema = z.object({
AI_SUPPORT_DEFAULT_LOW_CONFIDENCE: z.coerce.number().default(0.4),
AI_SUPPORT_DEFAULT_MAX_CLARIFYING_QUESTIONS: z.coerce.number().default(2),
AI_SUPPORT_MAX_REASONING_ITERATIONS_PER_TURN: z.coerce.number().default(4),
// Orchestration and Assignment (007) — the strategy used when no hierarchy node matched a
// ticket's context (FR-004/spec.md) — configurable, never hardcoded (Constitution Principle
// II), consistent with every other policy default in this codebase.
ORCHESTRATION_DEFAULT_STRATEGY: z.string().default('ROUND_ROBIN'),
});
export type EnvConfig = z.infer<typeof envSchema>;
+1
View File
@@ -4,3 +4,4 @@ export * from './redis';
export * from './queue';
export * from './storage';
export * from './ai';
export * from './orchestration';
+5
View File
@@ -0,0 +1,5 @@
import { env } from './env';
export const orchestrationConfig = {
defaultStrategy: env.ORCHESTRATION_DEFAULT_STRATEGY,
};
+32 -21
View File
@@ -1,32 +1,43 @@
import EventEmitter from 'events';
import { BaseDomainEvent } from './event-types';
import { logger } from '@/infrastructure/observability';
type Handler<T> = (event: BaseDomainEvent<T>) => Promise<void> | void;
/**
* Deliberately not built on Node's `EventEmitter` — `EventEmitter.emit()` invokes async
* listeners without awaiting them, so a caller has no way to know when a published event's
* subscribers have actually finished (found while building 007-orchestration-assignment: an
* escalation's automatic-assignment subscriber needs to have run by the time the status-update
* HTTP call returns, for the response to accurately reflect what happened). `publish` here
* awaits every matching handler (this event's own name, plus any `'*'` wildcard subscribers),
* each independently try/caught so one handler's failure never blocks or fails the others —
* the same isolation guarantee the previous EventEmitter-based implementation had.
*/
export class EventBus {
private emitter: EventEmitter;
private handlers: Map<string, Handler<unknown>[]> = new Map();
constructor() {
this.emitter = new EventEmitter();
this.emitter.setMaxListeners(50);
}
publish<T>(event: BaseDomainEvent<T>): void {
async publish<T>(event: BaseDomainEvent<T>): Promise<void> {
logger.info({ eventName: event.eventName, eventId: event.eventId }, 'Publishing domain event');
this.emitter.emit(event.eventName, event);
this.emitter.emit('*', event);
const named = this.handlers.get(event.eventName) ?? [];
const wildcard = this.handlers.get('*') ?? [];
await Promise.all(
[...named, ...wildcard].map(async (handler) => {
try {
await handler(event);
} catch (error) {
logger.error(
{ error, eventName: event.eventName, eventId: event.eventId },
'Error executing event handler',
);
}
}),
);
}
subscribe<T>(
eventName: string,
handler: (event: BaseDomainEvent<T>) => Promise<void> | void,
): void {
this.emitter.on(eventName, async (event: BaseDomainEvent<T>) => {
try {
await handler(event);
} catch (error) {
logger.error({ error, eventName, eventId: event.eventId }, 'Error executing event handler');
}
});
subscribe<T>(eventName: string, handler: Handler<T>): void {
const existing = this.handlers.get(eventName) ?? [];
existing.push(handler as Handler<unknown>);
this.handlers.set(eventName, existing);
}
}
+32
View File
@@ -2,8 +2,28 @@ import { eventBus } from '../event-bus';
import { DomainEventName } from '../domain-events';
import { BaseDomainEvent } from '../event-types';
import { sessionsService } from '@/modules/ai-support/sessions';
import { orchestrationService } from '@/modules/orchestration/orchestration';
interface TicketUpdatedPayload {
ticketId: string;
actor: string;
previousStatus: string;
newStatus: string;
}
let registered = false;
/**
* Idempotent — `buildApp()` calls this on every build (see app.ts's comment on why domain-event
* wiring, unlike the queue's background workers, belongs there rather than only in server.ts's
* production startup path), and `buildApp()` is called once per test file across this codebase's
* whole suite; without this guard, `eventBus`'s module-level singleton would accumulate a
* duplicate set of subscribers per test file, each firing on every subsequent test's events.
*/
export function registerDomainEventHandlers(): void {
if (registered) return;
registered = true;
// 005-ai-support FR-023: a human actor changing a ticket's status ends its active AI session.
// Registered here (outside src/modules/) rather than inside ticketing/tickets, so that module
// never needs to import ai-support/sessions — see tickets.service.ts's updateStatus comment.
@@ -13,4 +33,16 @@ export function registerDomainEventHandlers(): void {
await sessionsService.handleTicketStatusChanged(event.payload);
},
);
// 007-orchestration-assignment FR-001/FR-013: a ticket reaching HUMAN_ESCALATION (whether for
// the first time or on re-escalation — the handler is identical either way, research.md) runs
// orchestration automatically. Same "tickets never needs to know this module exists" pattern
// as the subscriber above.
eventBus.subscribe(
DomainEventName.TICKET_UPDATED,
async (event: BaseDomainEvent<TicketUpdatedPayload>) => {
if (event.payload.newStatus !== 'HUMAN_ESCALATION') return;
await orchestrationService.handleHumanEscalation(event.payload.ticketId);
},
);
}
+9
View File
@@ -2,3 +2,12 @@ export { agentsRoutes } from './routes';
export { AgentsService, agentsService } from './service';
export { agentsRepository, AgentsRepository } from './repository';
export type { CreateAgentData, UpdateAgentData, FindAgentsFilter } from './repository';
// Exported for 007-orchestration-assignment's LEAST_LOADED/SKILL_BASED strategies — same
// "extend an existing module's public surface for a later feature" precedent used throughout
// this codebase (e.g. 004's productsRepository, 005's problemsRepository).
export {
agentSkillsRepository,
AgentSkillsRepository,
agentAvailabilityRepository,
AgentAvailabilityRepository,
} from './repository';
@@ -0,0 +1,30 @@
import { EligibleAgentWithSkills } from '../types/strategy.types';
export interface AgentWithSkillScore {
agent: EligibleAgentWithSkills;
totalLevel: number;
}
/** FR-007: total proficiency level across the required skills — "strongest match," not merely
* presence (eligibility already guaranteed presence; this is the weighting on top). An agent's
* skills beyond the required set don't count toward the score. */
export function withSkillScores(
eligible: EligibleAgentWithSkills[],
requiredSkills: string[],
): AgentWithSkillScore[] {
return eligible.map((agent) => {
const totalLevel = agent.skills
.filter((s) => requiredSkills.includes(s.skillTag))
.reduce((sum, s) => sum + s.level, 0);
return { agent, totalLevel };
});
}
/** Pure — which candidates are tied for the highest score. */
export function highestSkillScoreCandidates(
scored: AgentWithSkillScore[],
): EligibleAgentWithSkills[] {
if (scored.length === 0) return [];
const maxScore = Math.max(...scored.map((s) => s.totalLevel));
return scored.filter((s) => s.totalLevel === maxScore).map((s) => s.agent);
}
@@ -1,7 +1,29 @@
export class WorkloadCalculator {
async calculateAgentLoad(_agentId: string): Promise<number> {
return 0;
}
import { agentAvailabilityRepository } from '@/modules/identity/agents';
import { EligibleAgentWithSkills } from '../types/strategy.types';
export interface AgentWithLoad {
agent: EligibleAgentWithSkills;
currentLoad: number;
}
export const workloadCalculator = new WorkloadCalculator();
/** research.md "LEAST_LOADED reads currentLoad as-is": no agent with a set `AgentAvailability`
* record is ever penalized for having none — an agent with no availability record set at all is
* treated as load 0 (never assigned yet is the most available state, not the least). */
export async function withCurrentLoad(
eligible: EligibleAgentWithSkills[],
): Promise<AgentWithLoad[]> {
return Promise.all(
eligible.map(async (agent) => {
const availability = await agentAvailabilityRepository.findByAgent(agent.id);
return { agent, currentLoad: availability?.currentLoad ?? 0 };
}),
);
}
/** Pure — given the candidates' loads, which ones are tied for lowest. Unit-testable without the
* repository lookup above. */
export function lowestLoadCandidates(withLoads: AgentWithLoad[]): EligibleAgentWithSkills[] {
if (withLoads.length === 0) return [];
const minLoad = Math.min(...withLoads.map((w) => w.currentLoad));
return withLoads.filter((w) => w.currentLoad === minLoad).map((w) => w.agent);
}
@@ -0,0 +1,3 @@
export const ASSIGNMENTS_CONSTANTS = {
MODULE_NAME: 'ORCHESTRATION_ASSIGNMENTS',
} as const;
@@ -0,0 +1,38 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { assignmentsService, AssignmentsService } from '../service/assignments.service';
import { manualAssignmentSchema } from '../schema';
function actorFrom(request: FastifyRequest): string {
return request.reqContext?.actorId ?? 'unknown';
}
export class AssignmentsController {
constructor(private readonly service: AssignmentsService = assignmentsService) {}
async assignManually(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const body = manualAssignmentSchema.parse(request.body);
const assignment = await this.service.assignManually(
ticketId,
body.agentId,
actorFrom(request),
body.reason,
body.strategy ?? 'MANUAL',
);
return reply.status(200).send({ success: true, data: assignment, meta: null });
}
async getCurrent(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const assignment = await this.service.getCurrent(ticketId);
return reply.status(200).send({ success: true, data: assignment, meta: null });
}
async getHistory(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const history = await this.service.getHistory(ticketId);
return reply.status(200).send({ success: true, data: history, meta: null });
}
}
export const assignmentsController = new AssignmentsController();
@@ -0,0 +1 @@
export { AssignmentsController, assignmentsController } from './assignments.controller';
@@ -1,6 +1,96 @@
import { Assignment } from '@prisma/client';
import { ticketsService } from '@/modules/ticketing/tickets';
import { routingService, RoutingService } from '@/modules/orchestration/routing';
import { orchestrationConfig } from '@/config';
import {
assignmentRepository,
AssignmentRepository,
assignmentHistoryRepository,
AssignmentHistoryRepository,
} from '../repository';
import { resolveStrategy } from '../service/strategy-registry';
export interface AssignmentOutcome {
assignment: Assignment | null;
strategy: string;
}
export class AssignmentEngine {
async evaluateAndAssign(_ticketId: string): Promise<{ assignedAgentId: string | null }> {
return { assignedAgentId: null };
constructor(
private readonly routing: RoutingService = routingService,
private readonly assignments: AssignmentRepository = assignmentRepository,
private readonly history: AssignmentHistoryRepository = assignmentHistoryRepository,
) {}
/**
* FR-002/FR-003/FR-005/FR-008/FR-013/FR-014: the automatic path — resolves eligibility fresh
* (research.md/FR-013 — never a cached/stale set, so this is also what makes re-escalation
* (User Story 5) correct with no special-casing), runs the resolved (or system-default)
* strategy, persists the outcome either way, and moves the ticket to IN_PROGRESS on success.
*/
async evaluateAndAssign(ticketId: string): Promise<AssignmentOutcome> {
const resolution = await this.routing.resolveEligibleAgents(ticketId);
const strategyName = resolution.assignmentStrategy ?? orchestrationConfig.defaultStrategy;
const strategyFn = resolveStrategy(strategyName);
const selected = strategyFn
? await strategyFn(resolution.eligibleAgents, {
hierarchyNodeId: resolution.hierarchyNodeId,
requiredSkills: resolution.requiredSkills,
})
: null;
if (!selected) {
// FR-008: no eligible agent, or the resolved strategy has no implementation — recorded,
// never a silent no-op.
await this.history.record({
ticketId,
agentId: null,
action: 'unassigned',
strategy: strategyName,
actor: 'system',
});
return { assignment: null, strategy: strategyName };
}
return {
assignment: await this.persistAndTransition(ticketId, selected.id, strategyName, 'system'),
strategy: strategyName,
};
}
/** Shared by both the automatic path (above) and manual assignment
* (AssignmentsService.assignManually) — persists the Assignment + AssignmentHistory and moves
* the ticket to IN_PROGRESS (research.md "Ticket status transition"). */
async persistAndTransition(
ticketId: string,
agentId: string,
strategy: string,
actor: string,
reason?: string,
): Promise<Assignment> {
const priorCurrent = await this.assignments.findCurrent(ticketId);
const assignment = await this.assignments.createAssignment({
ticketId,
agentId,
strategy,
reason,
});
await this.history.record({
ticketId,
agentId,
action: priorCurrent ? 'reassigned' : 'assigned',
strategy,
reason,
actor,
});
const ticket = await ticketsService.getById(ticketId);
if (ticket.status === 'HUMAN_ESCALATION') {
await ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ticket.version, 'system');
}
return assignment;
}
}
@@ -1,3 +1,6 @@
export * from './engine/assignment.engine';
export * from './strategies/round-robin.strategy';
export * from './calculators/workload.calculator';
export { assignmentsRoutes } from './routes';
export { AssignmentsService, assignmentsService } from './service';
export { AssignmentEngine, assignmentEngine } from './engine/assignment.engine';
export type { AssignmentOutcome } from './engine/assignment.engine';
export { assignmentRepository, AssignmentRepository } from './repository';
export { assignmentHistoryRepository, AssignmentHistoryRepository } from './repository';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,38 @@
import { AssignmentHistory, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface RecordHistoryData {
ticketId: string;
agentId?: string | null | undefined;
action: 'assigned' | 'reassigned' | 'unassigned';
strategy: string;
reason?: string | undefined;
actor: string;
}
export class AssignmentHistoryRepository {
constructor(private readonly prisma = prismaClient) {}
/** FR-009: always additive — this table is never updated or deleted. */
async record(data: RecordHistoryData): Promise<AssignmentHistory> {
return this.prisma.assignmentHistory.create({
data: {
ticketId: data.ticketId,
agentId: data.agentId ?? null,
action: data.action,
strategy: data.strategy,
reason: data.reason,
actor: data.actor,
} as Prisma.AssignmentHistoryUncheckedCreateInput,
});
}
async findAllForTicket(ticketId: string): Promise<AssignmentHistory[]> {
return this.prisma.assignmentHistory.findMany({
where: { ticketId },
orderBy: { createdAt: 'asc' },
});
}
}
export const assignmentHistoryRepository = new AssignmentHistoryRepository();
@@ -0,0 +1,43 @@
import { Assignment, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateAssignmentData {
ticketId: string;
agentId: string;
strategy: string;
reason?: string | undefined;
}
export class AssignmentRepository {
constructor(private readonly prisma = prismaClient) {}
/**
* research.md "Assignment refined as a version-row-per-period model": in one transaction,
* supersedes any existing current row for this ticket (isCurrent: false, unassignedAt: now())
* and inserts the new current row — the same "never overwrite, always a new row" guarantee
* 004's KnowledgeEntry versioning already established for a different entity.
*/
async createAssignment(data: CreateAssignmentData): Promise<Assignment> {
return this.prisma.$transaction(async (tx) => {
await tx.assignment.updateMany({
where: { ticketId: data.ticketId, isCurrent: true },
data: { isCurrent: false, unassignedAt: new Date() },
});
return tx.assignment.create({
data: {
ticketId: data.ticketId,
agentId: data.agentId,
strategy: data.strategy,
reason: data.reason,
isCurrent: true,
} as Prisma.AssignmentUncheckedCreateInput,
});
});
}
async findCurrent(ticketId: string): Promise<Assignment | null> {
return this.prisma.assignment.findFirst({ where: { ticketId, isCurrent: true } });
}
}
export const assignmentRepository = new AssignmentRepository();
@@ -0,0 +1,2 @@
export * from './assignment.repository';
export * from './assignment-history.repository';
@@ -0,0 +1,21 @@
import { FastifyInstance } from 'fastify';
import { assignmentsController } from '../controller';
/**
* contracts/orchestration-contract.md: the manual-assignment write route is gated by
* fastify.authenticate (known limitation inherited from 002-006). The read routes are not
* gated — same "read path any caller can use" convention as 003's own ticket-status reads.
*/
export async function assignmentsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/tickets/:ticketId/assignment',
{ preHandler: fastify.authenticate },
(req, reply) => assignmentsController.assignManually(req, reply),
);
fastify.get('/tickets/:ticketId/assignment', (req, reply) =>
assignmentsController.getCurrent(req, reply),
);
fastify.get('/tickets/:ticketId/assignment-history', (req, reply) =>
assignmentsController.getHistory(req, reply),
);
}
@@ -0,0 +1 @@
export { assignmentsRoutes } from './assignments.routes';
@@ -0,0 +1,11 @@
import { z } from 'zod';
export const manualAssignmentSchema = z
.object({
agentId: z.string().min(1),
reason: z.string().optional(),
strategy: z.enum(['MANUAL', 'DIRECT']).optional(),
})
.strict();
export type ManualAssignmentBody = z.infer<typeof manualAssignmentSchema>;
@@ -0,0 +1 @@
export * from './assignments.schema';
@@ -0,0 +1,37 @@
import { Assignment, AssignmentHistory } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { agentsRepository } from '@/modules/identity/agents';
import { assignmentEngine, AssignmentEngine } from '../engine/assignment.engine';
import { assignmentRepository, assignmentHistoryRepository } from '../repository';
export class AssignmentsService {
constructor(private readonly engine: AssignmentEngine = assignmentEngine) {}
/** FR-011/FR-012: validates the target agent exists (resolve-or-404, same convention as
* every other feature's FK validation) before delegating to the same persist-and-transition
* path the automatic strategies use (research.md "Manual assignment"). */
async assignManually(
ticketId: string,
agentId: string,
actor: string,
reason?: string,
strategy: 'MANUAL' | 'DIRECT' = 'MANUAL',
): Promise<Assignment> {
const agent = await agentsRepository.findById(agentId);
if (!agent) throw new NotFoundError('Agent not found.');
return this.engine.persistAndTransition(ticketId, agentId, strategy, actor, reason);
}
async getCurrent(ticketId: string): Promise<Assignment> {
const current = await assignmentRepository.findCurrent(ticketId);
if (!current) throw new NotFoundError('No assignment found for this ticket.');
return current;
}
async getHistory(ticketId: string): Promise<AssignmentHistory[]> {
return assignmentHistoryRepository.findAllForTicket(ticketId);
}
}
export const assignmentsService = new AssignmentsService();
@@ -0,0 +1,2 @@
export { AssignmentsService, assignmentsService } from './assignments.service';
export { STRATEGY_REGISTRY, resolveStrategy } from './strategy-registry';
@@ -0,0 +1,22 @@
import { AssignmentStrategyFn } from '../types/strategy.types';
import { selectViaRoundRobin } from '../strategies/round-robin.strategy';
import { selectViaLeastLoaded } from '../strategies/least-loaded.strategy';
import { selectViaSkillBased } from '../strategies/skill-based.strategy';
/**
* FR-005/FR-008: the auto-selectable strategies. `MANUAL`/`DIRECT` are deliberately absent — they
* never come from this registry, only from an explicit caller-supplied `agentId`
* (AssignmentsService.assignManually) — an unregistered name here (a typo, or one of those two
* reaching this path by mistake) resolves to `undefined`, which the engine treats identically to
* "no agent" (FR-008), never a crash.
*/
export const STRATEGY_REGISTRY: Record<string, AssignmentStrategyFn> = {
ROUND_ROBIN: selectViaRoundRobin,
LEAST_LOADED: selectViaLeastLoaded,
SKILL_BASED: selectViaSkillBased,
};
export function resolveStrategy(name: string | null): AssignmentStrategyFn | null {
if (!name) return null;
return STRATEGY_REGISTRY[name] ?? null;
}
@@ -0,0 +1,23 @@
import { Agent } from '@prisma/client';
import { redisClient } from '@/infrastructure/cache';
import { EligibleAgentWithSkills, StrategyContext } from '../types/strategy.types';
import { withCurrentLoad, lowestLoadCandidates } from '../calculators/workload.calculator';
import { roundRobinSelectIndex } from './round-robin.strategy';
/** FR-007: lowest `currentLoad` wins; ties fall through to the same concurrency-safe cursor
* ROUND_ROBIN uses, scoped under its own Redis key (research.md "tie-breaking"). */
export async function selectViaLeastLoaded(
eligible: EligibleAgentWithSkills[],
context: StrategyContext,
): Promise<Agent | null> {
if (eligible.length === 0) return null;
const withLoads = await withCurrentLoad(eligible);
const tied = lowestLoadCandidates(withLoads).sort((a, b) => a.id.localeCompare(b.id));
if (tied.length === 1) return tied[0] ?? null;
const key = `ticketing:round_robin:least_loaded:${context.hierarchyNodeId ?? 'unscoped'}`;
const count = await redisClient.incr(key);
const index = roundRobinSelectIndex(count, tied.length);
return tied[index] ?? null;
}
@@ -1,7 +1,35 @@
export class RoundRobinAssignmentStrategy {
async selectNextAgent(_candidateIds: string[]): Promise<string | null> {
return _candidateIds[0] || null;
}
import { Agent } from '@prisma/client';
import { redisClient } from '@/infrastructure/cache';
import { EligibleAgentWithSkills, StrategyContext } from '../types/strategy.types';
const ROUND_ROBIN_KEY_PREFIX = 'ticketing:round_robin:';
/**
* FR-006/research.md: pure index-selection — given the Redis counter's post-increment value and
* the eligible-set size, which index is next. Separated from the Redis call itself so the
* selection math is unit-testable without a live Redis instance.
*/
export function roundRobinSelectIndex(incrementedCount: number, eligibleCount: number): number {
return (incrementedCount - 1) % eligibleCount;
}
export const roundRobinAssignmentStrategy = new RoundRobinAssignmentStrategy();
/**
* FR-006: concurrency-safe by construction — `INCR` is a single atomic Redis round trip, so two
* simultaneous calls against the same key always receive two different counter values, and
* therefore (research.md) two different indices whenever the eligible set has more than one
* agent. The eligible array is sorted by `id` first so the same counter value always maps to the
* same relative position for a given eligible set, regardless of the order the caller passed it
* in.
*/
export async function selectViaRoundRobin(
eligible: EligibleAgentWithSkills[],
context: StrategyContext,
): Promise<Agent | null> {
if (eligible.length === 0) return null;
const sorted = [...eligible].sort((a, b) => a.id.localeCompare(b.id));
const key = `${ROUND_ROBIN_KEY_PREFIX}${context.hierarchyNodeId ?? 'unscoped'}`;
const count = await redisClient.incr(key);
const index = roundRobinSelectIndex(count, sorted.length);
return sorted[index] ?? null;
}
@@ -0,0 +1,26 @@
import { Agent } from '@prisma/client';
import { redisClient } from '@/infrastructure/cache';
import { EligibleAgentWithSkills, StrategyContext } from '../types/strategy.types';
import {
withSkillScores,
highestSkillScoreCandidates,
} from '../calculators/skill-match.calculator';
import { roundRobinSelectIndex } from './round-robin.strategy';
/** FR-007: highest total proficiency across the required skills wins; ties fall through to the
* same concurrency-safe cursor ROUND_ROBIN uses (research.md "tie-breaking"). */
export async function selectViaSkillBased(
eligible: EligibleAgentWithSkills[],
context: StrategyContext,
): Promise<Agent | null> {
if (eligible.length === 0) return null;
const scored = withSkillScores(eligible, context.requiredSkills);
const tied = highestSkillScoreCandidates(scored).sort((a, b) => a.id.localeCompare(b.id));
if (tied.length === 1) return tied[0] ?? null;
const key = `ticketing:round_robin:skill_based:${context.hierarchyNodeId ?? 'unscoped'}`;
const count = await redisClient.incr(key);
const index = roundRobinSelectIndex(count, tied.length);
return tied[index] ?? null;
}
@@ -0,0 +1,5 @@
export type {
EligibleAgentWithSkills,
StrategyContext,
AssignmentStrategyFn,
} from './strategy.types';
@@ -0,0 +1,19 @@
import { Agent, AgentSkill } from '@prisma/client';
export interface EligibleAgentWithSkills extends Agent {
skills: AgentSkill[];
}
export interface StrategyContext {
hierarchyNodeId: string | null;
requiredSkills: string[];
}
/** Every auto-selectable strategy has this shape — given the eligible set and context, either
* picks exactly one agent or returns null (no agent — FR-008). Pure with respect to its inputs;
* any external state (Redis, DB) is read inside, never mutated as a side effect visible to the
* caller beyond the selection itself. */
export type AssignmentStrategyFn = (
eligible: EligibleAgentWithSkills[],
context: StrategyContext,
) => Promise<Agent | null>;
@@ -11,6 +11,19 @@ export interface CapabilityLookupContext {
export class CapabilityLookupService {
constructor(private readonly hierarchy: HierarchyRepository = hierarchyRepository) {}
/** Extracted so 007-orchestration-assignment's routing module can resolve the same matching
* node(s) findEligibleAgents already computes internally — reusing this exact scope-matching
* logic rather than a second, divergent implementation (007's research.md). */
async findMatchingNodes(context: CapabilityLookupContext) {
const activeNodes = await this.hierarchy.findActiveNodes();
return activeNodes.filter(
(node) =>
scopeMatches(node.productScope, context.productId) &&
scopeMatches(node.categoryScope, context.categoryId) &&
scopeMatches(node.priorityScope, context.priorityId),
);
}
/**
* FR-014/FR-015/FR-016 (research.md "Capability-eligibility lookup"): resolves which active
* hierarchy nodes apply to the given context, unions their `skills` into the caller-supplied
@@ -19,13 +32,7 @@ export class CapabilityLookupService {
* (FR-016).
*/
async findEligibleAgents(requiredSkills: string[], context: CapabilityLookupContext) {
const activeNodes = await this.hierarchy.findActiveNodes();
const matchingNodes = activeNodes.filter(
(node) =>
scopeMatches(node.productScope, context.productId) &&
scopeMatches(node.categoryScope, context.categoryId) &&
scopeMatches(node.priorityScope, context.priorityId),
);
const matchingNodes = await this.findMatchingNodes(context);
const combinedSkills = new Set(requiredSkills);
for (const node of matchingNodes) {
@@ -1,11 +1 @@
export const ORCHESTRATION_CONSTANTS = {
MODULE_NAME: 'ORCHESTRATION_ENGINE',
} as const;
export class OrchestrationService {
async orchestrateWorkflow(_event: string) {
return { status: 'HANDLED' };
}
}
export const orchestrationService = new OrchestrationService();
export { OrchestrationService, orchestrationService } from './service';
@@ -0,0 +1 @@
export { OrchestrationService, orchestrationService } from './orchestration.service';
@@ -0,0 +1,23 @@
import { logger } from '@/infrastructure/observability';
import { assignmentEngine, AssignmentEngine } from '@/modules/orchestration/assignments';
export class OrchestrationService {
constructor(private readonly engine: AssignmentEngine = assignmentEngine) {}
/**
* FR-001/FR-013: the entry point 005-ai-support's `TICKET_UPDATED` domain event subscriber
* (src/events/handlers/index.ts) calls whenever a ticket reaches `HUMAN_ESCALATION` — covers
* both a ticket's first escalation and any later re-escalation identically (User Story 5),
* since `AssignmentEngine.evaluateAndAssign` always resolves eligibility fresh, never from a
* cached prior result.
*/
async handleHumanEscalation(ticketId: string): Promise<void> {
const outcome = await this.engine.evaluateAndAssign(ticketId);
logger.info(
{ ticketId, strategy: outcome.strategy, agentId: outcome.assignment?.agentId ?? null },
outcome.assignment ? 'Ticket assigned' : 'No eligible agent — ticket remains escalated',
);
}
}
export const orchestrationService = new OrchestrationService();
@@ -0,0 +1 @@
export {};
+2 -11
View File
@@ -1,11 +1,2 @@
export const ROUTING_CONSTANTS = {
MODULE_NAME: 'ORCHESTRATION_ROUTING',
} as const;
export class RoutingService {
async routeTicket(_ticketId: string) {
return { targetTeamId: null };
}
}
export const routingService = new RoutingService();
export { RoutingService, routingService } from './service';
export type { ResolveResult, EligibleAgent } from './service';
@@ -0,0 +1,2 @@
export { RoutingService, routingService } from './routing.service';
export type { ResolveResult, EligibleAgent } from './routing.service';
@@ -0,0 +1,74 @@
import { Agent, AgentSkill } from '@prisma/client';
import { ticketsService } from '@/modules/ticketing/tickets';
import { productsRepository } from '@/modules/catalog/products';
import {
capabilityLookupService,
CapabilityLookupService,
} from '@/modules/orchestration/hierarchy';
import { sessionRepository, diagnosisRepository } from '@/modules/ai-support/sessions';
export interface EligibleAgent extends Agent {
skills: AgentSkill[];
}
export interface ResolveResult {
requiredSkills: string[];
eligibleAgents: EligibleAgent[];
/** The matched hierarchy node's own `assignmentStrategy`, if any node matched — the strategy
* the assignment engine (007) should run. `null` when no node matched (FR-004); the caller
* falls back to a configurable system default in that case. */
assignmentStrategy: string | null;
/** The matched node's id, if any — used to scope ROUND_ROBIN's Redis cursor per node
* (research.md) so unrelated nodes' cycles never interfere with each other. */
hierarchyNodeId: string | null;
}
export class RoutingService {
constructor(
private readonly capabilityLookup: CapabilityLookupService = capabilityLookupService,
) {}
/**
* FR-002/FR-003/FR-004: resolves the capability-eligible-agent set for a ticket, reusing
* 006's own matching algorithm directly (never a second one). research.md "Where a ticket's
* required skill actually comes from": the most recent AI diagnosis's problemType if one
* exists, else no skill constraint at all (never zero eligible agents).
*/
async resolveEligibleAgents(ticketId: string): Promise<ResolveResult> {
const ticket = await ticketsService.getById(ticketId);
const product = await productsRepository.findById(ticket.productId);
const context = {
productId: product?.externalProductId,
categoryId: ticket.categoryId ?? undefined,
priorityId: ticket.priority,
};
const requiredSkills = await this.deriveRequiredSkills(ticketId);
const [eligibleAgents, matchingNodes] = await Promise.all([
this.capabilityLookup.findEligibleAgents(requiredSkills, context),
this.capabilityLookup.findMatchingNodes(context),
]);
// Edge Cases: when multiple nodes match equally, the lowest `order` is the primary node
// whose strategy/id drives the assignment — every matched node's skills are still unioned
// into eligibility (findEligibleAgents already did that), only the strategy choice itself
// needs one deterministic winner.
const primaryNode = [...matchingNodes].sort((a, b) => a.order - b.order)[0] ?? null;
return {
requiredSkills,
eligibleAgents: eligibleAgents as EligibleAgent[],
assignmentStrategy: primaryNode?.assignmentStrategy ?? null,
hierarchyNodeId: primaryNode?.id ?? null,
};
}
private async deriveRequiredSkills(ticketId: string): Promise<string[]> {
const session = await sessionRepository.findMostRecentByTicketId(ticketId);
if (!session) return [];
const diagnosis = await diagnosisRepository.findLatestBySession(session.id);
return diagnosis ? [diagnosis.problemType] : [];
}
}
export const routingService = new RoutingService();
@@ -0,0 +1 @@
export {};
@@ -156,7 +156,10 @@ export class TicketsService {
// decouples this module from ai-support/sessions entirely; a subscriber decides whether a
// given event matters to it (e.g. "was this actor not 'ai'?"), this module just reports what
// happened. See src/events/handlers/index.ts.
eventBus.publish({
// Awaited (not fire-and-forget) so a caller of updateStatus — an HTTP request in
// particular — only sees its response once every subscriber (005's AI-session-ending hook,
// 007's automatic-orchestration hook) has actually run; see events/event-bus.ts.
await eventBus.publish({
eventId: randomUUID(),
eventName: DomainEventName.TICKET_UPDATED,
aggregateId: ticketId,
+1 -3
View File
@@ -8,7 +8,6 @@ import {
bootstrapStorage,
setupGracefulShutdown,
} from '@/bootstrap';
import { registerDomainEventHandlers } from '@/events';
async function startServer(): Promise<void> {
try {
@@ -19,9 +18,8 @@ async function startServer(): Promise<void> {
await bootstrapRedis();
await bootstrapQueue();
await bootstrapStorage();
registerDomainEventHandlers();
// Create Fastify Instance
// Create Fastify Instance — registers domain event handlers itself, see app.ts.
const app = await buildApp();
// Register Graceful Shutdown Processors
+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']);
});
});