/** * `clustly run` — the no-server wake daemon (T4). * * An indie agent runs this on a laptop or cheap host: it long-polls for jobs and * invokes the dev's handler when one lands, so the agent earns without exposing * a public webhook endpoint. * * The correctness rules the eng review made non-negotiable: * * 1. ACCEPT FIRST, then invoke. An order stays `awaiting_acceptance` until the * indexer sees TaskEnrolled (async). If we invoked-then-accepted, the next * poll would see it again and re-invoke. Calling accept first transitions it * off the polled status. * 2. PERSISTED IN-FLIGHT LEDGER keyed by order_id. A crash mid-job must not lose * the job (it's `enrolled` now and won't reappear), and we must never invoke * the same order twice. * 3. CROSS-PROCESS LEASE is the server, not local state: accept is * idempotency-keyed (by order_id) and the on-chain enroll rejects a * duplicate, so two daemons racing the same order resolve to one enroll. The * local ledger only guards re-invocation within a process / across restarts. * * poll awaiting_acceptance * │ * ▼ for each order not in ledger * ledger.start(order_id) ──persist──┐ * │ │ crash here → resume: order is * accept(order_id, idem=order_id) │ enrolled, ledger says in-flight, * │ │ handler re-runs (handler must be * invoke({ order, agent }) │ idempotent on its own work) * │ │ * ledger.finish(order_id) ──persist──┘ */ import { type ClustlyAgent, type Order } from "./index"; /** How long a transient fault (rate limit, outage, network) parks an order when the server named no Retry-After. */ export declare const TRANSIENT_RETRY_MS = 15000; /** * The SAFETY NET, not the discovery mechanism. * * Work is discovered by the heartbeat: its ack already carries `awaiting_acceptance`, * `threads_waiting` and `work_waiting`, so the beat an agent is making anyway says whether a poll * is worth making. * This interval only exists so that a beat which never lands — a network partition, a server that * stops answering — cannot strand a funded order forever. * * It replaces a 5-second default that ran whether or not anything was happening. That default cost * ~17,280 polls a day per agent, and `GET /v1/orders` fans out to at least seven database round * trips before it answers, so an idle daemon generated ~121,000 of them a day while the beat beside * it carried the same answer 1,440 times for a fraction of the cost. */ export declare const IDLE_POLL_MS = 300000; /** * Floor on a backoff-driven wake, and the reason this loop cannot spin. * * `backoffMs` defaults to `() => 0` — "no backoff, take it on the next tick". When the next tick * was a fixed 5 seconds away that read as a 5-second retry. A loop that sleeps until the deadline * would instead compute a zero wait and retry a permanently failing handler as fast as the event * loop allows, which is a tight loop against the agent's own API. The floor keeps the old cadence * for exactly that case and constrains nothing else: a real backoff is always longer than this. */ export declare const MIN_RETRY_WAKE_MS = 5000; /** * "Not now" — the platform or the network, never the job. Such a failure must not spend one * of the order's attempts: five of them burned in ~30 s and `finish()` then ABANDONED a paid * order the agent was already enrolled in (CLI audit 2026-09-16). The order's own attempts * are for the handler failing on it. */ export declare function isTransient(err: unknown): boolean; /** Tracks which orders this daemon has started/finished. Persist for crash-resume. */ export interface Ledger { /** True if we've already started (in-flight) or finished/abandoned this order. */ seen(orderId: string): boolean; /** Orders marked started but never finished — after a restart, the work a crash interrupted. */ inFlight(): string[]; start(orderId: string): void; finish(orderId: string): void; /** Drop an in-flight mark after a failure so the order can be retried. */ release(orderId: string): void; /** Record a failed attempt; returns the running attempt count for this order. */ fail(orderId: string): number; /** Earliest epoch-ms this order may be retried (0 = eligible now). */ notBefore(orderId: string): number; /** Set the backoff deadline (epoch-ms) before which this order is skipped. */ backoff(orderId: string, untilMs: number): void; /** * Earliest epoch-ms at which SOME order becomes retryable, or 0 when none is waiting. * * Optional so a ledger written against the old contract (the CLI's file-backed one) still * satisfies this interface. The run loop uses it to wake exactly when a backoff expires: the * loop no longer ticks every few seconds, so without this a 2-second backoff would wait for * the idle poll instead. A ledger that does not implement it keeps working — its retries just * land on the next tick rather than on the second they are due. */ nextDue?(): number; } /** In-memory ledger (used by tests; the CLI wraps a file-backed one). */ export declare class MemoryLedger implements Ledger { private active; private done; private failures; private retryAt; seen(id: string): boolean; inFlight(): string[]; start(id: string): void; finish(id: string): void; release(id: string): void; fail(id: string): number; notBefore(id: string): number; backoff(id: string, untilMs: number): void; nextDue(): number; } export type InvokeFn = (ctx: { order: Order; agent: ClustlyAgent; }) => Promise; /** Retry policy for a failing order. Defaults preserve legacy behavior (retry * forever, no backoff) so a caller that wants the old loop changes nothing. */ export interface RetryPolicy { /** Give up after this many failed attempts (default: Infinity — never give up). */ maxAttempts?: number; /** Backoff before the next retry, by attempt number (default: 0 — retry next tick). */ backoffMs?: (attempt: number) => number; /** Clock seam for tests. */ now?: () => number; } export interface TickDeps extends RetryPolicy { agent: Pick; ledger: Ledger; invoke: InvokeFn; /** * Only poll (and therefore only ever accept) orders for these listings. Absent = * every listing the key serves — the historical behaviour, right for a one-listing * agent and wrong the moment a second listing is pointed at a process that can't * do its work (Zhuang's report, 2026-08-22: `clustly run` accepted every open order). */ listingIds?: string[]; /** Surface per-order errors without killing the loop (transient — will retry). */ onError?: (orderId: string, err: unknown) => void; /** Called once when an order is abandoned after maxAttempts (terminal — no retry). */ onGiveUp?: (orderId: string, attempts: number, err: unknown) => void; /** What this daemon may accept unattended. Absent = accept anything it is shown. */ acceptPolicy?: AcceptPolicy; /** Called once per order the policy refuses, so the seller learns rather than wonders. */ onDecline?: (orderId: string, decline: Decline) => void; /** * Also sweep orders this agent is already ENROLLED in but has not delivered. * * Off by default because it is a second `GET /v1/orders` per tick, and this loop exists to stop * asking questions nobody needed answered. `run` turns it on for the two moments it can be the * only way to learn: a beat reporting `work_waiting`, and the idle safety poll that covers a * beat never landing. */ includeEnrolled?: boolean; } /** * What the daemon may commit the seller to, without a human or a model in the loop. * * Accepting is a PROMISE: it starts the delivery clock and puts the seller's reputation behind * the order. The daemon accepts mechanically — that is the point, it is what keeps a model out of * discovery — so the seller needs a way to bound what it may promise on their behalf. `--listing` * was the only bound and it answers "whose work", not "how much". */ export interface AcceptPolicy { /** Refuse an order priced above this, in MICRO-USDC. Absent = no ceiling. */ maxPriceMicroUsdc?: number; /** Discover and report, never accept. The seller (or their model) takes it from there. */ never?: boolean; } /** * How long a declined order is parked before the policy is applied to it again. * * A decline is a judgement made by MUTABLE config, so it can always be wrong later: a seller who * raises their ceiling means a previously-refused order should become workable. Marking it * permanently done would hide it from this daemon forever. Parking re-asks the question on a * schedule instead, and the wake mute in `run` is what stops that costing anything. */ export declare const DECLINE_TTL_MS: number; /** Why an order was not auto-accepted. `code` is for callers that must react; `message` is what a * seller reads. Null when the policy allows the order. */ export interface Decline { code: "policy_off" | "no_price" | "above_ceiling"; message: string; } export declare function declineReason(order: Order, policy: AcceptPolicy | undefined): Decline | null; /** * Pick up the orders a crash left `active` in the ledger (T4's promise, which the code did not * keep until 2026-09-16: `seen()` was true for an active order, so the poll skipped it forever * and the poll only ever asked for awaiting_acceptance while the order was now `enrolled` — * it sat until abandonEnrolled refunded the buyer and the agent took the reputation hit). * * Each id is re-read from the server, because the ledger only knows it STARTED something: * · enrolled → invoke again (the accept landed; the work did not) * · awaiting_acceptance → release the mark; the normal path accepts and invokes it * · anything else → finished or terminal server-side; mark done */ export type ResumeDeps = TickDeps & { agent: Pick; }; export declare function resumeInFlight(deps: ResumeDeps, ids: readonly string[]): Promise; /** * One poll cycle. Returns the order_ids it handled this tick. Each order is * processed independently — one failure never blocks the others. A failed order * is released for a later retry (after its backoff), unless it has hit * `maxAttempts`, in which case it is abandoned so it never tight-loops forever. */ export declare function tick(deps: TickDeps): Promise; export interface RunOptions extends RetryPolicy { agent: ClustlyAgent; invoke: InvokeFn; ledger?: Ledger; intervalMs?: number; signal?: AbortSignal; /** See {@link TickDeps.listingIds}. */ listingIds?: string[]; onError?: (orderId: string, err: unknown) => void; onGiveUp?: (orderId: string, attempts: number, err: unknown) => void; /** See {@link AcceptPolicy}. */ acceptPolicy?: AcceptPolicy; onDecline?: (orderId: string, decline: Decline) => void; } /** * Wake-and-invoke forever (until aborted). * * The beat drives the work. `startHeartbeat` already runs beside this loop, and its ack carries * `awaiting_acceptance` / `threads_waiting` / `work_waiting` — so news wakes the loop at once and * a quiet ack means there is provably nothing to fetch. Between beats the loop sleeps until * whichever comes first: a wake, a backoff deadline coming due, or the idle safety poll. * * `intervalMs` still overrides the idle poll for a caller that wants one, but it is no longer the * discovery mechanism, so the default moved from 5s to {@link IDLE_POLL_MS}. */ export declare function run(opts: RunOptions): Promise; /** Exponential backoff: base·2^(attempt-1), capped. The CLI's default policy. */ export declare function expBackoff(baseMs?: number, capMs?: number): (attempt: number) => number;