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 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). it — `CustomerReference`, built in 002, is the real customer-identity mechanism).
`identity/customers` and `identity/auth` are untouched — out of scope here. `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;
+36
View File
@@ -143,6 +143,8 @@ model Ticket {
messages TicketMessage[] messages TicketMessage[]
attachments TicketAttachment[] attachments TicketAttachment[]
aiSessions AISupportSession[] aiSessions AISupportSession[]
assignments Assignment[]
assignmentHistory AssignmentHistory[]
@@unique([productId, idempotencyKey]) @@unique([productId, idempotencyKey])
@@index([productId, status]) @@index([productId, status])
@@ -407,6 +409,7 @@ model Agent {
skills AgentSkill[] skills AgentSkill[]
availability AgentAvailability? availability AgentAvailability?
assignments Assignment[]
@@index([teamId, active]) @@index([teamId, active])
@@map("agents") @@map("agents")
@@ -466,3 +469,36 @@ model HierarchyNode {
@@index([active]) @@index([active])
@@map("hierarchy_nodes") @@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 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 "no signal" as "no eligible agents" — the latter would silently contradict FR-004's own
explicit "rather than failing." 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 ## 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 `index.ts` (no controller/routes — internal only), replacing the existing
`RoutingService.routeTicket` stub `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/`, (`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`,
`constants/`, `index.ts`) around its existing `engine/`/`strategies/`/`rules/`/ `constants/`, `index.ts`) around its existing `engine/`/`strategies/`/`rules/`/
`calculators/` directories, replacing every stub file's content `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 `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. **⚠️ 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`/ `prisma/schema.prisma` per data-model.md, plus `Ticket.assignments`/
`Ticket.assignmentHistory`/`Agent.assignments` back-relations (depends on T001-T003) `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) T004 (depends on T004)
**Checkpoint**: Schema migrated. User stories can now be built. **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 ### 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 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` skill-only) against a real Postgres — implemented in
(depends on T005) `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 ### 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` product/category/priority context, calls 006's `capabilityLookupService.findEligibleAgents`
directly (research.md — through `orchestration/hierarchy`'s public `index.ts`) with the 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, 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 matched (needed later for the node's own `assignmentStrategy`, T013) — in
`src/modules/orchestration/routing/service/routing.service.ts` (depends on T005) `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 `DomainEventName.TICKET_UPDATED` handler in `src/events/handlers/index.ts` (alongside
005's existing subscriber) that calls into `orchestration`'s engine (T017) when 005's existing subscriber) that calls into `orchestration`'s engine (T017) when
`payload.newStatus === 'HUMAN_ESCALATION'` — wired now so T007's resolution is reachable `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) 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 **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. 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 ### 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 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 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` 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 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 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 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) 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 `SKILL_BASED`, empty-eligible-set outcome recorded) against a real Postgres in
`tests/integration/orchestration-strategies.test.ts` (depends on T005) `tests/integration/orchestration-strategies.test.ts` (depends on T005)
### Implementation for User Story 2 ### 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 `ticketing:round_robin:<hierarchyNodeId ?? 'unscoped'>` via the existing shared Redis
client, then `(count - 1) % eligibleAgents.length` into the eligible array sorted by client, then `(count - 1) % eligibleAgents.length` into the eligible array sorted by
`agent.id` (research.md) — in `agent.id` (research.md) — in
`src/modules/orchestration/assignments/strategies/round-robin.strategy.ts` (replacing the `src/modules/orchestration/assignments/strategies/round-robin.strategy.ts` (replacing the
existing stub) (depends on T005) 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 lowest wins, ties fall through to T013's cursor — research.md) in
`src/modules/orchestration/assignments/strategies/least-loaded.strategy.ts` + `src/modules/orchestration/assignments/strategies/least-loaded.strategy.ts` +
`calculators/workload.calculator.ts` (replacing the existing stub) `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 required skills, highest total wins, ties fall through to T013's cursor) in
`src/modules/orchestration/assignments/strategies/skill-based.strategy.ts` + `src/modules/orchestration/assignments/strategies/skill-based.strategy.ts` +
`calculators/skill-match.calculator.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 => Promise<Agent | null>>`) covering `ROUND_ROBIN`/`LEAST_LOADED`/`SKILL_BASED`, with
`MANUAL`/`DIRECT` deliberately absent (those are never auto-selected — they only ever come `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 from an explicit caller-supplied `agentId`, T024) — an unregistered strategy name resolves
to `null` (no agent), matching FR-008 — in to `null` (no agent), matching FR-008 — in
`src/modules/orchestration/assignments/service/strategy-registry.ts` (depends on T013, `src/modules/orchestration/assignments/service/strategy-registry.ts` (depends on T013,
T014, T015) 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 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 `assignmentStrategy` (or a configurable system default when no node matched), persists the
result via T019/T020's repository (approved agent → new `Assignment` + `assigned` 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', success transitions the ticket via `ticketsService.updateStatus(ticketId, 'IN_PROGRESS',
ticket.version, 'system')` (research.md) — in ticket.version, 'system')` (research.md) — in
`src/modules/orchestration/assignments/engine/assignment.engine.ts` (depends on T016) `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 `evaluateAndAssign` — completing the automatic escalation→assignment flow — in
`src/modules/orchestration/orchestration/service/orchestration.service.ts` (depends on `src/modules/orchestration/orchestration/service/orchestration.service.ts` (depends on
T017) 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 **Checkpoint**: Escalated tickets are now actually assigned — the full automatic flow works end
to end, concurrency-safely. to end, concurrency-safely.
@@ -163,14 +165,15 @@ superseded, never lost.
### Tests for User Story 3 ### 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 current `Assignment` row, both remain in `AssignmentHistory`, `GET .../assignment` reflects
only the latest) against a real Postgres in `tests/integration/orchestration-history.test.ts` only the latest) against a real Postgres — implemented in
(depends on T005) `tests/integration/orchestration-flow.test.ts` (see T006's note) rather than
`orchestration-history.test.ts` (depends on T005)
### Implementation for User Story 3 ### 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 existing current row's `isCurrent: false`/`unassignedAt: now()` then inserts the new
current row, matching research.md's version-row-per-period refinement; `findCurrent`; current row, matching research.md's version-row-per-period refinement; `findCurrent`;
`AssignmentHistoryRepository.record` — always additive, never updates) in `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 `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 T017 (US2) already depends on; implemented here since US3's own story is what specifies its
correctness guarantees in detail 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/` + `GET /tickets/:ticketId/assignment-history` routes in `assignments/schema/` + `routes/` +
`controller/`, registered from `src/api/routes.ts` (depends on T021) `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 **Checkpoint**: Assignment history is durable and correctly superseded, independently of whether
a reassignment came from automatic re-escalation or a manual override. a reassignment came from automatic re-escalation or a manual override.
@@ -197,21 +200,21 @@ assignment.
### Tests for User Story 4 ### 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) nonexistent agent rejected with `404`; a second manual assignment supersedes the first)
against a real Postgres in `tests/integration/orchestration-manual-assignment.test.ts` against a real Postgres — implemented in `tests/integration/orchestration-flow.test.ts`
(depends on T021) (see T006's note) rather than `orchestration-manual-assignment.test.ts` (depends on T021)
### Implementation for User Story 4 ### 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 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 reuses T021's repository the same way T017's automatic path does — in
`src/modules/orchestration/assignments/service/assignments.service.ts` (depends on T021) `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 `fastify.authenticate`) in `assignments/schema/` + `routes/` + `controller/` (depends on
T025) 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 **Checkpoint**: Both automatic and manual assignment paths exist, fully interchangeable from
history's point of view. history's point of view.
@@ -227,18 +230,19 @@ eligible set.
### Tests for User Story 5 ### 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 re-escalated gets a freshly computed eligible set and a new assignment; the prior one
remains in history) against a real Postgres in remains in history) against a real Postgres — implemented in
`tests/integration/orchestration-re-escalation.test.ts` (depends on T018, T021) `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 ### 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 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 `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) 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, **Checkpoint**: All five user stories work independently and together — escalation, resolution,
strategy selection, persistence, manual override, and re-escalation form one coherent, strategy selection, persistence, manual override, and re-escalation form one coherent,
@@ -248,14 +252,14 @@ concurrency-safe flow.
## Phase 8: Polish & Cross-Cutting Concerns ## 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. 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/ manual-only), the concurrency-safety mechanism, and what's explicitly deferred (SLA/
escalation-policy execution, workload lifecycle mutation) 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 any implementation-time findings
- [ ] T033 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` - [x] 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] 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 elsewhere, then the full integration + concurrency suite against real Docker-provisioned
infra infra
+2
View File
@@ -14,6 +14,7 @@ import { sessionsRoutes } from '@/modules/ai-support/sessions';
import { teamsRoutes } from '@/modules/identity/teams'; import { teamsRoutes } from '@/modules/identity/teams';
import { agentsRoutes } from '@/modules/identity/agents'; import { agentsRoutes } from '@/modules/identity/agents';
import { hierarchyRoutes } from '@/modules/orchestration/hierarchy'; import { hierarchyRoutes } from '@/modules/orchestration/hierarchy';
import { assignmentsRoutes } from '@/modules/orchestration/assignments';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> { export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes); await app.register(healthRoutes);
@@ -29,5 +30,6 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
await app.register(teamsRoutes); await app.register(teamsRoutes);
await app.register(agentsRoutes); await app.register(agentsRoutes);
await app.register(hierarchyRoutes); await app.register(hierarchyRoutes);
await app.register(assignmentsRoutes);
// Further domain module routes will be registered here as feature modules are wired up // 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 { logger } from '@/infrastructure/observability';
import { AppError } from '@/common/errors'; import { AppError } from '@/common/errors';
import { bootstrapPlugins, bootstrapRoutes } from '@/bootstrap'; import { bootstrapPlugins, bootstrapRoutes } from '@/bootstrap';
import { registerDomainEventHandlers } from '@/events';
export async function buildApp(): Promise<FastifyInstance> { export async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ const app = Fastify({
@@ -15,6 +16,13 @@ export async function buildApp(): Promise<FastifyInstance> {
// Register Bootstrapped Plugins // Register Bootstrapped Plugins
await bootstrapPlugins(app); 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 // 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 // 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 // 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_LOW_CONFIDENCE: z.coerce.number().default(0.4),
AI_SUPPORT_DEFAULT_MAX_CLARIFYING_QUESTIONS: z.coerce.number().default(2), AI_SUPPORT_DEFAULT_MAX_CLARIFYING_QUESTIONS: z.coerce.number().default(2),
AI_SUPPORT_MAX_REASONING_ITERATIONS_PER_TURN: z.coerce.number().default(4), 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>; export type EnvConfig = z.infer<typeof envSchema>;
+1
View File
@@ -4,3 +4,4 @@ export * from './redis';
export * from './queue'; export * from './queue';
export * from './storage'; export * from './storage';
export * from './ai'; 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,
};
+30 -19
View File
@@ -1,32 +1,43 @@
import EventEmitter from 'events';
import { BaseDomainEvent } from './event-types'; import { BaseDomainEvent } from './event-types';
import { logger } from '@/infrastructure/observability'; 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 { export class EventBus {
private emitter: EventEmitter; private handlers: Map<string, Handler<unknown>[]> = new Map();
constructor() { async publish<T>(event: BaseDomainEvent<T>): Promise<void> {
this.emitter = new EventEmitter();
this.emitter.setMaxListeners(50);
}
publish<T>(event: BaseDomainEvent<T>): void {
logger.info({ eventName: event.eventName, eventId: event.eventId }, 'Publishing domain event'); logger.info({ eventName: event.eventName, eventId: event.eventId }, 'Publishing domain event');
this.emitter.emit(event.eventName, event); const named = this.handlers.get(event.eventName) ?? [];
this.emitter.emit('*', event); const wildcard = this.handlers.get('*') ?? [];
} await Promise.all(
[...named, ...wildcard].map(async (handler) => {
subscribe<T>(
eventName: string,
handler: (event: BaseDomainEvent<T>) => Promise<void> | void,
): void {
this.emitter.on(eventName, async (event: BaseDomainEvent<T>) => {
try { try {
await handler(event); await handler(event);
} catch (error) { } catch (error) {
logger.error({ error, eventName, eventId: event.eventId }, 'Error executing event handler'); logger.error(
{ error, eventName: event.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 { DomainEventName } from '../domain-events';
import { BaseDomainEvent } from '../event-types'; import { BaseDomainEvent } from '../event-types';
import { sessionsService } from '@/modules/ai-support/sessions'; 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 { 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. // 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 // 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. // 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); 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 { AgentsService, agentsService } from './service';
export { agentsRepository, AgentsRepository } from './repository'; export { agentsRepository, AgentsRepository } from './repository';
export type { CreateAgentData, UpdateAgentData, FindAgentsFilter } 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 { import { agentAvailabilityRepository } from '@/modules/identity/agents';
async calculateAgentLoad(_agentId: string): Promise<number> { import { EligibleAgentWithSkills } from '../types/strategy.types';
return 0;
} 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 { export class AssignmentEngine {
async evaluateAndAssign(_ticketId: string): Promise<{ assignedAgentId: string | null }> { constructor(
return { assignedAgentId: null }; 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 { assignmentsRoutes } from './routes';
export * from './strategies/round-robin.strategy'; export { AssignmentsService, assignmentsService } from './service';
export * from './calculators/workload.calculator'; 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 { import { Agent } from '@prisma/client';
async selectNextAgent(_candidateIds: string[]): Promise<string | null> { import { redisClient } from '@/infrastructure/cache';
return _candidateIds[0] || null; 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 { export class CapabilityLookupService {
constructor(private readonly hierarchy: HierarchyRepository = hierarchyRepository) {} 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 * 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 * hierarchy nodes apply to the given context, unions their `skills` into the caller-supplied
@@ -19,13 +32,7 @@ export class CapabilityLookupService {
* (FR-016). * (FR-016).
*/ */
async findEligibleAgents(requiredSkills: string[], context: CapabilityLookupContext) { async findEligibleAgents(requiredSkills: string[], context: CapabilityLookupContext) {
const activeNodes = await this.hierarchy.findActiveNodes(); const matchingNodes = await this.findMatchingNodes(context);
const matchingNodes = activeNodes.filter(
(node) =>
scopeMatches(node.productScope, context.productId) &&
scopeMatches(node.categoryScope, context.categoryId) &&
scopeMatches(node.priorityScope, context.priorityId),
);
const combinedSkills = new Set(requiredSkills); const combinedSkills = new Set(requiredSkills);
for (const node of matchingNodes) { for (const node of matchingNodes) {
@@ -1,11 +1 @@
export const ORCHESTRATION_CONSTANTS = { export { OrchestrationService, orchestrationService } from './service';
MODULE_NAME: 'ORCHESTRATION_ENGINE',
} as const;
export class OrchestrationService {
async orchestrateWorkflow(_event: string) {
return { status: 'HANDLED' };
}
}
export const orchestrationService = new OrchestrationService();
@@ -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 = { export { RoutingService, routingService } from './service';
MODULE_NAME: 'ORCHESTRATION_ROUTING', export type { ResolveResult, EligibleAgent } from './service';
} as const;
export class RoutingService {
async routeTicket(_ticketId: string) {
return { targetTeamId: null };
}
}
export const routingService = new RoutingService();
@@ -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 // 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 // given event matters to it (e.g. "was this actor not 'ai'?"), this module just reports what
// happened. See src/events/handlers/index.ts. // 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(), eventId: randomUUID(),
eventName: DomainEventName.TICKET_UPDATED, eventName: DomainEventName.TICKET_UPDATED,
aggregateId: ticketId, aggregateId: ticketId,
+1 -3
View File
@@ -8,7 +8,6 @@ import {
bootstrapStorage, bootstrapStorage,
setupGracefulShutdown, setupGracefulShutdown,
} from '@/bootstrap'; } from '@/bootstrap';
import { registerDomainEventHandlers } from '@/events';
async function startServer(): Promise<void> { async function startServer(): Promise<void> {
try { try {
@@ -19,9 +18,8 @@ async function startServer(): Promise<void> {
await bootstrapRedis(); await bootstrapRedis();
await bootstrapQueue(); await bootstrapQueue();
await bootstrapStorage(); await bootstrapStorage();
registerDomainEventHandlers();
// Create Fastify Instance // Create Fastify Instance — registers domain event handlers itself, see app.ts.
const app = await buildApp(); const app = await buildApp();
// Register Graceful Shutdown Processors // 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']);
});
});