bareloop 0.22.0 → 0.23.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 +125 -0
- package/README.md +1 -1
- package/bareloop.context.md +60 -13
- package/package.json +2 -2
- package/src/authorflow.js +7 -2
- package/src/authorscout.js +83 -9
- package/src/calibrate.js +10 -0
- package/src/cli.js +40 -19
- package/src/declaredclose.js +9 -2
- package/src/job.js +37 -2
- package/src/judged.js +108 -5
- package/src/kinds.js +12 -0
- package/src/planrun.js +180 -39
- package/src/providers.js +176 -0
- package/src/ratelimit.js +116 -0
- package/src/reuse.js +9 -1
- package/src/run.js +59 -5
- package/src/transport.js +29 -12
- package/types/authorscout.d.ts +27 -2
- package/types/declaredclose.d.ts +6 -1
- package/types/job.d.ts +4 -0
- package/types/judged.d.ts +7 -2
- package/types/kinds.d.ts +9 -0
- package/types/providers.d.ts +104 -0
- package/types/ratelimit.d.ts +59 -0
- package/types/run.d.ts +8 -0
- package/types/transport.d.ts +10 -4
package/src/providers.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Provider factory (PRD item 28, shape (1)): ONE seam mapping a job spec's
|
|
2
|
+
// `provider` name to the bare-agent constructor it drives, the env var
|
|
3
|
+
// carrying its key, and the per-tier model table — including any
|
|
4
|
+
// provider-specific request-key gating. Before this module existed, both
|
|
5
|
+
// runners constructed `AnthropicProvider` BY NAME (`src/cli.js`'s
|
|
6
|
+
// `buildProviders`, `scripts/run-u.mjs`'s top-level wiring) and any second
|
|
7
|
+
// provider would have meant scattering `if (provider === …)` across both.
|
|
8
|
+
//
|
|
9
|
+
// hamr's ruling (PRD 30.7, 2026-09-09): `deepseek-chat` is THE secondary
|
|
10
|
+
// provider — "settle on one secondary/replacement for claude … don't
|
|
11
|
+
// pickpocket random models". This table is not a menu of half-tested
|
|
12
|
+
// models; it is exactly the two provider identities the PROVIDERS menu
|
|
13
|
+
// (`src/job.js`) admits.
|
|
14
|
+
//
|
|
15
|
+
// What this module is NOT: it never picks a judge (the judge stays pinned
|
|
16
|
+
// to `anthropic-api`/`JUDGE_MODEL` regardless of the job's worker provider —
|
|
17
|
+
// arbiter territory, PRD item 28), never prices a round (F113: rates
|
|
18
|
+
// passthrough is dead), and never decides a budget. It only constructs the
|
|
19
|
+
// provider objects `bare-agent`'s `Loop` calls.
|
|
20
|
+
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const { AnthropicProvider, OpenAIProvider } = require('bare-agent/providers');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
|
|
28
|
+
* runners hardcoded before this factory existed. Any change to these two
|
|
29
|
+
* values is a change to shipped anthropic-api behaviour, not a refactor.
|
|
30
|
+
*/
|
|
31
|
+
export const ANTHROPIC_TIER_MODELS = Object.freeze({
|
|
32
|
+
sonnet: 'claude-sonnet-5',
|
|
33
|
+
haiku: 'claude-haiku-4-5-20251001',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* DeepSeek (F149/F150) is a SINGLE model, not a tiered family — hamr's
|
|
38
|
+
* ruling above: one secondary provider, not a menu. Both tiers map to the
|
|
39
|
+
* same model id ON PURPOSE: `resolveWorkerModel`/a runner's `providerFor
|
|
40
|
+
* (tier)` only ever LOOK UP a tier's model id (src/job.js:818-827,
|
|
41
|
+
* scripts/run-u.mjs's `providerFor`) — neither assumes the tiers name
|
|
42
|
+
* different models — so repeating the one id is the honest representation
|
|
43
|
+
* of a single-model provider, not a second model masquerading as `haiku`.
|
|
44
|
+
* If DeepSeek (or a future openai-api entrant) ever ships a genuinely
|
|
45
|
+
* cheaper tier model, this is where it would earn its own `haiku` id.
|
|
46
|
+
*/
|
|
47
|
+
export const OPENAI_TIER_MODELS = Object.freeze({
|
|
48
|
+
sonnet: 'deepseek-chat',
|
|
49
|
+
haiku: 'deepseek-chat',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-MODEL request-key gating (F149, live-measured 2026-09-09): DeepSeek
|
|
54
|
+
* silently IGNORES the modern `max_completion_tokens` key — asked for 64
|
|
55
|
+
* output tokens, it returned 665/608 — and only honours the legacy
|
|
56
|
+
* `max_tokens` key. `bare-agent`'s `OpenAIProvider` already exposes the
|
|
57
|
+
* switch (`legacyMaxTokens`, BA-24; no model-name sniffing inside
|
|
58
|
+
* bare-agent itself — "the caller declares the dialect"), so THIS table is
|
|
59
|
+
* bareloop's routing of it, keyed per model, never a global default. An
|
|
60
|
+
* output cap that does not bind is a money hazard: a reasoning model can
|
|
61
|
+
* run to its own ceiling on every round, silently, so getting this wrong
|
|
62
|
+
* for even one model defeats every budget that model's tier sets.
|
|
63
|
+
*
|
|
64
|
+
* A model with no entry here gets `{}` — bare-agent's own default
|
|
65
|
+
* (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
|
|
66
|
+
*/
|
|
67
|
+
const OPENAI_MODEL_OPTIONS = Object.freeze({
|
|
68
|
+
'deepseek-chat': Object.freeze({ legacyMaxTokens: true }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @typedef {object} ProviderTableEntry
|
|
73
|
+
* @property {new (options: any) => any} ctor the bare-agent provider class
|
|
74
|
+
* @property {string} envKey the environment variable this provider reads its key from
|
|
75
|
+
* @property {Readonly<Record<string, string>>} tiers tier name -> model id
|
|
76
|
+
* @property {(model: string|undefined) => Record<string, any>} paramsFor per-model request-key/param gating, applied on TOP of {exposeErrorBody:true, apiKey, model, baseUrl?}
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/** @type {Readonly<Record<string, ProviderTableEntry>>} */
|
|
80
|
+
const PROVIDER_TABLE = Object.freeze({
|
|
81
|
+
'anthropic-api': Object.freeze({
|
|
82
|
+
ctor: AnthropicProvider,
|
|
83
|
+
envKey: 'ANTHROPIC_API_KEY',
|
|
84
|
+
tiers: ANTHROPIC_TIER_MODELS,
|
|
85
|
+
// Anthropic has only ever had one request key for the output cap
|
|
86
|
+
// (`max_tokens`) — nothing to gate here, ever (unlike OpenAI-shaped
|
|
87
|
+
// backends, which fork on `max_tokens` vs `max_completion_tokens`).
|
|
88
|
+
paramsFor: () => ({}),
|
|
89
|
+
}),
|
|
90
|
+
'openai-api': Object.freeze({
|
|
91
|
+
ctor: OpenAIProvider,
|
|
92
|
+
envKey: 'OPENAI_API_KEY',
|
|
93
|
+
tiers: OPENAI_TIER_MODELS,
|
|
94
|
+
paramsFor: (model) => OPENAI_MODEL_OPTIONS[/** @type {string} */ (model)] ?? {},
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Look up a provider's table entry. Throws a NAMED error on an unrecognized
|
|
100
|
+
* provider — never a silent default (the `resolveRates`-style-substring-
|
|
101
|
+
* match lesson: a forward hazard even at zero observed rounds, MEMORY.md).
|
|
102
|
+
* `src/job.js`'s `PROVIDERS` menu is the one place a spec's `provider`
|
|
103
|
+
* field is validated; this is the runtime construction seam it feeds.
|
|
104
|
+
* @param {string} providerName
|
|
105
|
+
* @returns {ProviderTableEntry}
|
|
106
|
+
*/
|
|
107
|
+
export function resolveProvider(providerName) {
|
|
108
|
+
const entry = PROVIDER_TABLE[/** @type {keyof typeof PROVIDER_TABLE} */ (providerName)];
|
|
109
|
+
if (!entry) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`src/providers.js: unknown provider "${providerName}" — no silent default. `
|
|
112
|
+
+ `Known providers: ${Object.keys(PROVIDER_TABLE).join(', ')}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Construct ONE provider instance for `providerName`/`model`, applying
|
|
120
|
+
* `exposeErrorBody: true` (F153 — every constructed provider, unconditionally)
|
|
121
|
+
* and the model's own request-key gating (`paramsFor`). `baseUrl` (PRD item
|
|
122
|
+
* 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given —
|
|
123
|
+
* every provider constructor here defaults it on its own when absent.
|
|
124
|
+
* @param {string} providerName
|
|
125
|
+
* @param {{apiKey?: string, model?: string, baseUrl?: string}} [options]
|
|
126
|
+
*/
|
|
127
|
+
export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
|
|
128
|
+
const entry = resolveProvider(providerName);
|
|
129
|
+
return new entry.ctor({
|
|
130
|
+
exposeErrorBody: true,
|
|
131
|
+
apiKey,
|
|
132
|
+
model,
|
|
133
|
+
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
134
|
+
...entry.paramsFor(model),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build the `{provider, providerFor, judgeProvider}` triple both runners
|
|
140
|
+
* need (`src/cli.js`'s `buildProviders`, `scripts/run-u.mjs`'s top-level
|
|
141
|
+
* wiring) — ONE seam instead of two copies of the same construction and
|
|
142
|
+
* tier-memoization logic. `model`/`tierModels` are already-RESOLVED inputs
|
|
143
|
+
* (the caller runs `resolveWorkerModel` itself, exactly as both runners did
|
|
144
|
+
* before this factory existed — that decision stays where each runner's own
|
|
145
|
+
* `--model` flag/spec precedence lives, not duplicated in here).
|
|
146
|
+
*
|
|
147
|
+
* The JUDGE stays pinned to `anthropic-api`/`judgeModel` regardless of
|
|
148
|
+
* `providerName` — arbiter territory (PRD item 28: "the judge stays PINNED
|
|
149
|
+
* … regardless of the job's worker provider"). A signed `judge:{provider,
|
|
150
|
+
* model}` field with its own calibration record is PRD item 28 part (2),
|
|
151
|
+
* NOT built here; until it lands, the judge always needs its own
|
|
152
|
+
* `judgeApiKey` (today, always `ANTHROPIC_API_KEY`), independent of which
|
|
153
|
+
* key the worker used.
|
|
154
|
+
* @param {object} args
|
|
155
|
+
* @param {string} args.providerName spec.provider (or the anthropic-api default)
|
|
156
|
+
* @param {string|undefined} args.apiKey the WORKER's key, read from the provider's own `envKey`
|
|
157
|
+
* @param {string} args.model the already-resolved worker model id
|
|
158
|
+
* @param {Readonly<Record<string, string>>} args.tierModels the already-resolved tier -> model map (spec override folded in by the caller)
|
|
159
|
+
* @param {string|undefined} [args.baseUrl] spec.baseUrl, if any
|
|
160
|
+
* @param {string|undefined} args.judgeApiKey the judge's own key (today: `ANTHROPIC_API_KEY`)
|
|
161
|
+
* @param {string} args.judgeModel `JUDGE_MODEL`
|
|
162
|
+
*/
|
|
163
|
+
export function buildRunnerProviders({
|
|
164
|
+
providerName, apiKey, model, tierModels, baseUrl, judgeApiKey, judgeModel,
|
|
165
|
+
}) {
|
|
166
|
+
const provider = makeProvider(providerName, { apiKey, model, baseUrl });
|
|
167
|
+
/** @type {Record<string, any>} */
|
|
168
|
+
const tierCache = {};
|
|
169
|
+
const providerFor = (/** @type {string} */ tier) => (tierCache[tier] ??= (
|
|
170
|
+
tierModels[tier] === model
|
|
171
|
+
? provider
|
|
172
|
+
: makeProvider(providerName, { apiKey, model: tierModels[tier], baseUrl })
|
|
173
|
+
));
|
|
174
|
+
const judgeProvider = makeProvider('anthropic-api', { apiKey: judgeApiKey, model: judgeModel });
|
|
175
|
+
return { provider, providerFor, judgeProvider };
|
|
176
|
+
}
|
package/src/ratelimit.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// PRD item 28's parked (a/b) ruling, hamr's verbatim call: "do a/b as a
|
|
2
|
+
// fallback, and verify/validate + no regression." A vendor HTTP 429 is a
|
|
3
|
+
// response — the provider answered, unlike src/transport.js's no-response
|
|
4
|
+
// class — but unlike every other status this repo already declines to retry
|
|
5
|
+
// (4xx/5xx generally, F115's own boundary), a 429 carries the ONE piece of
|
|
6
|
+
// information that turns "retry blindly" into "retry honestly": the vendor's
|
|
7
|
+
// own stated wait. This module is transport.js's SIBLING, not an extension
|
|
8
|
+
// of it — `isTransportFailure` must keep returning FALSE for anything
|
|
9
|
+
// carrying an HTTP status (429 included), and nothing here widens that.
|
|
10
|
+
//
|
|
11
|
+
// This is a FALLBACK, not the primary answer. The primary answer (F139,
|
|
12
|
+
// measured 2026-09-08) is picking a worker model with adequate rate
|
|
13
|
+
// headroom: gpt-4.1 = 30,000 TPM (429s deterministically on our workload,
|
|
14
|
+
// 2 of 2 runs), gpt-4.1-mini = 200,000 TPM, gpt-5-mini/gpt-5 = 500,000 TPM.
|
|
15
|
+
// One bounded retry only softens the failure mode when that primary answer
|
|
16
|
+
// was not followed (or headroom was still exceeded); it does not replace it.
|
|
17
|
+
|
|
18
|
+
/** The fixed retry budget: ONE extra attempt after a 429. Never read from a
|
|
19
|
+
* job spec or CLI flag — raising it is arbiter territory, mirroring
|
|
20
|
+
* transport.js's TRANSPORT_RETRIES exactly (tighten-only doctrine). */
|
|
21
|
+
export const RATE_LIMIT_RETRIES = 1;
|
|
22
|
+
|
|
23
|
+
/** bare-agent's `Retry.call` (if ever used) counts ATTEMPTS, not retries;
|
|
24
|
+
* this module's own hand-rolled ladder (src/planrun.js) does the same for
|
|
25
|
+
* symmetry with TRANSPORT_MAX_ATTEMPTS. */
|
|
26
|
+
export const RATE_LIMIT_MAX_ATTEMPTS = RATE_LIMIT_RETRIES + 1;
|
|
27
|
+
|
|
28
|
+
/** The hard cap on how long we will ever wait for a vendor-stated 429 delay.
|
|
29
|
+
* A vendor asking for longer than this is not honoured — we do not retry at
|
|
30
|
+
* all in that case. Waiting that long is the operator's decision, not ours
|
|
31
|
+
* (arbiter territory: nothing here escalates the wait past what hamr set). */
|
|
32
|
+
export const RATE_LIMIT_MAX_WAIT_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
/** Used only when the vendor's message carries no parseable delay at all. */
|
|
35
|
+
export const RATE_LIMIT_DEFAULT_WAIT_MS = 5_000;
|
|
36
|
+
|
|
37
|
+
/** A parsed delay is the EARLIEST moment the vendor's own window resets, not
|
|
38
|
+
* a safe one to fire on the dot — this margin is added to any PARSED value
|
|
39
|
+
* (never to the default) so the retry lands just after the window, not on
|
|
40
|
+
* its leading edge. Never allowed to push a parsed value over the cap. */
|
|
41
|
+
const PARSED_DELAY_MARGIN_MS = 250;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Is `err` an HTTP 429 (rate-limited) response? True ONLY for an explicit
|
|
45
|
+
* status of 429 on the error itself. Nothing else qualifies — not message
|
|
46
|
+
* text (a message merely containing "429" is not this), not `retryable`,
|
|
47
|
+
* not any other status. Symmetric with `isTransportFailure`'s own status
|
|
48
|
+
* check (src/transport.js), just reading the opposite branch of it.
|
|
49
|
+
* @param {any} err
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function isRateLimited(err) {
|
|
53
|
+
if (err == null) return false;
|
|
54
|
+
const status = err.status ?? err.statusCode;
|
|
55
|
+
return status === 429;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The vendor's RAW stated wait, in milliseconds, parsed straight from the
|
|
60
|
+
* error message — no default, no margin, no cap. `null` when nothing in the
|
|
61
|
+
* message matches a known shape. Exported separately from `rateLimitWaitMs`
|
|
62
|
+
* so a caller that wants to report the vendor's own number on the spine
|
|
63
|
+
* (`statedMs`) never has to re-derive it from the margin-adjusted figure.
|
|
64
|
+
*
|
|
65
|
+
* bare-agent does not expose the vendor's `retry-after` /
|
|
66
|
+
* `x-ratelimit-reset-*` response headers on the thrown error (BA-26) — the
|
|
67
|
+
* delay exists only in the vendor's English message text, so this parses
|
|
68
|
+
* that text. Verified real shape (OpenAI, run `8ev2sdkn`, 2026-09-08):
|
|
69
|
+
* "Please try again in 11.996s." This also supports "try again in 1.5
|
|
70
|
+
* seconds", "try again in 20ms", "retry after 30s", and "retry after 30
|
|
71
|
+
* seconds" — case-insensitive.
|
|
72
|
+
* @param {any} err
|
|
73
|
+
* @returns {number|null}
|
|
74
|
+
*/
|
|
75
|
+
export function statedWaitMs(err) {
|
|
76
|
+
const message = String(err?.message ?? err ?? '');
|
|
77
|
+
const match = message.match(/(?:try again in|retry after)\s*([\d.]+)\s*(ms|milliseconds?|s|seconds?)?/i);
|
|
78
|
+
if (!match) return null;
|
|
79
|
+
|
|
80
|
+
const value = Number(match[1]);
|
|
81
|
+
if (!Number.isFinite(value) || value < 0) return null;
|
|
82
|
+
|
|
83
|
+
const unit = (match[2] ?? 's').toLowerCase();
|
|
84
|
+
const isMs = unit === 'ms' || unit.startsWith('millisecond');
|
|
85
|
+
return isMs ? value : value * 1000;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How long to wait before retrying a 429, in milliseconds — or `null`
|
|
90
|
+
* meaning "do not retry at all" (either the parsed wait exceeds
|
|
91
|
+
* `RATE_LIMIT_MAX_WAIT_MS`, in which case waiting that long is the
|
|
92
|
+
* operator's decision, not ours).
|
|
93
|
+
*
|
|
94
|
+
* A parsed value gets `PARSED_DELAY_MARGIN_MS` added (the vendor's stated
|
|
95
|
+
* number is the earliest moment its window resets, not a safe one to fire
|
|
96
|
+
* on), capped so the margin itself can never push a parsed value over
|
|
97
|
+
* `RATE_LIMIT_MAX_WAIT_MS`. When nothing parses, `RATE_LIMIT_DEFAULT_WAIT_MS`
|
|
98
|
+
* is returned as-is, with no margin added (there is nothing to add margin
|
|
99
|
+
* to — it is already a conservative guess, not an earliest-moment reading).
|
|
100
|
+
* @param {any} err
|
|
101
|
+
* @returns {number|null}
|
|
102
|
+
*/
|
|
103
|
+
export function rateLimitWaitMs(err) {
|
|
104
|
+
const parsedMs = statedWaitMs(err);
|
|
105
|
+
if (parsedMs === null) {
|
|
106
|
+
return RATE_LIMIT_DEFAULT_WAIT_MS <= RATE_LIMIT_MAX_WAIT_MS ? RATE_LIMIT_DEFAULT_WAIT_MS : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The RAW parsed value is what is judged against the cap ("a vendor asking
|
|
110
|
+
// for longer than this is not honoured") — the margin is added only AFTER
|
|
111
|
+
// that gate, and clamped so it can never itself push the result past the
|
|
112
|
+
// cap, but it never turns an already-honoured wait into a refusal.
|
|
113
|
+
if (parsedMs > RATE_LIMIT_MAX_WAIT_MS) return null;
|
|
114
|
+
if (parsedMs === 0) return 0;
|
|
115
|
+
return Math.min(parsedMs + PARSED_DELAY_MARGIN_MS, RATE_LIMIT_MAX_WAIT_MS);
|
|
116
|
+
}
|
package/src/reuse.js
CHANGED
|
@@ -60,6 +60,7 @@ import { readGrade } from './trend.js';
|
|
|
60
60
|
import { HITL_PAUSE, HUMAN_CHECKPOINTS } from './declaredclose.js';
|
|
61
61
|
import { extractArtifact, priceOf } from './text.js';
|
|
62
62
|
import { runJob as shippedRunJob, ACCOUNTED_ROUND_TYPES } from './run.js';
|
|
63
|
+
import { PROVIDER_TIMEOUT_MS } from './clock.js';
|
|
63
64
|
|
|
64
65
|
const require = createRequire(import.meta.url);
|
|
65
66
|
const { Loop } = require('bare-agent');
|
|
@@ -416,7 +417,14 @@ export async function selectBridge({ registry, job, ask, provider, pinned = null
|
|
|
416
417
|
let result;
|
|
417
418
|
try {
|
|
418
419
|
const loop = new Loop({ provider });
|
|
419
|
-
|
|
420
|
+
// F152/PRD 30.4 — the picker's call carried no time bound at all: a live
|
|
421
|
+
// endpoint that accepts and never answers hangs the process forever
|
|
422
|
+
// (measured elsewhere in this repo). This runs BETWEEN bridge tries, not
|
|
423
|
+
// inside any one try's own signed `perTryWallMs`, so there is no wall to
|
|
424
|
+
// derive a tighter number from — the same "no clock here" reasoning
|
|
425
|
+
// `authorscout.js` documents, and the same fallback: the idle-timeout
|
|
426
|
+
// default the run path itself uses when ITS OWN wall is unbounded.
|
|
427
|
+
result = await loop.run([{ role: 'user', content: prompt }], [], { maxTokens: SELECTION_MAX_TOKENS, timeoutMs: PROVIDER_TIMEOUT_MS });
|
|
420
428
|
} catch (e) {
|
|
421
429
|
// a throw out of the selection call is TRANSPORT — a casualty, and the run stops on
|
|
422
430
|
// it rather than quietly drafting cold on a decision that was never made
|
package/src/run.js
CHANGED
|
@@ -228,6 +228,14 @@ async function primitiveSmoke(workdir) {
|
|
|
228
228
|
* 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
|
|
229
229
|
* 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' |
|
|
230
230
|
* 'hitl-pause' | 'hitl-decision-red' | `step-red:<id>`
|
|
231
|
+
* NOTE: `job-end` can ALSO land with outcome 'runner-drained' (F140/PRD item
|
|
232
|
+
* 28(c)) — the `beforeExit` backstop's own terminal, emitted from OUTSIDE this
|
|
233
|
+
* function's normal control flow when the process drains with no `job-end` at
|
|
234
|
+
* all (a provider promise that never settled). It never appears as this
|
|
235
|
+
* function's own RETURN value (nothing is left to receive it), only as the
|
|
236
|
+
* spine's terminal record. Non-zero exit, `spendComplete:false` always, and
|
|
237
|
+
* deliberately NOT in the resumable/checkpoint set (`src/reuse.js`) — an
|
|
238
|
+
* unknown in-flight state is not a known-safe resume point.
|
|
231
239
|
*/
|
|
232
240
|
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, judgeProvider = null, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, closeDir = null, layerRoot = false, readShim = false, scout = true, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null, humanRuling = null, heldRuling = null, reviewDoor = null, doorRerun = null, resumable = true }) {
|
|
233
241
|
// THE READ SHIM's ARM, resolved at the door — the FIRST thing this entry does,
|
|
@@ -345,6 +353,15 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
345
353
|
// than guessing.
|
|
346
354
|
verdictType: job.verdictType,
|
|
347
355
|
...(typeof provider?.model === 'string' ? { model: provider.model } : {}),
|
|
356
|
+
// PRD item 28 / F156 — WHICH provider actually ran, and against which
|
|
357
|
+
// endpoint. Report-only, but without it an archived spine cannot be
|
|
358
|
+
// segmented by provider at all: every cost, duration and outcome row from
|
|
359
|
+
// the day openai-api joined the menu would pool anthropic and non-anthropic
|
|
360
|
+
// runs into one contaminated aggregate, which is the reading error this repo
|
|
361
|
+
// has already paid for once. `baseUrl` rides along only when the job set one
|
|
362
|
+
// (an absent baseUrl is the vendor's own host, not an unknown).
|
|
363
|
+
provider: typeof job.provider === 'string' ? job.provider : null,
|
|
364
|
+
...(typeof job.baseUrl === 'string' ? { baseUrl: job.baseUrl } : {}),
|
|
348
365
|
// F118: replay reads this; report-only. `dirty` is deliberately omitted —
|
|
349
366
|
// it can't be known without a shell, and null = unknown, never faked.
|
|
350
367
|
code: (() => { const { version, sha } = codeVersion(); return { version, sha }; })(),
|
|
@@ -380,12 +397,46 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
380
397
|
: {}),
|
|
381
398
|
});
|
|
382
399
|
|
|
400
|
+
// F140/PRD item 28 (c) — the backstop that fires on the ABSENCE of a
|
|
401
|
+
// `job-end`. A provider promise that never settles (BA-25's class; 0.42.0
|
|
402
|
+
// closed the known drop cases per F141 but the class itself is "an
|
|
403
|
+
// instrument fires on absence", not a specific bug) lets node drain with
|
|
404
|
+
// nothing else holding the event loop, and the process exits with no
|
|
405
|
+
// terminal record at all — no escalation, no outcome, no way to tell a
|
|
406
|
+
// silent drain from an operator kill. `ended` is the one flag every
|
|
407
|
+
// job-end site sets via the local `end()` helper below (never a second
|
|
408
|
+
// spelling of "did we finish" that could fall out of sync with the real
|
|
409
|
+
// emits). One `beforeExit` listener per call, removed in the `finally` so
|
|
410
|
+
// multiple runs in one process (tests included) never leak listeners.
|
|
411
|
+
let ended = false;
|
|
412
|
+
/** @type {(outcome: string, extra?: object) => object} */
|
|
413
|
+
const end = (outcome, extra = {}) => {
|
|
414
|
+
ended = true;
|
|
415
|
+
return emit('job-end', { outcome, ...extra });
|
|
416
|
+
};
|
|
417
|
+
const onBeforeExit = () => {
|
|
418
|
+
if (ended) return;
|
|
419
|
+
// A drained run's in-flight spend is unknowable — the floor already
|
|
420
|
+
// accumulated is honest, an exact-looking total would not be (F6).
|
|
421
|
+
end('runner-drained', {
|
|
422
|
+
...spend(),
|
|
423
|
+
spendComplete: false,
|
|
424
|
+
detail: 'process drained with no job-end — a provider call never settled (F140)',
|
|
425
|
+
});
|
|
426
|
+
// Never process.exit() (can discard queued stdout) — set the code and
|
|
427
|
+
// let node exit on its own; preserve a more specific code if one is
|
|
428
|
+
// already set.
|
|
429
|
+
process.exitCode = process.exitCode || 1;
|
|
430
|
+
};
|
|
431
|
+
process.once('beforeExit', onBeforeExit);
|
|
432
|
+
|
|
433
|
+
try {
|
|
383
434
|
// 2. known-answer smoke before tokens (A3: silent degradation throws nothing)
|
|
384
435
|
const smoke = await primitiveSmoke(workdir);
|
|
385
436
|
emit('primitive-smoke', smoke);
|
|
386
437
|
if (!smoke.ok) {
|
|
387
438
|
emit('escalation', { category: 'smoke-red', decisionReady: true, decision: `The ${smoke.primitive} primitive failed its known-answer check — no run verdict is trustworthy on a degraded primitive.`, options: ['fix the primitive/store', 'abandon the run'], detail: smoke.detail });
|
|
388
|
-
|
|
439
|
+
end('smoke-red', spend());
|
|
389
440
|
return 'smoke-red';
|
|
390
441
|
}
|
|
391
442
|
|
|
@@ -426,7 +477,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
426
477
|
};
|
|
427
478
|
const pricingRed = () => {
|
|
428
479
|
emit('escalation', { category: 'pricing-red', decisionReady: true, decision: 'A provider result carried no priced cost — the hard cap cannot govern spend it cannot see (unpriced is never free, F6).', options: ['bind a priced provider/model', 'abandon the run'], spentUsd });
|
|
429
|
-
|
|
480
|
+
end('pricing-red', spend());
|
|
430
481
|
return 'pricing-red';
|
|
431
482
|
};
|
|
432
483
|
|
|
@@ -443,7 +494,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
443
494
|
});
|
|
444
495
|
if (unpriced) return pricingRed();
|
|
445
496
|
if (outcome.startsWith('step-red:')) {
|
|
446
|
-
|
|
497
|
+
end('step-red', { step: outcome.slice('step-red:'.length), ...spend() });
|
|
447
498
|
} else if (outcome === 'provider-red' || outcome === 'step-stalled') {
|
|
448
499
|
// F44: a transport-throw provider-red never returned a usage figure for the
|
|
449
500
|
// failed call, so the priced sum is a FLOOR, not the total — spendComplete
|
|
@@ -453,10 +504,13 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
453
504
|
// the known cost of self-heal — and an unbilled reissue is indistinguishable
|
|
454
505
|
// from a billed one from here. Reporting the priced sum as exact would be F6
|
|
455
506
|
// in a self-heal coat.
|
|
456
|
-
|
|
507
|
+
end(outcome, { ...spend(), spendComplete: false });
|
|
457
508
|
} else {
|
|
458
|
-
|
|
509
|
+
end(outcome, spend());
|
|
459
510
|
}
|
|
460
511
|
return outcome;
|
|
461
512
|
}
|
|
513
|
+
} finally {
|
|
514
|
+
process.removeListener('beforeExit', onBeforeExit);
|
|
515
|
+
}
|
|
462
516
|
}
|
package/src/transport.js
CHANGED
|
@@ -3,14 +3,18 @@
|
|
|
3
3
|
//
|
|
4
4
|
// A transport failure is fetch THROWING — the request never produced an HTTP
|
|
5
5
|
// response at all (a TLS fault mid-record, a reset socket, a DNS/network
|
|
6
|
-
// failure)
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
6
|
+
// failure) — OR bare-agent's own BA-25 `guardResponseSettles` rejecting a
|
|
7
|
+
// response cut after headers but before the body completed (a peer
|
|
8
|
+
// `'aborted'`/`'error'`/premature `'close'`), tagged `context.bound:
|
|
9
|
+
// 'transport'` (bare-agent 0.42.0, node_modules/bare-agent/src/provider-http.js).
|
|
10
|
+
// That is a DIFFERENT unknown from an HTTP response the provider DID send
|
|
11
|
+
// (4xx/5xx/429): a response means the provider answered, so bare-agent's own
|
|
12
|
+
// retry policy for those stays exactly what it is — this module never widens
|
|
13
|
+
// that. Retrying only the no-response case is the whole point: a call that
|
|
14
|
+
// dies mid-read may already have been billed (retry can pay twice), so the
|
|
15
|
+
// retry budget stays as tight as hamr authorized it — exactly one extra
|
|
16
|
+
// attempt, never configurable upward from a job spec or argv (tighten-only
|
|
17
|
+
// doctrine).
|
|
14
18
|
//
|
|
15
19
|
// Scope: this module wires ONE seam — the worker Loop built in
|
|
16
20
|
// src/planrun.js's `newLoop` (~line 2239), which is the ONE factory behind
|
|
@@ -31,11 +35,17 @@ export const TRANSPORT_MAX_ATTEMPTS = TRANSPORT_RETRIES + 1;
|
|
|
31
35
|
* Is `err` a transport-class failure — the request never produced an HTTP
|
|
32
36
|
* response? True for: a TLS fault mid-record ("bad record mac" and kin), a
|
|
33
37
|
* reset/broken/timed-out socket (ECONNRESET, EPIPE, ETIMEDOUT, ECONNREFUSED,
|
|
34
|
-
* ENETUNREACH — by code on the error itself or on `err.cause`),
|
|
35
|
-
*
|
|
36
|
-
*
|
|
38
|
+
* ENETUNREACH — by code on the error itself or on `err.cause`), `fetch
|
|
39
|
+
* failed` wrapping a network `cause`, and — as of bare-agent 0.42.0 (BA-25)
|
|
40
|
+
* — a `ProviderError` whose `err.context.bound === 'transport'`: the
|
|
41
|
+
* response stream ended (`'aborted'`/`'error'`/premature `'close'`) after
|
|
42
|
+
* headers but before the body completed, which bare-agent itself rejects as
|
|
43
|
+
* retryable with no HTTP status. False for anything carrying an HTTP status
|
|
44
|
+
* (`err.status`/`err.statusCode`, 4xx/5xx/429) or an explicit
|
|
37
45
|
* `err.retryable === false` — those are provider RESPONSES, not transport
|
|
38
|
-
* casualties, and bare-agent's own policy governs them unchanged
|
|
46
|
+
* casualties, and bare-agent's own policy governs them unchanged; both
|
|
47
|
+
* guards run BEFORE the `context.bound` check, so a status or an explicit
|
|
48
|
+
* `retryable:false` wins even if `bound` says 'transport'.
|
|
39
49
|
* @param {any} err
|
|
40
50
|
* @returns {boolean}
|
|
41
51
|
*/
|
|
@@ -47,6 +57,13 @@ export function isTransportFailure(err) {
|
|
|
47
57
|
if (typeof status === 'number') return false;
|
|
48
58
|
if (err.retryable === false) return false;
|
|
49
59
|
|
|
60
|
+
// BA-25 (bare-agent 0.42.0): bare-agent's own transport classification —
|
|
61
|
+
// `guardResponseSettles` tags a body-cut-after-headers rejection with
|
|
62
|
+
// `context.bound: 'transport'`. Only 'transport' qualifies here — a
|
|
63
|
+
// sibling bound like 'idle' (BA-19's deadline-idle guard) already
|
|
64
|
+
// qualifies via its ETIMEDOUT code below, never by bound alone.
|
|
65
|
+
if (err.context && err.context.bound === 'transport') return true;
|
|
66
|
+
|
|
50
67
|
const codeOf = (e) => (e && typeof e.code === 'string' ? e.code : null);
|
|
51
68
|
const TRANSPORT_CODES = new Set(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ECONNREFUSED', 'ENETUNREACH', 'EAI_AGAIN']);
|
|
52
69
|
const code = codeOf(err) ?? codeOf(err.cause);
|
package/types/authorscout.d.ts
CHANGED
|
@@ -91,7 +91,7 @@ export function defaultSurveyor({ workdir, granted, auditPath, ctx }: {
|
|
|
91
91
|
*
|
|
92
92
|
* @param {{workdir: string, provider?: any,
|
|
93
93
|
* rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
|
|
94
|
-
* attempts?: number, ceilingUsd?: number|null,
|
|
94
|
+
* attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
|
|
95
95
|
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
|
|
96
96
|
* createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any}) => any,
|
|
97
97
|
* createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
|
|
@@ -99,7 +99,7 @@ export function defaultSurveyor({ workdir, granted, auditPath, ctx }: {
|
|
|
99
99
|
* calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
|
|
100
100
|
* budgetStop: 'cap-halt'|'pricing-red'|null}>}
|
|
101
101
|
*/
|
|
102
|
-
export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, onCall, createLoop, createSurveyor, }: {
|
|
102
|
+
export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, maxTokens, ctx, attempts, ceilingUsd, callTimeoutMs, onCall, createLoop, createSurveyor, }: {
|
|
103
103
|
workdir: string;
|
|
104
104
|
provider?: any;
|
|
105
105
|
rounds?: number;
|
|
@@ -109,6 +109,7 @@ export function runAuthorScout({ workdir, provider, rounds, minBytes, blobMax, m
|
|
|
109
109
|
ctx?: boolean;
|
|
110
110
|
attempts?: number;
|
|
111
111
|
ceilingUsd?: number | null;
|
|
112
|
+
callTimeoutMs?: number;
|
|
112
113
|
onCall?: (call: {
|
|
113
114
|
label: string;
|
|
114
115
|
costUsd: number | null;
|
|
@@ -205,6 +206,30 @@ export function buildSeedListing({ workdir, seedRef, sourcePaths, testPaths, lis
|
|
|
205
206
|
tier: string;
|
|
206
207
|
};
|
|
207
208
|
}>;
|
|
209
|
+
/**
|
|
210
|
+
* F152/PRD 30.4 — the scout's per-call IDLE bound. Every `loop.run()` in this
|
|
211
|
+
* module carried NO time bound at all: a live endpoint that accepts and never
|
|
212
|
+
* answers hangs the process forever (measured) — an open socket is an active
|
|
213
|
+
* handle, so node never drains and no backstop can fire.
|
|
214
|
+
*
|
|
215
|
+
* UNLIKE the run path (`callBounds()`, src/planrun.js:1518), this pipeline has
|
|
216
|
+
* NO wall clock to derive a bound FROM — the authoring pipeline is genuinely
|
|
217
|
+
* clockless (`src/authorjob.js`/`src/authorflow.js`/`scripts/run-author.mjs`
|
|
218
|
+
* carry a money ceiling, `ceilingUsd`/`capStop`, and never a `maxWallMs`).
|
|
219
|
+
* Rather than inventing a fresh number, this reuses the EXACT ceiling the run
|
|
220
|
+
* path itself falls back to when ITS OWN wall is unbounded
|
|
221
|
+
* (`PROVIDER_TIMEOUT_MS`, `src/clock.js` — BA-18's idle timeout, already an
|
|
222
|
+
* approved "no wall present" default one layer up): an authoring-time call is
|
|
223
|
+
* the identical situation, one call earlier.
|
|
224
|
+
*
|
|
225
|
+
* It bounds SOCKET INACTIVITY only (reset by every byte, per BA-18) — a call
|
|
226
|
+
* that is merely slow keeps streaming and is untouched; only a truly silent
|
|
227
|
+
* endpoint trips it. TIGHTEN-ONLY, like every cap in this file: this is an
|
|
228
|
+
* operator-set ceiling fixed here, not a param a caller or a signed spec may
|
|
229
|
+
* widen (a defaulted cap is a silent second ceiling, and there is no clock
|
|
230
|
+
* whose narrower reading could ever tighten it further).
|
|
231
|
+
*/
|
|
232
|
+
export const AUTHOR_CALL_TIMEOUT_MS: 600000;
|
|
208
233
|
/** src/planrun.js:42 — the survey's hard round bound. */
|
|
209
234
|
export const AUTHOR_SCOUT_ROUNDS: 8;
|
|
210
235
|
/** src/planrun.js:44 — the survey is a PROMPT INGREDIENT for the authoring call. */
|
package/types/declaredclose.d.ts
CHANGED
|
@@ -110,7 +110,8 @@ export function guardNames(closeDecl: any, verdictType: string): string[];
|
|
|
110
110
|
* seedTrees?: any, gapCap?: number, maxBuffer?: number, baselineMode?: 'auto'|'worktree',
|
|
111
111
|
* humanRuling?: {decision: string, text?: string|null}|null,
|
|
112
112
|
* judgeLoop?: ((o: {system: string}) => any)|null,
|
|
113
|
-
* onJudgeCost?: ((c: any) => void)|null
|
|
113
|
+
* onJudgeCost?: ((c: any) => void)|null,
|
|
114
|
+
* callBounds?: (() => {timeoutMs?: number, deadlineMs?: number})|null}} [opts]
|
|
114
115
|
* @returns {Promise<any>}
|
|
115
116
|
*/
|
|
116
117
|
export function runDeclaredStages(stages: any[], redact?: (s: string) => string, opts?: {
|
|
@@ -129,6 +130,10 @@ export function runDeclaredStages(stages: any[], redact?: (s: string) => string,
|
|
|
129
130
|
system: string;
|
|
130
131
|
}) => any) | null;
|
|
131
132
|
onJudgeCost?: ((c: any) => void) | null;
|
|
133
|
+
callBounds?: (() => {
|
|
134
|
+
timeoutMs?: number;
|
|
135
|
+
deadlineMs?: number;
|
|
136
|
+
}) | null;
|
|
132
137
|
}): Promise<any>;
|
|
133
138
|
/**
|
|
134
139
|
* The trend reader's input for ONE close verdict, whichever executor produced
|
package/types/job.d.ts
CHANGED
|
@@ -207,6 +207,10 @@ export const CADENCE_UNITS: readonly string[];
|
|
|
207
207
|
* subscription — its costUsd axis is NOTIONAL (API-equivalent value, not
|
|
208
208
|
* billed), so it is a DISTINCT condition: rows never pool with anthropic-api
|
|
209
209
|
* rows on the cost axis, and budgets sized for one do not transfer. */
|
|
210
|
+
/** PRD item 28 (2026-09-06/09-09): `openai-api` — an OpenAI-shaped provider,
|
|
211
|
+
* constructed via `src/providers.js`'s factory. hamr's ruling (PRD 30.7):
|
|
212
|
+
* `deepseek-chat` is THE secondary provider (one, not a menu of half-tested
|
|
213
|
+
* models); the factory's tier table is where that lives, not this menu. */
|
|
210
214
|
export const PROVIDERS: readonly string[];
|
|
211
215
|
/** V3 environment label: declared keys only — every field is a lineage-key
|
|
212
216
|
* candidate at N3. `provider` is part of the key by definition (top-level,
|
package/types/judged.d.ts
CHANGED
|
@@ -130,12 +130,13 @@ export function locatePrompt(card: any): string;
|
|
|
130
130
|
* On every route out, red or clean, `onCost` fires exactly once with the honest
|
|
131
131
|
* read: a paid call that leaves no meter record is F12 wearing a judge's coat.
|
|
132
132
|
* @param {{artifactText: string, card: any, loopFactory: (o: {provider?: any, system: string}) => any,
|
|
133
|
-
* maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void
|
|
133
|
+
* maxTokens?: number, attempt?: number, onCost?: (c: {costUsd: number|null, unpricedRounds: number}) => void,
|
|
134
|
+
* callBounds?: {timeoutMs?: number, deadlineMs?: number}}} o
|
|
134
135
|
* @returns {Promise<{ok: boolean, facts: any|null, red: {axis: string, detail: string}|null,
|
|
135
136
|
* costUsd: number|null, unpricedRounds: number, truncated: boolean, parseError: boolean,
|
|
136
137
|
* raw: ReturnType<typeof scrubRaw>}>}
|
|
137
138
|
*/
|
|
138
|
-
export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt, onCost }: {
|
|
139
|
+
export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt, onCost, callBounds, }: {
|
|
139
140
|
artifactText: string;
|
|
140
141
|
card: any;
|
|
141
142
|
loopFactory: (o: {
|
|
@@ -148,6 +149,10 @@ export function runLocate({ artifactText, card, loopFactory, maxTokens, attempt,
|
|
|
148
149
|
costUsd: number | null;
|
|
149
150
|
unpricedRounds: number;
|
|
150
151
|
}) => void;
|
|
152
|
+
callBounds?: {
|
|
153
|
+
timeoutMs?: number;
|
|
154
|
+
deadlineMs?: number;
|
|
155
|
+
};
|
|
151
156
|
}): Promise<{
|
|
152
157
|
ok: boolean;
|
|
153
158
|
facts: any | null;
|
package/types/kinds.d.ts
CHANGED
|
@@ -539,6 +539,11 @@ export type Red = {
|
|
|
539
539
|
* funded honestly: this executor cannot reach the run's ledger, so every locate
|
|
540
540
|
* call's cost is reported OUT through it, once per call, on every route including
|
|
541
541
|
* the red ones — a paid call that leaves no meter record is F12 in a judge's coat.
|
|
542
|
+
* `callBounds` is F152/PRD 30.4's per-call TIME bound, read fresh before every
|
|
543
|
+
* locate attempt — the same derivation the run's own worker rounds carry
|
|
544
|
+
* (`callBounds()`, src/planrun.js). Absent is the honest unbounded default:
|
|
545
|
+
* an adopter or test that wires no clock through behaves exactly as before
|
|
546
|
+
* this field existed.
|
|
542
547
|
* `onStage` is a REPORTING seam and nothing else: the seed read spawns a real
|
|
543
548
|
* toolchain per stage (a suite is the slow one) and a whole read is one opaque
|
|
544
549
|
* await, so the shell gets told as each stage lands. It decides nothing, it is
|
|
@@ -571,6 +576,10 @@ export type Ctx = {
|
|
|
571
576
|
costUsd: number | null;
|
|
572
577
|
unpricedRounds: number;
|
|
573
578
|
}) => void;
|
|
579
|
+
callBounds?: () => {
|
|
580
|
+
timeoutMs?: number;
|
|
581
|
+
deadlineMs?: number;
|
|
582
|
+
};
|
|
574
583
|
onStage?: (row: {
|
|
575
584
|
stage: string | null;
|
|
576
585
|
kind: string | null;
|