/** * RequestSlaSubscriber (INBOUND-1) * * Closes the user-facing reliability gap noted by Steve on 2026-04-27: * inbound user messages on Slack/Chat-v2 land as Requests today * (`slack-orchestrator-bridge.ts:377`), but no WorkItem is auto-created on * the orchestrator's plate. The orc therefore has no SLA to track and can * silently drop a user request when busy with other work. * * This subscriber listens to `request:created` and — when the Request was * sourced from an inbound user channel (tags include `slack` or `chat-v2`) * — auto-creates a `respond_to_user` WorkItem assigned to the orc with a * 5-minute SLA deadline. If the orc has not transitioned the WI to a * terminal status by 5 minutes, the subscriber emits `request:sla_breached` * (CRITICAL — surfaces in the orc terminal). At 10 minutes the subscriber * fires the *escalation* hook, which the production wiring uses to send a * Slack DM nudge back to the user (so they're never blind to the miss). * * Auto-close paths: * (a) {@link markResolvedByThread} — `slack-orchestrator-bridge` calls * this when the orc replies in a thread, so the WI auto-transitions * to `done` and the SLA timers no-op against a terminal status. * (b) {@link handleWorkItemQueued} (INBOUND-1.f1) — when the orc decomposes * a Request into other WorkItems via `taskPool.addToPool`, every new * WI fires `workitem:queued`. The handler treats decomposition as * "the orc has done the right thing" and resolves the tracked * respond_to_user WI with reason `workitem_decompose`. Self-recursion * is prevented by an id-shape guard * (`request:${requestId}:respond_to_user`) — the respond_to_user WI's * own enqueue cannot trigger its own resolution. * (c) Timer self-check — every breach handler reads the WI status before * publishing or escalating, so a manual orc completeItem() also * silences the chain. * * Idempotency contract (Arch Veto V1): * The respond_to_user WorkItem id is deterministic * (`request:${requestId}:respond_to_user`); the underlying * {@link TaskPoolService.addToPool} already short-circuits on duplicate * id. A redelivered `request:created` event therefore fires the handler * again, the bridge re-builds the same id, and addToPool no-ops — no * separate idempotency store is needed. * * No new ingress event types (Arch Veto V7 spirit): * The orc's revised framing on 2026-04-27 explicitly preferred reusing * the existing Request creation path over adding new `inbound:*` event * vocabulary. INBOUND-1 adds two events (`request:created`, * `request:sla_breached`) that match the Request lifecycle the rest of * the system already knows about — no parallel ingress entity. * * @module services/v3/request-sla.subscriber */ import { type ComponentLogger } from '../core/logger.service.js'; import { type WorkItem, type WorkItemStatus } from '../../types/v2/work-item.types.js'; import { type Request, type RequestStatus } from '../../types/v2/request.types.js'; import type { EventType } from '../../types/event-bus.types.js'; import type { EventBusService } from '../event-bus/event-bus.service.js'; import type { TaskPoolService } from '../task-pool/task-pool.service.js'; import type { RequestService } from './request.service.js'; /** * Wire the subscriber instance accessible via {@link getRequestSlaSubscriber}. * Called once from boot before the slack listener can dispatch a reply. * * @param sub - The live subscriber, or null to clear (tests) */ export declare function setRequestSlaSubscriber(sub: RequestSlaSubscriber | null): void; /** * Read the currently-wired subscriber, or null if boot has not finished yet. * Returns null in test setups that don't wire one — callers must tolerate. */ export declare function getRequestSlaSubscriber(): RequestSlaSubscriber | null; /** * Default SLA breach threshold — orc must respond within this window or * `request:sla_breached` fires with breachLevel=5. */ export declare const DEFAULT_SLA_MS: number; /** * Default escalation threshold — at this point the user-facing escalation * hook fires (production: Slack DM back to user). */ export declare const DEFAULT_ESCALATION_MS: number; /** * Defensive retry delay for {@link RequestSlaSubscriber.markResolvedByThread} * and {@link RequestSlaSubscriber.markResolvedByChatV2} on index miss. * * The Slack bridge fires `slackResolve('')` from the queue processor right * after delivering a message — within ~10ms of `request:created`. The * `handleRequestCreated` reordering covers the common case (timer/index * setup is now synchronous, before the `taskPool.addToPool` await), but * the leading `await requestService.getById(...)` still leaves a ~1ms * window. This single delayed retry absorbs that residual race without * adding latency to the happy path. * * 250ms is comfortably above any reasonable in-memory lookup + microtask * scheduling latency, well below the 5min SLA window. Verified for the * 2026-05-03 ESTestNode auto-nudge variant 2 incident. */ export declare const MARK_RESOLVED_RETRY_MS = 250; /** * Retry parameters for {@link RequestSlaSubscriber.findWorkItemWithRetry}. * Cover the case where {@link RequestSlaSubscriber.markResolved} is called * before {@link RequestSlaSubscriber.handleRequestCreated} has completed * its `taskPool.addToPool(wi)` await — i.e. the WI's tracking entry exists * in memory but the pool persistence is still in flight. * * 5 attempts × 50ms = 250ms worst-case wait, matching MARK_RESOLVED_RETRY_MS * so both race windows close on the same upper bound. */ export declare const FIND_WI_MAX_ATTEMPTS = 5; export declare const FIND_WI_RETRY_INTERVAL_MS = 50; /** * Tags we treat as "user-facing inbound channels" — Requests with any of * these tags get an SLA-tracked respond_to_user WI. Slack is wired today; * `chat-v2` is reserved for the channel-rail Phase E surface. */ export declare const DEFAULT_INBOUND_TAGS: readonly string[]; /** * Event types the subscriber listens to. * * - `request:created` (INBOUND-1) — seed a respond_to_user WI for inbound * user messages. * - `workitem:queued` (INBOUND-1.f1) — auto-close path b: when the orc * decomposes a Request into other WorkItems, the respond_to_user WI for * that Request resolves automatically. * * Future iterations may add `request:cancelled` etc. for cleanup. */ export declare const REQUEST_SLA_SUBSCRIBED_EVENTS: readonly EventType[]; /** * Build the deterministic respond_to_user WorkItem id for a Request. The * SLA subscriber uses this both for creating the WI and as the id-shape * guard against self-resolve recursion in {@link handleWorkItemQueued}. * * @param requestId - The Request id * @returns The deterministic WI id */ export declare function respondToUserWorkItemId(requestId: string): string; /** * Pick the legal terminal status for resolving an SLA-tracked WI. The V3 * `WORK_ITEM_TRANSITIONS` matrix (see `types/v2/work-item.types.ts`) * forbids `queued → done`, so the previous `markResolved` always-`'done'` * path was throwing in production while the test fake silently accepted it. * * Mapping: * - `running` → `done` (someone explicitly claimed; close cleanly). * - `done_by_worker` → `null` — leave it alone (#813). It is awaiting its * reviewer; the user having been answered is not a review, and resolving * it here used to auto-`verified` it as `system`. * - `proposed` → `accepted` then handled separately — but in practice * the SLA WI never lands here, so we route to `cancelled` (legal). * - everything else → `cancelled` (the V3 matrix permits `* → cancelled` * from all of `queued`/`scheduled`/`accepted`/`blocked`/`escalated`). * * @param current - The WI's current (non-terminal) status. * @returns The legal terminal status to transition into, or `null` when the * item must be left for its reviewer. */ export declare function pickResolveTarget(current: WorkItemStatus): WorkItemStatus | null; /** * Pick the legal terminal status for FAILING an SLA-tracked WI on * escalation timeout (10-minute miss). * * Mapping: * - `running` → `failed` (canonical fail edge). * - `done_by_worker` → `rejected` (the only fail-shaped edge from * `done_by_worker`; `done_by_worker → failed` is illegal). * - everything else → `cancelled` (queued/scheduled/etc. cannot reach * `failed` directly, so we route them to `cancelled` — matches the * "we gave up tracking, nothing actually failed" semantic). * * @param current - The WI's current (non-terminal) status. * @returns The legal terminal status to transition into. */ export declare function pickFailTarget(current: WorkItemStatus): WorkItemStatus; /** * Compute the legal Request status path from the current state to `done`. * Returns an empty array if the Request is already terminal (no work to * do) — the call site is expected to guard for this. * * Per `REQUEST_TRANSITIONS` in `types/v2/request.types.ts`: * - `open` → `done` (direct) * - `running` → `done` (direct) * - `waiting_confirmation` → `done` (direct) * - `ready` → `running` → `done` (two-step; ready→done illegal) * - `blocked` → `running` → `done` (two-step; blocked→done illegal) * * @param from - Current Request status (must be non-terminal). * @returns Ordered array of statuses to transition through (excluding `from`). */ export declare function closeRequestPath(from: RequestStatus): RequestStatus[]; /** * Reason tags that represent a VERIFIED actual reply / decomposition by an * agent and therefore permit the parent Request to cascade-close to `done`. * * Defense-in-depth gate for {@link RequestSlaSubscriber.maybeCloseRequest} * (Steve 2026-04-30 incident): even if an upstream caller somehow invokes * `markResolved` with a non-reply reason, the Request must NOT be flipped * to `done` unless the reason is one of these verified paths. * * - `orc_reply` — slackResolve callback fired (real orc reply via * reply-slack skill). Gated by the `fromOrcReply` * flag on the orchestrator bridge so timeout * placeholders cannot reach this branch. * - `chatv2_reply` — chat-v2 controller persisted an agent-typed * reply to the channel (real agent reply). * - `workitem_decompose` — the orc decomposed the Request into other WIs; * those WIs carry the actual work, so the * respond_to_user tracker is silenced and the * Request close is gated separately by the * sibling-count check. * * Any other reason ({@link RequestSlaSubscriber.failOrphanRespondWi} fires * `escalation_timeout`, callers MAY pass arbitrary diagnostic strings) is * treated as "do NOT auto-close the parent Request". */ export declare const VERIFIED_REPLY_REASONS: ReadonlySet; /** * Extract the Slack thread timestamp from a Request's * `sourceConversationItemId`. The slack-orchestrator-bridge stamps these * as `slack-${channelId}-${ts}` (see slack-orchestrator-bridge.ts:372), * so the trailing dotted-decimal segment is the threadTs. * * @param sourceId - The Request's sourceConversationItemId * @returns The threadTs, or null if the id doesn't match the Slack shape */ export declare function extractSlackThreadTs(sourceId: string | undefined): string | null; /** * Extract the Slack channel id from a sourceConversationItemId. * * @param sourceId - The Request's sourceConversationItemId * @returns The channelId, or null if the id doesn't match the Slack shape */ export declare function extractSlackChannelId(sourceId: string | undefined): string | null; /** * Extract the chat-v2 channel id from a Request's * `sourceConversationItemId`. The chat-v2 controller (INBOUND-2) stamps * these as `chatv2-${channelId}__${messageId}` — UUID-safe delimiter. * * @param sourceId - The Request's sourceConversationItemId * @returns The channelId, or null if the id doesn't match the chat-v2 shape */ export declare function extractChatV2ChannelId(sourceId: string | undefined): string | null; /** * Extract the chat-v2 message id from a `chatv2-${channelId}__${messageId}` * sourceConversationItemId. The messageId acts as the auto-close lookup * key analog to a Slack threadTs. * * @param sourceId - The Request's sourceConversationItemId * @returns The messageId, or null if the id doesn't match the chat-v2 shape */ export declare function extractChatV2MessageId(sourceId: string | undefined): string | null; /** * Thrown when the subscriber cannot resolve a Request for an event. The * Request was deleted between publish and dispatch — rare, but observable * in tests, so we encode the case as a typed error. */ export declare class RequestNotFoundError extends Error { constructor(message: string); } /** * Pluggable callback that performs the user-facing 10-minute escalation — * production wires this to {@link SlackService.sendMessage} so we DM the * user back. Tests can fake. */ export type EscalationSlackCallback = (args: { channelId: string; threadTs: string; messageText: string; request: Request; workItem: WorkItem | null; }) => Promise; /** * Optional dependency container. Production wiring uses * {@link RequestSlaSubscriber.boot}; tests construct directly with fakes. */ export interface RequestSlaSubscriberDependencies { /** Live event bus. */ eventBus: EventBusService; /** TaskPool for addToPool + transitionStatus + findWorkItem. */ taskPool: TaskPoolService; /** RequestService for tag/source lookups by id. */ requestService: RequestService; /** Slack DM hook — fires at 10min escalation. Optional; subscriber * no-ops if missing (logs at warn level instead). */ sendEscalationDm?: EscalationSlackCallback; /** Override the orchestrator session (testing). */ orchestratorSession?: string; /** Override the SLA window (testing). */ slaMs?: number; /** Override the escalation window (testing). */ escalationMs?: number; /** Override the inbound tag list (testing / future channel sources). */ inboundTags?: readonly string[]; /** Optional logger. */ logger?: ComponentLogger; } /** * RequestSlaSubscriber — the INBOUND-1 deliverable. * * @example * ```typescript * const sub = RequestSlaSubscriber.boot(eventBus, slackEscalationFn); * sub.start(); * // … on shutdown: * sub.stop(); * ``` */ export declare class RequestSlaSubscriber { private readonly eventBus; private readonly taskPool; private readonly requestService; private readonly sendEscalationDm?; private readonly orchestratorSession; private readonly slaMs; private readonly escalationMs; private readonly inboundTags; private readonly logger; private unsubscribers; private started; /** requestId → tracked record; primary index for breach handlers. */ private trackedByRequest; /** Slack threadTs → requestId; secondary index for {@link markResolvedByThread}. */ private threadIndex; /** * chat-v2 channelId → requestId. INBOUND-2 secondary index used by * {@link markResolvedByChatV2}. Last-write-wins when multiple inbound * Requests pile up in the same channel — v1 polish accepts the simpler * 1:1 semantics; the orphan handler still cleans the WI on escalation. */ private chatV2Index; /** In-flight async dispatch promises (test affordance). */ private pendingDispatches; constructor(deps: RequestSlaSubscriberDependencies); /** * Production wiring helper. Tests should use the constructor directly. * * @param eventBus - Live event bus * @param requestService - Live RequestService * @param taskPool - Live TaskPoolService * @param sendEscalationDm - Slack DM callback for the 10-minute escalation * @returns A subscriber ready to `start()` */ static boot(eventBus: EventBusService, requestService: RequestService, taskPool: TaskPoolService, sendEscalationDm?: EscalationSlackCallback): RequestSlaSubscriber; /** * Subscribe + register the in-process handlers. Idempotent. */ start(): void; /** * Number of inbound user Requests still awaiting an orchestrator response — * i.e. live `respond_to_user` SLA trackers that have neither been answered * nor breached-and-cleared. Powers the `/health` orchestrator-liveness signal * (issue #686): `agents.active === 0` while this is `> 0` means inbound user * messages are queued with no one to answer them (the silent 假死 symptom). * * @returns Count of pending tracked `respond_to_user` work items. */ getPendingUserRequestCount(): number; /** * Wait for in-flight async dispatches to settle. Test affordance. */ flushPending(): Promise; /** * Detach subscriptions + clear all SLA timers. Safe to call repeatedly. */ stop(): void; /** * Mark an in-flight respond_to_user WI as done because the orchestrator * just replied to the matching Slack thread. Called by * {@link slack-orchestrator-bridge.sendSlackResponse}. * * Best-effort: a non-Slack-shaped or unknown threadTs is a no-op. * * Defensive retry (Steve 2026-05-03 auto-nudge variant 2): the bridge * may call this *before* {@link handleRequestCreated} has populated * `threadIndex` (e.g. QueueProcessor fires `slackResolve('')` right * after delivery, ~10ms before the SLA subscriber finishes its async * `request:created` handler). On index-miss we schedule a single * delayed retry to absorb that race without holding up the caller. * The reordering inside {@link handleRequestCreated} is the primary * fix; this retry is belt-and-suspenders to absorb any residual * `await requestService.getById` skew. * * @param threadTs - The Slack message timestamp the orc replied to */ markResolvedByThread(threadTs: string): Promise; /** * INBOUND-2: mark the in-flight respond_to_user WI as done because an * agent replied in the chat-v2 channel where the user's message arrived. * Called by `chat-v2.controller.sendMessage` after a `senderType=agent` * message persists to the channel. * * Best-effort: a non-tracked or unknown channelId is a no-op. * * @param channelId - The chat-v2 channel where the agent just replied */ markResolvedByChatV2(channelId: string): Promise; /** * Orphan-cleanup path for the Slack case in {@link markResolvedByThread}. * * The threadIndex / chatV2Index are populated when an SLA tracker is * registered for the Request and cleared whenever {@link markResolved} * runs — including the `workitem_decompose` resolve. After decomposition, * the Request stays open with non-terminal child WIs while the SLA * indexes are empty. If the orc then replies directly in the original * thread, neither the primary `threadIndex` lookup nor the 250ms retry * will find anything, and the orphan child WIs sit queued/blocked * indefinitely (closie incident, 2026-05-21). * * This fallback authoritatively scans `RequestService.listAll()` for an * open Request whose `sourceConversationItemId` references `threadTs`, * then force-removes any non-terminal child WIs from the pool and * cascades the close. We bypass the index because the index is the * very thing that's stale. * * @param threadTs - The Slack message timestamp the orc replied to */ private resolveOrphanedRequestByThread; /** * Orphan-cleanup path for the chat-v2 case in * {@link markResolvedByChatV2}. Mirrors {@link resolveOrphanedRequestByThread} * but matches on `chatV2-` source id shape. * * @param channelId - The chat-v2 channel id where the agent replied */ private resolveOrphanedRequestByChatV2; /** * Cancel all non-terminal WorkItems belonging to a Request, then cascade * the Request to `done`. Shared tail of * {@link resolveOrphanedRequestByThread} and * {@link resolveOrphanedRequestByChatV2}. * * Each WI is transitioned individually so a single failure (illegal * transition, stale claim) does not block the others — the cascade * close at the end only fires if at least one WI flipped to terminal * (otherwise `countOtherActiveWorkItems` will keep the Request open * via {@link maybeCloseRequest}'s sibling-count gate). * * @param requestId - The Request to clean up * @param reason - Diagnostic tag recorded on each cancelled WI's * metadata and on the Request's `result` field */ private cancelOrphansAndCloseRequest; /** * Mark an in-flight respond_to_user WI as resolved by Request id (e.g. the * orc replied on Slack, or decomposed the Request into other WorkItems and * we want to silence the SLA chain). After the WI transitions, we also * cascade the close to the parent Request when this was the last * non-terminal WI for it (Steve 2026-04-29: Requests stuck on "Active" in * /tasks UI even after the team replied). * * Transition path is selected from the WI's current status to satisfy the * V3 state machine — `transitionStatus` enforces `WORK_ITEM_TRANSITIONS` * and `queued → done` is NOT a legal edge: * - `queued` → `cancelled`: SLA tracker was a placeholder, never claimed. * Semantic: "no longer needed because the orc handled this directly". * - `running` → `done`: Someone explicitly claimed the SLA WI; close * it as a normal completion. * - terminal status: no-op (already settled). * * Before this fix, `markResolved` always called `transitionStatus(_, 'done')` * which threw on the queued case, the catch swallowed it at warn level, the * WI stayed `queued` forever, and the Request never closed — the * user-reported "Active count never goes down" bug. * * @param requestId - The Request whose SLA chain should be silenced * @param reason - Diagnostic tag (`orc_reply` / `chatv2_reply` / * `workitem_decompose`) recorded in WI metadata + Request `result`. */ markResolved(requestId: string, reason: string): Promise; /** * Find a WorkItem in the task pool, retrying briefly on null. Covers the * race where {@link markResolved} is called before * {@link handleRequestCreated} has finished `taskPool.addToPool(wi)` * (Steve 2026-05-03 auto-nudge variant 2). * * Up to {@link FIND_WI_MAX_ATTEMPTS} attempts spaced by * {@link FIND_WI_RETRY_INTERVAL_MS}. Total worst-case wait stays well * below the 5min SLA window. Resolves with `null` if the WI never * appears (e.g. addToPool persistently failed); the caller logs and * moves on. * * @param workItemId - The deterministic respond_to_user WI id * @returns The WorkItem if found, or null after all retries exhausted */ private findWorkItemWithRetry; /** * Cascade close the parent Request after an SLA-tracked WI resolves. * * The Request is moved to `done` only when ALL of: * 1. `reason` is in {@link VERIFIED_REPLY_REASONS} (defense-in-depth * against false-resolve paths — see Steve 2026-04-30 incident, * where a `responseTimeoutMs` placeholder fired * `markResolvedByThread` and cascaded the Request to `done` * without an actual orc reply). * 2. it exists and is not already terminal (`done`/`cancelled`), AND * 3. no other non-terminal WIs remain for it (the orc may have * decomposed the Request into other WIs that are still in flight — * in that case we leave the Request alone and let the existing * `cascadeRequestStatus` machinery in v3-data.service close it * when those WIs finish). * * Picks the shortest legal transition path per `REQUEST_TRANSITIONS`: * - `open` / `running` / `waiting_confirmation` → `done` (direct) * - `ready` / `blocked` → `running` → `done` * * Errors are caught at the call site (markResolved); this method must * never propagate, so a Request-update failure does not leak into the * Slack-reply flow. * * @param requestId - The Request to close. * @param reason - The same reason tag recorded on the WI; passed * through to `Request.result` so the UI shows why it auto-closed. */ private maybeCloseRequest; /** * Count WorkItems linked to the given Request that are NOT the SLA tracker * AND are still non-terminal. Returns 0 when only the SLA tracker existed. * * @param requestId - The Request id to scan. * @returns Count of other in-flight WorkItems. */ private countOtherActiveWorkItems; /** * Snapshot of the tracked-WI count. Test affordance. */ get trackedCount(): number; /** * `request:created` handler. Filters by inbound tag, creates the * respond_to_user WI, and schedules the breach + escalation timers. */ private handleRequestCreated; /** * `workitem:queued` handler (INBOUND-1.f1, auto-close path b). * * Treats decomposition of a Request into other WorkItems as "the orc has * done the right thing" and resolves the tracked respond_to_user WI for * that Request. * * Self-recursion guard: the respond_to_user WI itself fires * `workitem:queued` from {@link handleRequestCreated}'s `addToPool` call. * Without a guard this handler would call markResolved against its own * enqueue and prematurely close the SLA chain. The id-shape check * (`incomingId === respondToUserWorkItemId(requestId)`) is more reliable * than reading `wi.metadata.slaSource` — the metadata can in principle * be mutated, the id cannot. * * No-ops: * - event missing `requestId` (orphan WI, can't correlate) * - event missing `workItemId` (malformed publisher) * - the respond_to_user WI's own enqueue (id-shape match) * - no tracked respond_to_user WI for the requestId (already resolved * or never tracked because the source Request wasn't inbound-tagged) * * @param event - The `workitem:queued` event from TaskPoolService.addToPool */ private handleWorkItemQueued; /** * 5-minute breach handler. Re-checks the WI status and emits * `request:sla_breached` if the WI is still non-terminal. * * @param requestId - The Request whose breach is firing * @param level - Breach level: 5 (first SLA) or 10 (escalation) */ private handleBreach; /** * 10-minute escalation handler. Emits the level-10 breach event and — * if a Slack DM callback is wired — sends the user a "still working on * it" nudge so they're never blind to the miss. After the DM (or DM-skip), * transitions the orphaned respond_to_user WI to `'failed'` with * `slaResolvedReason: 'escalation_timeout'` so the orc queue does not keep * a stale `queued` WI forever (Arch N3 on PR #357). */ private handleEscalation; /** * Transition an escalated respond_to_user WI to `'failed'` with * `slaResolvedReason: 'escalation_timeout'` so the orc queue does not * keep a stale `queued` WI forever after a 10-min escalation. No-op if * the WI is already terminal (e.g. user gave up + an out-of-band cleanup * already closed it). * * Mirrors {@link markResolved}'s terminal-status guard. Errors are * logged but never propagated — the SLA chain is already terminal at * this point and we do not want to mask the original DM-path outcome. * * @param workItemId - The respond_to_user WI id to close. * @param requestId - The originating Request id (logging context only). */ private failOrphanRespondWi; /** * Drop tracking + clear timers for a Request. Used both on auto-resolve * and on terminal escalation — once we reach 10min the SLA chain is done. */ private cleanupTracked; /** * Check whether a Request's tags include any of the configured inbound * channel tags. */ private matchesInboundTag; /** * Build the respond_to_user WorkItem with the standard metadata invariants. * * INBOUND-2: metadata embeds slack-* OR chatV2-* fields based on the * source surface so downstream consumers (status panes, escalation * hooks) can branch without re-parsing `sourceConversationItemId`. */ private buildRespondWorkItem; /** * Wrap a dispatch so a thrown handler is logged and isolated. Mirrors * EventToWorkItemBridge.safeDispatch. */ private safeDispatch; } //# sourceMappingURL=request-sla.subscriber.d.ts.map