/** * Stall detection (task 164; model-agnostic classifier per task 165). * * A thread that ends a turn on a promised-but-untaken next action ("Let me * look at how X renders.") with nothing after it is not empty (task 150/156 * doesn't catch it) and may never have armed a `schedule_trigger` (task 156 * only helps if the model remembers to). This module supplies the two * detection signals `server.ts` uses on the busy→idle edge: * * 1. `hasUncheckedTodo` — free, structural: the thread's own `` block * still has an open item. * 2. `classifyStall` — a cheap-model fallback for the harder case: no todo * block at all, prose just trails off. Deliberately conservative — the * caller must default to "no nudge" on anything it isn't sure about, * since prodding a thread that finished and is waiting on the user is * the worse failure (Karl, 2026-08-30). * * The classifier runs on WHATEVER model the operator configured * (`stallClassifierModel`), not just a Claude sub-model (task 165), via * `runOneShotModel` — the one way the gateway itself calls a model (task 179). * This module owns the PROMPT and the fail-closed reading of the answer; which * provider runs it, and how, lives in `one-shot-model.ts`. */ import { type OneShotModelContext } from './one-shot-model.js'; export declare function hasUncheckedTodo(text: string): boolean; /** Last `n` sentences of a message — the classifier only needs the tail, not the whole thing. */ export declare function lastSentences(text: string, n?: number): string; /** The single dangling sentence to quote back in the nudge message. */ export declare function extractDanglingSentence(text: string): string; export interface ClassifyStallOptions { timeoutMs?: number; /** * The configured model id. When no `context` is supplied this is used directly * as a Claude CLI sub-model (the task-164 behavior, preserved for callers and * tests that don't care about provider routing). */ model?: string; /** * Full provider-resolution context. When present, the configured `model` is * routed through `resolveModelProvider` and run on whatever provider that * names — a Claude CLI sub-model or a direct-provider HTTP model (task 165). */ context?: OneShotModelContext; } /** * Cheap-model classification for the "trails off with no `` at all" case. * * The provider branch lives in `runOneShotModel` (task 179); what stays here is * the part that is the classifier's own — the prompt, the tail-of-message snippet * it runs on, and the fail-closed reading of the answer. * * `reasoningEffort: 'low'` (task 166) because the classification is a trivial * binary read of a few sentences and must not deliberate; `CLASSIFIER_MAX_TOKENS` * (task 167) because on both HTTP providers that budget bounds REASONING + * CONTENT together, so it has to hold the whole low-effort thinking pass before * the one-word verdict can be emitted at all. * * Fails closed on every branch: a `null` answer (no model configured, spawn * failure, non-zero exit, timeout, provider throw) and a non-"YES" answer are * both "no nudge", because prodding a thread that correctly finished is the worse * failure. * * "No model configured" reaches here as the same `null` as any other miss, and * that is deliberate: an unset `stallClassifierModel` means stall detection is * simply off (task 184), which is a state to leave alone, not an error to raise * on a turn that has already ended. The settings screen is where it says so. */ export declare function classifyStall(text: string, opts?: ClassifyStallOptions): Promise; /** Is the last message from the thread's own ``/classifier signals a stall? */ export declare function isStalled(text: string, opts?: ClassifyStallOptions): Promise; /** * A stall that needs no classifier at all. * * A livelock exit (`metadata.livelocked`) is the strongest possible stall signal: * the thread was told to keep working and demonstrably could not make progress * (identical tool call ×N). It is stronger than an unchecked ``, so it * nudges immediately with a livelock-specific message instead of consulting the * classifier — which would read the "I stopped because I was looping…" message as * a question back to the user and correctly decline, leaving a livelocked thread * parked and waiting for a human. */ export interface DefiniteStall { /** True when the message is a definite stall that needs no classifier. */ definite: boolean; /** The nudge text, present only when `definite`. */ text?: string; } /** * Decide whether a stalled thread's last message is a definite stall (and what to * say), before the classifier is consulted. Pure so it can be unit-tested without * the server harness. */ export declare function definiteStallForMessage(last: { content: string; metadata?: Record; }): DefiniteStall; /** The subset of a `JobRecord` this gate reads. Structural, so `jobs.ts` stays uncoupled. */ export interface PendingWakeJob { status: string; label?: string; id?: string; } /** The subset of a thread config's `schedules[]` this gate reads. */ export interface PendingWakeSchedule { id: string; trigger: 'once' | 'cron'; at?: string; } /** * Is this thread already guaranteed to be woken? (task 175) * * The detector's two inferred signals — `hasUncheckedTodo` and `classifyStall` — * both read PROSE, and the single most common way a thread correctly ends a turn * is by parking on a background job, which the system prompt explicitly instructs * it to do ("Then END YOUR TURN. Do not wait for it and do not poll — you are * woken automatically"). A thread that obeys writes "Ending the turn here rather * than polling — the job wakes me when it exits", which to a prose classifier is * indistinguishable from trailing off mid-task. * * Measured on disk 2026-09-02: **47 of 118 nudges (40%) fired while a job was * literally running on that thread** — a lower bound, since MAX_FINISHED_PER_THREAD * prunes older job records so their nudges can no longer be attributed. Task 164's * own doc names the gap without closing it: the nudge exists for a thread that * "may never have armed a `schedule_trigger`". Nobody ever checked whether it had. * * Returns a reason string (for the log line) when a wake is pending, `null` * otherwise. Pure, so the whole policy is unit-testable without the server * harness — same shape as `definiteStallForMessage`. * * Two conditions, and deliberately only two: * * 1. A job with `status === 'running'`, excluding server jobs owned by the * Preview registry. Those long-lived services do not represent pending work. * Static Preview builds and ordinary background jobs still report back. * 2. A `once` schedule whose `at` is still in the future — precisely the * mechanism the system prompt tells a thread to arm when it means to continue * later, and the one task 164 says the nudge compensates for the ABSENCE of. * Armed, the nudge is redundant by its own charter. * * A `cron` trigger is NOT a gate. It is a PERIODIC wake, not a wake for the work * in hand: a thread with a daily 09:00 report that stalls at 14:00 is genuinely * stalled for the next 19 hours. Gating on cron would silently disable the * detector on any thread that happens to own a recurring schedule. */ export declare function pendingWakeReason(jobs: PendingWakeJob[] | undefined, schedules: PendingWakeSchedule[] | undefined, now?: number, serviceJobIds?: ReadonlySet): string | null; //# sourceMappingURL=stall-detection.d.ts.map