bareloop 0.5.1 → 0.7.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/CHANGELOG.md +1129 -1
- package/README.md +40 -13
- package/bareloop.context.md +799 -245
- package/package.json +3 -3
- package/src/bridges.js +571 -0
- package/src/clock.js +233 -0
- package/src/exits.js +8 -2
- package/src/index.js +52 -17
- package/src/job.js +231 -108
- package/src/ladder.js +196 -0
- package/src/ledger.js +55 -3
- package/src/plan.js +334 -20
- package/src/planrun.js +1519 -134
- package/src/ralph.js +373 -34
- package/src/reuse.js +1602 -0
- package/src/root.js +32 -12
- package/src/run.js +129 -391
- package/src/selection.js +160 -0
- package/src/spine.js +7 -2
- package/src/stall.js +252 -0
- package/src/tools.js +611 -0
- package/src/trend.js +405 -0
- package/src/validate.js +35 -250
- package/types/bridges.d.ts +198 -0
- package/types/clock.d.ts +131 -0
- package/types/index.d.ts +7 -6
- package/types/job.d.ts +83 -8
- package/types/ladder.d.ts +86 -0
- package/types/ledger.d.ts +3 -1
- package/types/plan.d.ts +86 -7
- package/types/planrun.d.ts +142 -5
- package/types/ralph.d.ts +143 -7
- package/types/reuse.d.ts +388 -0
- package/types/root.d.ts +10 -2
- package/types/run.d.ts +56 -17
- package/types/selection.d.ts +46 -0
- package/types/spine.d.ts +8 -1
- package/types/stall.d.ts +80 -0
- package/types/tools.d.ts +262 -0
- package/types/trend.d.ts +108 -0
- package/types/validate.d.ts +21 -38
- package/src/extract.js +0 -103
- package/src/interpret.js +0 -887
- package/types/extract.d.ts +0 -25
- package/types/interpret.d.ts +0 -201
package/src/selection.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Layer 3 — D3's DISPLAY half: the workflow LISTING, and the prompt that asks for a
|
|
2
|
+
// pick (design record docs/plans/2026-08-01-layer-3-reuse-design.md, D3).
|
|
3
|
+
//
|
|
4
|
+
// hamr's ruling, verbatim: *"i think there should be a listing for workflows names that
|
|
5
|
+
// user can choose or auto-choose, or do we want llm to always decide (we still need to
|
|
6
|
+
// keep a way for user to inspect and view existing workflows … but llm can override and
|
|
7
|
+
// say none matches the ask and that agent has to create a new one)"*.
|
|
8
|
+
//
|
|
9
|
+
// **Both functions are pure text.** Nothing here reads a file, calls a model, or decides
|
|
10
|
+
// a run: the selection CALL, the operator's pin/shortlist/force-cold flow, and the parse
|
|
11
|
+
// of the answer are the runner's (modules 4/5). Choosing is not authoring — the user
|
|
12
|
+
// picks among workflows the agent authored and greens minted, and never writes a step, a
|
|
13
|
+
// check or a plan (PRD v1.28, unbroken).
|
|
14
|
+
//
|
|
15
|
+
// Two display rules are load-bearing rather than cosmetic:
|
|
16
|
+
//
|
|
17
|
+
// - **F6 — unpriced is never free.** An unknown cost or duration renders as UNKNOWN, and
|
|
18
|
+
// a band that skipped unknowns says how many it skipped. A `$0.00` where nothing was
|
|
19
|
+
// priced is a floor that reads as exact, which is the dishonesty F6 exists to stop; the
|
|
20
|
+
// same discipline extends to time.
|
|
21
|
+
// - **A listing shorter than its directory must say so.** `loadRegistry` skips-and-reports
|
|
22
|
+
// a malformed entry; a listing that quietly shows the survivors would leave the picker
|
|
23
|
+
// choosing from a set nobody told it was incomplete — the blind-instrument class.
|
|
24
|
+
//
|
|
25
|
+
// There is deliberately NO score and no ranking beyond name order (D6): n=1 is an anecdote
|
|
26
|
+
// in either direction, a percentage over 2–3 runs is fake precision, and if a score ever
|
|
27
|
+
// earns its way in its threshold is hamr's, from a measured base rate.
|
|
28
|
+
|
|
29
|
+
import { listingRow } from './bridges.js';
|
|
30
|
+
|
|
31
|
+
/** minutes, one decimal — the granularity a human picks a workflow at (the unit is
|
|
32
|
+
* written once, by the band) */
|
|
33
|
+
const minutes = (/** @type {number} */ ms) => (ms / 60_000).toFixed(1);
|
|
34
|
+
const usd = (/** @type {number} */ n) => `$${n.toFixed(2)}`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One aggregate, rendered honestly (F6). `min`/`max` are null when NOTHING in the set was
|
|
38
|
+
* known — the answer is UNKNOWN, never a zero — and a partial set carries the count it
|
|
39
|
+
* could not read.
|
|
40
|
+
* @param {number|null} min @param {number|null} max
|
|
41
|
+
* @param {(n: number) => string} fmt
|
|
42
|
+
* @param {number} unknown how many of the greens carried no figure
|
|
43
|
+
* @param {number} total how many greens there are
|
|
44
|
+
* @param {string} word the honest name for a missing figure ('unpriced' | 'untimed')
|
|
45
|
+
* @param {string} [suffix] the unit, written ONCE after the range ("8.9–31.6 min", never
|
|
46
|
+
* "8.9 min–31.6 min")
|
|
47
|
+
*/
|
|
48
|
+
function band(min, max, fmt, unknown, total, word, suffix = '') {
|
|
49
|
+
if (min === null || max === null) return `UNKNOWN (${total || 'no'} green${total === 1 ? '' : 's'}, none ${word === 'unpriced' ? 'priced' : 'timed'})`;
|
|
50
|
+
const core = `${min === max ? fmt(min) : `${fmt(min)}–${fmt(max)}`}${suffix}`;
|
|
51
|
+
return unknown > 0 ? `${core} (${unknown} of ${total} ${word})` : core;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* D3's listing: what the operator inspects and what the selecting model reads — the SAME
|
|
56
|
+
* text, so the two can never be choosing from different sets.
|
|
57
|
+
*
|
|
58
|
+
* Per bridge, one compact block: name, the job sentence it greened, status, greens/reds
|
|
59
|
+
* with the last outcome, and the cost/time band of its greens.
|
|
60
|
+
*
|
|
61
|
+
* Total and never throws: a listing is a display surface, and one malformed entry must
|
|
62
|
+
* not take the whole listing down (`listingRow` holds the same contract). Deterministic
|
|
63
|
+
* by name, whatever order the entries arrived in — a set that reshuffles between calls
|
|
64
|
+
* would make one picker's answer unreadable against another's.
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} registry `loadRegistry`'s result (`{ok, reds, bridges}`) or a bare
|
|
67
|
+
* array of entries. The RESULT form is preferred: it carries the skip reds, and the
|
|
68
|
+
* skips are the half a bare array cannot tell you about.
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function renderListing(registry) {
|
|
72
|
+
const r = /** @type {any} */ (registry);
|
|
73
|
+
const bridges = (Array.isArray(r) ? r : (Array.isArray(r?.bridges) ? r.bridges : [])).filter((b) => b && typeof b === 'object');
|
|
74
|
+
const reds = Array.isArray(r?.reds) ? r.reds : [];
|
|
75
|
+
const rows = bridges.map(listingRow).sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
76
|
+
|
|
77
|
+
const skipped = reds.length
|
|
78
|
+
? [`\n\nNOTE: ${reds.length} registry entr${reds.length === 1 ? 'y' : 'ies'} could not be read and ${reds.length === 1 ? 'is' : 'are'} NOT listed — this listing is smaller than the directory:`,
|
|
79
|
+
...reds.map((/** @type {any} */ x) => ` ${x.path ?? '(unknown file)'}${x.detail ? ` — ${x.detail}` : ''}`)].join('\n')
|
|
80
|
+
: '';
|
|
81
|
+
|
|
82
|
+
if (!rows.length) {
|
|
83
|
+
return `WORKFLOWS ON RECORD — none.\nThere are no workflows to reuse; a plan has to be drafted from scratch.${skipped}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const blocks = rows.map((row) => {
|
|
87
|
+
const g = row.greenCost;
|
|
88
|
+
return [
|
|
89
|
+
`[${row.name ?? '(unnamed)'}] ${(row.status ?? 'no-green').toUpperCase()}`,
|
|
90
|
+
` goal ${row.goal ?? '(no goal recorded)'}`,
|
|
91
|
+
` runs ${row.greens} green · ${row.reds} red${row.lastOutcome ? ` (last: ${row.lastOutcome})` : ''}`,
|
|
92
|
+
` green cost ${band(g.minUsd, g.maxUsd, usd, g.unpricedCount, row.greens, 'unpriced')}`
|
|
93
|
+
+ ` · time ${band(g.minWallMs, g.maxWallMs, minutes, g.untimedCount, row.greens, 'untimed', ' min')}`,
|
|
94
|
+
].join('\n');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return `WORKFLOWS ON RECORD — ${rows.length}\nCANDIDATE = greened once · PROVEN = greened on two distinct repositories. There is deliberately no success score.\n\n${blocks.join('\n\n')}${skipped}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The SELECT-WORKFLOW prompt: the text a later module sends to ask which workflow (if
|
|
102
|
+
* any) the run should start from. Pure — it sends nothing and parses nothing.
|
|
103
|
+
*
|
|
104
|
+
* D3's two standing rules are IN the prompt rather than enforced after the fact:
|
|
105
|
+
* - *"none matches"* is a first-class answer, and it means the agent drafts new. A picker
|
|
106
|
+
* with no way to decline would return the closest thing on the shelf every time, which
|
|
107
|
+
* is how a registry becomes a lookup table.
|
|
108
|
+
* - a PINNED workflow may be refused **only explicitly, with a reason** — never silently
|
|
109
|
+
* ignored or quietly substituted. The operator's pick is the operator's; overriding it
|
|
110
|
+
* invisibly is the one thing a picker must not be able to do.
|
|
111
|
+
*
|
|
112
|
+
* The output contract is stated so the answer is machine-readable, but the PARSE belongs
|
|
113
|
+
* to the runner that sends this (modules 4/5) — and the runner's parser and this shape are
|
|
114
|
+
* one contract in two places, which is why the shape is spelled out here rather than left
|
|
115
|
+
* to the model.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} listing `renderListing`'s text — verbatim, so the picker sees exactly
|
|
118
|
+
* what the human inspecting the registry sees
|
|
119
|
+
* @param {string} ask the job's goal sentence, in the operator's own words
|
|
120
|
+
* @param {string|null} [pinned] the operator's PIN, stated IN the call. D3 rules that a
|
|
121
|
+
* pin is never bypassed and never silently overridden — so the pin does not skip the
|
|
122
|
+
* call, it enters it, and the model's only legal ways out are confirming it or
|
|
123
|
+
* refusing it by name with a reason. Stated here rather than assembled by the runner:
|
|
124
|
+
* a second spelling of the pin sentence is a second prompt, and the two would drift.
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function selectionPrompt(listing, ask, pinned = null) {
|
|
128
|
+
const pin = pinned
|
|
129
|
+
? `\nThe operator has PINNED the workflow "${pinned}". Start from it unless it is genuinely the
|
|
130
|
+
wrong kind of recipe for this ask. If you cannot use it, you must say so by picking a
|
|
131
|
+
DIFFERENT name (or null) and giving the reason — the operator's pick is then handed back
|
|
132
|
+
to the operator, never quietly replaced by yours.\n`
|
|
133
|
+
: '';
|
|
134
|
+
return `SELECT-WORKFLOW
|
|
135
|
+
A previous run's workflow can be handed to the planner as a STARTING DRAFT for a new job —
|
|
136
|
+
it saves the planner starting from a blank page. Your job is to decide whether any workflow
|
|
137
|
+
on record is close enough to be worth starting from, or whether this ask needs a new plan.
|
|
138
|
+
|
|
139
|
+
The ask:
|
|
140
|
+
${ask}
|
|
141
|
+
|
|
142
|
+
${listing}
|
|
143
|
+
${pin}
|
|
144
|
+
Rules:
|
|
145
|
+
- Pick AT MOST ONE workflow, by its exact name, copied character for character from the listing.
|
|
146
|
+
- A workflow is worth reusing when it did the same KIND of work — the same sort of change,
|
|
147
|
+
judged the same sort of way. It does NOT have to name the same files: it ran on a different
|
|
148
|
+
repository, so its paths, scopes and targets are expected to be wrong here and the planner
|
|
149
|
+
will replace them.
|
|
150
|
+
- If none of them matches this ask, say so plainly and pick nothing.
|
|
151
|
+
The agent then drafts a new plan from scratch. "None matches" is a real answer, and a bad
|
|
152
|
+
starting draft costs more than a blank page.
|
|
153
|
+
- If the operator PINNED a workflow, you may refuse it — but only EXPLICITLY, and with a
|
|
154
|
+
reason. Never silently ignore the pin or quietly substitute another one.
|
|
155
|
+
- You are choosing, not authoring. You cannot edit, merge or invent a workflow here; the
|
|
156
|
+
planner does the tweaking afterwards, and the job's own verification is the only truth.
|
|
157
|
+
|
|
158
|
+
Output ONLY this JSON object, no fences and no commentary:
|
|
159
|
+
{"choice": "<exact name from the listing>" or null, "reason": "<one sentence>"}`;
|
|
160
|
+
}
|
package/src/spine.js
CHANGED
|
@@ -11,10 +11,15 @@ import { appendFileSync } from 'node:fs';
|
|
|
11
11
|
/**
|
|
12
12
|
* Create an emitter bound to one JSONL file.
|
|
13
13
|
* @param {string} file absolute path; created on first emit
|
|
14
|
+
* @param {{startSeq?: number}} [opts] `startSeq`: the last `seq` already IN the file, for a
|
|
15
|
+
* process that CONTINUES an existing spine (a resumed run appends to the dead run's own
|
|
16
|
+
* log — module C). `seq` is monotonic per SPINE, so a second series restarting at 1
|
|
17
|
+
* would give one append-only log two rows with the same number and silently break every
|
|
18
|
+
* reader that orders by it. Default 0: a fresh file, which is every other caller.
|
|
14
19
|
* @returns {(type: string, data?: object) => object} emit — returns the event as written
|
|
15
20
|
*/
|
|
16
|
-
export function makeSpine(file) {
|
|
17
|
-
let seq = 0;
|
|
21
|
+
export function makeSpine(file, { startSeq = 0 } = {}) {
|
|
22
|
+
let seq = typeof startSeq === 'number' && Number.isFinite(startSeq) && startSeq > 0 ? Math.floor(startSeq) : 0;
|
|
18
23
|
return function emit(type, data = {}) {
|
|
19
24
|
// type/seq/ts are the envelope's, by mechanism not comment: callers spread
|
|
20
25
|
// foreign objects into events, and a payload that grows one of these keys
|
package/src/stall.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// The stall watchdog (F66) — the only instrument that fires on the ABSENCE of ROUNDS.
|
|
2
|
+
//
|
|
3
|
+
// MEASURED reason this exists. bare-agent's `timeoutMs` is an IDLE-SOCKET timeout:
|
|
4
|
+
// `applyRequestTimeout` is one `req.setTimeout(...)` in provider-http.js, and Node
|
|
5
|
+
// resets that timer on every byte. Its own docstring is explicit — "the timer
|
|
6
|
+
// resets on activity, so a slow-but-streaming response is not killed". On U run
|
|
7
|
+
// ms3197n8 the connection stayed alive for 274 minutes with ZERO rounds emitted,
|
|
8
|
+
// then died `read ECONNRESET`. bare-agent's timer never fired; the run spent
|
|
9
|
+
// 287.3min against a 45min wall and $3.23 for nothing. The bound was correctly
|
|
10
|
+
// derived and correctly forwarded (loop.js:732 passes options per provider call) —
|
|
11
|
+
// it just watches bytes, and bytes kept flowing while the run made no progress.
|
|
12
|
+
//
|
|
13
|
+
// So the heartbeat here is A COMPLETED ROUND, not a byte. That swap IS the fix,
|
|
14
|
+
// and it is the one signal bare-agent cannot see from inside the HTTP request.
|
|
15
|
+
//
|
|
16
|
+
// SELF-HEAL, not a stop (hamr's ruling): "the goal is always self heal and killing
|
|
17
|
+
// and coming back is not an option — what would we tell the user, it timed out, do
|
|
18
|
+
// something?!" A stall abandons the call and REISSUES it; the operator never hears
|
|
19
|
+
// about it. Only after `maxStalls` does the watch give up, and even then it does
|
|
20
|
+
// not end the run — it throws `step-stalled`, which the step loop reads as a
|
|
21
|
+
// replan trigger. The run adapts; it does not surrender.
|
|
22
|
+
//
|
|
23
|
+
// What still bounds this, so self-heal cannot run away: the wallet is unchanged (a
|
|
24
|
+
// reissue spends from the same signed budget), the wall clock still ends the run,
|
|
25
|
+
// and the step's round bound is untouched. The one honest cost is that an
|
|
26
|
+
// abandoned call MAY already have been billed, so a reissue can pay twice — that
|
|
27
|
+
// is capped by the wallet, and unknown spend is reported as unknown, never zero
|
|
28
|
+
// (F6/F44). It is also exactly why `Retry` was left unwired; self-heal is the
|
|
29
|
+
// ruling that overrides it.
|
|
30
|
+
//
|
|
31
|
+
// L4 — "the wall clock still ends the run" was the half-true half of that. The
|
|
32
|
+
// clock is consulted where a ROUND completes (planrun's `metered`) and after a step
|
|
33
|
+
// returns; during a stall no round completes and no step returns, so nothing read it
|
|
34
|
+
// between the wall passing and the next reissue. A worker stalling at its wall could
|
|
35
|
+
// therefore be reissued for up to maxStalls × stallMs of calls the run had already
|
|
36
|
+
// declined to authorize. So the watch takes the wall as a callback and consults it
|
|
37
|
+
// BEFORE reissuing: past the deadline it does not self-heal, it gives up. That is
|
|
38
|
+
// not a retreat from hamr's ruling — self-heal is what a run does with time left,
|
|
39
|
+
// and there is none.
|
|
40
|
+
//
|
|
41
|
+
// Timers are injected so the whole mechanism is deterministically testable — a
|
|
42
|
+
// watchdog validated with real sleeps is a flake generator, and one that is never
|
|
43
|
+
// watched failing is not validated at all.
|
|
44
|
+
|
|
45
|
+
/** hamr's number: no completed round for five minutes is a stall. */
|
|
46
|
+
export const STALL_MS = 300_000;
|
|
47
|
+
/** hamr's number: three stalls, then stop reissuing and replan. The count is
|
|
48
|
+
* CUMULATIVE over the watch's whole life and `watch()` never resets it — planrun
|
|
49
|
+
* builds one watch per WORKER, so the ceiling is spent across the STEP, not per
|
|
50
|
+
* call: two stalls on one call and one on the next give up just the same. That
|
|
51
|
+
* is deliberate (a step that keeps hanging is a step to replan, not a call to
|
|
52
|
+
* retry forever), and it is why nothing here says "on one call". */
|
|
53
|
+
export const MAX_STALLS = 3;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A stall gave up. Carries the category the step loop's replan trigger reads and
|
|
57
|
+
* the `lib` attribution stamped AT THE THROW SITE (never sniffed from prose).
|
|
58
|
+
*/
|
|
59
|
+
export class StallError extends Error {
|
|
60
|
+
/** @param {string} message @param {number} stalls */
|
|
61
|
+
constructor(message, stalls) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = 'StallError';
|
|
64
|
+
/** @type {string} */
|
|
65
|
+
this.category = 'step-stalled';
|
|
66
|
+
/** @type {string} */
|
|
67
|
+
this.lib = 'bare-agent';
|
|
68
|
+
/** @type {number} */
|
|
69
|
+
this.stalls = stalls;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {Object} StallWatch
|
|
75
|
+
* @property {<T>(call: (gen: number) => Promise<T>) => Promise<T>} watch run `call`,
|
|
76
|
+
* reissuing it whenever it goes `stallMs` without a beat; rejects `StallError` past
|
|
77
|
+
* `maxStalls`. Each issue is handed its GENERATION — the token every callback that
|
|
78
|
+
* call installs must carry back, so a corpse's callbacks cannot touch the live call.
|
|
79
|
+
* @property {(gen: number) => void} beat a round completed on generation `gen` — reset
|
|
80
|
+
* the window. From any other generation it is inert.
|
|
81
|
+
* @property {(gen: number) => boolean} isCurrent is `gen` still the call this watch is
|
|
82
|
+
* waiting on? The caller reads it to withhold everything else a dead call would do.
|
|
83
|
+
* @property {() => number} stalls how many stalls this watch has absorbed
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Start a stall watchdog.
|
|
88
|
+
* @param {{ stallMs?: number, maxStalls?: number, onStall?: (n: number) => void,
|
|
89
|
+
* expired?: () => boolean,
|
|
90
|
+
* setTimer?: (fn: () => void, ms: number) => any,
|
|
91
|
+
* clearTimer?: (id: any) => void }} [opts]
|
|
92
|
+
* `onStall` is announced for EVERY stall including the last — a trim or a
|
|
93
|
+
* recovery that happens silently is indistinguishable from one that never
|
|
94
|
+
* happened (F28's rule, applied to time).
|
|
95
|
+
* `expired` is the run's WALL, read at the one moment this module decides to
|
|
96
|
+
* spend again (L4). Omitted, the watch behaves exactly as it did before the wall
|
|
97
|
+
* existed — a caller with no clock reissues on the same rules.
|
|
98
|
+
* @returns {StallWatch}
|
|
99
|
+
*/
|
|
100
|
+
export function createStallWatch({
|
|
101
|
+
stallMs = STALL_MS,
|
|
102
|
+
maxStalls = MAX_STALLS,
|
|
103
|
+
onStall = () => {},
|
|
104
|
+
expired = () => false,
|
|
105
|
+
setTimer = setTimeout,
|
|
106
|
+
clearTimer = clearTimeout,
|
|
107
|
+
} = {}) {
|
|
108
|
+
let stalls = 0;
|
|
109
|
+
/** @type {any} */
|
|
110
|
+
let timerId = null;
|
|
111
|
+
/** the generation of the call currently being watched: NOTHING from an ABANDONED
|
|
112
|
+
* call may touch the one that replaced it — not its trip, not its late settlement,
|
|
113
|
+
* and not its beats. The corpse keeps streaming (ms3197n8's socket lived 274min
|
|
114
|
+
* after we would have moved on), so every callback it installed keeps firing; the
|
|
115
|
+
* generation is the token that tells those callbacks apart from the live call's.
|
|
116
|
+
* It is handed to each issued call and carried back through `beat`/`isCurrent`. */
|
|
117
|
+
let generation = 0;
|
|
118
|
+
let settled = true;
|
|
119
|
+
/** @type {((v: any) => void) | null} */
|
|
120
|
+
let resolve = null;
|
|
121
|
+
/** @type {((e: any) => void) | null} */
|
|
122
|
+
let reject = null;
|
|
123
|
+
/** @type {((gen: number) => Promise<any>) | null} */
|
|
124
|
+
let theCall = null;
|
|
125
|
+
|
|
126
|
+
const disarm = () => {
|
|
127
|
+
if (timerId !== null) { clearTimer(timerId); timerId = null; }
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** @param {number} gen */
|
|
131
|
+
function arm(gen) {
|
|
132
|
+
disarm();
|
|
133
|
+
timerId = setTimer(() => {
|
|
134
|
+
timerId = null;
|
|
135
|
+
if (settled || gen !== generation) return; // a trip from a superseded call
|
|
136
|
+
trip();
|
|
137
|
+
}, stallMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function trip() {
|
|
141
|
+
stalls += 1;
|
|
142
|
+
onStall(stalls);
|
|
143
|
+
generation += 1; // everything from the abandoned call is now inert
|
|
144
|
+
// L4 — the WALL, read before the decision to spend again, and read FIRST. A
|
|
145
|
+
// reissue past the deadline is money and time the run has already declined to
|
|
146
|
+
// authorize, and it is the only spend on this path nothing else can catch: no
|
|
147
|
+
// round completes during a stall, so `metered`'s clock check never fires, and no
|
|
148
|
+
// step returns, so the step loop's never does either.
|
|
149
|
+
//
|
|
150
|
+
// It outranks the `maxStalls` ceiling deliberately, and the category is the
|
|
151
|
+
// reason. `step-stalled` is a REPLAN TRIGGER (planrun's third trigger): it says
|
|
152
|
+
// "the model stopped answering, re-allocate what is left" and buys a fresh
|
|
153
|
+
// drafting round. On an expired wall there is nothing left to re-allocate, so
|
|
154
|
+
// that route would spend a draft on a run that is over and hand the operator
|
|
155
|
+
// "retry the run / check provider status" for a stop whose only remedy is
|
|
156
|
+
// `maxWallMs`. `wall-halt` is the truthful reading — the wall bound the run, not
|
|
157
|
+
// the model — and it is the SAME ruling clock.js already made for the F64
|
|
158
|
+
// ambiguity: a call that dies on a run whose wall has also passed reads as a
|
|
159
|
+
// wall-halt, because the run is out of time either way and the remedy is
|
|
160
|
+
// identical. No new category, no new routing: every consumer (ralph's
|
|
161
|
+
// DECISIONS, the step loop's terminal, `relay`, the ledger's exclusions) already
|
|
162
|
+
// handles `wall-halt`, and none of them replans on it.
|
|
163
|
+
if (expired()) {
|
|
164
|
+
settled = true;
|
|
165
|
+
disarm();
|
|
166
|
+
// Not a StallError: that class IS the `step-stalled` category, and a stall
|
|
167
|
+
// count is what happened on the way here, not what stopped it. No `lib`
|
|
168
|
+
// either — the wall is bareloop's own governance instrument, and stamping an
|
|
169
|
+
// upstream package would stand a fake bug of ours where real evidence goes.
|
|
170
|
+
const err = /** @type {any} */ (new Error(
|
|
171
|
+
`the run's wall-clock cap passed while this call was stalled (${stalls} stall${stalls === 1 ? '' : 's'}) — not reissuing`,
|
|
172
|
+
));
|
|
173
|
+
err.category = 'wall-halt';
|
|
174
|
+
err.stalls = stalls;
|
|
175
|
+
reject?.(err);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (stalls >= maxStalls) {
|
|
179
|
+
settled = true;
|
|
180
|
+
disarm();
|
|
181
|
+
reject?.(new StallError(
|
|
182
|
+
`no round completed for ${Math.round(stallMs / 1000)}s, ${stalls} times in this step — not reissuing again`,
|
|
183
|
+
stalls,
|
|
184
|
+
));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
issue(); // self-heal: abandon the hung call, start a fresh one
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function issue() {
|
|
191
|
+
const gen = generation;
|
|
192
|
+
arm(gen);
|
|
193
|
+
let p;
|
|
194
|
+
try {
|
|
195
|
+
// the call is TOLD its generation: the callbacks it wires (metering, the round
|
|
196
|
+
// bound, the wall cut) outlive the call itself, and this is the only token that
|
|
197
|
+
// lets them ask "am I still the live one?".
|
|
198
|
+
p = /** @type {(g: number) => Promise<any>} */ (theCall)(gen);
|
|
199
|
+
} catch (e) { // a synchronous throw is a real failure, not a stall
|
|
200
|
+
settled = true;
|
|
201
|
+
disarm();
|
|
202
|
+
reject?.(e);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
p.then(
|
|
206
|
+
(v) => {
|
|
207
|
+
if (settled || gen !== generation) return; // an abandoned call finally answered
|
|
208
|
+
settled = true;
|
|
209
|
+
disarm();
|
|
210
|
+
resolve?.(v);
|
|
211
|
+
},
|
|
212
|
+
(e) => {
|
|
213
|
+
// The abandoned socket dies LATER — ms3197n8's did, at +274min. Nobody is
|
|
214
|
+
// listening any more, and an unhandled rejection here would take the whole
|
|
215
|
+
// run down. Swallowing it is the point, not an oversight.
|
|
216
|
+
if (settled || gen !== generation) return;
|
|
217
|
+
settled = true;
|
|
218
|
+
disarm();
|
|
219
|
+
reject?.(e);
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @type {<T>(call: (gen: number) => Promise<T>) => Promise<T>} */
|
|
225
|
+
function watch(call) {
|
|
226
|
+
theCall = call;
|
|
227
|
+
settled = false;
|
|
228
|
+
generation += 1;
|
|
229
|
+
return new Promise((res, rej) => {
|
|
230
|
+
resolve = res;
|
|
231
|
+
reject = rej;
|
|
232
|
+
issue();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
stalls: () => stalls,
|
|
238
|
+
// A beat is SCOPED TO ITS CALL. Rounds do arrive from a call we abandoned, and
|
|
239
|
+
// re-arming on one is not a harmless late timer: it re-arms the watch that is
|
|
240
|
+
// timing the REPLACEMENT, so the corpse feeds the watchdog and the live call can
|
|
241
|
+
// hang for the rest of the run without ever tripping. A beat after the watch has
|
|
242
|
+
// settled is inert for the same reason.
|
|
243
|
+
/** @param {number} gen the generation `watch` handed the call that produced this round */
|
|
244
|
+
beat(gen) {
|
|
245
|
+
if (settled || gen !== generation) return;
|
|
246
|
+
arm(generation);
|
|
247
|
+
},
|
|
248
|
+
/** @param {number} gen @returns {boolean} */
|
|
249
|
+
isCurrent: (gen) => !settled && gen === generation,
|
|
250
|
+
watch,
|
|
251
|
+
};
|
|
252
|
+
}
|