/** * Sub-Agent Message Queue Service * * Buffers messages destined for sub-agents that have not yet completed * initialization (agentStatus !== 'active'). Messages are flushed once * the agent registers via the MCP `register_agent_status` tool. * * The orchestrator is excluded from this queue because it already has * its own deferral mechanism via QueueProcessorService. * * @module sub-agent-message-queue */ import { type QueueMessageMeta } from './queue-priority.js'; /** * A single queued message destined for a sub-agent. */ /** Why queued messages were dropped undelivered. */ export type QueueDropReason = 'aged-out' | 'capacity' | 'undeliverable'; /** * Told when undelivered messages are dropped, so someone hears about it. * * @param sessionName - The agent they were for * @param dropped - The dropped messages * @param reason - Why */ export type QueueDropListener = (sessionName: string, dropped: QueuedAgentMessage[], reason: QueueDropReason) => void; export interface QueuedAgentMessage { /** * Stable identity of this queue entry. Flush matches entries by it, never * by text: two entries can carry identical text (a re-queue by `send`, a * reminder repeated later) and must stay distinguishable. Entries stored by * an older version get one when loaded. */ id?: string; /** The raw data string to write to the agent's terminal */ data: string; /** Timestamp (ms since epoch) when the message was enqueued */ queuedAt: number; /** The target session name */ sessionName: string; /** Times a flush found the agent busy and kept this message (log noise is cut after the first) */ deferrals?: number; /** The last time a flush kept it because the agent was busy (ms): not stale while this is recent */ lastDeferredAt?: number; /** Failed delivery attempts so far (a send that threw or reported failure) */ attempts?: number; /** Times it was kept, instead of dropped, because the agent was down */ keptWhileDown?: number; /** * The WorkItem this message hands over (a `/deliver` with `workItemId` * held by the restart drain): its hand-over — dispatcher dedup, fresh * conversation — runs when it is finally delivered (crewly#1015 review). */ workItemId?: string; /** * Who wrote it and where its answer belongs (see queue-priority.ts): * owner messages go ahead of system traffic, and a message answered in * its thread, a second copy of an owner message, or a reminder replaced * by a newer one is dropped at flush time. */ meta?: QueueMessageMeta; /** * When the owner message was last handed to the busy agent at a tool * boundary ({@link SubAgentMessageQueue.takeOwnerMessageForHook}). It * stays queued until the agent posts in its conversation after this. */ surfacedAt?: number; /** How many times it was handed over at a tool boundary */ surfaceCount?: number; } /** An owner message handed to a busy agent at a tool boundary. */ export interface SurfacedOwnerMessage { /** The queued text (it already carries where and how to reply) */ data: string; /** When it was queued */ queuedAt: number; /** 1 the first time, 2 for the reminder */ surfaceCount: number; } /** * Owner messages first, each group in its existing order (stable). * * @param list - Queue * @returns The queue in delivery order */ export declare function ownerFirst(list: QueuedAgentMessage[]): QueuedAgentMessage[]; /** * The text an idle delivery writes: an owner message the agent was already * shown at a tool boundary (and has not answered in its conversation) gets * a one-line notice, so an agent that did answer elsewhere does not answer twice. * * @param m - Queued message * @returns Text to deliver */ export declare function withSurfacedNotice(m: QueuedAgentMessage): string; /** Why a queued message was dropped before delivery as no longer needed. */ export type QueuePruneReason = 'answered-in-thread' | 'answered-after-surfacing' | 'duplicate-owner-message' | 'superseded' | 'nothing-left-to-announce'; /** A hand-over prepared for a queued WorkItem brief (see {@link SubAgentMessageQueue.setHandOverPreparer}). */ export interface QueuedHandOver { /** Text to write */ message: string; /** Call after a successful write */ delivered: () => void; /** Call after a failed write */ failed: () => void; /** The WorkItem was already delivered to this session by the dispatcher: drop the brief */ alreadyDispatched?: boolean; } /** Prepares the hand-over of a queued WorkItem brief right before it is written. */ export type QueuedHandOverPreparer = (sessionName: string, workItemId: string, data: string) => Promise; /** * Decides whether a queued message has gone stale and must not be delivered. * Wired at boot (the WorkItem dispatcher's check: a `[CREWLY-DISPATCH]` * notice whose WorkItems are all finished). Errors count as "not stale". */ export type StaleMessageCheck = (data: string, sessionName: string) => Promise | boolean; /** * Brings a queued message up to date right before delivery: returns the text * to send, or null to drop it (see {@link SubAgentMessageQueue.setMessageRefresher}). */ export type QueuedMessageRefresher = (data: string, sessionName: string) => Promise | string | null; /** * Singleton service that holds pending messages per agent session. * * Messages are enqueued when a `mode: 'message'` write arrives at * the terminal controller for an agent that is not yet active. * They are dequeued and delivered sequentially when the agent * registers (status becomes 'active'). */ export declare class SubAgentMessageQueue { private static instance; private pendingMessages; /** Tail of each agent's flush chain: one flush per agent at a time. */ private flushTails; private logger; private readonly storePath; /** Set while the store file is bad and could not be copied aside; saves copy it aside first. */ private blockedReason; /** The store file could not be read (EMFILE, EIO…); saves re-read it first. */ private unread; private staleCheck; private refresher; /** Told when undelivered messages are dropped (never silently: crewly#1014) */ private dropListener; /** Whether an agent's session is down (no live session) */ private isDown; /** Prepares queued WorkItem briefs (wired by the terminal controller) */ private handOverPreparer; /** Told about each message the flush delivered */ private deliveredListener; /** Drops that happened before a listener was set (aged out at load) */ private unreportedDrops; private constructor(); /** * Read back anything that was still undelivered when the process ended. * * This queue is the only record that a person's message has not reached * its agent. Holding it in memory alone meant a restart dropped it with * no trace: the owner had asked for something, seen "working on it", and * the request simply ceased to exist (2026-09-21). */ private load; /** * Write the queue out. Best-effort: a failure must never lose the * in-memory copy, which is still the live one. */ private save; /** * Get the singleton instance. * * @returns The SubAgentMessageQueue singleton */ static getInstance(storePath?: string): SubAgentMessageQueue; /** * Reset the singleton instance (for testing only). */ static resetInstance(): void; /** * Whether this exact message is already queued for the agent. * * @param sessionName - The agent * @param data - The message * @returns True when queued */ contains(sessionName: string, data: string): boolean; /** * Remove a queued message that reached the agent another way (our Enter * submitted a paste of it that had been sitting in the box), so a held * retry does not deliver it twice. * * Also takes back a message held while its agent was being started, once * it is about to be sent directly. * * @param sessionName - The agent * @param data - The message * @param why - Why it is removed (logged) * @returns True when a queued copy was removed */ remove(sessionName: string, data: string, why?: string): boolean; /** * Install the listener told about messages dropped undelivered (aged out * at load, or the oldest at capacity). Drops from before it was set are * reported right away. * * @param listener - The listener, or null */ /** * Install the check for an agent whose session is down. Messages that ran * out of attempts because the agent was not running are kept for its next * start (the queued-wake sweep starts it) instead of dropped: 10-16 Pia and * Atlas each lost owner work that way (2026-10-22 audit). * * @param check - Whether a session is down, or null to always drop */ setDownCheck(check: ((sessionName: string) => boolean) | null): void; setDropListener(listener: QueueDropListener | null): void; /** * Tell the listener about a drop (or keep it until one is set). * * @param sessionName - The agent * @param dropped - The dropped messages * @param reason - Why */ private reportDrop; /** * Install the check that drops stale messages before delivery. * * The queue is persisted and replayed after a restart, so a notice queued * hours earlier ("WorkItem X queued for you") reached the agent after X was * already verified, and each stale notice cost a turn re-checking the pool * (#836). The queue cannot judge staleness itself; the dispatcher can. * * @param check - The stale check, or null to deliver everything */ setStaleMessageCheck(check: StaleMessageCheck | null): void; /** * Install what brings a queued message up to date right before it is * delivered (CREW-266: a batch reminder that waited on the queue listed * WorkItems the agent had finished meanwhile). A failing refresher * delivers the message as queued. * * @param refresher - The refresher, or null */ setMessageRefresher(refresher: QueuedMessageRefresher | null): void; /** * The text to deliver for a queued message, per the installed refresher. * * @param data - Queued text * @param sessionName - Target session * @returns Text to send, or null to drop it */ private refreshed; /** * Where a queued message stands in its session's queue: 1 is next. * Owner messages go ahead of the rest, so this is the delivery order, * not the order of arrival. * * @param sessionName - The agent session * @param data - The queued message * @returns 1-based position of its last copy, or null when it is not queued */ positionOf(sessionName: string, data: string): number | null; /** * The messages waiting for a session, oldest first (read only). * * @param sessionName - The agent session * @returns A copy of its queue */ peek(sessionName: string): readonly QueuedAgentMessage[]; /** * Install the listener told about every message the flush delivered (the * dispatcher marks a delivered dispatch notice's WorkItems as delivered, * so a held brief for the same WorkItem is then dropped — crewly#1015 * follow-up re-review). * * @param listener - The listener, or null */ setDeliveredListener(listener: ((sessionName: string, data: string) => void) | null): void; private notifyDelivered; /** * Install what prepares a queued WorkItem brief's hand-over right before * it is written (the terminal controller's `prepareWorkItemHandOver`). * * @param preparer - The preparer, or null */ setHandOverPreparer(preparer: QueuedHandOverPreparer | null): void; /** * Whether a queued message is stale per the installed check. * * @param data - Message text * @param sessionName - Target session * @returns True when the message must be dropped */ private isStale; /** * Drop every stale message from every queue. Run once at boot, so an agent * whose only pending messages are stale notices is not restored for them. * * @returns How many messages were examined and how many were dropped */ pruneStale(): Promise<{ examined: number; skippedStale: number; }>; /** * Enqueue a message for a session that is not yet active. * If the queue exceeds MAX_QUEUE_SIZE, the oldest non-owner message is * dropped (the oldest message when all are the owner's). * * A message identical to one already waiting for the same session is not * added again: the reconciler re-sends the same reminder while an agent * is stopped (daily token cap, busy), and each copy would cost the agent * a turn once delivered. The waiting copy keeps its place. Neither is a * second copy of the same owner message (same `ref`). * * Owner messages (see {@link QueueMessageMeta}, taken from `meta.queueMeta` * or the delivery running now — {@link withQueueMeta}) go to the front, * behind owner messages already waiting; everything else goes to the back. * * @param sessionName - The target agent session name * @param data - The raw data string to deliver later * @param meta - `workItemId` when the message hands over a WorkItem; `queueMeta` to override the context */ enqueue(sessionName: string, data: string, meta?: { workItemId?: string; queueMeta?: QueueMessageMeta; }): void; /** * Dequeue all pending messages for a session (FIFO order). * The queue is cleared after dequeuing. * * @param sessionName - The agent session name * @returns Array of queued messages in insertion order, or empty array */ dequeueAll(sessionName: string): QueuedAgentMessage[]; /** * Remove the copy a `send` inside {@link flush} put back on the queue. * Only entries that were not queued before the send (their ids are not in * `knownIds`) are candidates, so an identical message queued earlier or by * someone else is never taken by mistake. * * @param sessionName - The agent session * @param data - The message text the send re-queued * @param knownIds - Ids of the entries queued before the send */ private removeRequeuedCopy; /** * Hand every queued message to `send`, one at a time. * * `sendMessageToAgent` answers `{ success: true, queued: true }` when it * finds the agent busy and puts the message back — so a caller that only * checked for a thrown error logged "Delivered" in the same millisecond * as the re-queue, and reading the log pointed at the agent when the * message had never reached it. Both call sites had their own copy of * this loop; this is the shared one, and it distinguishes the two * outcomes (2026-09-21, Ella). * * @param sessionName - The agent session name * @param send - Delivers one message; `queued` means it went back on the queue * @param gapMs - Pause between messages so the agent can process each * Messages the stale check rejects (see {@link setStaleMessageCheck}) are * dropped instead of sent. * * @returns How many were delivered, deferred again, failed, and dropped as stale */ flush(sessionName: string, send: (data: string) => Promise<{ queued?: boolean; success?: boolean; error?: string; }>, gapMs?: number): Promise<{ delivered: number; deferred: number; failed: number; skippedStale: number; }>; /** The body of {@link flush}; callers go through the per-agent lock. */ private flushLocked; /** * Drop queued messages that are no longer needed, before delivery: * a later copy of an owner message already queued (same `ref`), and a * reminder replaced by a newer one (same `supersedeKey`; the newest is * kept). Each drop is logged. Messages without metadata are untouched. * * @param sessionName - The agent * @param pending - Its queue, in delivery order * @param out - Counters (`skippedStale` is incremented per drop) * @returns The messages still to deliver, in order */ private pruneNoLongerNeeded; /** * Hand the next owner message waiting for a busy agent to its PostToolUse * hook, so it reaches the agent at the next tool boundary instead of at * the end of the turn. At most one per call. * * The message is NOT removed: it is marked surfaced (time and count) and * stays queued until the agent posts in its conversation after that time * (then it is dropped here or at the flush). Unanswered after * {@link OWNER_HOOK_MESSAGE_CONSTANTS.RESURFACE_AFTER_MS} it is shown once * more; after {@link OWNER_HOOK_MESSAGE_CONSTANTS.MAX_SURFACES} only the * normal idle delivery remains. Nothing is lost; at worst it arrives twice. * * Only the owner's own messages (and the owner's decision answers) that * name a conversation — not harness reminders, not other traffic. * * @param sessionName - The agent whose hook is asking * @param now - Clock * @returns The message to show, or null */ takeOwnerMessageForHook(sessionName: string, now?: number): SurfacedOwnerMessage | null; /** * Whether a surfaced owner message was answered: the agent posted in its * conversation after it was shown it. * * @param m - Queued message * @returns True when it was answered */ /** * Conversations of owner messages shown to the agent at a tool boundary * that it has not answered yet (the reply resolver will not guess while * one of these is open in another conversation). * * @param sessionName - Agent * @returns chat-v2 conversation ids, deduplicated */ surfacedUnansweredConversations(sessionName: string): string[]; private answeredAfterSurfacing; /** * Whether the agent has already posted in the thread a queued message * belongs to, after it was queued — it was answered (the agent read the * thread). Only messages that name a thread are judged this way. * * Never an owner's own message (or the owner's decision answer): the owner * may ask a follow-up in the same thread while the agent is still * answering the first question, and that post must not make the follow-up * look answered. Owner messages are dropped only as exact duplicates. * Harness reminders carrying the owner's words (`reminder`) are judged. * * @param m - Queued message * @returns True when it was answered */ private answeredInThread; private logPrune; /** * Sessions that still have messages waiting for them. A restart must bring * these agents back: a message queued for an agent is work in hand even * when no WorkItem records it (2026-09-24: Atlas was left down after a * restart with the owner's message queued, and answered four hours later * only because the owner wrote again). * * @returns Session names with at least one queued message */ sessionsWithPending(): string[]; /** * Check if there are pending messages for a session. * * @param sessionName - The agent session name * @returns True if there are pending messages */ hasPending(sessionName: string): boolean; /** * Clear all pending messages for a session (e.g. on agent stop/exit). * * @param sessionName - The agent session name */ clear(sessionName: string): void; /** * Get the number of pending messages for a session. * * @param sessionName - The agent session name * @returns Number of pending messages */ getQueueSize(sessionName: string): number; /** * Total number of messages waiting across every session. Used by restart * readiness to report how much is held on this (persistent) queue. * * @returns Number of queued messages */ getTotalQueued(): number; } //# sourceMappingURL=sub-agent-message-queue.service.d.ts.map