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:
co-authored by
Claude Sonnet 5
parent
d3d57b9954
commit
77928c4878
@@ -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;
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export const orchestrationConfig = {
|
||||||
|
defaultStrategy: env.ORCHESTRATION_DEFAULT_STRATEGY,
|
||||||
|
};
|
||||||
+30
-19
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user