atris 3.61.0 → 3.62.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 +37 -5
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +1 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +33 -8
- package/commands/mission.js +112 -3
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/team.js +82 -10
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +36 -26
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +283 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/member-engine.js +11 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +37 -0
- package/lib/roster-markdown.js +1 -1
- package/lib/roster-prep.js +289 -0
- package/lib/roster-runs.js +389 -0
- package/lib/roster-suggest.js +145 -0
- package/lib/router-brain.js +111 -3
- package/lib/self-drive.js +26 -2
- package/lib/state-detection.js +3 -3
- package/lib/task-db.js +38 -3
- package/lib/team-lineup.js +7 -2
- package/lib/todo.js +1 -1
- package/lib/wish-audit.js +1 -1
- package/package.json +1 -1
- package/scripts/det/cpu-hotspots.js +216 -0
- package/scripts/det/ytnotes +105 -19
- package/scripts/det/ytquote-repair.js +18 -4
- package/templates/members/host/MEMBER.md +5 -3
- package/templates/members/host/MISSION.md +1 -1
- package/templates/members/host/skills/host/SKILL.md +64 -16
package/lib/engine-registry.js
CHANGED
|
@@ -14,7 +14,9 @@ const {
|
|
|
14
14
|
engineTakesModel,
|
|
15
15
|
engineEffortLevels,
|
|
16
16
|
} = require('./runner-command');
|
|
17
|
-
|
|
17
|
+
// Through the module object so tests can see when ranking runs.
|
|
18
|
+
const routerBrain = require('./router-brain');
|
|
19
|
+
const { routerPickExplanation } = routerBrain;
|
|
18
20
|
// Friendly name for a saved model: claude-opus-5-5 reads as "opus 5.5".
|
|
19
21
|
const { modelLabel: rosterModelLabel } = require('./roster-models');
|
|
20
22
|
|
|
@@ -22,29 +24,116 @@ const ENGINE_REGISTRY_SCHEMA = 'atris.engine_registry.v2';
|
|
|
22
24
|
const ENGINE_TIERS = Object.freeze(['fast', 'pro', 'max']);
|
|
23
25
|
const ENGINE_ROLES = Object.freeze(['navigator', 'executor', 'validator']);
|
|
24
26
|
const ENGINE_DUTIES = Object.freeze(['leader', 'errands', 'learning']);
|
|
25
|
-
const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error']);
|
|
27
|
+
const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error', 'cooling']);
|
|
28
|
+
const DEFAULT_ENGINE_COOLDOWN_MINUTES = 30;
|
|
29
|
+
|
|
30
|
+
// How long a stalled engine sits out: ATRIS_ENGINE_COOLDOWN_MINUTES, else 30.
|
|
31
|
+
function engineCooldownMs() {
|
|
32
|
+
const minutes = Number(process.env.ATRIS_ENGINE_COOLDOWN_MINUTES);
|
|
33
|
+
return (Number.isFinite(minutes) && minutes > 0 ? minutes : DEFAULT_ENGINE_COOLDOWN_MINUTES) * 60000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nowMs(now) {
|
|
37
|
+
if (now === undefined || now === null) return Date.now();
|
|
38
|
+
const ms = now instanceof Date ? now.getTime() : Date.parse(now);
|
|
39
|
+
return Number.isFinite(ms) ? ms : Date.now();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// True while an engine sits out after a stall. Once the cooldown passes the
|
|
43
|
+
// engine is ready again with no human step.
|
|
44
|
+
function engineCooling(engine, now) {
|
|
45
|
+
const health = engine && engine.health;
|
|
46
|
+
if (!health || health.status !== 'cooling') return false;
|
|
47
|
+
const until = Date.parse(health.cooling_until || '');
|
|
48
|
+
return Number.isFinite(until) && until > nowMs(now);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Every resolver asks this one question: can this engine take work now?
|
|
52
|
+
function engineReadyAt(engine, now) {
|
|
53
|
+
const health = engine && engine.health;
|
|
54
|
+
const status = health && health.status;
|
|
55
|
+
if (status === 'ready') return true;
|
|
56
|
+
if (status === 'cooling') return !engineCooling(engine, now);
|
|
57
|
+
// 'error' survives from before failed runs stopped benching their engine:
|
|
58
|
+
// it came from any errored run, real task failures included. It sits out
|
|
59
|
+
// the same window as a stall; a missing stamp (older saves) routes at once.
|
|
60
|
+
if (status === 'error') {
|
|
61
|
+
const failedAt = Date.parse(health.last_failure_ts || '');
|
|
62
|
+
return !Number.isFinite(failedAt) || failedAt + engineCooldownMs() <= nowMs(now);
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TRANSIENT_FAILURE_PATTERN = /\b(?:econnreset|econnrefused|econnaborted|etimedout|enotfound|eai_again|epipe|enetunreach|ehostunreach)\b|socket hang up|connection (?:reset|refused|closed|dropped|lost|error|timed out|interrupted)|lost (?:the )?connection|network (?:error|failure|is unreachable)|stream (?:disconnected|error|closed)|\bdisconnected\b|(?:service|server|model|temporarily|currently)[ _-]unavailable|overloaded|bad gateway|gateway time-?out/;
|
|
68
|
+
|
|
69
|
+
function tailText(value, size = 600) {
|
|
70
|
+
const text = String(value || '');
|
|
71
|
+
return text.length > size ? text.slice(-size) : text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Why a run stalled, in plain words, or null when it failed for a real task
|
|
75
|
+
// reason. A stall is a time cap (the run hit its max, a spawn timeout, or the
|
|
76
|
+
// codex watchdog's 124/125) or a transient error (a dropped connection, a
|
|
77
|
+
// network error, an unavailable service). Only engine-level signals count:
|
|
78
|
+
// the task's own output (report, stdout, the claude summary and receipt) is
|
|
79
|
+
// the work it produced, and its words must not decide the engine's health.
|
|
80
|
+
function engineStallReason(result) {
|
|
81
|
+
if (!result || typeof result !== 'object') return null;
|
|
82
|
+
const claude = result.claude || {};
|
|
83
|
+
const reason = String(result.reason || '');
|
|
84
|
+
if (/wall-exceeded/.test(reason)) return null;
|
|
85
|
+
const exitCode = Number.isInteger(result.exitCode) ? result.exitCode
|
|
86
|
+
: Number.isInteger(result.exit_code) ? result.exit_code : null;
|
|
87
|
+
const capSecondsValue = Math.floor(Number(result.max_seconds || result.maxSeconds)) || 0;
|
|
88
|
+
const capText = capSecondsValue >= 60 ? `stalled at ${Math.round(capSecondsValue / 60)} min`
|
|
89
|
+
: capSecondsValue > 0 ? `stalled at ${capSecondsValue}s`
|
|
90
|
+
: 'hit its time limit';
|
|
91
|
+
if (exitCode === 124) return { kind: 'time_cap', text: 'stalled at startup' };
|
|
92
|
+
if (result.timed_out === true || claude.timed_out === true || exitCode === 125
|
|
93
|
+
|| /\bclaude-timeout\b|(?:^|\n)timeout(?:\n|$)/.test(reason)) {
|
|
94
|
+
return { kind: 'time_cap', text: capText };
|
|
95
|
+
}
|
|
96
|
+
const text = [
|
|
97
|
+
reason,
|
|
98
|
+
result.error && (result.error.message || result.error.code || result.error),
|
|
99
|
+
result.model_unavailable,
|
|
100
|
+
tailText(result.stderr),
|
|
101
|
+
tailText(claude.stderr),
|
|
102
|
+
].filter(Boolean).map(String).join('\n').toLowerCase();
|
|
103
|
+
if (/spawn[^\n]*etimedout/.test(text)) return { kind: 'time_cap', text: 'timed out starting up' };
|
|
104
|
+
if (TRANSIENT_FAILURE_PATTERN.test(text)) return { kind: 'transient', text: 'lost its connection' };
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
26
107
|
|
|
27
108
|
function engineFailureHealthStatus(result) {
|
|
28
109
|
if (!result || result.status !== 'errored') return null;
|
|
110
|
+
// Same rule as engineStallReason: only engine-level signals decide engine
|
|
111
|
+
// health. The task's own output (report, stdout, the claude summary and
|
|
112
|
+
// receipt) is the work product, never the engine's vital signs.
|
|
113
|
+
const rateLimitInfo = result.rate_limit_info;
|
|
114
|
+
const rateLimitStatus = String(
|
|
115
|
+
rateLimitInfo && typeof rateLimitInfo === 'object' ? rateLimitInfo.status || '' : rateLimitInfo || '',
|
|
116
|
+
).trim().toLowerCase();
|
|
117
|
+
// The engine's own rate-limit event is a credit wall only when the window
|
|
118
|
+
// is actually closed: claude reports resetsAt with status 'allowed' on
|
|
119
|
+
// healthy turns too.
|
|
120
|
+
if (rateLimitStatus && rateLimitStatus !== 'allowed') return 'credit_out';
|
|
29
121
|
const signalText = [
|
|
30
122
|
result.reason,
|
|
31
123
|
result.model_unavailable,
|
|
32
|
-
result.report,
|
|
33
|
-
result.stdout,
|
|
34
124
|
result.stderr,
|
|
35
|
-
result.error,
|
|
36
|
-
result.claude && result.claude.summary,
|
|
37
|
-
result.claude && result.claude.receipt_text,
|
|
125
|
+
result.error && (result.error.message || result.error.code || result.error),
|
|
38
126
|
result.claude && result.claude.stderr,
|
|
39
|
-
|
|
127
|
+
rateLimitStatus,
|
|
40
128
|
].filter(Boolean).join('\n').toLowerCase();
|
|
41
129
|
if (/usage[ _-]?limit|purchase more credits|insufficient credits|credit(?:s)?[ _-]?(?:out|limit)|rate[ _-]?limit|not authenticated|please log in|login required|auth(?:entication)?[ _-]?expired|payment required|subscription/.test(signalText)) {
|
|
42
130
|
return 'credit_out';
|
|
43
131
|
}
|
|
44
132
|
if (/not installed|command not found|\benoent\b/.test(signalText)) return 'not_installed';
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
133
|
+
// A real task failure (its tests failed, its report says why) says nothing
|
|
134
|
+
// about the engine, so health stays. 'error' survives only as the stall
|
|
135
|
+
// marker: the caller walks it through the cooling path.
|
|
136
|
+
return engineStallReason(result) ? 'error' : null;
|
|
48
137
|
}
|
|
49
138
|
|
|
50
139
|
const ENGINE_SEED_META = Object.freeze({
|
|
@@ -55,12 +144,12 @@ const ENGINE_SEED_META = Object.freeze({
|
|
|
55
144
|
// review when picked; it stays out of roles, so every review list built
|
|
56
145
|
// from roles (the router, one lap, wish audit, the chart) is unchanged.
|
|
57
146
|
codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), roster_only_roles: Object.freeze(['validator']), models: Object.freeze(['codex']), fallback_order: 10 }),
|
|
58
|
-
claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor', 'navigator']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['opus 5.5', 'opus 5', '
|
|
147
|
+
claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor', 'navigator']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['opus 5.5', 'opus 5', 'fable', 'haiku']), fallback_order: 20 }),
|
|
59
148
|
cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['composer 2.5', 'grok 4.6', 'kimi 3']), fallback_order: 30 }),
|
|
60
149
|
// Devin gathers context for search only when a roster picks it.
|
|
61
150
|
devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 40 }),
|
|
62
151
|
grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['grok 4.7 fast', 'grok 4.7']), fallback_order: 45 }),
|
|
63
|
-
fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5.5', 'opus 5', '
|
|
152
|
+
fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5.5', 'opus 5', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
|
|
64
153
|
composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
|
|
65
154
|
haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator', 'navigator']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
|
|
66
155
|
agy: Object.freeze({
|
|
@@ -77,7 +166,7 @@ const ENGINE_SEED_META = Object.freeze({
|
|
|
77
166
|
duty: 'errands',
|
|
78
167
|
fallback_order: 90,
|
|
79
168
|
}),
|
|
80
|
-
opencode: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['opencode/big-pickle', 'opencode/claude-opus-
|
|
169
|
+
opencode: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['opencode/big-pickle', 'opencode/claude-opus-5-5', 'opencode/gpt-5.2']), fallback_order: 95 }),
|
|
81
170
|
commandcode: Object.freeze({
|
|
82
171
|
tier: 'max',
|
|
83
172
|
roles: Object.freeze(['validator', 'executor']),
|
|
@@ -155,7 +244,7 @@ function normalizeFallbackOrder(value, fallback) {
|
|
|
155
244
|
return Number.isInteger(order) ? order : fallback;
|
|
156
245
|
}
|
|
157
246
|
|
|
158
|
-
function normalizeEngineEntry(id, saved = {}) {
|
|
247
|
+
function normalizeEngineEntry(id, saved = {}, now) {
|
|
159
248
|
const def = RUNNER_PROFILE_DEFS[id];
|
|
160
249
|
const seed = ENGINE_SEED_META[id] || { tier: 'pro', roles: ['executor'], models: [id], fallback_order: 100 };
|
|
161
250
|
const savedHealth = saved && saved.health && typeof saved.health === 'object' ? saved.health : {};
|
|
@@ -173,8 +262,18 @@ function normalizeEngineEntry(id, saved = {}) {
|
|
|
173
262
|
installed = binInstalled(def.bin);
|
|
174
263
|
status = installed ? 'ready' : 'not_installed';
|
|
175
264
|
}
|
|
176
|
-
|
|
265
|
+
let health = { status };
|
|
177
266
|
if (status !== 'ready' && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
|
|
267
|
+
if (status === 'cooling') {
|
|
268
|
+
// A cooldown that has passed reads as ready, so the engine comes back
|
|
269
|
+
// with no human step.
|
|
270
|
+
if (engineCooling({ health: savedHealth }, now)) {
|
|
271
|
+
health.cooling_until = String(savedHealth.cooling_until);
|
|
272
|
+
if (savedHealth.cooling_reason) health.cooling_reason = String(savedHealth.cooling_reason);
|
|
273
|
+
} else {
|
|
274
|
+
health = { status: 'ready' };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
178
277
|
return {
|
|
179
278
|
...saved,
|
|
180
279
|
id,
|
|
@@ -182,7 +281,13 @@ function normalizeEngineEntry(id, saved = {}) {
|
|
|
182
281
|
bin: def.bin,
|
|
183
282
|
tier: normalizeTier(saved.tier, seed.tier),
|
|
184
283
|
roles: normalizeRoles(saved.roles, Array.from(seed.roles)),
|
|
185
|
-
|
|
284
|
+
// The seed model list is the live catalog and always wins on read, unless
|
|
285
|
+
// the owner set models on purpose: `atris engine set --models` saves
|
|
286
|
+
// models_set_by_owner next to the list. A list copied in at seed time has
|
|
287
|
+
// no mark, so it thaws to the current seed instead of pinning stale names.
|
|
288
|
+
models: saved.models_set_by_owner === true
|
|
289
|
+
? normalizeModels(saved.models, Array.from(seed.models))
|
|
290
|
+
: Array.from(seed.models),
|
|
186
291
|
duty: normalizeDuty(saved.duty, seed.duty),
|
|
187
292
|
fallback_order: normalizeFallbackOrder(saved.fallback_order, seed.fallback_order),
|
|
188
293
|
installed,
|
|
@@ -190,7 +295,7 @@ function normalizeEngineEntry(id, saved = {}) {
|
|
|
190
295
|
};
|
|
191
296
|
}
|
|
192
297
|
|
|
193
|
-
function seededRegistry(root = process.cwd(), preloadedRaw = null) {
|
|
298
|
+
function seededRegistry(root = process.cwd(), preloadedRaw = null, now) {
|
|
194
299
|
const file = engineRegistryFile(root);
|
|
195
300
|
const raw = preloadedRaw || readRawRegistry(file);
|
|
196
301
|
const savedById = new Map();
|
|
@@ -204,7 +309,7 @@ function seededRegistry(root = process.cwd(), preloadedRaw = null) {
|
|
|
204
309
|
updated_at: new Date().toISOString(),
|
|
205
310
|
// Hidden profiles are registered engines too: they seed, route, and take
|
|
206
311
|
// health policy exactly like visible ones.
|
|
207
|
-
engines: Object.keys(RUNNER_PROFILE_DEFS).map((id) => normalizeEngineEntry(id, savedById.get(id) || {})),
|
|
312
|
+
engines: Object.keys(RUNNER_PROFILE_DEFS).map((id) => normalizeEngineEntry(id, savedById.get(id) || {}, now)),
|
|
208
313
|
};
|
|
209
314
|
}
|
|
210
315
|
|
|
@@ -237,6 +342,7 @@ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
|
|
|
237
342
|
const models = normalizeModels(overrides.models, []);
|
|
238
343
|
if (!models.length) throw new Error('models must include at least one name');
|
|
239
344
|
nextOverrides.models = models;
|
|
345
|
+
nextOverrides.models_set_by_owner = true;
|
|
240
346
|
}
|
|
241
347
|
if (!Object.keys(nextOverrides).length) throw new Error('set requires --duty or --models');
|
|
242
348
|
|
|
@@ -282,7 +388,7 @@ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
|
|
|
282
388
|
// one read, one decision.
|
|
283
389
|
function readEngineRegistry(root = process.cwd(), options = {}) {
|
|
284
390
|
const raw = readRawRegistry(engineRegistryFile(root));
|
|
285
|
-
const registry = seededRegistry(root, raw);
|
|
391
|
+
const registry = seededRegistry(root, raw, options.now);
|
|
286
392
|
const needsPersist = JSON.stringify(raw.engines || []) !== JSON.stringify(registry.engines);
|
|
287
393
|
if (options.persist !== false && needsPersist && canPersistEngineRegistry(root)) {
|
|
288
394
|
writeEngineRegistry(root, registry);
|
|
@@ -787,6 +893,7 @@ function rosterPickFromValue(value, role, options = {}) {
|
|
|
787
893
|
let maxSeconds = null;
|
|
788
894
|
let namedModel = false;
|
|
789
895
|
let namedEffort = false;
|
|
896
|
+
let prep = '';
|
|
790
897
|
for (const raw of extras) {
|
|
791
898
|
// "max: 20 min" and "until: 2026-10-24" read like "max 20 min".
|
|
792
899
|
const part = raw.replace(/^(max|until)\s*:\s*/i, '$1 ');
|
|
@@ -795,7 +902,15 @@ function rosterPickFromValue(value, role, options = {}) {
|
|
|
795
902
|
const maxMatch = /^max\b/i.test(part) && !/^max$/i.test(part);
|
|
796
903
|
const modelMatch = /^model\s*(?::\s*|\s+)(.+)$/i.exec(part);
|
|
797
904
|
const effortMatch = /^effort\s*(?::\s*|\s+)(.+)$/i.exec(part);
|
|
798
|
-
|
|
905
|
+
const prepMatch = /^prep\s*(?::\s*|\s+)(.+)$/i.exec(part);
|
|
906
|
+
if (prepMatch && !prep) {
|
|
907
|
+
// "prep: search": that job's lead reads the task first and hands this
|
|
908
|
+
// worker a trimmed brief. Whether it names this same job is checked
|
|
909
|
+
// where the job is known.
|
|
910
|
+
const words = prepMatch[1].trim();
|
|
911
|
+
prep = rosterJobKey(splitJobName(words).name);
|
|
912
|
+
if (!prep) throw new Error(`"prep: ${words}" does not name a job; use prep: search or another job name`);
|
|
913
|
+
} else if (modelMatch && !namedModel) {
|
|
799
914
|
if (primary.model) throw new Error(`names two models for ${primary.engine}`);
|
|
800
915
|
const words = modelMatch[1].trim();
|
|
801
916
|
if (!engineTakesModel(primary.engine)) throw new Error(`${primary.engine} runs one fixed model and cannot take "${words}"`);
|
|
@@ -840,12 +955,21 @@ function rosterPickFromValue(value, role, options = {}) {
|
|
|
840
955
|
...(backup && backup.model ? { backup_model: backup.model } : {}),
|
|
841
956
|
...(backup && backup.effort ? { backup_effort: backup.effort } : {}),
|
|
842
957
|
...(maxSeconds ? { max_seconds: maxSeconds } : {}),
|
|
958
|
+
...(prep ? { prep } : {}),
|
|
843
959
|
until,
|
|
844
960
|
...(until ? {} : { never_expires: true }),
|
|
845
961
|
...(untilYearless ? { until_needs_year: untilYearless } : {}),
|
|
846
962
|
};
|
|
847
963
|
}
|
|
848
964
|
|
|
965
|
+
// A worker that names its own job as prep would wait on itself. The line
|
|
966
|
+
// still counts; only the prep is dropped, with a plain warning.
|
|
967
|
+
function selfPrepWarning(worker, key, label) {
|
|
968
|
+
if (!worker || !worker.prep || worker.prep !== key) return '';
|
|
969
|
+
delete worker.prep;
|
|
970
|
+
return `asks ${label} to prep for itself, so this worker runs without prep`;
|
|
971
|
+
}
|
|
972
|
+
|
|
849
973
|
// A job line's name part, "small build (like build)", split into the name
|
|
850
974
|
// and the kind it says it is like.
|
|
851
975
|
function splitJobName(text) {
|
|
@@ -873,6 +997,7 @@ function rosterWorkerFromValue(value, role, options = {}) {
|
|
|
873
997
|
model: pick.model,
|
|
874
998
|
...(pick.effort ? { effort: pick.effort } : {}),
|
|
875
999
|
...(pick.max_seconds ? { max_seconds: pick.max_seconds } : {}),
|
|
1000
|
+
...(pick.prep ? { prep: pick.prep } : {}),
|
|
876
1001
|
until: pick.until,
|
|
877
1002
|
...(pick.never_expires ? { never_expires: true } : {}),
|
|
878
1003
|
...(pick.until_needs_year ? { until_needs_year: pick.until_needs_year } : {}),
|
|
@@ -921,6 +1046,7 @@ function pickWorkers(pick) {
|
|
|
921
1046
|
model: pick.model || '',
|
|
922
1047
|
...(pick.effort ? { effort: pick.effort } : {}),
|
|
923
1048
|
...cap,
|
|
1049
|
+
...(pick.prep ? { prep: pick.prep } : {}),
|
|
924
1050
|
until: pick.until,
|
|
925
1051
|
...(pick.never_expires ? { never_expires: true } : {}),
|
|
926
1052
|
...(pick.line ? { line: pick.line } : {}),
|
|
@@ -971,6 +1097,8 @@ function parseRosterMarkdown(text, file, options = {}) {
|
|
|
971
1097
|
for (const entry of lines) {
|
|
972
1098
|
try {
|
|
973
1099
|
const worker = rosterWorkerFromValue(entry.value, ENGINE_JOBS[kind], options);
|
|
1100
|
+
const selfPrep = selfPrepWarning(worker, key, label);
|
|
1101
|
+
if (selfPrep) warn(entry, selfPrep);
|
|
974
1102
|
workers.push({ ...worker, line: entry.lineNumber, text: entry.raw });
|
|
975
1103
|
if (worker.until_needs_year) warn(entry, `has no year in its until date, so this ${label} worker counts as expired; write until ${worker.until_needs_year}`);
|
|
976
1104
|
} catch (error) {
|
|
@@ -988,6 +1116,7 @@ function parseRosterMarkdown(text, file, options = {}) {
|
|
|
988
1116
|
backup: second ? second.engine : '',
|
|
989
1117
|
...(second && second.model ? { backup_model: second.model } : {}),
|
|
990
1118
|
...(first.max_seconds ? { max_seconds: first.max_seconds } : {}),
|
|
1119
|
+
...(first.prep ? { prep: first.prep } : {}),
|
|
991
1120
|
until: first.until,
|
|
992
1121
|
...(first.never_expires ? { never_expires: true } : {}),
|
|
993
1122
|
...(builtIn ? {} : { like: kind }),
|
|
@@ -1040,6 +1169,8 @@ function parseRosterMarkdown(text, file, options = {}) {
|
|
|
1040
1169
|
}
|
|
1041
1170
|
try {
|
|
1042
1171
|
const pick = rosterPickFromValue(entry.value, ENGINE_JOBS[kind], options);
|
|
1172
|
+
const selfPrep = selfPrepWarning(pick, key, label);
|
|
1173
|
+
if (selfPrep) warn(entry, selfPrep);
|
|
1043
1174
|
picks[key] = { ...pick, ...(builtIn ? {} : { like: kind }), file: display, line: entry.lineNumber };
|
|
1044
1175
|
if (pick.until_needs_year) {
|
|
1045
1176
|
warn(entry, `has no year in its until date, so ${label} counts as expired; write until ${pick.until_needs_year}`);
|
|
@@ -1215,12 +1346,13 @@ function workerModelText(engine, model) {
|
|
|
1215
1346
|
return model;
|
|
1216
1347
|
}
|
|
1217
1348
|
|
|
1218
|
-
// One worker line: "- claude code, model: opus 5.5, effort: high, max: 20 min, until 2026-10-24".
|
|
1349
|
+
// One worker line: "- claude code, model: opus 5.5, effort: high, max: 20 min, prep: search, until 2026-10-24".
|
|
1219
1350
|
function workerLineText(worker) {
|
|
1220
1351
|
const parts = [rosterToolLabel(worker.engine)];
|
|
1221
1352
|
if (worker.model) parts.push(`model: ${workerModelText(worker.engine, worker.model)}`);
|
|
1222
1353
|
if (worker.effort) parts.push(`effort: ${worker.effort}`);
|
|
1223
1354
|
if (Number(worker.max_seconds) > 0) parts.push(`max: ${rosterMaxText(worker.max_seconds).replace(/^max /, '')}`);
|
|
1355
|
+
if (worker.prep) parts.push(`prep: ${rosterJobLabel(worker.prep)}`);
|
|
1224
1356
|
if (worker.until) parts.push(`until ${worker.until}`);
|
|
1225
1357
|
return `- ${parts.join(', ')}`;
|
|
1226
1358
|
}
|
|
@@ -1360,6 +1492,24 @@ function assignedWorkerLines(key, engineName, options, context) {
|
|
|
1360
1492
|
if (keep.length === currentLines.length) throw new Error(`${label} has no ${tool} worker to remove`);
|
|
1361
1493
|
return { lines: keep.length ? keep : null };
|
|
1362
1494
|
}
|
|
1495
|
+
// --promote moves the first line naming exactly the tool and model (and
|
|
1496
|
+
// effort, if given) to the top, every line exactly as written. Unlike an
|
|
1497
|
+
// assign, it rewrites nothing: the demoted lead keeps its own cap, prep,
|
|
1498
|
+
// and notes. With no model the words match only a line with no model, so
|
|
1499
|
+
// promoting "claude" can never move "claude, model: ..." by accident.
|
|
1500
|
+
if (options.promote !== undefined) {
|
|
1501
|
+
const words = String(options.promote).trim();
|
|
1502
|
+
const want = resolveRosterWords(words);
|
|
1503
|
+
const matches = currentWorkers
|
|
1504
|
+
.map((worker, index) => index)
|
|
1505
|
+
.filter((index) => currentWorkers[index].engine === want.engine
|
|
1506
|
+
&& (currentWorkers[index].model || '') === (want.model || '')
|
|
1507
|
+
&& (currentWorkers[index].effort || '') === (want.effort || ''));
|
|
1508
|
+
if (!matches.length) throw new Error(`${label} has no ${words} worker to move up`);
|
|
1509
|
+
const index = matches[0];
|
|
1510
|
+
const note = matches.length > 1 ? `${label} has ${matches.length} workers named ${words}; the first moved up` : '';
|
|
1511
|
+
return { lines: [currentLines[index], ...currentLines.filter((line, i) => i !== index)], ...(note ? { note } : {}) };
|
|
1512
|
+
}
|
|
1363
1513
|
const likeName = String(options.like || '').trim();
|
|
1364
1514
|
const likeKind = likeName ? rosterJob(likeName) : '';
|
|
1365
1515
|
if (likeName && !likeKind) throw new Error(`unknown kind "${likeName}". use --like search, build, or review`);
|
|
@@ -1397,12 +1547,17 @@ function assignedWorkerLines(key, engineName, options, context) {
|
|
|
1397
1547
|
if (effort && !engineEffortLevels(engine.id).includes(effort)) throw new Error(effortRefusal(engine.id, effort));
|
|
1398
1548
|
const maxSeconds = options.max === undefined ? null : parseRosterMax(`max ${String(options.max).replace(/^max\s*:?\s*/i, '')}`);
|
|
1399
1549
|
if (options.max !== undefined && !maxSeconds) throw new Error(`"${options.max}" is not a time cap; use --max "20 min", --max 90s, or --max 2h`);
|
|
1550
|
+
const prepWords = options.prep === undefined ? '' : String(options.prep).trim();
|
|
1551
|
+
const prep = prepWords ? rosterJobKey(splitJobName(prepWords).name) : '';
|
|
1552
|
+
if (options.prep !== undefined && !prep) throw new Error(`"${options.prep}" is not a job name; use --prep search or another job`);
|
|
1553
|
+
if (prep && prep === key) throw new Error(`${label} cannot prep for itself; pick another job, like --prep search`);
|
|
1400
1554
|
const now = rosterDate(options.now);
|
|
1401
1555
|
const line = workerLineText({
|
|
1402
1556
|
engine: engine.id,
|
|
1403
1557
|
model,
|
|
1404
1558
|
...(effort ? { effort } : {}),
|
|
1405
1559
|
...(maxSeconds ? { max_seconds: maxSeconds } : {}),
|
|
1560
|
+
...(prep ? { prep } : {}),
|
|
1406
1561
|
until: days ? rosterUntil(now, days) : '',
|
|
1407
1562
|
});
|
|
1408
1563
|
const heading = jobHeadingText(key, builtIn ? '' : kind);
|
|
@@ -1470,13 +1625,14 @@ function setRosterPick(jobName, engineName, options = {}, root = process.cwd())
|
|
|
1470
1625
|
const worker = savedPick && savedPick.workers ? savedPick.workers.find((item) => item.line === entry.lineNumber) : null;
|
|
1471
1626
|
return worker || { engine: rosterToolName(String(entry.value).split(',')[0]), model: '' };
|
|
1472
1627
|
});
|
|
1473
|
-
const { lines, heading } = assignedWorkerLines(key, engineName, options, {
|
|
1628
|
+
const { lines, heading, note } = assignedWorkerLines(key, engineName, options, {
|
|
1474
1629
|
registry,
|
|
1475
1630
|
savedPick,
|
|
1476
1631
|
currentLines: workerEntries.map((entry) => entry.raw),
|
|
1477
1632
|
currentWorkers,
|
|
1478
1633
|
jobName,
|
|
1479
1634
|
});
|
|
1635
|
+
if (note && typeof options.note === 'function') options.note(note);
|
|
1480
1636
|
const next = replaceJobSection(base, {
|
|
1481
1637
|
matches: (title) => rosterJobHeading(title).key === key,
|
|
1482
1638
|
// Only an assign names a heading, and only when the job's kind changes;
|
|
@@ -1576,7 +1732,7 @@ const ROSTER_LAYERS = Object.freeze(['session', 'project', 'machine']);
|
|
|
1576
1732
|
// or not ready (down, or it cannot do this kind of work).
|
|
1577
1733
|
function rosterWorkerWalk(pick, role, registry, now) {
|
|
1578
1734
|
const ready = registry.engines
|
|
1579
|
-
.filter((engine) => engine
|
|
1735
|
+
.filter((engine) => engineReadyAt(engine, now))
|
|
1580
1736
|
.filter((engine) => engineHasRole(engine, role));
|
|
1581
1737
|
return pickWorkers(pick).map((worker) => {
|
|
1582
1738
|
if (worker.error) return { worker, skip: 'bad line', engine: null, raw: null };
|
|
@@ -1584,17 +1740,24 @@ function rosterWorkerWalk(pick, role, registry, now) {
|
|
|
1584
1740
|
const saved = savedRosterModel(worker.engine, worker.model);
|
|
1585
1741
|
if (saved === null && !worker.lenient_model) return { worker, skip: 'bad model', engine: null, raw: null };
|
|
1586
1742
|
const raw = ready.find((engine) => engine.id === worker.engine) || null;
|
|
1587
|
-
if (!raw)
|
|
1743
|
+
if (!raw) {
|
|
1744
|
+
// A worker benched after a stall says so and when it comes back.
|
|
1745
|
+
const benched = registry.engines.find((engine) => engine.id === worker.engine && engineCooling(engine, now));
|
|
1746
|
+
if (benched) return { worker, skip: 'cooling', cooling: coolingView(benched), engine: null, raw: null };
|
|
1747
|
+
return { worker, skip: 'not ready', engine: null, raw: null };
|
|
1748
|
+
}
|
|
1588
1749
|
// A backup runs its own model and effort, never the lead's.
|
|
1589
1750
|
const model = saved || '';
|
|
1590
1751
|
const effort = savedRosterEffort(raw.id, worker.effort);
|
|
1591
1752
|
const maxSeconds = Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : 0;
|
|
1592
|
-
const
|
|
1753
|
+
const prep = worker.prep || '';
|
|
1754
|
+
const engine = model || effort || maxSeconds || prep
|
|
1593
1755
|
? {
|
|
1594
1756
|
...raw,
|
|
1595
1757
|
...(model ? { roster_model: model } : {}),
|
|
1596
1758
|
...(effort ? { roster_effort: effort } : {}),
|
|
1597
1759
|
...(maxSeconds ? { roster_max_seconds: maxSeconds } : {}),
|
|
1760
|
+
...(prep ? { roster_prep: prep } : {}),
|
|
1598
1761
|
}
|
|
1599
1762
|
: raw;
|
|
1600
1763
|
return { worker, skip: '', engine, raw };
|
|
@@ -1615,6 +1778,7 @@ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeTe
|
|
|
1615
1778
|
const after = walk.slice(leadIndex + 1).filter((step) => !step.skip);
|
|
1616
1779
|
const first = walk[0];
|
|
1617
1780
|
const why = first.skip === 'expired' ? 'expired'
|
|
1781
|
+
: first.skip === 'cooling' ? `is ${first.cooling.text}`
|
|
1618
1782
|
: first.skip === 'bad model' ? `names a model ${first.worker.engine} cannot run`
|
|
1619
1783
|
: first.skip === 'bad line' ? 'has a bad first line'
|
|
1620
1784
|
: 'is not ready';
|
|
@@ -1633,6 +1797,8 @@ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeTe
|
|
|
1633
1797
|
};
|
|
1634
1798
|
}
|
|
1635
1799
|
|
|
1800
|
+
const ROSTER_PIN_FIELDS = Object.freeze(['roster_model', 'roster_effort', 'roster_max_seconds', 'roster_prep']);
|
|
1801
|
+
|
|
1636
1802
|
const ROSTER_SCOPE_TEXT = Object.freeze({ session: ' (this session)', project: '', machine: ' (all projects)' });
|
|
1637
1803
|
|
|
1638
1804
|
// Walk one job's roster layers in order: this session, this project, all
|
|
@@ -1689,36 +1855,58 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
|
|
|
1689
1855
|
if (!ENGINE_ROLES.includes(normalizedRole)) {
|
|
1690
1856
|
throw new Error(`Unknown role "${role}". Known roles: ${ENGINE_ROLES.join(', ')}`);
|
|
1691
1857
|
}
|
|
1692
|
-
const registry = readEngineRegistry(root);
|
|
1858
|
+
const registry = readEngineRegistry(root, { now: options.now });
|
|
1693
1859
|
const engines = registry.engines
|
|
1694
1860
|
.filter((engine) => engine.roles.includes(normalizedRole))
|
|
1695
1861
|
.filter((engine) => !routerSkipsRole(engine, normalizedRole))
|
|
1696
|
-
.filter((engine) => engine
|
|
1862
|
+
.filter((engine) => engineReadyAt(engine, options.now))
|
|
1697
1863
|
.sort((a, b) => {
|
|
1698
1864
|
const byOrder = Number(a.fallback_order) - Number(b.fallback_order);
|
|
1699
1865
|
return byOrder || String(a.id).localeCompare(String(b.id));
|
|
1700
1866
|
});
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1867
|
+
// Ranking reads the router's history off disk (task receipts, mission
|
|
1868
|
+
// events, dispatch receipts). A valid roster pick decides without it, so
|
|
1869
|
+
// rank only when something asks for the full ranked list.
|
|
1870
|
+
let rankedCache = null;
|
|
1871
|
+
const rank = () => {
|
|
1872
|
+
if (!rankedCache) {
|
|
1873
|
+
rankedCache = routerBrain.rankEnginesDetailed(engines, {
|
|
1874
|
+
root,
|
|
1875
|
+
taskType: options.taskType || options.task_type || normalizedRole,
|
|
1876
|
+
lowStakes: options.lowStakes,
|
|
1877
|
+
stakes: options.stakes,
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
return rankedCache;
|
|
1881
|
+
};
|
|
1707
1882
|
const choice = rosterChoice(normalizedRole, withProjectRoster(registry, root, options), options);
|
|
1708
1883
|
if (choice) {
|
|
1709
1884
|
// The job's own workers go first, in order, then the router's list.
|
|
1710
1885
|
const after = choice.walk.slice(choice.lead_index + 1).filter((step) => !step.skip).map((step) => step.raw);
|
|
1711
1886
|
const lead = [choice.engine, ...after].filter((engine, index, list) => list.findIndex((other) => other.id === engine.id) === index);
|
|
1712
1887
|
const leadIds = new Set(lead.map((engine) => engine.id));
|
|
1713
|
-
|
|
1888
|
+
const result = {
|
|
1714
1889
|
engine: choice.engine,
|
|
1715
1890
|
reason: choice.reason,
|
|
1716
1891
|
source: choice.source,
|
|
1717
1892
|
job: choice.job,
|
|
1718
1893
|
team: choice.team,
|
|
1719
|
-
ranked: [...lead, ...ranked.candidates.filter((candidate) => !leadIds.has(candidate.id))],
|
|
1720
1894
|
};
|
|
1895
|
+
let rankedList = null;
|
|
1896
|
+
Object.defineProperty(result, 'ranked', {
|
|
1897
|
+
enumerable: true,
|
|
1898
|
+
configurable: true,
|
|
1899
|
+
get() {
|
|
1900
|
+
if (!rankedList) rankedList = [...lead, ...rank().candidates.filter((candidate) => !leadIds.has(candidate.id))];
|
|
1901
|
+
return rankedList;
|
|
1902
|
+
},
|
|
1903
|
+
set(value) {
|
|
1904
|
+
rankedList = value;
|
|
1905
|
+
},
|
|
1906
|
+
});
|
|
1907
|
+
return result;
|
|
1721
1908
|
}
|
|
1909
|
+
const ranked = rank();
|
|
1722
1910
|
return { engine: ranked.candidates[0] || null, reason: routerPickExplanation(ranked), source: 'router', team: [], ranked: ranked.candidates };
|
|
1723
1911
|
}
|
|
1724
1912
|
|
|
@@ -1754,7 +1942,8 @@ function resolveEngineForRole(role, root = process.cwd(), options = {}) {
|
|
|
1754
1942
|
}
|
|
1755
1943
|
|
|
1756
1944
|
// Everything the roster pins on this job's live pick when that pick is this
|
|
1757
|
-
// exact engine: roster_model, roster_effort, roster_max_seconds
|
|
1945
|
+
// exact engine: roster_model, roster_effort, roster_max_seconds, roster_prep.
|
|
1946
|
+
// {} otherwise.
|
|
1758
1947
|
// A caller that already chose an engine (a mission's preferred engine,
|
|
1759
1948
|
// --engine) still gets the pins.
|
|
1760
1949
|
function rosterPinFor(role, engineId, root = process.cwd(), options = {}) {
|
|
@@ -1763,7 +1952,7 @@ function rosterPinFor(role, engineId, root = process.cwd(), options = {}) {
|
|
|
1763
1952
|
const choice = rosterChoice(normalizedRole, withProjectRoster(readEngineRegistry(root, { persist: false }), root, options), options);
|
|
1764
1953
|
if (!choice || choice.engine.id !== engineId) return {};
|
|
1765
1954
|
const pin = {};
|
|
1766
|
-
for (const field of
|
|
1955
|
+
for (const field of ROSTER_PIN_FIELDS) {
|
|
1767
1956
|
if (choice.engine[field]) pin[field] = choice.engine[field];
|
|
1768
1957
|
}
|
|
1769
1958
|
return pin;
|
|
@@ -1779,7 +1968,7 @@ function resolveEngineForRoleWithPreference(role, root = process.cwd(), preferre
|
|
|
1779
1968
|
};
|
|
1780
1969
|
}
|
|
1781
1970
|
const preferred = resolveRegisteredEngine(requested, root);
|
|
1782
|
-
if (preferred
|
|
1971
|
+
if (engineReadyAt(preferred, options.now)) {
|
|
1783
1972
|
const pin = rosterPinFor(role, preferred.id, root, options);
|
|
1784
1973
|
return {
|
|
1785
1974
|
engine: Object.keys(pin).length ? { ...preferred, ...pin } : preferred,
|
|
@@ -1805,6 +1994,7 @@ function setEngineHealth(name, status, root = process.cwd()) {
|
|
|
1805
1994
|
if (!ENGINE_HEALTH_STATUSES.includes(normalizedStatus)) {
|
|
1806
1995
|
throw new Error(`Unknown health "${status}". Known health statuses: ${ENGINE_HEALTH_STATUSES.join(', ')}`);
|
|
1807
1996
|
}
|
|
1997
|
+
if (normalizedStatus === 'cooling') return setEngineCooling(id, { root });
|
|
1808
1998
|
const registry = readEngineRegistry(root, { persist: false });
|
|
1809
1999
|
const engines = registry.engines.map((engine) => {
|
|
1810
2000
|
if (engine.id !== id) return engine;
|
|
@@ -1817,6 +2007,55 @@ function setEngineHealth(name, status, root = process.cwd()) {
|
|
|
1817
2007
|
return engines.find((engine) => engine.id === id);
|
|
1818
2008
|
}
|
|
1819
2009
|
|
|
2010
|
+
// Bench an engine after a stall: it is not ready until the cooldown passes,
|
|
2011
|
+
// so every resolver hands its jobs to the roster's next worker meanwhile.
|
|
2012
|
+
function setEngineCooling(name, { root = process.cwd(), now, reason = 'stalled', minutes } = {}) {
|
|
2013
|
+
const id = canonicalEngineName(name);
|
|
2014
|
+
if (!id) throw new Error(`Unknown engine "${name}". Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`);
|
|
2015
|
+
const at = nowMs(now);
|
|
2016
|
+
const ms = Number(minutes) > 0 ? Number(minutes) * 60000 : engineCooldownMs();
|
|
2017
|
+
const registry = readEngineRegistry(root, { persist: false, now });
|
|
2018
|
+
const engines = registry.engines.map((engine) => {
|
|
2019
|
+
if (engine.id !== id) return engine;
|
|
2020
|
+
return {
|
|
2021
|
+
...engine,
|
|
2022
|
+
health: {
|
|
2023
|
+
status: 'cooling',
|
|
2024
|
+
last_failure_ts: new Date(at).toISOString(),
|
|
2025
|
+
cooling_until: new Date(at + ms).toISOString(),
|
|
2026
|
+
cooling_reason: String(reason || 'stalled'),
|
|
2027
|
+
},
|
|
2028
|
+
};
|
|
2029
|
+
});
|
|
2030
|
+
writeEngineRegistry(root, { ...registry, updated_at: new Date(at).toISOString(), engines });
|
|
2031
|
+
return engines.find((engine) => engine.id === id);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// "cooling until 14:32 (stalled)" for the roster and engine views.
|
|
2035
|
+
function coolingView(engine) {
|
|
2036
|
+
const health = (engine && engine.health) || {};
|
|
2037
|
+
const until = new Date(health.cooling_until);
|
|
2038
|
+
const clock = Number.isFinite(until.getTime())
|
|
2039
|
+
? `${String(until.getHours()).padStart(2, '0')}:${String(until.getMinutes()).padStart(2, '0')}`
|
|
2040
|
+
: 'later';
|
|
2041
|
+
const reason = String(health.cooling_reason || 'stalled');
|
|
2042
|
+
return { until: health.cooling_until || null, reason, text: `cooling until ${clock} (${reason})` };
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// The one health write for a finished run: a stall benches the engine for
|
|
2046
|
+
// the cooldown, a credit wall or a missing binary writes its own status, and
|
|
2047
|
+
// a real task failure changes nothing. Returns the updated engine, or null.
|
|
2048
|
+
function recordEngineRunHealth(engineId, result, root = process.cwd(), { ok = false, now } = {}) {
|
|
2049
|
+
if (!engineId) return null;
|
|
2050
|
+
if (ok) return setEngineHealth(engineId, 'ready', root);
|
|
2051
|
+
const status = engineFailureHealthStatus({ ...result, status: 'errored' });
|
|
2052
|
+
if (status === 'error') {
|
|
2053
|
+
const stall = engineStallReason(result);
|
|
2054
|
+
if (stall) return setEngineCooling(engineId, { root, now, reason: stall.kind === 'transient' ? 'connection dropped' : 'stalled' });
|
|
2055
|
+
}
|
|
2056
|
+
return status ? setEngineHealth(engineId, status, root) : null;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1820
2059
|
// Execution-stage guard. Routing hands out engines from policy without ever
|
|
1821
2060
|
// touching the machine, so the moment we are about to spawn one is where a
|
|
1822
2061
|
// missing binary has to fail loudly, in one plain sentence naming the binary.
|
|
@@ -1872,6 +2111,7 @@ module.exports = {
|
|
|
1872
2111
|
parseRosterUntil,
|
|
1873
2112
|
rosterPickExpired,
|
|
1874
2113
|
rosterPinFor,
|
|
2114
|
+
ROSTER_PIN_FIELDS,
|
|
1875
2115
|
rosterMaxText,
|
|
1876
2116
|
mainCheckoutRoot,
|
|
1877
2117
|
ROSTER_LAYERS,
|
|
@@ -1896,6 +2136,10 @@ module.exports = {
|
|
|
1896
2136
|
resolveEngineForRole,
|
|
1897
2137
|
resolveEngineForRoleWithPreference,
|
|
1898
2138
|
engineFailureHealthStatus,
|
|
2139
|
+
engineStallReason,
|
|
2140
|
+
engineReadyAt,
|
|
2141
|
+
coolingView,
|
|
2142
|
+
recordEngineRunHealth,
|
|
1899
2143
|
setEngineOverrides,
|
|
1900
2144
|
setEngineHealth,
|
|
1901
2145
|
};
|