atris 3.71.2 → 3.71.3

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.
@@ -10,14 +10,22 @@ const crypto = require('crypto');
10
10
  const { spawn } = require('child_process');
11
11
  const { decide } = require('./campaign-core');
12
12
  const { getCampaign, openCampaignStore } = require('./campaign-store');
13
- const { defaultReserve, reconcileCampaign } = require('./campaign-missions');
13
+ const { defaultReserve, missionCost, reconcileCampaign } = require('./campaign-missions');
14
14
  const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
15
- const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion } = require('./campaign-lanes');
15
+ const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion, openLaneAsks } = require('./campaign-lanes');
16
16
 
17
17
  const TERMINAL = new Set(['stopped', 'complete']);
18
18
 
19
19
  const CLI = path.join(__dirname, '..', 'bin', 'atris.js');
20
20
  const PROPOSE_TIMEOUT_MS = 5 * 60 * 1000;
21
+ // A campaign only the outside world can move checks its judge again after 10
22
+ // minutes, doubling while the reading stays the same, up to an hour.
23
+ const WAIT_FIRST_MS = 10 * 60 * 1000;
24
+ const WAIT_MAX_MS = 60 * 60 * 1000;
25
+
26
+ // Two readings are the same when they pass or fail alike and say the same thing.
27
+ const readingKey = (passed, reading, summary) => JSON.stringify([Boolean(passed), typeof reading === 'number' ? reading : null, summary || null]);
28
+ const clockTime = (ms) => new Date(ms).toISOString().slice(11, 16) + ' UTC';
21
29
 
22
30
  function claimKey(objective) {
23
31
  return String(objective).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
@@ -28,12 +36,56 @@ function proposalId(objective) {
28
36
  return `m-${slug || 'mission'}-${crypto.randomBytes(3).toString('hex')}`;
29
37
  }
30
38
 
39
+ function rankCampaignMembers(view, rsiSummary = null) {
40
+ const seats = Array.isArray(view && view.seats) ? view.seats.map((s) => s.member).filter(Boolean) : [];
41
+ const candidates = [...new Set([view && view.owner, ...seats].filter(Boolean))];
42
+ const rsiMap = new Map();
43
+ if (rsiSummary && Array.isArray(rsiSummary.members)) {
44
+ for (const row of rsiSummary.members) {
45
+ if (row && row.member) rsiMap.set(row.member, row);
46
+ }
47
+ }
48
+ const missions = Array.isArray(view && view.missions) ? view.missions : [];
49
+ const raw = candidates.map((member) => {
50
+ const info = rsiMap.get(member) || null;
51
+ const overall = info && typeof info.overall === 'number' ? info.overall : 75;
52
+ const overallLabel = (info && (info.overallLabel || info.note)) || 'unproven';
53
+ const repeatFailures = info && typeof info.repeatFailures === 'number'
54
+ ? info.repeatFailures
55
+ : (info && typeof info.repeat_failures === 'number' ? info.repeat_failures : 0);
56
+ const mine = missions.filter((m) => m && m.member === member && m.status === 'ended');
57
+ let streak = 0;
58
+ for (let i = mine.length - 1; i >= 0; i -= 1) {
59
+ const outcome = String(mine[i].outcome || '');
60
+ if (outcome && outcome !== 'kept' && !mine[i].credited) streak += 1;
61
+ else break;
62
+ }
63
+ const cold = streak >= 2 || (overall < 60 && repeatFailures >= 3);
64
+ return { member, overall, overallLabel, consecutiveFailures: streak, cold, benched: false };
65
+ });
66
+ const hasWarmAlternative = raw.some((r) => !r.cold);
67
+ for (const row of raw) {
68
+ if (row.cold && hasWarmAlternative && raw.length > 1) row.benched = true;
69
+ }
70
+ return raw.sort((a, b) => {
71
+ if (a.benched !== b.benched) return a.benched ? 1 : -1;
72
+ if (b.overall !== a.overall) return b.overall - a.overall;
73
+ return a.consecutiveFailures - b.consecutiveFailures;
74
+ });
75
+ }
76
+
31
77
  // What the owner's engine reads before proposing: the goal, how it is judged,
32
78
  // what has been tried, and how it went.
33
- function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
34
- const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none yet';
35
- const seats = view.seats.map((s) => s.member).filter(Boolean);
36
- const members = [...new Set([view.owner, ...seats])];
79
+ function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null) {
80
+ const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}${typeof m.reward === 'number' ? `, reward ${m.reward >= 0 ? '+' : ''}${m.reward}` : ''}`).join('\n') || '- none yet';
81
+ const ranked = rankCampaignMembers(view, rsiSummary);
82
+ const memberLine = ranked.map((r) => {
83
+ if (r.benched) return `${r.member} (${r.overall} ovr, benched after ${r.consecutiveFailures || 'repeat'} misses)`;
84
+ return rsiSummary ? `${r.member} (${r.overall} ovr, ${r.overallLabel})` : r.member;
85
+ }).join(', ');
86
+ const watchouts = ranked
87
+ .filter((r) => r.benched || r.consecutiveFailures > 0 || /repeat miss/i.test(r.overallLabel || ''))
88
+ .map((r) => `${r.member}: ${r.overallLabel}${r.benched ? ' (benched)' : ''}`);
37
89
  return [
38
90
  `You plan the next missions for an Atris campaign. Answer with JSON only.`,
39
91
  ``,
@@ -43,7 +95,8 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
43
95
  judgeOutput ? `Last judge output:\n\`\`\`\n${judgeOutput.replace(/```/g, "'''")}\n\`\`\`` : '',
44
96
  view.bet ? `Current bet: ${view.bet.text}${view.bet.wrong_if ? ` (wrong if ${view.bet.wrong_if})` : ''}` : '',
45
97
  `Money left: $${view.budget.available.toFixed(2)} of $${view.budget.total.toFixed(2)}`,
46
- `Members who can take a mission: ${members.join(', ')}`,
98
+ `Members who can take a mission: ${memberLine}`,
99
+ watchouts.length ? `Seat watchouts: ${watchouts.join('; ')}` : '',
47
100
  `Missions so far:`,
48
101
  tried,
49
102
  lessons ? `Lessons from earlier rounds (follow them):\n${lessons}` : '',
@@ -55,7 +108,7 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
55
108
  ].filter((line) => line !== '').join('\n');
56
109
  }
57
110
 
58
- function parseProposals(text, view, count) {
111
+ function parseProposals(text, view, count, rsiSummary = null) {
59
112
  const match = String(text || '').match(/\[[\s\S]*\]/);
60
113
  if (!match) return [];
61
114
  let rows;
@@ -65,12 +118,14 @@ function parseProposals(text, view, count) {
65
118
  return [];
66
119
  }
67
120
  if (!Array.isArray(rows)) return [];
68
- const members = new Set([view.owner, ...view.seats.map((s) => s.member)]);
121
+ const ranked = rankCampaignMembers(view, rsiSummary);
122
+ const activeMembers = new Set(ranked.filter((r) => !r.benched).map((r) => r.member));
123
+ const fallbackMember = (ranked.find((r) => !r.benched) || ranked[0] || { member: view.owner }).member;
69
124
  return rows.slice(0, count)
70
125
  .filter((row) => row && typeof row.objective === 'string' && row.objective.trim())
71
126
  .map((row) => ({
72
127
  objective: row.objective.trim().slice(0, 300),
73
- member: members.has(row.member) ? row.member : view.owner,
128
+ member: activeMembers.has(row.member) ? row.member : fallbackMember,
74
129
  verify: typeof row.verify === 'string' && row.verify.trim() ? row.verify.trim() : view.judge,
75
130
  estimate_usd: typeof row.estimate_usd === 'number' && row.estimate_usd > 0 ? Math.min(row.estimate_usd, view.budget.available) : null,
76
131
  }));
@@ -134,18 +189,32 @@ function makeLoop(root, options = {}, deps = {}) {
134
189
  const ask = deps.ask || askOwner;
135
190
  const cli = deps.cli || ((args, extra = {}) => runCli(args, { cwd: root, ...extra }));
136
191
  const judge = deps.judge;
192
+ const now = deps.now || Date.now;
137
193
 
138
194
  const view = (id) => getCampaign(store, id);
139
195
  const append = (id, type, data, as = actor) => store.append({ campaign_id: id, type, actor: as, data });
140
196
 
141
197
  async function propose(id, count = parallel) {
142
198
  const current = view(id);
143
- const prompt = proposalPrompt(current, count, latestJudgeOutput(store.read(id)), deps.lessons ? deps.lessons(id) : '');
199
+ const events = store.read(id);
200
+ let rsiSummary = null;
201
+ try {
202
+ const { missionRewardMap, summarizeStoreRsi } = require('./campaign-rsi');
203
+ const rewards = missionRewardMap(events);
204
+ current.missions = current.missions.map((m) => {
205
+ const raw = rewards && typeof rewards.get === 'function' ? rewards.get(m.id) : (rewards && rewards[m.id]);
206
+ const reward = typeof raw === 'number' ? raw : (raw && typeof raw.reward === 'number' ? raw.reward : undefined);
207
+ return typeof reward === 'number' ? { ...m, reward } : m;
208
+ });
209
+ rsiSummary = summarizeStoreRsi(store);
210
+ } catch {}
211
+ const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary);
144
212
  const answer = await ask(root, current, prompt);
145
- const proposals = parseProposals(answer.text, current, count);
213
+ const proposals = parseProposals(answer.text, current, count, rsiSummary);
146
214
  // Planning costs money too. It rides on the first mission it produced as a
147
215
  // step, so it is counted when that mission ends without making the judge
148
- // due. A plan that produced nothing becomes its own ended mission.
216
+ // due. A plan that produced nothing is not failed work: the step saves a
217
+ // wait instead (the owner's engine reports no dollar cost to carry).
149
218
  const plan = { kind: 'plan', engine: answer.engine };
150
219
  if (typeof answer.cost_usd === 'number') plan.cost_usd = answer.cost_usd;
151
220
  proposals.forEach((proposal, index) => {
@@ -154,11 +223,6 @@ function makeLoop(root, options = {}, deps = {}) {
154
223
  append(id, 'mission_claimed', { mission_id: missionId, claim_key: claimKey(proposal.objective) }, proposal.member);
155
224
  if (index === 0) append(id, 'step', { mission_id: missionId, ...plan }, current.owner);
156
225
  });
157
- if (!proposals.length) {
158
- const planId = `plan-${crypto.randomBytes(3).toString('hex')}`;
159
- append(id, 'mission_started', { mission_id: planId, member: current.owner, kind: 'plan' }, current.owner);
160
- append(id, 'mission_ended', { mission_id: planId, outcome: 'failed', cost_usd: plan.cost_usd || 0, uncosted_runs: plan.cost_usd === undefined ? 1 : 0, engine: answer.engine }, current.owner);
161
- }
162
226
  return proposals;
163
227
  }
164
228
 
@@ -191,6 +255,7 @@ function makeLoop(root, options = {}, deps = {}) {
191
255
  log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
192
256
  // The engine's live steps ("researcher 06:47 editing stable.mjs") pass through to
193
257
  // the campaign's own output, so a screen watching the run can show the work.
258
+ const stepsBefore = (view(id).missions.find((m) => m.id === work.mission_id) || {}).steps || 0;
194
259
  const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass'], { onLine: (line) => { if (ACTIVITY_LINE.test(line)) log(` ${line.trim()}`); } });
195
260
  const after = view(id).missions.find((m) => m.id === work.mission_id);
196
261
  // A mission in a protected lane (billing, security, deploy, customer) pauses
@@ -205,6 +270,13 @@ function makeLoop(root, options = {}, deps = {}) {
205
270
  log(` ${work.mission_id} needs you: it touches ${lane}`);
206
271
  return { mission_id: work.mission_id, started: true, outcome: null, needs: lane };
207
272
  }
273
+ if (after && after.status !== 'ended' && after.steps === stepsBefore) {
274
+ // The run left no step behind: the job never ran. Score it as a fault
275
+ // with the run's own words, not as failed work by the member.
276
+ const why = `${ran.err || ''}\n${ran.out || ''}`.trim().split('\n').filter(Boolean).pop() || `exit ${ran.code}`;
277
+ append(id, 'mission_ended', { mission_id: work.mission_id, outcome: 'fault', reason: why.slice(0, 200), cost_usd: missionCost(root, work.mission_id, { store }).cost_usd }, work.member);
278
+ log(` ${work.mission_id} never ran: ${why}`);
279
+ }
208
280
  if (after && after.status !== 'ended') {
209
281
  // The run stopped without finishing (tick cap, error): close it so its
210
282
  // reserve comes back and its cost is counted.
@@ -221,6 +293,21 @@ function makeLoop(root, options = {}, deps = {}) {
221
293
  // how many events it pulled.
222
294
  const sync = deps.sync || (async () => 0);
223
295
 
296
+ // Run the judge and record its reading. waitFor(data) returns how long to
297
+ // wait before the next check (0 for no wait); a passing reading never waits.
298
+ async function readJudge(id, current, waitFor = () => 0) {
299
+ const reading = await judge(current.judge);
300
+ const data = judgeRanData(reading);
301
+ const waitMs = data.passed ? 0 : waitFor(data);
302
+ if (waitMs > 0) data.wait = { wake_at: now() + waitMs, wait_ms: waitMs };
303
+ append(id, 'judge_ran', data, 'judge');
304
+ if (!deps.skipReconcile) {
305
+ try { require('./campaign-rsi').writeRsiSnapshot(root, store, id); } catch {}
306
+ }
307
+ log(`judge ${reading.passed ? 'passed' : 'failed'}: ${reading.summary || `exit ${reading.exit}`}`);
308
+ return data;
309
+ }
310
+
224
311
  // One step, with a sync on each side.
225
312
  async function step(id) {
226
313
  await sync(id);
@@ -240,29 +327,52 @@ function makeLoop(root, options = {}, deps = {}) {
240
327
  if (current.error) return { stop: true, reason: `campaign cannot be read: ${current.error}` };
241
328
  const move = decide(current);
242
329
  if (move.action === 'none') return { stop: true, reason: move.reason };
243
- if (move.action === 'wait' && move.reason === 'open asks') return { stop: true, reason: `needs you: ${move.asks.length} open ask${move.asks.length === 1 ? '' : 's'}` };
330
+ const needsYou = (n) => ({ stop: true, reason: `needs you: ${n} open ask${n === 1 ? '' : 's'}` });
331
+ if (move.action === 'wait' && move.reason === 'open asks') return needsYou(move.asks.length);
332
+ // A protected lane waiting on a person holds the whole campaign: the loop
333
+ // cannot tell which next plan would touch that lane again.
334
+ if (openLaneAsks(store.read(id)).size) return needsYou(current.asks.length);
335
+ // A saved wait is a promise: nothing runs before it is due, even in a new
336
+ // process. When it is due, only the judge runs. The same reading waits
337
+ // twice as long; a changed one lets the next step plan again.
338
+ const saved = current.next_move;
339
+ if (saved && saved.action === 'wait') {
340
+ if (now() < saved.wake_at) return { stop: true, reason: `waiting on the world: next check at ${clockTime(saved.wake_at)}` };
341
+ const before = current.last_judge ? readingKey(current.last_judge.passed, current.last_judge.reading, current.answer.text) : null;
342
+ const data = await readJudge(id, current, (d) => readingKey(d.passed, d.reading, d.summary) === before ? Math.min(saved.wait_ms * 2, WAIT_MAX_MS) : 0);
343
+ if (data.wait) return { stop: true, reason: `nothing changed: next check at ${clockTime(data.wait.wake_at)}` };
344
+ return { stop: false, did: 'judge' };
345
+ }
244
346
  if (move.action === 'run_judge') {
245
- const reading = await judge(current.judge);
246
- append(id, 'judge_ran', judgeRanData(reading), 'judge');
247
- log(`judge ${reading.passed ? 'passed' : 'failed'}: ${reading.summary || `exit ${reading.exit}`}`);
347
+ await readJudge(id, current);
248
348
  return { stop: false, did: 'judge' };
249
349
  }
250
350
  if (move.action === 'start_mission' || (move.action === 'wait' && move.reason === 'budget')) {
251
351
  const work = claimedWork(id).slice(0, parallel);
252
- if (!work.length) return { stop: true, reason: 'claimed missions have no objective to run; start them by hand or veto them' };
352
+ if (!work.length) return { ok: false, stop: true, reason: 'claimed missions have no objective to run; start them by hand or veto them' };
253
353
  log(`starting ${work.length} mission${work.length === 1 ? '' : 's'} at once`);
254
354
  const results = await Promise.all(work.map((w) => startAndRun(id, w)));
255
- if (!results.some((r) => r.started)) return { stop: true, reason: 'no claimed mission could start' };
355
+ if (!results.some((r) => r.started)) return { ok: false, stop: true, reason: 'no claimed mission could start' };
256
356
  return { stop: false, did: 'missions' };
257
357
  }
258
358
  if (move.action === 'wait') return { stop: true, reason: `waiting: ${move.reason} (started elsewhere)` };
259
359
  log(`asking ${current.owner} to propose up to ${parallel} missions`);
260
360
  const proposals = await propose(id, parallel);
261
- if (!proposals.length) return { stop: true, reason: `${current.owner} proposed nothing usable` };
361
+ if (!proposals.length) {
362
+ const data = await readJudge(id, current, () => WAIT_FIRST_MS);
363
+ const next = data.wait ? `; next check at ${clockTime(data.wait.wake_at)}` : '';
364
+ return { ok: false, stop: true, reason: `${current.owner} proposed nothing usable${next}` };
365
+ }
262
366
  for (const p of proposals) log(` proposed for ${p.member}: ${p.objective}`);
263
367
  return { stop: false, did: 'propose' };
264
368
  }
265
369
 
370
+ // One tick: the one step that is due now, with a sync on each side. A timer
371
+ // or a woken process calls this; run() is for a person watching.
372
+ async function tick(id) {
373
+ return step(id);
374
+ }
375
+
266
376
  async function run(id, maxSteps = 20) {
267
377
  await sync(id);
268
378
  for (let i = 0; i < maxSteps; i++) {
@@ -275,7 +385,7 @@ function makeLoop(root, options = {}, deps = {}) {
275
385
  return { steps: maxSteps, reason: `stopped after ${maxSteps} steps`, campaign: view(id) };
276
386
  }
277
387
 
278
- return { step, run, propose };
388
+ return { step, tick, run, propose };
279
389
  }
280
390
 
281
- module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, runCli };
391
+ module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readingKey, runCli };