/** * Unanswered-owner-message watchdog. * * Every owner message delivered to an agent on this machine ends in either an * answer the owner can see, or ONE plain-words note in the same thread saying * who it is waiting on and why. Four different bugs on 2026-09-30 each lost an * owner message silently; each was fixed on its own, but nothing guaranteed * the end-to-end property. This does. * * Timeline per message (constants in OWNER_MESSAGE_WATCHDOG_CONSTANTS): * - an answer (or a settled placeholder, or `reply --none`) clears it; * - a placeholder showing while the agent is mid-turn extends the wait, up * to BUSY_EXTEND_CAP_MS, then a note; * - T1 → one re-delivery ("nudge") to the responsible agent, waking it; * an agent that cannot be reached skips straight to the note; * - T2 → one note, and the message is no longer tracked. * * The service is transport-agnostic: what counts as "delivered", "answered", * "busy" and how to nudge / post a note are injected (see the wiring module). * * @module services/messaging/owner-message-watchdog.service * @see specs/2026-09-30-owner-message-guarantee.md */ /** Where the owner wrote: a Slack conversation, or a Crewly chat (portal / Talk / dashboard). */ export type OwnerMessageSurface = 'slack' | 'chat'; /** What a watched message needs to be tracked. */ export interface OwnerMessageTrackInput { surface: OwnerMessageSurface; /** Slack channel / DM id (slack surface) */ slackChannelId?: string; /** Slack thread root the answer belongs in (slack surface) */ threadTs?: string; /** The owner's Slack message ts (dedupe key for slack) */ sourceTs?: string; /** chat-v2 channel / conversation the message was recorded in (both surfaces when known) */ chatChannelId?: string; /** chat-v2 thread the answer belongs in (huddles) */ chatThreadId?: string; /** chat-v2 message id (dedupe key for chat) */ messageId?: string; /** The agent that must answer (nudged at T1, named in the note) */ responsible: string; /** Every agent that holds the message */ recipients: readonly string[]; /** True when at least one recipient was required to answer (DM, @mention, woken lead) */ required: boolean; /** The owner's words */ text: string; /** When it arrived (epoch ms); defaults to now */ receivedAt?: number; } /** One tracked message. */ export interface OwnerMessageEntry { key: string; surface: OwnerMessageSurface; slackChannelId?: string; threadTs?: string; sourceTs?: string; chatChannelId?: string; chatThreadId?: string; messageId?: string; responsible: string; recipients: string[]; required: boolean; /** Clipped owner text */ preview: string; receivedAt: number; /** * `waiting` → `nudged` (re-delivered once) → noted. `login_wait`: the * agent's runtime is signed out; the owner was told, and the message is * re-delivered when the login is back ({@link OwnerMessageWatchdogService.resumeAfterLogin}). * `failed_wait`: the agent's turns fail (its model run errors); the owner * was told once, and the message is re-delivered on a backing-off timer * (FAILED_RETRY_BACKOFF_MS; never for credit / quota) and when a turn * succeeds ({@link OwnerMessageWatchdogService.resumeAfterRecovery}). */ stage: 'waiting' | 'nudged' | 'login_wait' | 'failed_wait'; /** Why the agent's turns fail, while `failed_wait` */ failedDetail?: string; /** Timed re-deliveries made while `failed_wait` */ failedRetries?: number; /** The failure needs credit / quota back: no timed re-delivery */ failedNeedsCredit?: boolean; /** When the owner was told the agent's turns fail (once per message) */ failedNotedAt?: number; /** Runtime word of the login it waits on ("claude"), while `login_wait` */ loginRuntime?: string; nudgedAt?: number; /** A nudge that could not reach the agent */ nudgeBlocked?: { reason: NudgeBlockReason; detail?: string; }; /** The nudge was queued for the agent, not typed into its session */ nudgeQueued?: boolean; /** An interim note was posted in chat (a visible "working on it") */ interimAt?: number; /** The nudged agent started a turn after the nudge */ busyAfterNudge?: boolean; /** …and finished it */ turnDoneAfterNudge?: boolean; /** Extension already logged */ extendedLogged?: boolean; } /** * Why a nudge could not reach the agent. `slot_wait`: its start waits for a * running-agent slot — the message is queued and goes in when it starts, so * the owner gets no note (2026-10-19: "Still waiting on Dex — … resource * pressure …" in the owner's thread, which the owner could do nothing about). */ export type NudgeBlockReason = 'asleep' | 'login' | 'error' | 'spend_cap' | 'slot_wait'; /** Outcome of one nudge. */ export type NudgeOutcome = /** `queued`: accepted into the agent's queue (busy, or its input held), not delivered yet */ { outcome: 'sent'; queued?: boolean; } | { outcome: 'blocked'; reason: NudgeBlockReason; detail?: string; }; /** Pending sign-in, for the one-tap fix in the note. */ export interface LoginHint { /** Human name of the runtime ("Claude", "Codex") */ runtime: string; /** Word the owner types after `relogin` / 重新登录 ("claude", "codex") */ runtimeCmd: string; } /** Injected behaviour. */ export interface OwnerMessageWatchdogDeps { /** Is the agent mid-turn right now? */ isBusy: (agentSession: string) => boolean; /** Is a "working on it" placeholder showing where the answer will land (slack surface)? */ hasVisiblePlaceholder?: (entry: OwnerMessageEntry) => boolean; /** Re-deliver the message to its responsible agent, waking it when stopped. */ nudge: (entry: OwnerMessageEntry, waitedMinutes: number) => Promise; /** Post the note where the owner wrote. Returns false when it could not be posted. */ postNote: (entry: OwnerMessageEntry, text: string) => Promise; /** A sign-in the agent's runtime is waiting for, if any. */ loginRequired?: (agentSession: string) => LoginHint | null; /** The agent's daily token cap stop, if any (specs/2026-10-02-spend-cap.md) */ spendCapped?: (agentSession: string) => { capTokens: number; scope?: string; teamName?: string; } | null; /** * Why messages to the agent are held, if they are: its input box cannot be * read (Crewly recovers it: resize, then restart), or holds text Crewly did * not write. A note never claims a reminder was sent while it is held. */ inputHeld?: (agentSession: string) => 'unreadable' | 'foreign' | null; /** Display name for notes ("Ella"); defaults to the session name. */ displayNameOf?: (agentSession: string) => string; /** Persisted state; omitted in tests that do not exercise restarts. */ storePath?: string; /** Clock (tests). */ now?: () => number; } /** Row of the debug listing. */ export interface OwnerMessageListing { key: string; surface: OwnerMessageSurface; where: string; responsible: string; recipients: string[]; required: boolean; ageMinutes: number; stage: OwnerMessageEntry['stage']; nudgedMinutesAgo?: number; nudgeBlocked?: OwnerMessageEntry['nudgeBlocked']; preview: string; } /** * Whether a whole message is a bare acknowledgement ("好", "ok", "👍", "谢谢") * that needs no answer. * * @param text - The owner's message * @returns True for an acknowledgement from ACK_WORDS */ export declare function isAcknowledgement(text: string): boolean; /** * The dedupe key of a message. * * @param input - Track input (or entry) * @returns `slack::` / `chat::`, or null when underspecified */ export declare function ownerMessageKey(input: Pick): string | null; /** * Tracks owner messages until they are answered, nudging and then noting. */ export declare class OwnerMessageWatchdogService { private readonly deps; private readonly logger; private readonly entries; /** Recently finished keys, oldest first (Map keeps insertion order). */ private readonly resolved; /** Thread → when an answer was last seen there (answers can beat the tracking call). */ private readonly recentAnswers; /** Last "session is stuck" note per agent (one per STUCK_NOTE_COOLDOWN_MS) */ private readonly stuckNotedAt; private timer; private ticking; /** Told about every tracked (or re-assigned) owner message — the owner-thread sentinel */ private readonly trackListeners; /** * @param deps - Injected behaviour (see {@link OwnerMessageWatchdogDeps}) */ constructor(deps: OwnerMessageWatchdogDeps); private now; /** Start the periodic evaluation. */ start(): void; /** Stop the periodic evaluation. */ stop(): void; /** * Be told about every owner message the watchdog starts tracking, and * every hand-off of one (the owner-thread sentinel watches the thread). * * @param listener - Called with the entry; must not throw * @returns Unsubscribe */ onTrack(listener: (entry: OwnerMessageEntry) => void): () => void; private emitTracked; /** * Start watching an owner message that was delivered to at least one agent. * A second delivery of the same message (a hand-off, a re-dispatch) updates * the entry rather than adding one; a message already answered is ignored. * * `afterDecision`: the message answered a decision card. The decision * path counts the card's thread as answered when it consumes the reply, * but the asker still owes the owner a reply to it, so that earlier * "answer" does not count. * * @param input - The message and who holds it * @param opts - `afterDecision` (see above) * @returns The entry, or null when it is not tracked (ack, answered, underspecified) */ track(input: OwnerMessageTrackInput, opts?: { afterDecision?: boolean; }): OwnerMessageEntry | null; /** * Something the owner can see was posted in a Slack thread (an answer, a * file, a settled placeholder, a colleague on another machine). * * @param slackChannelId - Slack channel / DM * @param threadTs - Thread root (undefined = top level: clears nothing) * @param why - For the log * @returns How many entries were cleared */ noteSlackAnswer(slackChannelId: string, threadTs: string | undefined, why: string): number; /** * An agent (or the orchestrator) spoke in a Crewly chat channel. * * @param chatChannelId - chat-v2 channel * @param threadId - Thread of the turn (null/undefined = top level) * @param interim - An interim note: shown, but the answer is still owed * @returns How many entries were cleared */ noteChatAnswer(chatChannelId: string, threadId: string | null | undefined, interim: boolean): number; /** * The responsible agent said no answer is needed (`reply --none`) for what * it was asked in this conversation. * * @param agentSession - The agent * @param where - Conversation (and thread) of its turn origin * @returns How many entries were closed */ closeByAgent(agentSession: string, where: { chatChannelId?: string; slackChannelId?: string; threadTs?: string; }): number; /** * An agent's working status changed. After a nudge, an optional-only * message whose agent then finished a whole turn without answering is * that agent's judgement ("not for me") — closed quietly at the next tick. * * @param agentSession - The agent * @param busy - True when a turn started, false when it ended */ noteAgentTurn(agentSession: string, busy: boolean): void; /** * Open messages an agent holds (responsible or recipient), oldest first. * * @param agentSession - The agent * @returns Entries */ owedBy(agentSession: string): OwnerMessageEntry[]; /** * Debug listing of what is still waiting. * * @returns One row per tracked message, oldest first */ list(): OwnerMessageListing[]; /** Number of tracked messages. */ get size(): number; /** * Evaluate every tracked message once. Safe to call concurrently (a second * call while one runs is a no-op). * * @returns When the evaluation is done */ tick(): Promise; private evaluate; private isVisiblyWorking; private runNudge; /** * Post the one note and stop tracking. * * @param entry - The message * @param kind - Why now */ private note; /** * The note in plain words. * * @param entry - The message * @param kind - Why now * @returns The note text */ noteText(entry: OwnerMessageEntry, kind: 'cap' | 'silent' | 'blocked'): string; /** * Post the "signed out" note for a message without finishing it. * * @param entry - The message */ private postLoginNote; /** * A runtime's login is back: re-deliver every message that waited on it * (or on one of the restarted agents), waking the agent when needed. The * normal timeline (note if still silent) continues from there. * * @param match - Runtime word ("claude") and/or the agents that were restarted * @returns How many messages were re-delivered */ resumeAfterLogin(match: { runtimeCmd?: string; sessions?: readonly string[]; }): Promise; /** * The agent's turn failed (its model run errored, crewly#1015 §2). Every * message it must answer is parked (`failed_wait`) — kept, re-delivered * on a backing-off timer (not at all when credit / quota is out) and when * a turn succeeds — and the owner is told once per message, with the * reason, instead of a "hasn't replied" note twenty minutes later. * * @param agentSession - The agent whose turn failed * @param detail - Why, in plain words * @param opts - `needsCredit`: out of credit / quota — no timed re-delivery * @returns How many messages are parked */ noteTurnFailed(agentSession: string, detail: string, opts?: { needsCredit?: boolean; }): Promise; /** * An agent whose turns were failing completed one: re-deliver what it * still owes (`failed_wait`). The normal timeline continues from there. * * @param agentSession - The agent * @returns How many messages were re-delivered */ resumeAfterRecovery(agentSession: string): Promise; /** * Whether an answer was seen in a place since a time — the retry of a * failed turn is skipped when that turn already answered (crewly#1015 * review H3). * * @param placePrefix - `slack::` or `chat::` (any thread) * @param since - Epoch ms * @returns True when answered there since then */ answeredSince(placePrefix: string, since: number): boolean; private noteRecentAnswer; private remember; private finish; private persist; private load; } /** @returns The wired watchdog, or null before it started (and in tests). */ export declare function getOwnerMessageWatchdog(): OwnerMessageWatchdogService | null; /** @param service - The watchdog to expose (null to clear) */ export declare function setOwnerMessageWatchdog(service: OwnerMessageWatchdogService | null): void; //# sourceMappingURL=owner-message-watchdog.service.d.ts.map