atris 3.61.0 → 3.63.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/features/README.md +8 -0
- package/atris/skills/README.md +1 -0
- package/atris/skills/commerce/SKILL.md +96 -0
- package/atris/skills/engines/SKILL.md +9 -9
- package/bin/atris.js +12 -0
- package/commands/autoland.js +36 -5
- package/commands/autopilot.js +105 -6
- package/commands/buy.js +242 -0
- package/commands/engine.js +96 -21
- package/commands/host.js +56 -4
- package/commands/land.js +129 -8
- package/commands/member.js +68 -14
- package/commands/mission.js +116 -4
- package/commands/one-lap.js +14 -10
- package/commands/stream.js +56 -22
- package/commands/task.js +7 -0
- package/commands/team.js +82 -10
- package/commands/transactions.js +115 -0
- package/commands/worktree.js +39 -0
- package/commands/youtube.js +58 -26
- package/lib/commerce.js +75 -0
- package/lib/engine-ask.js +112 -9
- package/lib/engine-registry.js +300 -39
- package/lib/fleet.js +444 -26
- package/lib/functional-owner.js +15 -6
- package/lib/host.js +296 -170
- package/lib/known-commands.js +1 -1
- package/lib/member-engine.js +25 -1
- package/lib/member-park.js +138 -0
- package/lib/member-scaffold.js +39 -0
- package/lib/member-type.js +27 -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/runner-command.js +37 -2
- 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 +169 -23
- package/scripts/det/ytquote-repair.js +18 -4
- package/scripts/det/ytrail-bench.js +214 -0
- package/scripts/det/ytrail-eval.js +128 -56
- 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);
|
|
@@ -754,6 +860,22 @@ function resolveEngineModelWords(text) {
|
|
|
754
860
|
throw new Error(`"${clean}" is not an engine or a model atris knows`);
|
|
755
861
|
}
|
|
756
862
|
|
|
863
|
+
// The engine a model name belongs to, read the same way a roster line is,
|
|
864
|
+
// or '' when the name is not one atris can place. The claude cli's
|
|
865
|
+
// own aliases ("default", "opusplan", with or without [1m]) count as claude.
|
|
866
|
+
function modelEngineName(model) {
|
|
867
|
+
const base = String(model || '').trim().replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
868
|
+
try {
|
|
869
|
+
return resolveEngineModelWords(base).engine || '';
|
|
870
|
+
} catch {
|
|
871
|
+
return CLAUDE_MODEL_ALIASES.includes(base.toLowerCase()) ? 'claude' : '';
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function isClaudeFamilyModel(model) {
|
|
876
|
+
return CLAUDE_FAMILY_ENGINES.includes(modelEngineName(model));
|
|
877
|
+
}
|
|
878
|
+
|
|
757
879
|
// What an engine can do by its seed, used to check hand-written lines before
|
|
758
880
|
// any registry is read.
|
|
759
881
|
function engineCanTake(engineId, role) {
|
|
@@ -787,6 +909,7 @@ function rosterPickFromValue(value, role, options = {}) {
|
|
|
787
909
|
let maxSeconds = null;
|
|
788
910
|
let namedModel = false;
|
|
789
911
|
let namedEffort = false;
|
|
912
|
+
let prep = '';
|
|
790
913
|
for (const raw of extras) {
|
|
791
914
|
// "max: 20 min" and "until: 2026-10-24" read like "max 20 min".
|
|
792
915
|
const part = raw.replace(/^(max|until)\s*:\s*/i, '$1 ');
|
|
@@ -795,7 +918,15 @@ function rosterPickFromValue(value, role, options = {}) {
|
|
|
795
918
|
const maxMatch = /^max\b/i.test(part) && !/^max$/i.test(part);
|
|
796
919
|
const modelMatch = /^model\s*(?::\s*|\s+)(.+)$/i.exec(part);
|
|
797
920
|
const effortMatch = /^effort\s*(?::\s*|\s+)(.+)$/i.exec(part);
|
|
798
|
-
|
|
921
|
+
const prepMatch = /^prep\s*(?::\s*|\s+)(.+)$/i.exec(part);
|
|
922
|
+
if (prepMatch && !prep) {
|
|
923
|
+
// "prep: search": that job's lead reads the task first and hands this
|
|
924
|
+
// worker a trimmed brief. Whether it names this same job is checked
|
|
925
|
+
// where the job is known.
|
|
926
|
+
const words = prepMatch[1].trim();
|
|
927
|
+
prep = rosterJobKey(splitJobName(words).name);
|
|
928
|
+
if (!prep) throw new Error(`"prep: ${words}" does not name a job; use prep: search or another job name`);
|
|
929
|
+
} else if (modelMatch && !namedModel) {
|
|
799
930
|
if (primary.model) throw new Error(`names two models for ${primary.engine}`);
|
|
800
931
|
const words = modelMatch[1].trim();
|
|
801
932
|
if (!engineTakesModel(primary.engine)) throw new Error(`${primary.engine} runs one fixed model and cannot take "${words}"`);
|
|
@@ -840,12 +971,21 @@ function rosterPickFromValue(value, role, options = {}) {
|
|
|
840
971
|
...(backup && backup.model ? { backup_model: backup.model } : {}),
|
|
841
972
|
...(backup && backup.effort ? { backup_effort: backup.effort } : {}),
|
|
842
973
|
...(maxSeconds ? { max_seconds: maxSeconds } : {}),
|
|
974
|
+
...(prep ? { prep } : {}),
|
|
843
975
|
until,
|
|
844
976
|
...(until ? {} : { never_expires: true }),
|
|
845
977
|
...(untilYearless ? { until_needs_year: untilYearless } : {}),
|
|
846
978
|
};
|
|
847
979
|
}
|
|
848
980
|
|
|
981
|
+
// A worker that names its own job as prep would wait on itself. The line
|
|
982
|
+
// still counts; only the prep is dropped, with a plain warning.
|
|
983
|
+
function selfPrepWarning(worker, key, label) {
|
|
984
|
+
if (!worker || !worker.prep || worker.prep !== key) return '';
|
|
985
|
+
delete worker.prep;
|
|
986
|
+
return `asks ${label} to prep for itself, so this worker runs without prep`;
|
|
987
|
+
}
|
|
988
|
+
|
|
849
989
|
// A job line's name part, "small build (like build)", split into the name
|
|
850
990
|
// and the kind it says it is like.
|
|
851
991
|
function splitJobName(text) {
|
|
@@ -873,6 +1013,7 @@ function rosterWorkerFromValue(value, role, options = {}) {
|
|
|
873
1013
|
model: pick.model,
|
|
874
1014
|
...(pick.effort ? { effort: pick.effort } : {}),
|
|
875
1015
|
...(pick.max_seconds ? { max_seconds: pick.max_seconds } : {}),
|
|
1016
|
+
...(pick.prep ? { prep: pick.prep } : {}),
|
|
876
1017
|
until: pick.until,
|
|
877
1018
|
...(pick.never_expires ? { never_expires: true } : {}),
|
|
878
1019
|
...(pick.until_needs_year ? { until_needs_year: pick.until_needs_year } : {}),
|
|
@@ -921,6 +1062,7 @@ function pickWorkers(pick) {
|
|
|
921
1062
|
model: pick.model || '',
|
|
922
1063
|
...(pick.effort ? { effort: pick.effort } : {}),
|
|
923
1064
|
...cap,
|
|
1065
|
+
...(pick.prep ? { prep: pick.prep } : {}),
|
|
924
1066
|
until: pick.until,
|
|
925
1067
|
...(pick.never_expires ? { never_expires: true } : {}),
|
|
926
1068
|
...(pick.line ? { line: pick.line } : {}),
|
|
@@ -971,6 +1113,8 @@ function parseRosterMarkdown(text, file, options = {}) {
|
|
|
971
1113
|
for (const entry of lines) {
|
|
972
1114
|
try {
|
|
973
1115
|
const worker = rosterWorkerFromValue(entry.value, ENGINE_JOBS[kind], options);
|
|
1116
|
+
const selfPrep = selfPrepWarning(worker, key, label);
|
|
1117
|
+
if (selfPrep) warn(entry, selfPrep);
|
|
974
1118
|
workers.push({ ...worker, line: entry.lineNumber, text: entry.raw });
|
|
975
1119
|
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
1120
|
} catch (error) {
|
|
@@ -988,6 +1132,7 @@ function parseRosterMarkdown(text, file, options = {}) {
|
|
|
988
1132
|
backup: second ? second.engine : '',
|
|
989
1133
|
...(second && second.model ? { backup_model: second.model } : {}),
|
|
990
1134
|
...(first.max_seconds ? { max_seconds: first.max_seconds } : {}),
|
|
1135
|
+
...(first.prep ? { prep: first.prep } : {}),
|
|
991
1136
|
until: first.until,
|
|
992
1137
|
...(first.never_expires ? { never_expires: true } : {}),
|
|
993
1138
|
...(builtIn ? {} : { like: kind }),
|
|
@@ -1040,6 +1185,8 @@ function parseRosterMarkdown(text, file, options = {}) {
|
|
|
1040
1185
|
}
|
|
1041
1186
|
try {
|
|
1042
1187
|
const pick = rosterPickFromValue(entry.value, ENGINE_JOBS[kind], options);
|
|
1188
|
+
const selfPrep = selfPrepWarning(pick, key, label);
|
|
1189
|
+
if (selfPrep) warn(entry, selfPrep);
|
|
1043
1190
|
picks[key] = { ...pick, ...(builtIn ? {} : { like: kind }), file: display, line: entry.lineNumber };
|
|
1044
1191
|
if (pick.until_needs_year) {
|
|
1045
1192
|
warn(entry, `has no year in its until date, so ${label} counts as expired; write until ${pick.until_needs_year}`);
|
|
@@ -1215,12 +1362,13 @@ function workerModelText(engine, model) {
|
|
|
1215
1362
|
return model;
|
|
1216
1363
|
}
|
|
1217
1364
|
|
|
1218
|
-
// One worker line: "- claude code, model: opus 5.5, effort: high, max: 20 min, until 2026-10-24".
|
|
1365
|
+
// One worker line: "- claude code, model: opus 5.5, effort: high, max: 20 min, prep: search, until 2026-10-24".
|
|
1219
1366
|
function workerLineText(worker) {
|
|
1220
1367
|
const parts = [rosterToolLabel(worker.engine)];
|
|
1221
1368
|
if (worker.model) parts.push(`model: ${workerModelText(worker.engine, worker.model)}`);
|
|
1222
1369
|
if (worker.effort) parts.push(`effort: ${worker.effort}`);
|
|
1223
1370
|
if (Number(worker.max_seconds) > 0) parts.push(`max: ${rosterMaxText(worker.max_seconds).replace(/^max /, '')}`);
|
|
1371
|
+
if (worker.prep) parts.push(`prep: ${rosterJobLabel(worker.prep)}`);
|
|
1224
1372
|
if (worker.until) parts.push(`until ${worker.until}`);
|
|
1225
1373
|
return `- ${parts.join(', ')}`;
|
|
1226
1374
|
}
|
|
@@ -1360,6 +1508,24 @@ function assignedWorkerLines(key, engineName, options, context) {
|
|
|
1360
1508
|
if (keep.length === currentLines.length) throw new Error(`${label} has no ${tool} worker to remove`);
|
|
1361
1509
|
return { lines: keep.length ? keep : null };
|
|
1362
1510
|
}
|
|
1511
|
+
// --promote moves the first line naming exactly the tool and model (and
|
|
1512
|
+
// effort, if given) to the top, every line exactly as written. Unlike an
|
|
1513
|
+
// assign, it rewrites nothing: the demoted lead keeps its own cap, prep,
|
|
1514
|
+
// and notes. With no model the words match only a line with no model, so
|
|
1515
|
+
// promoting "claude" can never move "claude, model: ..." by accident.
|
|
1516
|
+
if (options.promote !== undefined) {
|
|
1517
|
+
const words = String(options.promote).trim();
|
|
1518
|
+
const want = resolveRosterWords(words);
|
|
1519
|
+
const matches = currentWorkers
|
|
1520
|
+
.map((worker, index) => index)
|
|
1521
|
+
.filter((index) => currentWorkers[index].engine === want.engine
|
|
1522
|
+
&& (currentWorkers[index].model || '') === (want.model || '')
|
|
1523
|
+
&& (currentWorkers[index].effort || '') === (want.effort || ''));
|
|
1524
|
+
if (!matches.length) throw new Error(`${label} has no ${words} worker to move up`);
|
|
1525
|
+
const index = matches[0];
|
|
1526
|
+
const note = matches.length > 1 ? `${label} has ${matches.length} workers named ${words}; the first moved up` : '';
|
|
1527
|
+
return { lines: [currentLines[index], ...currentLines.filter((line, i) => i !== index)], ...(note ? { note } : {}) };
|
|
1528
|
+
}
|
|
1363
1529
|
const likeName = String(options.like || '').trim();
|
|
1364
1530
|
const likeKind = likeName ? rosterJob(likeName) : '';
|
|
1365
1531
|
if (likeName && !likeKind) throw new Error(`unknown kind "${likeName}". use --like search, build, or review`);
|
|
@@ -1397,12 +1563,17 @@ function assignedWorkerLines(key, engineName, options, context) {
|
|
|
1397
1563
|
if (effort && !engineEffortLevels(engine.id).includes(effort)) throw new Error(effortRefusal(engine.id, effort));
|
|
1398
1564
|
const maxSeconds = options.max === undefined ? null : parseRosterMax(`max ${String(options.max).replace(/^max\s*:?\s*/i, '')}`);
|
|
1399
1565
|
if (options.max !== undefined && !maxSeconds) throw new Error(`"${options.max}" is not a time cap; use --max "20 min", --max 90s, or --max 2h`);
|
|
1566
|
+
const prepWords = options.prep === undefined ? '' : String(options.prep).trim();
|
|
1567
|
+
const prep = prepWords ? rosterJobKey(splitJobName(prepWords).name) : '';
|
|
1568
|
+
if (options.prep !== undefined && !prep) throw new Error(`"${options.prep}" is not a job name; use --prep search or another job`);
|
|
1569
|
+
if (prep && prep === key) throw new Error(`${label} cannot prep for itself; pick another job, like --prep search`);
|
|
1400
1570
|
const now = rosterDate(options.now);
|
|
1401
1571
|
const line = workerLineText({
|
|
1402
1572
|
engine: engine.id,
|
|
1403
1573
|
model,
|
|
1404
1574
|
...(effort ? { effort } : {}),
|
|
1405
1575
|
...(maxSeconds ? { max_seconds: maxSeconds } : {}),
|
|
1576
|
+
...(prep ? { prep } : {}),
|
|
1406
1577
|
until: days ? rosterUntil(now, days) : '',
|
|
1407
1578
|
});
|
|
1408
1579
|
const heading = jobHeadingText(key, builtIn ? '' : kind);
|
|
@@ -1470,13 +1641,14 @@ function setRosterPick(jobName, engineName, options = {}, root = process.cwd())
|
|
|
1470
1641
|
const worker = savedPick && savedPick.workers ? savedPick.workers.find((item) => item.line === entry.lineNumber) : null;
|
|
1471
1642
|
return worker || { engine: rosterToolName(String(entry.value).split(',')[0]), model: '' };
|
|
1472
1643
|
});
|
|
1473
|
-
const { lines, heading } = assignedWorkerLines(key, engineName, options, {
|
|
1644
|
+
const { lines, heading, note } = assignedWorkerLines(key, engineName, options, {
|
|
1474
1645
|
registry,
|
|
1475
1646
|
savedPick,
|
|
1476
1647
|
currentLines: workerEntries.map((entry) => entry.raw),
|
|
1477
1648
|
currentWorkers,
|
|
1478
1649
|
jobName,
|
|
1479
1650
|
});
|
|
1651
|
+
if (note && typeof options.note === 'function') options.note(note);
|
|
1480
1652
|
const next = replaceJobSection(base, {
|
|
1481
1653
|
matches: (title) => rosterJobHeading(title).key === key,
|
|
1482
1654
|
// Only an assign names a heading, and only when the job's kind changes;
|
|
@@ -1576,7 +1748,7 @@ const ROSTER_LAYERS = Object.freeze(['session', 'project', 'machine']);
|
|
|
1576
1748
|
// or not ready (down, or it cannot do this kind of work).
|
|
1577
1749
|
function rosterWorkerWalk(pick, role, registry, now) {
|
|
1578
1750
|
const ready = registry.engines
|
|
1579
|
-
.filter((engine) => engine
|
|
1751
|
+
.filter((engine) => engineReadyAt(engine, now))
|
|
1580
1752
|
.filter((engine) => engineHasRole(engine, role));
|
|
1581
1753
|
return pickWorkers(pick).map((worker) => {
|
|
1582
1754
|
if (worker.error) return { worker, skip: 'bad line', engine: null, raw: null };
|
|
@@ -1584,17 +1756,24 @@ function rosterWorkerWalk(pick, role, registry, now) {
|
|
|
1584
1756
|
const saved = savedRosterModel(worker.engine, worker.model);
|
|
1585
1757
|
if (saved === null && !worker.lenient_model) return { worker, skip: 'bad model', engine: null, raw: null };
|
|
1586
1758
|
const raw = ready.find((engine) => engine.id === worker.engine) || null;
|
|
1587
|
-
if (!raw)
|
|
1759
|
+
if (!raw) {
|
|
1760
|
+
// A worker benched after a stall says so and when it comes back.
|
|
1761
|
+
const benched = registry.engines.find((engine) => engine.id === worker.engine && engineCooling(engine, now));
|
|
1762
|
+
if (benched) return { worker, skip: 'cooling', cooling: coolingView(benched), engine: null, raw: null };
|
|
1763
|
+
return { worker, skip: 'not ready', engine: null, raw: null };
|
|
1764
|
+
}
|
|
1588
1765
|
// A backup runs its own model and effort, never the lead's.
|
|
1589
1766
|
const model = saved || '';
|
|
1590
1767
|
const effort = savedRosterEffort(raw.id, worker.effort);
|
|
1591
1768
|
const maxSeconds = Number(worker.max_seconds) > 0 ? Number(worker.max_seconds) : 0;
|
|
1592
|
-
const
|
|
1769
|
+
const prep = worker.prep || '';
|
|
1770
|
+
const engine = model || effort || maxSeconds || prep
|
|
1593
1771
|
? {
|
|
1594
1772
|
...raw,
|
|
1595
1773
|
...(model ? { roster_model: model } : {}),
|
|
1596
1774
|
...(effort ? { roster_effort: effort } : {}),
|
|
1597
1775
|
...(maxSeconds ? { roster_max_seconds: maxSeconds } : {}),
|
|
1776
|
+
...(prep ? { roster_prep: prep } : {}),
|
|
1598
1777
|
}
|
|
1599
1778
|
: raw;
|
|
1600
1779
|
return { worker, skip: '', engine, raw };
|
|
@@ -1615,6 +1794,7 @@ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeTe
|
|
|
1615
1794
|
const after = walk.slice(leadIndex + 1).filter((step) => !step.skip);
|
|
1616
1795
|
const first = walk[0];
|
|
1617
1796
|
const why = first.skip === 'expired' ? 'expired'
|
|
1797
|
+
: first.skip === 'cooling' ? `is ${first.cooling.text}`
|
|
1618
1798
|
: first.skip === 'bad model' ? `names a model ${first.worker.engine} cannot run`
|
|
1619
1799
|
: first.skip === 'bad line' ? 'has a bad first line'
|
|
1620
1800
|
: 'is not ready';
|
|
@@ -1633,6 +1813,8 @@ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeTe
|
|
|
1633
1813
|
};
|
|
1634
1814
|
}
|
|
1635
1815
|
|
|
1816
|
+
const ROSTER_PIN_FIELDS = Object.freeze(['roster_model', 'roster_effort', 'roster_max_seconds', 'roster_prep']);
|
|
1817
|
+
|
|
1636
1818
|
const ROSTER_SCOPE_TEXT = Object.freeze({ session: ' (this session)', project: '', machine: ' (all projects)' });
|
|
1637
1819
|
|
|
1638
1820
|
// Walk one job's roster layers in order: this session, this project, all
|
|
@@ -1689,36 +1871,58 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
|
|
|
1689
1871
|
if (!ENGINE_ROLES.includes(normalizedRole)) {
|
|
1690
1872
|
throw new Error(`Unknown role "${role}". Known roles: ${ENGINE_ROLES.join(', ')}`);
|
|
1691
1873
|
}
|
|
1692
|
-
const registry = readEngineRegistry(root);
|
|
1874
|
+
const registry = readEngineRegistry(root, { now: options.now });
|
|
1693
1875
|
const engines = registry.engines
|
|
1694
1876
|
.filter((engine) => engine.roles.includes(normalizedRole))
|
|
1695
1877
|
.filter((engine) => !routerSkipsRole(engine, normalizedRole))
|
|
1696
|
-
.filter((engine) => engine
|
|
1878
|
+
.filter((engine) => engineReadyAt(engine, options.now))
|
|
1697
1879
|
.sort((a, b) => {
|
|
1698
1880
|
const byOrder = Number(a.fallback_order) - Number(b.fallback_order);
|
|
1699
1881
|
return byOrder || String(a.id).localeCompare(String(b.id));
|
|
1700
1882
|
});
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1883
|
+
// Ranking reads the router's history off disk (task receipts, mission
|
|
1884
|
+
// events, dispatch receipts). A valid roster pick decides without it, so
|
|
1885
|
+
// rank only when something asks for the full ranked list.
|
|
1886
|
+
let rankedCache = null;
|
|
1887
|
+
const rank = () => {
|
|
1888
|
+
if (!rankedCache) {
|
|
1889
|
+
rankedCache = routerBrain.rankEnginesDetailed(engines, {
|
|
1890
|
+
root,
|
|
1891
|
+
taskType: options.taskType || options.task_type || normalizedRole,
|
|
1892
|
+
lowStakes: options.lowStakes,
|
|
1893
|
+
stakes: options.stakes,
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
return rankedCache;
|
|
1897
|
+
};
|
|
1707
1898
|
const choice = rosterChoice(normalizedRole, withProjectRoster(registry, root, options), options);
|
|
1708
1899
|
if (choice) {
|
|
1709
1900
|
// The job's own workers go first, in order, then the router's list.
|
|
1710
1901
|
const after = choice.walk.slice(choice.lead_index + 1).filter((step) => !step.skip).map((step) => step.raw);
|
|
1711
1902
|
const lead = [choice.engine, ...after].filter((engine, index, list) => list.findIndex((other) => other.id === engine.id) === index);
|
|
1712
1903
|
const leadIds = new Set(lead.map((engine) => engine.id));
|
|
1713
|
-
|
|
1904
|
+
const result = {
|
|
1714
1905
|
engine: choice.engine,
|
|
1715
1906
|
reason: choice.reason,
|
|
1716
1907
|
source: choice.source,
|
|
1717
1908
|
job: choice.job,
|
|
1718
1909
|
team: choice.team,
|
|
1719
|
-
ranked: [...lead, ...ranked.candidates.filter((candidate) => !leadIds.has(candidate.id))],
|
|
1720
1910
|
};
|
|
1911
|
+
let rankedList = null;
|
|
1912
|
+
Object.defineProperty(result, 'ranked', {
|
|
1913
|
+
enumerable: true,
|
|
1914
|
+
configurable: true,
|
|
1915
|
+
get() {
|
|
1916
|
+
if (!rankedList) rankedList = [...lead, ...rank().candidates.filter((candidate) => !leadIds.has(candidate.id))];
|
|
1917
|
+
return rankedList;
|
|
1918
|
+
},
|
|
1919
|
+
set(value) {
|
|
1920
|
+
rankedList = value;
|
|
1921
|
+
},
|
|
1922
|
+
});
|
|
1923
|
+
return result;
|
|
1721
1924
|
}
|
|
1925
|
+
const ranked = rank();
|
|
1722
1926
|
return { engine: ranked.candidates[0] || null, reason: routerPickExplanation(ranked), source: 'router', team: [], ranked: ranked.candidates };
|
|
1723
1927
|
}
|
|
1724
1928
|
|
|
@@ -1754,7 +1958,8 @@ function resolveEngineForRole(role, root = process.cwd(), options = {}) {
|
|
|
1754
1958
|
}
|
|
1755
1959
|
|
|
1756
1960
|
// 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
|
|
1961
|
+
// exact engine: roster_model, roster_effort, roster_max_seconds, roster_prep.
|
|
1962
|
+
// {} otherwise.
|
|
1758
1963
|
// A caller that already chose an engine (a mission's preferred engine,
|
|
1759
1964
|
// --engine) still gets the pins.
|
|
1760
1965
|
function rosterPinFor(role, engineId, root = process.cwd(), options = {}) {
|
|
@@ -1763,7 +1968,7 @@ function rosterPinFor(role, engineId, root = process.cwd(), options = {}) {
|
|
|
1763
1968
|
const choice = rosterChoice(normalizedRole, withProjectRoster(readEngineRegistry(root, { persist: false }), root, options), options);
|
|
1764
1969
|
if (!choice || choice.engine.id !== engineId) return {};
|
|
1765
1970
|
const pin = {};
|
|
1766
|
-
for (const field of
|
|
1971
|
+
for (const field of ROSTER_PIN_FIELDS) {
|
|
1767
1972
|
if (choice.engine[field]) pin[field] = choice.engine[field];
|
|
1768
1973
|
}
|
|
1769
1974
|
return pin;
|
|
@@ -1779,7 +1984,7 @@ function resolveEngineForRoleWithPreference(role, root = process.cwd(), preferre
|
|
|
1779
1984
|
};
|
|
1780
1985
|
}
|
|
1781
1986
|
const preferred = resolveRegisteredEngine(requested, root);
|
|
1782
|
-
if (preferred
|
|
1987
|
+
if (engineReadyAt(preferred, options.now)) {
|
|
1783
1988
|
const pin = rosterPinFor(role, preferred.id, root, options);
|
|
1784
1989
|
return {
|
|
1785
1990
|
engine: Object.keys(pin).length ? { ...preferred, ...pin } : preferred,
|
|
@@ -1805,6 +2010,7 @@ function setEngineHealth(name, status, root = process.cwd()) {
|
|
|
1805
2010
|
if (!ENGINE_HEALTH_STATUSES.includes(normalizedStatus)) {
|
|
1806
2011
|
throw new Error(`Unknown health "${status}". Known health statuses: ${ENGINE_HEALTH_STATUSES.join(', ')}`);
|
|
1807
2012
|
}
|
|
2013
|
+
if (normalizedStatus === 'cooling') return setEngineCooling(id, { root });
|
|
1808
2014
|
const registry = readEngineRegistry(root, { persist: false });
|
|
1809
2015
|
const engines = registry.engines.map((engine) => {
|
|
1810
2016
|
if (engine.id !== id) return engine;
|
|
@@ -1817,6 +2023,55 @@ function setEngineHealth(name, status, root = process.cwd()) {
|
|
|
1817
2023
|
return engines.find((engine) => engine.id === id);
|
|
1818
2024
|
}
|
|
1819
2025
|
|
|
2026
|
+
// Bench an engine after a stall: it is not ready until the cooldown passes,
|
|
2027
|
+
// so every resolver hands its jobs to the roster's next worker meanwhile.
|
|
2028
|
+
function setEngineCooling(name, { root = process.cwd(), now, reason = 'stalled', minutes } = {}) {
|
|
2029
|
+
const id = canonicalEngineName(name);
|
|
2030
|
+
if (!id) throw new Error(`Unknown engine "${name}". Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`);
|
|
2031
|
+
const at = nowMs(now);
|
|
2032
|
+
const ms = Number(minutes) > 0 ? Number(minutes) * 60000 : engineCooldownMs();
|
|
2033
|
+
const registry = readEngineRegistry(root, { persist: false, now });
|
|
2034
|
+
const engines = registry.engines.map((engine) => {
|
|
2035
|
+
if (engine.id !== id) return engine;
|
|
2036
|
+
return {
|
|
2037
|
+
...engine,
|
|
2038
|
+
health: {
|
|
2039
|
+
status: 'cooling',
|
|
2040
|
+
last_failure_ts: new Date(at).toISOString(),
|
|
2041
|
+
cooling_until: new Date(at + ms).toISOString(),
|
|
2042
|
+
cooling_reason: String(reason || 'stalled'),
|
|
2043
|
+
},
|
|
2044
|
+
};
|
|
2045
|
+
});
|
|
2046
|
+
writeEngineRegistry(root, { ...registry, updated_at: new Date(at).toISOString(), engines });
|
|
2047
|
+
return engines.find((engine) => engine.id === id);
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// "cooling until 14:32 (stalled)" for the roster and engine views.
|
|
2051
|
+
function coolingView(engine) {
|
|
2052
|
+
const health = (engine && engine.health) || {};
|
|
2053
|
+
const until = new Date(health.cooling_until);
|
|
2054
|
+
const clock = Number.isFinite(until.getTime())
|
|
2055
|
+
? `${String(until.getHours()).padStart(2, '0')}:${String(until.getMinutes()).padStart(2, '0')}`
|
|
2056
|
+
: 'later';
|
|
2057
|
+
const reason = String(health.cooling_reason || 'stalled');
|
|
2058
|
+
return { until: health.cooling_until || null, reason, text: `cooling until ${clock} (${reason})` };
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// The one health write for a finished run: a stall benches the engine for
|
|
2062
|
+
// the cooldown, a credit wall or a missing binary writes its own status, and
|
|
2063
|
+
// a real task failure changes nothing. Returns the updated engine, or null.
|
|
2064
|
+
function recordEngineRunHealth(engineId, result, root = process.cwd(), { ok = false, now } = {}) {
|
|
2065
|
+
if (!engineId) return null;
|
|
2066
|
+
if (ok) return setEngineHealth(engineId, 'ready', root);
|
|
2067
|
+
const status = engineFailureHealthStatus({ ...result, status: 'errored' });
|
|
2068
|
+
if (status === 'error') {
|
|
2069
|
+
const stall = engineStallReason(result);
|
|
2070
|
+
if (stall) return setEngineCooling(engineId, { root, now, reason: stall.kind === 'transient' ? 'connection dropped' : 'stalled' });
|
|
2071
|
+
}
|
|
2072
|
+
return status ? setEngineHealth(engineId, status, root) : null;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
1820
2075
|
// Execution-stage guard. Routing hands out engines from policy without ever
|
|
1821
2076
|
// touching the machine, so the moment we are about to spawn one is where a
|
|
1822
2077
|
// missing binary has to fail loudly, in one plain sentence naming the binary.
|
|
@@ -1868,10 +2123,12 @@ module.exports = {
|
|
|
1868
2123
|
setRosterPick,
|
|
1869
2124
|
confirmRoster,
|
|
1870
2125
|
normalizeRosterModel,
|
|
2126
|
+
isClaudeFamilyModel,
|
|
1871
2127
|
rosterModelLabel,
|
|
1872
2128
|
parseRosterUntil,
|
|
1873
2129
|
rosterPickExpired,
|
|
1874
2130
|
rosterPinFor,
|
|
2131
|
+
ROSTER_PIN_FIELDS,
|
|
1875
2132
|
rosterMaxText,
|
|
1876
2133
|
mainCheckoutRoot,
|
|
1877
2134
|
ROSTER_LAYERS,
|
|
@@ -1896,6 +2153,10 @@ module.exports = {
|
|
|
1896
2153
|
resolveEngineForRole,
|
|
1897
2154
|
resolveEngineForRoleWithPreference,
|
|
1898
2155
|
engineFailureHealthStatus,
|
|
2156
|
+
engineStallReason,
|
|
2157
|
+
engineReadyAt,
|
|
2158
|
+
coolingView,
|
|
2159
|
+
recordEngineRunHealth,
|
|
1899
2160
|
setEngineOverrides,
|
|
1900
2161
|
setEngineHealth,
|
|
1901
2162
|
};
|