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.
Files changed (41) hide show
  1. package/README.md +37 -5
  2. package/atris/skills/engines/SKILL.md +9 -9
  3. package/bin/atris.js +1 -0
  4. package/commands/autoland.js +36 -5
  5. package/commands/autopilot.js +105 -6
  6. package/commands/engine.js +96 -21
  7. package/commands/host.js +56 -4
  8. package/commands/land.js +129 -8
  9. package/commands/member.js +33 -8
  10. package/commands/mission.js +112 -3
  11. package/commands/one-lap.js +14 -10
  12. package/commands/stream.js +56 -22
  13. package/commands/team.js +82 -10
  14. package/commands/worktree.js +39 -0
  15. package/commands/youtube.js +36 -26
  16. package/lib/engine-ask.js +112 -9
  17. package/lib/engine-registry.js +283 -39
  18. package/lib/fleet.js +444 -26
  19. package/lib/functional-owner.js +15 -6
  20. package/lib/host.js +296 -170
  21. package/lib/member-engine.js +11 -1
  22. package/lib/member-park.js +138 -0
  23. package/lib/member-scaffold.js +37 -0
  24. package/lib/roster-markdown.js +1 -1
  25. package/lib/roster-prep.js +289 -0
  26. package/lib/roster-runs.js +389 -0
  27. package/lib/roster-suggest.js +145 -0
  28. package/lib/router-brain.js +111 -3
  29. package/lib/self-drive.js +26 -2
  30. package/lib/state-detection.js +3 -3
  31. package/lib/task-db.js +38 -3
  32. package/lib/team-lineup.js +7 -2
  33. package/lib/todo.js +1 -1
  34. package/lib/wish-audit.js +1 -1
  35. package/package.json +1 -1
  36. package/scripts/det/cpu-hotspots.js +216 -0
  37. package/scripts/det/ytnotes +105 -19
  38. package/scripts/det/ytquote-repair.js +18 -4
  39. package/templates/members/host/MEMBER.md +5 -3
  40. package/templates/members/host/MISSION.md +1 -1
  41. package/templates/members/host/skills/host/SKILL.md +64 -16
@@ -14,7 +14,9 @@ const {
14
14
  engineTakesModel,
15
15
  engineEffortLevels,
16
16
  } = require('./runner-command');
17
- const { rankEnginesDetailed, routerPickExplanation } = require('./router-brain');
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
- result.rate_limit_info && JSON.stringify(result.rate_limit_info),
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
- // Timeouts and unavailable models are transient: 'error' keeps the engine
46
- // routable, while 'not_installed' would drop it from routing until a doctor run.
47
- return 'error';
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', 'opus 4.8', 'fable', 'haiku']), fallback_order: 20 }),
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', 'opus 4.8', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
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-4-8', 'opencode/gpt-5.2']), fallback_order: 95 }),
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
- const health = { status };
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
- models: normalizeModels(saved.models, Array.from(seed.models)),
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
- if (modelMatch && !namedModel) {
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.health && engine.health.status === 'ready')
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) return { worker, skip: 'not ready', engine: null, raw: null };
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 engine = model || effort || maxSeconds
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.health && engine.health.status === 'ready')
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
- const ranked = rankEnginesDetailed(engines, {
1702
- root,
1703
- taskType: options.taskType || options.task_type || normalizedRole,
1704
- lowStakes: options.lowStakes,
1705
- stakes: options.stakes,
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
- return {
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. {} otherwise.
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 ['roster_model', 'roster_effort', 'roster_max_seconds']) {
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.health && preferred.health.status === 'ready') {
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
  };