@juno-ai/bind 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Classification: a thrown transport error → the {@link InferenceAttemptError}
3
+ * taxonomy the executor routes on.
4
+ *
5
+ * `errors.ts` owns what the router *does* with a classified failure
6
+ * (`failureDisposition`) and `executor.ts` applies it. This module is the step
7
+ * before both — the one every host was writing itself. The PRD's split is
8
+ * "transports classify facts; the executor decides," and shipping only the
9
+ * decider left each consumer to hand-roll the classifier, where the mistakes
10
+ * are not obvious: misfile a moderation refusal as a credential failure and the
11
+ * circuit breaker opens the endpoint for everyone sharing that breaker key —
12
+ * which, for a host that does not set `credentialScope`, is every tenant on the
13
+ * process.
14
+ *
15
+ * The host keeps the two things that are genuinely its own — recognizing its
16
+ * error class ({@link AttemptClassification.asTransportFailure}) and any
17
+ * provider-specific status reading ({@link AttemptClassification.categorizeStatus}).
18
+ * Neither can live here: the first is a class this package must not import, and
19
+ * the second is provider-registry knowledge.
20
+ */
21
+ import type { AttemptTarget, HttpFailureCategory, InferenceAttemptError } from "./errors.js";
22
+ import type { ProviderId } from "./canonical-model.js";
23
+ import type { CompletionDefectKind } from "../completion/defects.js";
24
+ /**
25
+ * What a transport observed, in the vocabulary the taxonomy needs. A host maps
26
+ * its own error class onto this once.
27
+ *
28
+ * `"api"` is the catch-all for a response the provider rejected with a status
29
+ * the transport did not interpret further; it is the only kind whose routing
30
+ * behavior depends on {@link TransportFailure.statusCode}.
31
+ */
32
+ export type TransportFailureKind = CompletionDefectKind | "server_error" | "rate_limit" | "no_credits" | "api" | "network";
33
+ export interface TransportFailure {
34
+ readonly kind: TransportFailureKind;
35
+ /** HTTP status when the failure carried one; null for transport-level faults. */
36
+ readonly statusCode: number | null;
37
+ /** Parsed `Retry-After`, which bounds-extends the breaker cooldown. */
38
+ readonly retryAfterMs: number | null;
39
+ }
40
+ export interface AttemptClassification {
41
+ /**
42
+ * Recognize the host's own transport-error class and describe it. Return
43
+ * `null` for anything that is not one — those propagate as a client error
44
+ * rather than burning the plan (see {@link classifyAttemptError}).
45
+ */
46
+ readonly asTransportFailure: (error: unknown) => TransportFailure | null;
47
+ /**
48
+ * Provider-specific status → category, consulted before the neutral
49
+ * {@link categorizeHttpStatus}. Return `null` to fall through to it.
50
+ *
51
+ * This exists because status codes are not portable across providers: one
52
+ * gateway answers 403 for moderation-flagged *input* (request-shaped — a
53
+ * different provider may accept it, and the endpoint is healthy), where the
54
+ * neutral mapping reads 403 as a credential failure and opens the circuit
55
+ * immediately.
56
+ */
57
+ readonly categorizeStatus?: (statusCode: number, providerId: ProviderId) => HttpFailureCategory | null;
58
+ /**
59
+ * Recognize a caller-cancellation. Defaults to {@link isAbortByName} —
60
+ * `error.name === "AbortError"`, which is what `AbortSignal` and `fetch`
61
+ * produce.
62
+ *
63
+ * **Override this if your SDK wraps aborts in its own class.** The one that
64
+ * bites: `openai`'s `APIUserAbortError` extends its `APIError` and never sets
65
+ * `name`, so `error.name` is the inherited `"Error"` — it matches neither the
66
+ * default nor an `instanceof` check you didn't write. Left unrecognized, a
67
+ * deliberate cancellation classifies as a propagating `client_error`: it
68
+ * still stops the plan, but it is attributed as a fault rather than a
69
+ * cancellation, which pollutes failure telemetry and any breaker or retry
70
+ * accounting keyed off it.
71
+ *
72
+ * ```ts
73
+ * isAbort: (e) => e instanceof OpenAI.APIUserAbortError || isAbortByName(e),
74
+ * ```
75
+ */
76
+ readonly isAbort?: (error: unknown) => boolean;
77
+ }
78
+ /**
79
+ * The default abort test: the `name` an `AbortSignal`-driven `fetch` rejection
80
+ * carries. Exported so a host overriding {@link AttemptClassification.isAbort}
81
+ * can widen it rather than replace it.
82
+ */
83
+ export declare function isAbortByName(error: unknown): boolean;
84
+ /**
85
+ * Map a thrown error onto the attempt-error taxonomy. The original error rides
86
+ * in `cause` and is re-thrown verbatim if the plan exhausts, so a host's error
87
+ * classes and messages survive routing untouched.
88
+ *
89
+ * Order is deliberate. An abort is checked first: a cancelled call must never
90
+ * be reclassified as a provider fault, whatever else is true of it. Anything
91
+ * the host does not recognize as a transport failure is classified
92
+ * `client_error` — which propagates rather than traverses — because an error
93
+ * that escaped the transport without becoming one of its own is a programming
94
+ * defect (a `TypeError`, a validation throw), and burning every provider and
95
+ * the fallback model retrying a bug wastes a whole plan to arrive at the same
96
+ * exception.
97
+ */
98
+ export declare function classifyAttemptError(error: unknown, target: AttemptTarget, classification: AttemptClassification): InferenceAttemptError;
99
+ /**
100
+ * Parse a `Retry-After` response header into milliseconds, per RFC 9110: either
101
+ * delta-seconds or an HTTP-date. Returns `null` when absent or unparseable —
102
+ * the breaker then falls back to its own cooldown, so a header this cannot read
103
+ * degrades to the default rather than to no cooldown at all.
104
+ *
105
+ * **This value is attacker-influenceable and is not bounded here.** It comes
106
+ * from whatever answered the request — the provider, a gateway, a proxy — and
107
+ * RFC 9110 puts no ceiling on it, so a hostile or malfunctioning upstream can
108
+ * ask for a delay of years. Clamp before using it as a delay:
109
+ * {@link createCircuitBreaker} already does (`maxCooldownMs`), but a host that
110
+ * sleeps on this directly must impose its own bound, or one bad response header
111
+ * parks an endpoint indefinitely.
112
+ *
113
+ * `headers` is deliberately `unknown`: SDKs hand back a `Headers`, a plain
114
+ * object, or a `Map` depending on version and runtime. Anything with a `get`
115
+ * method is asked for the header (covering `Headers` and `Map` without naming
116
+ * either global, which keeps this portable to runtimes that ship neither);
117
+ * a plain object is read case-insensitively for the two spellings that occur
118
+ * in practice.
119
+ *
120
+ * @param now Injectable clock for the HTTP-date branch; defaults to `Date.now`.
121
+ */
122
+ export declare function retryAfterMsFromHeaders(headers: unknown, now?: () => number): number | null;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Classification: a thrown transport error → the {@link InferenceAttemptError}
3
+ * taxonomy the executor routes on.
4
+ *
5
+ * `errors.ts` owns what the router *does* with a classified failure
6
+ * (`failureDisposition`) and `executor.ts` applies it. This module is the step
7
+ * before both — the one every host was writing itself. The PRD's split is
8
+ * "transports classify facts; the executor decides," and shipping only the
9
+ * decider left each consumer to hand-roll the classifier, where the mistakes
10
+ * are not obvious: misfile a moderation refusal as a credential failure and the
11
+ * circuit breaker opens the endpoint for everyone sharing that breaker key —
12
+ * which, for a host that does not set `credentialScope`, is every tenant on the
13
+ * process.
14
+ *
15
+ * The host keeps the two things that are genuinely its own — recognizing its
16
+ * error class ({@link AttemptClassification.asTransportFailure}) and any
17
+ * provider-specific status reading ({@link AttemptClassification.categorizeStatus}).
18
+ * Neither can live here: the first is a class this package must not import, and
19
+ * the second is provider-registry knowledge.
20
+ */
21
+ import { categorizeHttpStatus } from "./errors.js";
22
+ /**
23
+ * The default abort test: the `name` an `AbortSignal`-driven `fetch` rejection
24
+ * carries. Exported so a host overriding {@link AttemptClassification.isAbort}
25
+ * can widen it rather than replace it.
26
+ */
27
+ export function isAbortByName(error) {
28
+ return error instanceof Error && error.name === "AbortError";
29
+ }
30
+ /**
31
+ * Map a thrown error onto the attempt-error taxonomy. The original error rides
32
+ * in `cause` and is re-thrown verbatim if the plan exhausts, so a host's error
33
+ * classes and messages survive routing untouched.
34
+ *
35
+ * Order is deliberate. An abort is checked first: a cancelled call must never
36
+ * be reclassified as a provider fault, whatever else is true of it. Anything
37
+ * the host does not recognize as a transport failure is classified
38
+ * `client_error` — which propagates rather than traverses — because an error
39
+ * that escaped the transport without becoming one of its own is a programming
40
+ * defect (a `TypeError`, a validation throw), and burning every provider and
41
+ * the fallback model retrying a bug wastes a whole plan to arrive at the same
42
+ * exception.
43
+ */
44
+ export function classifyAttemptError(error, target, classification) {
45
+ const cause = error instanceof Error ? error : new Error(String(error));
46
+ const isAbort = classification.isAbort ?? isAbortByName;
47
+ if (isAbort(error)) {
48
+ return { kind: "aborted", target, cause };
49
+ }
50
+ const failure = classification.asTransportFailure(error);
51
+ if (failure === null) {
52
+ return {
53
+ kind: "http",
54
+ category: "client_error",
55
+ statusCode: 0,
56
+ retryAfterMs: null,
57
+ target,
58
+ cause,
59
+ };
60
+ }
61
+ switch (failure.kind) {
62
+ case "empty_completion":
63
+ case "truncated_tool_call":
64
+ return { kind: "completion_defect", defect: failure.kind, target, cause };
65
+ case "network":
66
+ return { kind: "network", target, cause };
67
+ case "rate_limit":
68
+ return httpError("rate_limit", failure.statusCode ?? 429, failure, target, cause);
69
+ case "no_credits":
70
+ return httpError("credits", failure.statusCode ?? 402, failure, target, cause);
71
+ case "server_error":
72
+ return httpError("server_error", failure.statusCode ?? 500, failure, target, cause);
73
+ case "api": {
74
+ const statusCode = failure.statusCode;
75
+ // No status at all means the transport could not attribute the rejection
76
+ // to the endpoint; propagate rather than traverse.
77
+ const category = statusCode === null
78
+ ? "client_error"
79
+ : (classification.categorizeStatus?.(statusCode, target.providerId) ??
80
+ categorizeHttpStatus(statusCode));
81
+ return httpError(category, statusCode ?? 0, failure, target, cause);
82
+ }
83
+ default: {
84
+ const _exhaustive = failure.kind;
85
+ throw new Error(`unknown transport failure kind: ${JSON.stringify(_exhaustive)}`);
86
+ }
87
+ }
88
+ }
89
+ function httpError(category, statusCode, failure, target, cause) {
90
+ return {
91
+ kind: "http",
92
+ category,
93
+ statusCode,
94
+ retryAfterMs: failure.retryAfterMs,
95
+ target,
96
+ cause,
97
+ };
98
+ }
99
+ /**
100
+ * Parse a `Retry-After` response header into milliseconds, per RFC 9110: either
101
+ * delta-seconds or an HTTP-date. Returns `null` when absent or unparseable —
102
+ * the breaker then falls back to its own cooldown, so a header this cannot read
103
+ * degrades to the default rather than to no cooldown at all.
104
+ *
105
+ * **This value is attacker-influenceable and is not bounded here.** It comes
106
+ * from whatever answered the request — the provider, a gateway, a proxy — and
107
+ * RFC 9110 puts no ceiling on it, so a hostile or malfunctioning upstream can
108
+ * ask for a delay of years. Clamp before using it as a delay:
109
+ * {@link createCircuitBreaker} already does (`maxCooldownMs`), but a host that
110
+ * sleeps on this directly must impose its own bound, or one bad response header
111
+ * parks an endpoint indefinitely.
112
+ *
113
+ * `headers` is deliberately `unknown`: SDKs hand back a `Headers`, a plain
114
+ * object, or a `Map` depending on version and runtime. Anything with a `get`
115
+ * method is asked for the header (covering `Headers` and `Map` without naming
116
+ * either global, which keeps this portable to runtimes that ship neither);
117
+ * a plain object is read case-insensitively for the two spellings that occur
118
+ * in practice.
119
+ *
120
+ * @param now Injectable clock for the HTTP-date branch; defaults to `Date.now`.
121
+ */
122
+ export function retryAfterMsFromHeaders(headers, now = Date.now) {
123
+ const raw = rawRetryAfter(headers);
124
+ if (raw === null || raw === "")
125
+ return null;
126
+ const seconds = Number(raw);
127
+ if (Number.isFinite(seconds)) {
128
+ // A negative delta is malformed, and the date branch below would not
129
+ // reliably reject a bare "-5" either. Reject here instead.
130
+ if (seconds < 0)
131
+ return null;
132
+ // `1e308` is finite but overflows to Infinity once scaled to ms, and a
133
+ // non-finite delay is the one value that makes a timer fire immediately
134
+ // rather than never — the same trap `requirePositiveMs` guards in the
135
+ // watchdog. Refuse it rather than hand a caller a poisoned number.
136
+ const ms = Math.round(seconds * 1000);
137
+ return Number.isFinite(ms) ? ms : null;
138
+ }
139
+ const dateMs = Date.parse(raw);
140
+ if (Number.isFinite(dateMs))
141
+ return Math.max(0, dateMs - now());
142
+ return null;
143
+ }
144
+ function rawRetryAfter(headers) {
145
+ if (typeof headers !== "object" || headers === null)
146
+ return null;
147
+ const getter = headers.get;
148
+ if (typeof getter === "function") {
149
+ // The getter belongs to the caller's object, so it is arbitrary code. This
150
+ // runs inside a host's `catch` while classifying a failure; letting a throw
151
+ // escape would replace the real provider error with this one and lose the
152
+ // classification entirely.
153
+ let value;
154
+ try {
155
+ value = getter.call(headers, "retry-after");
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ if (typeof value === "string")
161
+ return value;
162
+ // A `Map<string, number>` is as plausible as a `Map<string, string>`; the
163
+ // plain-object branch below accepts a number, so this one must too or the
164
+ // same header is read from one container and dropped from the other.
165
+ if (typeof value === "number")
166
+ return String(value);
167
+ return null;
168
+ }
169
+ const record = headers;
170
+ const value = record["retry-after"] ?? record["Retry-After"];
171
+ if (typeof value === "string")
172
+ return value;
173
+ if (typeof value === "number")
174
+ return String(value);
175
+ return null;
176
+ }
@@ -42,7 +42,22 @@ export async function executeRoutePlan(options) {
42
42
  endpointAttempt,
43
43
  });
44
44
  attemptCount += 1;
45
- const outcome = await options.attempt(candidate, cursor);
45
+ // `attempt` is host code and is expected to resolve to an outcome, not
46
+ // throw — but if it does throw (a bug in its own classification, a
47
+ // host-supplied port raising), the throw must not carry a half-open
48
+ // probe out of the loop with it. The probe slot would stay occupied for
49
+ // the process's lifetime and every later call for this endpoint would
50
+ // be refused admission, silently, with no failure recorded to ever
51
+ // reopen it.
52
+ let outcome;
53
+ try {
54
+ outcome = await options.attempt(candidate, cursor);
55
+ }
56
+ catch (error) {
57
+ if (halfOpenProbe)
58
+ breaker?.releaseProbe(breakerKey);
59
+ throw error;
60
+ }
46
61
  if (outcome.kind === "success") {
47
62
  breaker?.recordSuccess(breakerKey);
48
63
  return {
@@ -7,5 +7,6 @@ export { buildRoutePlanWithConfigDegradation, type DegradedStage, type DegradedP
7
7
  export { computeConfiguredRatesCostCents, type BillingBasisUsage, type BillingBasisResult, } from "./billing-basis.js";
8
8
  export { fallbackKindOfCursor } from "./executor.js";
9
9
  export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, type RouteAttemptCursor, type AttemptTarget, type HttpFailureCategory, type InferenceAttemptError, type FailureDisposition, type BreakerEffect, } from "./errors.js";
10
+ export { classifyAttemptError, retryAfterMsFromHeaders, isAbortByName, type AttemptClassification, type TransportFailure, type TransportFailureKind, } from "./attempt-errors.js";
10
11
  export { createCircuitBreaker, type BreakerKey, type CircuitBreakerOptions, type EndpointAdmission, type RecordFailureOptions, type RouteCircuitBreaker, } from "./circuit-breaker.js";
11
12
  export { executeRoutePlan, type AttemptOutcome, type AttemptFn, type FallbackKind, type ExecutePlanOptions, type RouteExecutionResult, } from "./executor.js";
package/routing/index.js CHANGED
@@ -7,5 +7,6 @@ export { buildRoutePlanWithConfigDegradation, } from "./plan-degradation.js";
7
7
  export { computeConfiguredRatesCostCents, } from "./billing-basis.js";
8
8
  export { fallbackKindOfCursor } from "./executor.js";
9
9
  export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, } from "./errors.js";
10
+ export { classifyAttemptError, retryAfterMsFromHeaders, isAbortByName, } from "./attempt-errors.js";
10
11
  export { createCircuitBreaker, } from "./circuit-breaker.js";
11
12
  export { executeRoutePlan, } from "./executor.js";
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Child runs: lineage, admission, and waiting.
3
+ *
4
+ * A sub-agent is not a special kind of thing — it is **a run that another run
5
+ * asked for**. Everything the harness already gives a run applies to it
6
+ * unchanged: its own deadline, its own heartbeat, its own stats, its own route
7
+ * plan. What is genuinely new is the relationship between runs, and that is
8
+ * what this module owns:
9
+ *
10
+ * - **Lineage** — where a run sits in the chain that produced it.
11
+ * - **Admission** — whether the next child may be created at all.
12
+ * - **Waiting** — how long to sleep before checking on a child again.
13
+ *
14
+ * All three are pure. The queue, the persistence, the counting, and the choice
15
+ * of bounds stay with the host: the harness decides, it does not measure. That
16
+ * split is deliberate — counting runs needs a database, and deciding whether a
17
+ * count is too high does not.
18
+ */
19
+ /**
20
+ * A run's position in the chain that spawned it.
21
+ *
22
+ * `rootRunId` and `parentRunId` are `null` at depth 0, where the run *is* the
23
+ * root — a chain's origin has no id to point at until its own row exists, so
24
+ * the convention is "null means me". Read the root of any chain as
25
+ * `chain.rootRunId ?? thisRunId`; {@link descendChain} does exactly that when
26
+ * it hands the id down, so every descendant carries a concrete root.
27
+ */
28
+ export interface ChainRef {
29
+ /** 0 for a run nobody spawned; one more than its parent otherwise. */
30
+ readonly depth: number;
31
+ /** The run that started the chain, or `null` when this run is that run. */
32
+ readonly rootRunId: string | null;
33
+ /** The run that spawned this one, or `null` when nothing did. */
34
+ readonly parentRunId: string | null;
35
+ }
36
+ /** The lineage of a run that nothing spawned — the origin of a new chain. */
37
+ export declare function rootChain(): ChainRef;
38
+ /**
39
+ * The lineage a child of `parentRunId` should carry.
40
+ *
41
+ * The root-id fallback is the part hosts get wrong: a depth-1 child must adopt
42
+ * its parent's *id* as the root (the parent's own `rootRunId` is null), while a
43
+ * depth-2 grandchild must adopt the root the parent already carries. Getting it
44
+ * backwards makes each generation start a fresh chain, which silently defeats
45
+ * every per-chain bound — the counts stay small because they count the wrong
46
+ * set.
47
+ */
48
+ export declare function descendChain(parentRunId: string, parent: Pick<ChainRef, "depth" | "rootRunId">): ChainRef;
49
+ /**
50
+ * One admission rule, carrying its limit and the measurement it applies to.
51
+ *
52
+ * Limit and fact travel together on purpose. The alternative — a bounds object
53
+ * beside a facts object — lets a host configure a bound whose count was never
54
+ * wired up, and the only symptom is a limit that silently never fires. Here a
55
+ * rule cannot be expressed without the number it judges, so a host pays only
56
+ * for what it actually measures and cannot ask for a bound it does not feed.
57
+ */
58
+ export type ChainRule =
59
+ /**
60
+ * How deep the chain may go. `parentDepth` is the spawning run's own depth,
61
+ * so the child would sit at `parentDepth + 1`; the rule rejects once that
62
+ * would reach `maxDepth`. A `maxDepth` of 5 therefore permits depths 0
63
+ * through 4 — five runs deep counting the root.
64
+ */
65
+ {
66
+ kind: "depth";
67
+ parentDepth: number;
68
+ maxDepth: number;
69
+ }
70
+ /**
71
+ * Total runs one chain may produce. Caps the spend of a chain that stays
72
+ * shallow but keeps fanning out, which a depth bound alone does not touch.
73
+ */
74
+ | {
75
+ kind: "chain_budget";
76
+ runsInChain: number;
77
+ maxRuns: number;
78
+ }
79
+ /**
80
+ * Minimum spacing between spawns from the same parent, which is what stops
81
+ * two runs ping-ponging work at each other. `msSinceLastSpawn` is `null` when
82
+ * this parent has not spawned before — always admitted.
83
+ */
84
+ | {
85
+ kind: "pair_cooldown";
86
+ msSinceLastSpawn: number | null;
87
+ cooldownMs: number;
88
+ }
89
+ /**
90
+ * A ceiling on concurrent runs for the whole tenant. Worth having alongside
91
+ * the chain rules: depth and chain budgets constrain one lineage, and neither
92
+ * stops someone starting a thousand independent ones.
93
+ */
94
+ | {
95
+ kind: "tenant_ceiling";
96
+ activeRuns: number;
97
+ maxActiveRuns: number;
98
+ };
99
+ export type ChildAdmission = {
100
+ admitted: true;
101
+ } | {
102
+ admitted: false;
103
+ /** Which rule refused, for metrics and for branching. */
104
+ rule: ChainRule["kind"];
105
+ /**
106
+ * Could waiting change this answer?
107
+ *
108
+ * The four rules are not the same kind of refusal, and prose alone does
109
+ * not separate them: a cooldown clears on its own, a spent chain budget
110
+ * never does. Without this a model reads "refused" and has to guess
111
+ * between waiting and giving up — and guessing wrong either wastes the
112
+ * run on retries into the same wall or abandons work it could have done
113
+ * a moment later.
114
+ */
115
+ retryable: boolean;
116
+ /**
117
+ * Why, in prose, for the model to read as a tool error. Names the limit,
118
+ * the measurement, and what to do instead — a refusal that reports only
119
+ * the failure invites a retry into the identical wall.
120
+ */
121
+ reason: string;
122
+ };
123
+ /**
124
+ * Decide whether one more child run may be created.
125
+ *
126
+ * Evaluated in the order given, first refusal wins, so a host controls which
127
+ * reason the model sees when several apply. An empty rule list admits — this
128
+ * function bounds what it is given and claims nothing about what it is not.
129
+ *
130
+ * **Call before enqueuing, never after.** A chain that is bounded only once its
131
+ * runs are already queued is not bounded; it is billed.
132
+ *
133
+ * **This is the decision, not the claim.** A counted rule (`chain_budget`,
134
+ * `tenant_ceiling`) bounds only as tightly as the host's count is atomic with
135
+ * the create. Two spawners that read the same count both admit — two replicas,
136
+ * or two spawn calls in one assistant batch, which the tool loop fans out
137
+ * concurrently. If you need the bound to hold under concurrency, take a lock or
138
+ * use a conditional insert around count-then-create; this function cannot see
139
+ * the race and will not tell you about it.
140
+ *
141
+ * A non-finite number anywhere in a rule — the measurement or the limit —
142
+ * refuses rather than admits. Comparisons against `NaN` are always false, so
143
+ * the natural reading of every rule below would silently admit, turning a
144
+ * broken count or a misread config into an unbounded chain. That is the one
145
+ * failure this function exists to prevent, so it fails toward refusing.
146
+ */
147
+ export declare function admitChildRun(rules: readonly ChainRule[]): ChildAdmission;
148
+ /**
149
+ * What a poller should do next while waiting on a child run.
150
+ *
151
+ * `wait` is already clamped to whatever budget remains, so sleeping for it can
152
+ * never overshoot the deadline — the next call returns `expired` instead.
153
+ */
154
+ export type PollStep = {
155
+ kind: "wait";
156
+ delayMs: number;
157
+ } | {
158
+ kind: "expired";
159
+ waitedMs: number;
160
+ };
161
+ export interface PollScheduleOptions {
162
+ /** First delay. Clamped to at least `minDelayMs` and at most `maxDelayMs`. */
163
+ readonly initialDelayMs: number;
164
+ /** Ceiling the doubling backoff climbs to. */
165
+ readonly maxDelayMs: number;
166
+ /** Total wall-clock the poll may consume before giving up. */
167
+ readonly budgetMs: number;
168
+ /**
169
+ * Floor on any single delay, defaulting to 50ms — which is the real guard
170
+ * against an `initialDelayMs` of 0 turning the poll into a busy loop.
171
+ *
172
+ * Setting it explicitly *lowers* that guard: the hard floor is 1ms, so
173
+ * `minDelayMs: 0` yields 1ms rather than the default. That is deliberate — a
174
+ * host asking for a sub-50ms poll gets one — but it means passing 0 to mean
175
+ * "no floor" gives you the tightest loop this module allows, not the safest.
176
+ *
177
+ * If this exceeds `maxDelayMs`, the floor wins and the ceiling is raised to
178
+ * match: a delay below the floor would defeat the busy-loop guard, while one
179
+ * above the ceiling only polls less often.
180
+ */
181
+ readonly minDelayMs?: number;
182
+ }
183
+ export interface PollSchedule {
184
+ /**
185
+ * The next step, given how long the poll has been running. Elapsed time is an
186
+ * argument rather than something read from a clock, which keeps this pure and
187
+ * lets a test drive the whole backoff without waiting for any of it.
188
+ *
189
+ * A schedule carries the backoff state for **one** wait and advances on every
190
+ * call, so it is not shareable between concurrent waiters — build one per
191
+ * wait, which is cheap. Two schedules from identical options are fully
192
+ * independent.
193
+ */
194
+ next(elapsedMs: number): PollStep;
195
+ }
196
+ /**
197
+ * A doubling backoff bounded by a total budget.
198
+ *
199
+ * Polling a child run is the alternative to suspending the parent and letting
200
+ * completion wake it. It keeps the parent's transcript intact but holds its
201
+ * worker slot for the duration — so the budget belongs well under the parent's
202
+ * own deadline, or the parent dies waiting instead of reporting what it learned.
203
+ */
204
+ export declare function createPollSchedule(options: PollScheduleOptions): PollSchedule;