/** * Escalation Router Service * * Routes escalations to the appropriate target: * - target === 'team_lead' → Send message to TL agent via MessageQueue * - target === 'human' → Send Slack notification to user + pause task + create pending record * * Also handles MissionPolicy escalation rules (cost_exceeded, failure_count, etc.) * by routing `escalateTo: 'user'` to human and `escalateTo: 'orchestrator'` to agent. * * Pending human escalations are stored in `.crewly/escalations/` and resolved * via POST /api/escalations/:id/resolve. * * @module services/v3/escalation-router.service */ import type { AlignmentRequest } from '../../types/v2/work-item.types.js'; import type { EscalationRule, Mission } from '../../types/v2/mission.types.js'; /** Status of a pending human escalation. */ export type EscalationStatus = 'pending' | 'resolved' | 'expired'; /** * Minimal WorkItem shape `escalateFailedWorkItem` needs. Defined locally * (rather than importing the full WorkItem type) to keep this service's * dependency surface narrow — escalation only reads, never writes, * fields it doesn't recognise. */ export interface WorkItemForEscalation { id: string; title: string; type: string; target?: string | null; retryCount: number; maxRetries: number; error?: string | null; requestId?: string | null; missionId?: string | null; parentWorkItemId?: string | null; priority?: string | null; } /** A persisted escalation record awaiting human resolution. */ export interface PendingEscalation { id: string; status: EscalationStatus; /** What triggered this escalation */ source: 'alignment_request' | 'policy_rule' | 'tl_verification' | 'manual' | 'workitem_failed' | 'agent_waiting_on_human'; /** Who needs to resolve it */ target: 'human' | 'team_lead' | 'orchestrator'; /** The escalation content */ summary: string; details: Record; /** Related IDs */ workItemId?: string; missionId?: string; taskId?: string; /** Who raised it */ raisedBy: string; raisedAt: string; /** Resolution (filled when resolved) */ resolvedAt?: string; resolvedBy?: string; resolution?: string; } export declare class EscalationRouterService { private static instance; private readonly logger; private readonly escalationsDir; private constructor(); static getInstance(projectPath?: string): EscalationRouterService; static resetInstance(): void; /** * Route a worker's AlignmentRequest to the correct target. * * @param request - The structured alignment request from the worker * @param workItemId - The WorkItem being escalated * @param workerSession - Worker's session name * @returns The escalation ID if routed to human, null if handled by agent */ routeAlignmentRequest(request: AlignmentRequest, workItemId: string, workerSession: string): Promise; /** * Record that a queued work item has no reachable target, without notifying * anyone — the caller has already sent its own alert. * * Orphan recovery used to borrow {@link routePolicyEscalation} with a * fabricated mission and a `scope_change` rule, which named neither the * work item nor the agent and fired a second Slack post on top of the * caller's. It re-recorded the same orphan on every sweep, so 278 identical * records piled up over five months (2026-04-14 → 2026-09-21). One pending * record per work item, and nothing is sent. * * @param wi - The orphaned work item * @returns The escalation id — existing or new — or null on failure */ recordOrphanedWorkItem(wi: { id: string; title: string; target?: string | null; }): Promise; /** * Put an agent that is blocked on a terminal prompt (approval, trust, * plan-mode menu) into the owner's escalations queue. crewly-mobile polls * `GET /api/escalations`, and the owner is also told on Slack. * * One pending record per session: a repeat call while one is pending * returns that record's id and sends nothing. * * Resolving the record does not answer the prompt. The owner answers it in * the agent's terminal; the record is closed by * {@link resolveAgentWaitingOnHuman} once the prompt is gone. * * @param input - Session, dialog kind, rule evidence and task label * @returns The escalation id (existing or new), or null on failure */ recordAgentWaitingOnHuman(input: { sessionName: string; kind: string; evidence: string[]; titleLabel?: string; }): Promise; /** * Close the pending waiting_on_human escalation(s) for a session once the * agent is no longer blocked. * * @param sessionName - The agent session * @returns Number of escalations closed */ resolveAgentWaitingOnHuman(sessionName: string): Promise; /** * Route a MissionPolicy escalation rule trigger. * * @param mission - The Mission that triggered the rule * @param rule - The escalation rule that fired * @param context - Current context values (cost, time, failures) */ routePolicyEscalation(mission: Mission, rule: EscalationRule, context: Record): Promise; /** * Resolve a pending human escalation. * * #819: `tl_verification` escalations (raised by * {@link escalateUnreviewedToOwner}) target a WorkItem that is * `done_by_worker` — awaiting review, never paused. The old code always * called {@link resumeWorkItem}, whose `done_by_worker → queued` * transition is illegal (not in `TRANSITION_PERMISSIONS`) and was silently * swallowed: the escalation was stamped `resolved` while the WorkItem * stayed stuck, unverified, forever. Only `alignment_request` escalations * (raised by {@link routeAlignmentRequest}, which actually calls * {@link pauseWorkItem}) get resumed to `queued`. A `tl_verification` * escalation instead renders the verdict directly, with the owner as * actor — the transition matrix always allows `owner` regardless of who * the recorded reviewer is (see `checkTransitionPermission`), so this * never hits the same illegal-transition trap. * * @param escalationId - The escalation to resolve * @param resolution - What the human decided (free text; also used as the * verdict comment when this resolves a `tl_verification` escalation) * @param resolvedBy - Who resolved it (user ID or name) * @param verdict - For a `tl_verification` escalation only: `'verified'` * or `'rejected'`. Defaults to `'verified'` — resolving an escalation is * an approval unless the owner says otherwise. Ignored for other * escalation sources. * @returns The updated escalation, or null if not found */ resolve(escalationId: string, resolution: string, resolvedBy: string, verdict?: 'verified' | 'rejected'): Promise; /** * Render a verdict on a `done_by_worker` WorkItem as the owner (#819). * * The owner is always permitted to verify or reject a WorkItem regardless * of who its recorded reviewer is — this is the escape hatch of last * resort for an item that has already been escalated all the way up. * Best-effort: a failure is logged, never thrown, so a resolve() call * always records the escalation as resolved even if the underlying * WorkItem verdict could not be rendered (e.g. it was deleted, or somehow * left `done_by_worker` already). * * @param workItemId - The WorkItem to verdict * @param verdict - `'verified'` or `'rejected'` * @param comment - The owner's resolution text, recorded on the verdict */ private renderOwnerVerdict; /** * Escalate a WorkItem that exhausted its retry budget to the * orchestrator. The orchestrator receives a structured message via * the same MessageQueue used by `target='team_lead'` escalations, * decides whether to (a) surface to the user, (b) replan, (c) hand * off to a different agent. Also persists a `PendingEscalation` * record for the dashboard / API. * * Called from `V3DataService.onTaskFailed` once `retryCount >= * maxRetries`. Best-effort — exceptions are caught and logged so a * messaging blip doesn't strand the upstream failure handler. * * @param wi - The failed WorkItem (post-`failItem`, with retryCount * and maxRetries reflecting the exhausted budget) * @param reason - Final failure reason (same string written to wi.error) * @returns The persisted escalation id, or null on persistence failure */ escalateFailedWorkItem(wi: WorkItemForEscalation, reason: string): Promise; /** * Deliver the FINAL deliverable judgment to the orchestrator when a Request * completes — every child WorkItem was verified (P2 acceptance gate), so the * whole deliverable is assembled and per-piece accepted. Asks the orc (the * top-level TL) to do the holistic check the per-piece verification cannot: * does the ASSEMBLED result meet the original goal and is it actually usable? * Accept to close, or reopen with concrete gaps (which re-delegates rework). * * Best-effort — enqueues a self-contained orc message; never throws. * * @param request - The completed Request (id + goal/title for the summary). * @param childItems - Its child WorkItems (title + status, for the summary). * @returns Resolves when the message is enqueued (or silently on failure). */ requestFinalDeliverableReview(request: { id: string; title?: string; description?: string; objective?: string; }, childItems: Array<{ title: string; status: string; }>): Promise; /** * Last step of the review escalation chain (#813): a `done_by_worker` item * nobody reviewed within the WorkItem TTL goes to the owner. * * Replaces the old TTL auto-verify. The item is NOT moved — it stays * `done_by_worker` until its reviewer, the orchestrator or the owner renders * a verdict. One pending `tl_verification` record targeted at the human, * plus the usual owner notification. * * @param wi - The unreviewed WorkItem (only read) * @param awaitingMs - How long it has awaited review * @returns The escalation id, or null on failure (logged, never thrown) */ escalateUnreviewedToOwner(wi: WorkItemForEscalation, awaitingMs: number): Promise; /** * Escalate a WorkItem whose worker reported done but whose Team Leader has * NOT verified it within the deadline (verification-enforcement, P1). * * Asks the orchestrator to render an EXPLICIT verdict — verify/accept * (→ verified) or reject (→ rejected → the existing rework loop). Second * step of lead → orchestrator → owner (#813); from here on the orchestrator * may render the verdict itself via `POST /api/task-pool/items/:id/verdict`. * Mirrors * {@link escalateFailedWorkItem}: persists a `tl_verification` escalation * targeted at the orchestrator and enqueues a self-contained orc-facing * message. Best-effort — persistence/message failures are logged, not * thrown, so the reconciler pass never strands on it. * * @param wi - The unverified WorkItem (only read). * @param awaitingMs - How long it has awaited verification (for the message). * @returns The escalation id, or null on a hard failure. */ escalateUnverifiedWorkItem(wi: WorkItemForEscalation, awaitingMs: number): Promise; /** * List all pending escalations. */ listPending(): Promise; private createPendingEscalation; private saveEscalation; private loadEscalation; /** * Notify human via the same channel the original request came from. * Falls back to Slack → Chat UI if source channel can't be determined. */ private notifyHuman; /** * Resolve the source channel for an escalation by tracing back to the * original Request/ChatMessage that started the work. */ private resolveSourceChannel; /** * Notify TL agent via message queue (for team_lead-targeted escalations). */ private notifyAgent; private pauseWorkItem; private resumeWorkItem; } //# sourceMappingURL=escalation-router.service.d.ts.map