@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.
- package/README.md +182 -25
- package/completion/defects.d.ts +138 -0
- package/completion/defects.js +130 -0
- package/completion/index.d.ts +3 -0
- package/completion/index.js +3 -0
- package/completion/tool-calls.d.ts +90 -0
- package/completion/tool-calls.js +67 -0
- package/completion/watchdog.d.ts +165 -0
- package/completion/watchdog.js +211 -0
- package/index.d.ts +9 -6
- package/index.js +9 -6
- package/loop/tool-loop.js +17 -5
- package/package.json +6 -2
- package/routing/attempt-errors.d.ts +122 -0
- package/routing/attempt-errors.js +176 -0
- package/routing/executor.js +16 -1
- package/routing/index.d.ts +1 -0
- package/routing/index.js +1 -0
- package/run/children.d.ts +55 -3
- package/run/children.js +43 -10
|
@@ -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
|
+
}
|
package/routing/executor.js
CHANGED
|
@@ -42,7 +42,22 @@ export async function executeRoutePlan(options) {
|
|
|
42
42
|
endpointAttempt,
|
|
43
43
|
});
|
|
44
44
|
attemptCount += 1;
|
|
45
|
-
|
|
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 {
|
package/routing/index.d.ts
CHANGED
|
@@ -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
|
|
109
|
-
* not separate them: a cooldown clears on its own, a
|
|
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
|
-
`
|
|
87
|
-
`
|
|
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
|
-
`
|
|
98
|
-
`
|
|
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, `
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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}) —
|
|
123
|
-
`
|
|
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: {
|