Architecture

HITL State Machine

Complete state machine specification with transitions, guards, and side effects.

The HITL (Human-in-the-Loop) state machine governs the full lifecycle of an interview. Each transition has a trigger (actor), a guard (precondition), and side effects (Kafka events, webhooks, state updates). The machine is implemented in the Workflow Module and is the single source of truth for valid state changes.

All States

StateDescriptionWaiting For
RECEIVEDA2A request received, data validation runningSystem validation
INFO_NEEDEDMissing critical data — processing blockedHITL to complete info
VALIDATING_SKILLSAgno Planner validating skills via pgvectorAgno Planner Agent
GENERATING_PLANLLM generating interview planAgno Planner Agent
PENDINGPlan ready, awaiting recruiter reviewRecruiter approval decision
APPROVEDPlan approved, candidate link generatedCandidate to join
REJECTEDPlan rejected by recruiter — terminalNone (terminal)
SCHEDULEDInterview scheduled for a future timeScheduled time
IN_PROGRESSLive interview session activeSession completion
COMPLETEDSession ended, assessment generatingAgno Assessor Agent
ASSESSMENT_PENDINGAI assessment ready, awaiting recruiter reviewRecruiter verdict
ASSESSMENT_APPROVEDAssessment approved — terminalNone (terminal)
CANCELLEDInterview cancelled at any stageNone (terminal)

State Diagram

text
RECEIVED
    │
    ├── [data complete]──────────────────► VALIDATING_SKILLS
    │                                            │
    └── [data incomplete]──► INFO_NEEDED         │
                │                 │              ▼
                │             HITL fills   GENERATING_PLAN
                │             missing data      │
                │                 │             ▼
                └─────────────────┘          PENDING
                                               │
                              ┌────────────────┤
                              │                │
                           APPROVE          REJECT ──── REJECTED (terminal)
                              │
                           APPROVED
                              │
                              ▼
                         SCHEDULED ──► IN_PROGRESS ──► COMPLETED
                                                           │
                                                    ASSESSMENT_PENDING
                                                           │
                                           ┌───────────────┤
                                           │               │
                                  ASSESSMENT_APPROVED   CANCELLED (terminal)

              Any state ──[cancel]──► CANCELLED (terminal)

Transitions Table

TransitionFromToActorSide Effect
VALIDATERECEIVEDVALIDATING_SKILLSSystemKafka: skill.validation.requested
REQUEST_INFORECEIVEDINFO_NEEDEDSystemKafka: interview.info_needed + webhook
COMPLETE_INFOINFO_NEEDEDVALIDATING_SKILLSHITLKafka: interview.info_completed + webhook
PLAN_READYGENERATING_PLANPENDINGAgno AgentWebhook: interview.plan_generated
APPROVEPENDINGAPPROVEDRecruiterGenerate interview link, substitute inmailDraft placeholders, webhook: interview.approved (includes interviewLink + inmailDraft)
REJECTPENDINGREJECTEDRecruiterWebhook: interview.rejected
MODIFYPENDINGGENERATING_PLANRecruiterKafka: interview.modification_requested
SCHEDULEAPPROVEDSCHEDULEDSystem/RecruiterCalendar event (optional)
STARTSCHEDULEDIN_PROGRESSCandidate (WebSocket)WebSocket session created
COMPLETEIN_PROGRESSCOMPLETEDVert.x EdgeKafka: interview.completed
ASSESSCOMPLETEDASSESSMENT_PENDINGAgno AgentWebhook: interview.assessment_ready
APPROVE_ASSESSMENTASSESSMENT_PENDINGASSESSMENT_APPROVEDRecruiterWebhook: assessment.approved
CANCELAnyCANCELLEDAdmin/RecruiterWebhook: interview.cancelled

Implementation

typescript
// WorkflowService: applyTransition
async applyTransition(
  interviewId: string,
  tenantId: string,
  transition: WorkflowTransition,
  context: TransitionContext,
): Promise<WorkflowResult> {
  const interview = await this.interviewService.findById(tenantId, interviewId);

  // Guard: check if transition is valid from current state
  const allowed = VALID_TRANSITIONS[interview.workflowState];
  if (!allowed?.includes(transition)) {
    throw new BadRequestException(
      `Transition ${transition} is not valid from state ${interview.workflowState}`
    );
  }

  // Determine next state
  const nextState = STATE_MACHINE[interview.workflowState][transition];

  // Apply transition atomically
  await this.prisma.$transaction(async (tx) => {
    // Update interview state
    await tx.interview.update({
      where: { id: interviewId, tenantId },
      data: { workflowState: nextState },
    });

    // Record transition in history
    await tx.workflowTransition.create({
      data: {
        interviewId,
        tenantId,
        from: interview.workflowState,
        to: nextState,
        transition,
        triggeredBy: context.userId ?? 'system',
        reason: context.reason,
      },
    });
  });

  // Publish side effects outside transaction
  await this.publishSideEffects(transition, interview, context);

  return { previousState: interview.workflowState, newState: nextState };
}
All transition logic lives in the WorkflowService. Never update workflowState directly with a Prisma update call outside of this service — doing so bypasses guards and side effects.
Was this page helpful?