@juno-ai/bind 3.0.0 → 4.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 +205 -62
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +26 -2
- package/contracts/turn.js +45 -0
- package/index.d.ts +9 -6
- package/index.js +9 -6
- package/loop/index.d.ts +1 -0
- package/loop/index.js +1 -0
- package/loop/tool-loop.d.ts +260 -0
- package/loop/tool-loop.js +276 -0
- package/package.json +6 -2
- package/run/children.d.ts +204 -0
- package/run/children.js +226 -0
- package/run/index.d.ts +1 -0
- package/run/index.js +1 -0
|
@@ -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;
|
package/run/children.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
/** The lineage of a run that nothing spawned — the origin of a new chain. */
|
|
20
|
+
export function rootChain() {
|
|
21
|
+
return { depth: 0, rootRunId: null, parentRunId: null };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The lineage a child of `parentRunId` should carry.
|
|
25
|
+
*
|
|
26
|
+
* The root-id fallback is the part hosts get wrong: a depth-1 child must adopt
|
|
27
|
+
* its parent's *id* as the root (the parent's own `rootRunId` is null), while a
|
|
28
|
+
* depth-2 grandchild must adopt the root the parent already carries. Getting it
|
|
29
|
+
* backwards makes each generation start a fresh chain, which silently defeats
|
|
30
|
+
* every per-chain bound — the counts stay small because they count the wrong
|
|
31
|
+
* set.
|
|
32
|
+
*/
|
|
33
|
+
export function descendChain(parentRunId,
|
|
34
|
+
// Only what the arithmetic reads, so a host storing lineage as flat columns
|
|
35
|
+
// can pass the two it has rather than assembling a whole `ChainRef` around a
|
|
36
|
+
// field this never looks at.
|
|
37
|
+
parent) {
|
|
38
|
+
return {
|
|
39
|
+
depth: parent.depth + 1,
|
|
40
|
+
rootRunId: parent.rootRunId ?? parentRunId,
|
|
41
|
+
parentRunId,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether one more child run may be created.
|
|
46
|
+
*
|
|
47
|
+
* Evaluated in the order given, first refusal wins, so a host controls which
|
|
48
|
+
* reason the model sees when several apply. An empty rule list admits — this
|
|
49
|
+
* function bounds what it is given and claims nothing about what it is not.
|
|
50
|
+
*
|
|
51
|
+
* **Call before enqueuing, never after.** A chain that is bounded only once its
|
|
52
|
+
* runs are already queued is not bounded; it is billed.
|
|
53
|
+
*
|
|
54
|
+
* **This is the decision, not the claim.** A counted rule (`chain_budget`,
|
|
55
|
+
* `tenant_ceiling`) bounds only as tightly as the host's count is atomic with
|
|
56
|
+
* the create. Two spawners that read the same count both admit — two replicas,
|
|
57
|
+
* or two spawn calls in one assistant batch, which the tool loop fans out
|
|
58
|
+
* concurrently. If you need the bound to hold under concurrency, take a lock or
|
|
59
|
+
* use a conditional insert around count-then-create; this function cannot see
|
|
60
|
+
* the race and will not tell you about it.
|
|
61
|
+
*
|
|
62
|
+
* A non-finite number anywhere in a rule — the measurement or the limit —
|
|
63
|
+
* refuses rather than admits. Comparisons against `NaN` are always false, so
|
|
64
|
+
* the natural reading of every rule below would silently admit, turning a
|
|
65
|
+
* broken count or a misread config into an unbounded chain. That is the one
|
|
66
|
+
* failure this function exists to prevent, so it fails toward refusing.
|
|
67
|
+
*/
|
|
68
|
+
export function admitChildRun(rules) {
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
const refusal = evaluateRule(rule);
|
|
71
|
+
if (refusal !== null)
|
|
72
|
+
return refusal;
|
|
73
|
+
}
|
|
74
|
+
return { admitted: true };
|
|
75
|
+
}
|
|
76
|
+
function evaluateRule(rule) {
|
|
77
|
+
switch (rule.kind) {
|
|
78
|
+
case "depth": {
|
|
79
|
+
const broken = unmeasurable(rule, rule.parentDepth, "parentDepth") ??
|
|
80
|
+
unmeasurable(rule, rule.maxDepth, "maxDepth");
|
|
81
|
+
if (broken)
|
|
82
|
+
return broken;
|
|
83
|
+
const childDepth = rule.parentDepth + 1;
|
|
84
|
+
return childDepth >= rule.maxDepth
|
|
85
|
+
? 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`)
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
case "chain_budget": {
|
|
91
|
+
const broken = unmeasurable(rule, rule.runsInChain, "runsInChain") ??
|
|
92
|
+
unmeasurable(rule, rule.maxRuns, "maxRuns");
|
|
93
|
+
if (broken)
|
|
94
|
+
return broken;
|
|
95
|
+
return rule.runsInChain >= rule.maxRuns
|
|
96
|
+
? 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`)
|
|
99
|
+
: null;
|
|
100
|
+
}
|
|
101
|
+
case "pair_cooldown": {
|
|
102
|
+
if (rule.msSinceLastSpawn === null)
|
|
103
|
+
return null;
|
|
104
|
+
const broken = unmeasurable(rule, rule.msSinceLastSpawn, "msSinceLastSpawn") ??
|
|
105
|
+
unmeasurable(rule, rule.cooldownMs, "cooldownMs");
|
|
106
|
+
if (broken)
|
|
107
|
+
return broken;
|
|
108
|
+
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`)
|
|
113
|
+
: null;
|
|
114
|
+
}
|
|
115
|
+
case "tenant_ceiling": {
|
|
116
|
+
const broken = unmeasurable(rule, rule.activeRuns, "activeRuns") ??
|
|
117
|
+
unmeasurable(rule, rule.maxActiveRuns, "maxActiveRuns");
|
|
118
|
+
if (broken)
|
|
119
|
+
return broken;
|
|
120
|
+
return rule.activeRuns >= rule.maxActiveRuns
|
|
121
|
+
? 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`)
|
|
124
|
+
: null;
|
|
125
|
+
}
|
|
126
|
+
default: {
|
|
127
|
+
const _exhaustive = rule;
|
|
128
|
+
throw new Error(`Unhandled chain rule: ${JSON.stringify(_exhaustive)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const refuse = (rule, retryable, reason) => ({
|
|
133
|
+
admitted: false,
|
|
134
|
+
rule: rule.kind,
|
|
135
|
+
retryable,
|
|
136
|
+
reason,
|
|
137
|
+
});
|
|
138
|
+
/**
|
|
139
|
+
* Every number a rule carries must be a finite, non-negative count — the
|
|
140
|
+
* measurement AND the limit.
|
|
141
|
+
*
|
|
142
|
+
* The limit matters as much as the count and for the same reason: every
|
|
143
|
+
* comparison below is `<` or `>=`, both false against `NaN`, so a `maxDepth`
|
|
144
|
+
* that arrived as `NaN` (a bad config read, an `undefined` coerced by
|
|
145
|
+
* arithmetic) leaves the chain unbounded with nothing to show for it.
|
|
146
|
+
*
|
|
147
|
+
* Negatives are rejected on the same grounds rather than as a style rule. No
|
|
148
|
+
* depth, count, or elapsed span can legitimately be below zero, so a negative
|
|
149
|
+
* means the host's bookkeeping is wrong — and the result is not a *stricter*
|
|
150
|
+
* bound but no bound at all: `runsInChain: -1` against `maxRuns: 10` admits,
|
|
151
|
+
* and keeps admitting. To express "no cooldown", omit the rule rather than
|
|
152
|
+
* passing a negative one.
|
|
153
|
+
*
|
|
154
|
+
* A rule that cannot be evaluated refuses, because "we could not check" and
|
|
155
|
+
* "it is fine" are not the same answer.
|
|
156
|
+
*/
|
|
157
|
+
const unmeasurable = (rule, value, field) => Number.isFinite(value) && value >= 0
|
|
158
|
+
? null
|
|
159
|
+
: // A noun phrase, like every other reason, so it still reads correctly
|
|
160
|
+
// when a host prefixes it ("Sub-agent chain bound unreadable…"). A verb
|
|
161
|
+
// phrase here would invert the actor and read as though the child were
|
|
162
|
+
// the one refusing.
|
|
163
|
+
refuse(rule, false, `chain bound unreadable (${field} is ${value}) — a misconfiguration, ` +
|
|
164
|
+
`not a limit that clears, so do the work in this run rather than ` +
|
|
165
|
+
`retrying`);
|
|
166
|
+
function requireFinite(value, field) {
|
|
167
|
+
if (!Number.isFinite(value)) {
|
|
168
|
+
throw new RangeError(`createPollSchedule: ${field} must be a finite number, got ${value}.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A doubling backoff bounded by a total budget.
|
|
173
|
+
*
|
|
174
|
+
* Polling a child run is the alternative to suspending the parent and letting
|
|
175
|
+
* completion wake it. It keeps the parent's transcript intact but holds its
|
|
176
|
+
* worker slot for the duration — so the budget belongs well under the parent's
|
|
177
|
+
* own deadline, or the parent dies waiting instead of reporting what it learned.
|
|
178
|
+
*/
|
|
179
|
+
export function createPollSchedule(options) {
|
|
180
|
+
// Rejected at construction, not clamped. `Math.min`/`Math.max` propagate
|
|
181
|
+
// `NaN`, so one non-finite option reaches the caller as `sleep(NaN)` — which
|
|
182
|
+
// fires immediately and turns the poll into a busy loop. A non-finite
|
|
183
|
+
// `budgetMs` is worse still: `NaN <= 0` is false, so it also never expires.
|
|
184
|
+
//
|
|
185
|
+
// Unlike a bad measurement in `admitChildRun`, which has a safe answer
|
|
186
|
+
// (refuse), a bad *option* here has none — there is no way to guess the
|
|
187
|
+
// budget that was meant. Substituting a default would hide the caller's bug
|
|
188
|
+
// behind a poll nobody configured, so this throws at the call that made the
|
|
189
|
+
// mistake rather than inside the loop that inherits it.
|
|
190
|
+
requireFinite(options.initialDelayMs, "initialDelayMs");
|
|
191
|
+
requireFinite(options.maxDelayMs, "maxDelayMs");
|
|
192
|
+
requireFinite(options.budgetMs, "budgetMs");
|
|
193
|
+
if (options.minDelayMs !== undefined) {
|
|
194
|
+
requireFinite(options.minDelayMs, "minDelayMs");
|
|
195
|
+
}
|
|
196
|
+
const minDelayMs = Math.max(1, options.minDelayMs ?? 50);
|
|
197
|
+
const maxDelayMs = Math.max(minDelayMs, options.maxDelayMs);
|
|
198
|
+
// Snapshot, not read live: the guards above run once, so a host that mutates
|
|
199
|
+
// its options object afterwards would otherwise walk a `NaN` budget straight
|
|
200
|
+
// past them on the next call.
|
|
201
|
+
const budgetMs = options.budgetMs;
|
|
202
|
+
let delayMs = Math.min(Math.max(options.initialDelayMs, minDelayMs), maxDelayMs);
|
|
203
|
+
return {
|
|
204
|
+
next(elapsedMs) {
|
|
205
|
+
// The options are validated at construction; this is the one number that
|
|
206
|
+
// arrives per call, and it fails the same way — `NaN <= 0` is false, so a
|
|
207
|
+
// bad elapsed never expires, and it poisons the delay into `sleep(NaN)`.
|
|
208
|
+
// A host computing `Date.now() - new Date(row.started_at).getTime()`
|
|
209
|
+
// against an unparsed timestamp string produces exactly that.
|
|
210
|
+
//
|
|
211
|
+
// Expires rather than throws: unlike a bad option there IS a safe reading
|
|
212
|
+
// here — we cannot tell how long this has been running, so stop waiting.
|
|
213
|
+
if (!Number.isFinite(elapsedMs)) {
|
|
214
|
+
return { kind: "expired", waitedMs: budgetMs };
|
|
215
|
+
}
|
|
216
|
+
const remainingMs = budgetMs - elapsedMs;
|
|
217
|
+
if (remainingMs <= 0)
|
|
218
|
+
return { kind: "expired", waitedMs: elapsedMs };
|
|
219
|
+
// Clamped to what is left, so the caller cannot sleep past its own
|
|
220
|
+
// budget and report a timeout later than the one it promised.
|
|
221
|
+
const step = Math.min(delayMs, remainingMs);
|
|
222
|
+
delayMs = Math.min(delayMs * 2, maxDelayMs);
|
|
223
|
+
return { kind: "wait", delayMs: step };
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
package/run/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, type RunDeadline, type CoalescedHeartbeat, } from "./harness.js";
|
|
2
2
|
export { runToolCallsPooledByTool } from "./tool-batch.js";
|
|
3
|
+
export { rootChain, descendChain, admitChildRun, createPollSchedule, type ChainRef, type ChainRule, type ChildAdmission, type PollStep, type PollSchedule, type PollScheduleOptions, } from "./children.js";
|
package/run/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, } from "./harness.js";
|
|
2
2
|
export { runToolCallsPooledByTool } from "./tool-batch.js";
|
|
3
|
+
export { rootChain, descendChain, admitChildRun, createPollSchedule, } from "./children.js";
|