@juno-ai/bind 4.0.0 → 6.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";
package/run/children.d.ts CHANGED
@@ -86,6 +86,26 @@ export type ChainRule =
86
86
  msSinceLastSpawn: number | null;
87
87
  cooldownMs: number;
88
88
  }
89
+ /**
90
+ * Whether this parent→child pair has *already* run, at all, within whatever
91
+ * scope the host measured over. The stronger sibling of
92
+ * `pair_cooldown`: a cooldown lets a pair repeat once enough time passes,
93
+ * while this permits the pair exactly once and never again.
94
+ *
95
+ * Reach for it where a repeat is not slow but wrong — two agents that answer
96
+ * each other are a loop whose every hop looks individually reasonable, and a
97
+ * time-based bound only makes such a loop cheaper per hour rather than
98
+ * ending it.
99
+ *
100
+ * The scope is the host's to choose and is deliberately not modelled here:
101
+ * "already paired in this chain" and "already paired in this conversation"
102
+ * are the same rule over different measurements, and naming either one would
103
+ * put a product's containment vocabulary into the harness.
104
+ */
105
+ | {
106
+ kind: "pair_seen";
107
+ alreadyPaired: boolean;
108
+ }
89
109
  /**
90
110
  * A ceiling on concurrent runs for the whole tenant. Worth having alongside
91
111
  * the chain rules: depth and chain budgets constrain one lineage, and neither
@@ -95,6 +115,26 @@ export type ChainRule =
95
115
  kind: "tenant_ceiling";
96
116
  activeRuns: number;
97
117
  maxActiveRuns: number;
118
+ }
119
+ /**
120
+ * How many runs a tenant may *start* within a rolling window, as opposed to
121
+ * how many may be in flight at once (`tenant_ceiling`).
122
+ *
123
+ * The two bound different abuses and neither implies the other. A ceiling
124
+ * caps concurrency, so a caller that starts and finishes runs quickly slips
125
+ * under it indefinitely — a message flood, or one fan-out across a large
126
+ * group, is exactly that shape. A rate caps total starts, so it bounds spend
127
+ * where the ceiling bounds load.
128
+ *
129
+ * `windowMs` is carried only so the refusal can say how long the wait is;
130
+ * the rule does not roll the window itself. The host measures over whatever
131
+ * window it chose and passes both.
132
+ */
133
+ | {
134
+ kind: "tenant_rate";
135
+ runsInWindow: number;
136
+ maxRuns: number;
137
+ windowMs: number;
98
138
  };
99
139
  export type ChildAdmission = {
100
140
  admitted: true;
@@ -105,9 +145,9 @@ export type ChildAdmission = {
105
145
  /**
106
146
  * Could waiting change this answer?
107
147
  *
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
148
+ * The rules are not all the same kind of refusal, and prose alone does
149
+ * not separate them: a cooldown or a rolling window clears on its own, a
150
+ * spent chain budget or a repeated pair never does. Without this a model reads "refused" and has to guess
111
151
  * between waiting and giving up — and guessing wrong either wastes the
112
152
  * run on retries into the same wall or abandons work it could have done
113
153
  * a moment later.
@@ -127,6 +167,18 @@ export type ChildAdmission = {
127
167
  * reason the model sees when several apply. An empty rule list admits — this
128
168
  * function bounds what it is given and claims nothing about what it is not.
129
169
  *
170
+ * **When a measurement fails, omit the rule — do not pass a sentinel.** The
171
+ * two failure postures here answer different questions and are easy to
172
+ * conflate. A rule whose number arrived broken (`NaN`, negative) refuses,
173
+ * because a nonsense count is not evidence of safety. A rule you could not
174
+ * measure at all — the count query threw, the store was unreachable — is
175
+ * absent, and absence admits. That is a deliberate seam, not an oversight:
176
+ * whether a transient infrastructure failure should stop all work or let it
177
+ * through is an availability judgement about a specific product, and this
178
+ * function has no standing to make it. Decide it at the call site, in a
179
+ * `catch`, by choosing whether to append the rule. Passing `NaN` or `-1` to
180
+ * mean "unknown" inverts the answer you almost certainly want.
181
+ *
130
182
  * **Call before enqueuing, never after.** A chain that is bounded only once its
131
183
  * runs are already queued is not bounded; it is billed.
132
184
  *
package/run/children.js CHANGED
@@ -48,6 +48,18 @@ parent) {
48
48
  * reason the model sees when several apply. An empty rule list admits — this
49
49
  * function bounds what it is given and claims nothing about what it is not.
50
50
  *
51
+ * **When a measurement fails, omit the rule — do not pass a sentinel.** The
52
+ * two failure postures here answer different questions and are easy to
53
+ * conflate. A rule whose number arrived broken (`NaN`, negative) refuses,
54
+ * because a nonsense count is not evidence of safety. A rule you could not
55
+ * measure at all — the count query threw, the store was unreachable — is
56
+ * absent, and absence admits. That is a deliberate seam, not an oversight:
57
+ * whether a transient infrastructure failure should stop all work or let it
58
+ * through is an availability judgement about a specific product, and this
59
+ * function has no standing to make it. Decide it at the call site, in a
60
+ * `catch`, by choosing whether to append the rule. Passing `NaN` or `-1` to
61
+ * mean "unknown" inverts the answer you almost certainly want.
62
+ *
51
63
  * **Call before enqueuing, never after.** A chain that is bounded only once its
52
64
  * runs are already queued is not bounded; it is billed.
53
65
  *
@@ -83,8 +95,8 @@ function evaluateRule(rule) {
83
95
  const childDepth = rule.parentDepth + 1;
84
96
  return childDepth >= rule.maxDepth
85
97
  ? refuse(rule, false, `chain depth limit reached (${childDepth}/${rule.maxDepth}) — ` +
86
- `this is as deep as the chain may go, so do the work in this run ` +
87
- `instead of spawning`)
98
+ `as far as work may be handed on, and waiting will not change ` +
99
+ `that; do this work here rather than delegating again`)
88
100
  : null;
89
101
  }
90
102
  case "chain_budget": {
@@ -94,8 +106,8 @@ function evaluateRule(rule) {
94
106
  return broken;
95
107
  return rule.runsInChain >= rule.maxRuns
96
108
  ? refuse(rule, false, `chain run budget exhausted (${rule.runsInChain}/${rule.maxRuns}) — ` +
97
- `the budget is spent for this whole chain and does not refill, so ` +
98
- `do the work in this run instead of spawning`)
109
+ `spent for this whole chain and it does not refill, so do this ` +
110
+ `work here rather than delegating again`)
99
111
  : null;
100
112
  }
101
113
  case "pair_cooldown": {
@@ -106,10 +118,19 @@ function evaluateRule(rule) {
106
118
  if (broken)
107
119
  return broken;
108
120
  return rule.msSinceLastSpawn < rule.cooldownMs
109
- ? refuse(rule, true, `spawn cooldown active (${rule.msSinceLastSpawn}ms since the last ` +
110
- `spawn, ${rule.cooldownMs}ms required) retry in ` +
111
- `${rule.cooldownMs - rule.msSinceLastSpawn}ms, or do the work in ` +
112
- `this run`)
121
+ ? refuse(rule, true, `handoff cooldown active retry in ` +
122
+ `${rule.cooldownMs - rule.msSinceLastSpawn}ms, or do this work ` +
123
+ `here (${rule.msSinceLastSpawn}ms since the last handoff, ` +
124
+ `${rule.cooldownMs}ms required)`)
125
+ : null;
126
+ }
127
+ case "pair_seen": {
128
+ // A boolean carries no measurement to validate — the host either
129
+ // observed the prior pairing or it did not.
130
+ return rule.alreadyPaired
131
+ ? refuse(rule, false, `repeat handoff refused — these two have already worked together ` +
132
+ `here and are allowed to only once, so waiting will not change ` +
133
+ `that; do this work here instead`)
113
134
  : null;
114
135
  }
115
136
  case "tenant_ceiling": {
@@ -119,8 +140,20 @@ function evaluateRule(rule) {
119
140
  return broken;
120
141
  return rule.activeRuns >= rule.maxActiveRuns
121
142
  ? refuse(rule, true, `concurrent run ceiling reached (${rule.activeRuns}/` +
122
- `${rule.maxActiveRuns}) — this clears as other runs finish, so ` +
123
- `retry shortly or do the work in this run`)
143
+ `${rule.maxActiveRuns}) — clears as other runs finish, so retry ` +
144
+ `shortly or do this work here`)
145
+ : null;
146
+ }
147
+ case "tenant_rate": {
148
+ const broken = unmeasurable(rule, rule.runsInWindow, "runsInWindow") ??
149
+ unmeasurable(rule, rule.maxRuns, "maxRuns") ??
150
+ unmeasurable(rule, rule.windowMs, "windowMs");
151
+ if (broken)
152
+ return broken;
153
+ return rule.runsInWindow >= rule.maxRuns
154
+ ? refuse(rule, true, `start-rate limit reached (${rule.runsInWindow}/${rule.maxRuns} ` +
155
+ `runs in the last ${rule.windowMs}ms) — the window rolls, so ` +
156
+ `wait up to ${rule.windowMs}ms and retry, or do this work here`)
124
157
  : null;
125
158
  }
126
159
  default: {