atris 3.71.3 → 3.72.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.
@@ -34,7 +34,10 @@ function globalLessonsPath(root) {
34
34
  function parseBulletLines(filePath) {
35
35
  try {
36
36
  const text = fs.readFileSync(filePath, 'utf8');
37
- return text.split('\n').filter((line) => line.trim().startsWith('- '));
37
+ return text.split('\n').filter((line) => {
38
+ const bullet = line.trim();
39
+ return bullet.startsWith('- ') && bullet !== '- (no review written this round)';
40
+ });
38
41
  } catch {
39
42
  return [];
40
43
  }
@@ -57,7 +60,12 @@ function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
57
60
  }
58
61
 
59
62
  function roundSummary(before, after) {
60
- const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
63
+ const previous = new Map();
64
+ for (const { id, status } of before.missions) {
65
+ if (!previous.has(id)) previous.set(id, new Set());
66
+ previous.get(id).add(status);
67
+ }
68
+ const fresh = after.missions.filter((m) => !previous.get(m.id)?.has(m.status));
61
69
  const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
62
70
  const failed = fresh.filter((m) => m.status === 'ended' && m.outcome !== 'kept').length;
63
71
  const vetoed = fresh.filter((m) => m.status === 'vetoed').length;
@@ -138,10 +146,12 @@ function makeCadence(root, loop, options = {}, deps = {}) {
138
146
  const until = now() + workMs;
139
147
  log(`round ${round}: working until ${new Date(until).toISOString().slice(11, 16)}`);
140
148
  let stopped = null;
149
+ let failed = false;
141
150
  while (now() < until) {
142
151
  const result = await loop.step(id);
143
152
  if (result.stop) {
144
153
  stopped = result.reason;
154
+ failed = result.ok === false;
145
155
  break;
146
156
  }
147
157
  }
@@ -150,15 +160,15 @@ function makeCadence(root, loop, options = {}, deps = {}) {
150
160
  const { summary, lessons, view: reviewed, quiet } = await review(id, before);
151
161
  log(quiet ? ' nothing new this round; no review' : ` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
152
162
  const view = getCampaign(store, id);
153
- last = { reason: stopped || 'work window ended', round };
163
+ last = { reason: stopped || 'work window ended', round, failed };
154
164
  // Finished, out of money, or waiting on a person: no point in another round.
155
165
  if (['done', 'out_of_budget', 'needs_you', 'paused'].includes(view.state) || (stopped && !/missions running|nothing usable/.test(stopped))) {
156
- return { rounds: round, reason: stopped || view.state, campaign: view };
166
+ return { ...(failed ? { ok: false } : {}), rounds: round, reason: stopped || view.state, campaign: view };
157
167
  }
158
168
  const rest = breakMs - (now() - breakStarted);
159
169
  if (round < rounds && rest > 0) await sleep(rest);
160
170
  }
161
- return { rounds: round, reason: last.reason, campaign: getCampaign(store, id) };
171
+ return { ...(last.failed ? { ok: false } : {}), rounds: round, reason: last.reason, campaign: getCampaign(store, id) };
162
172
  }
163
173
 
164
174
  return { run, review };
@@ -80,15 +80,27 @@ function fold(events) {
80
80
  // traversal. The synchronous projector is trusted and read-only; only its
81
81
  // result is retained, so callers need not keep every full historical view.
82
82
  function foldWithPrefixes(events, prefixLengths, project = (view) => view) {
83
+ return collectPrefixes(events, prefixLengths, (state, start, owner, count) =>
84
+ project(renderView(state, start, owner), count));
85
+ }
86
+
87
+ // Trace history needs counts, not detached copies of every earlier mission.
88
+ // Use the same transitions and rendering rules, retaining only summary fields.
89
+ function foldWithSummaryPrefixes(events, prefixLengths) {
90
+ return collectPrefixes(events, prefixLengths, (state, start, owner, count, missionCounts) =>
91
+ renderView(state, start, owner, missionCounts), true);
92
+ }
93
+
94
+ function collectPrefixes(events, prefixLengths, project, trackMissionCounts = false) {
83
95
  const requested = new Set(prefixLengths.filter((n) => Number.isSafeInteger(n) && n >= 1));
84
96
  const prefixes = new Map();
85
- const view = foldEvents(events, (state, start, owner, count) => {
86
- if (requested.has(count)) prefixes.set(count, project(renderView(state, start, owner), count));
87
- });
97
+ const view = foldEvents(events, (state, start, owner, count, missionCounts) => {
98
+ if (requested.has(count)) prefixes.set(count, project(state, start, owner, count, missionCounts));
99
+ }, trackMissionCounts);
88
100
  return view.error ? view : { view, prefixes };
89
101
  }
90
102
 
91
- function foldEvents(events, visit) {
103
+ function foldEvents(events, visit, trackMissionCounts = false) {
92
104
  if (!Array.isArray(events)) return { error: 'invalid_event' };
93
105
  for (const event of events) {
94
106
  if (!validateEvent(event).ok) return { error: 'invalid_event' };
@@ -132,6 +144,8 @@ function foldEvents(events, visit) {
132
144
  };
133
145
  const d = start.data;
134
146
  const owner = typeof d.owner === 'string' && d.owner ? d.owner : 'mission-lead';
147
+ const missionCounts = trackMissionCounts
148
+ ? { proposed: 0, claimed: 0, running: 0, ended: 0, vetoed: 0, dropped: 0 } : null;
135
149
  for (const seat of d.seats || []) addSeat(s.seats, seatFrom(seat));
136
150
  if (d.bet) s.bet = { text: d.bet, wrong_if: null };
137
151
 
@@ -144,6 +158,8 @@ function foldEvents(events, visit) {
144
158
  const sequence = [start, ...ordered.filter((e) => e !== start)];
145
159
  sequence.forEach((event, pos) => {
146
160
  const data = event.data;
161
+ const missionId = missionCounts ? data.mission_id : undefined;
162
+ const previousStatus = missionCounts ? s.missions.get(missionId)?.status : undefined;
147
163
  if (WAIT_ENDS.has(event.type)) s.wait = null;
148
164
  switch (event.type) {
149
165
  case 'campaign_started':
@@ -276,15 +292,22 @@ function foldEvents(events, visit) {
276
292
  default:
277
293
  break;
278
294
  }
279
- if (visit) visit(s, start, owner, pos + 1);
295
+ if (missionCounts) {
296
+ const status = s.missions.get(missionId)?.status;
297
+ if (status !== previousStatus) {
298
+ if (previousStatus !== undefined) missionCounts[previousStatus]--;
299
+ if (status !== undefined) missionCounts[status]++;
300
+ }
301
+ }
302
+ if (visit) visit(s, start, owner, pos + 1, missionCounts);
280
303
  });
281
304
 
282
305
  return renderView(s, start, owner);
283
306
  }
284
307
 
285
- function renderView(s, start, owner) {
308
+ function renderView(s, start, owner, missionCounts = null) {
286
309
  const d = start.data;
287
- const missions = [...s.missions.values()];
310
+ const missions = missionCounts ? null : [...s.missions.values()];
288
311
  let state;
289
312
  let stopReason = null;
290
313
  if (s.finished) {
@@ -297,9 +320,9 @@ function renderView(s, start, owner) {
297
320
  state = 'paused';
298
321
  } else if (s.asks.size > 0) {
299
322
  state = 'needs_you';
300
- } else if (missions.some((m) => m.status === 'running')) {
323
+ } else if (missionCounts ? missionCounts.running > 0 : missions.some((m) => m.status === 'running')) {
301
324
  state = 'running';
302
- } else if (missions.some((m) => m.status === 'proposed' || m.status === 'claimed')) {
325
+ } else if (missionCounts ? missionCounts.proposed + missionCounts.claimed > 0 : missions.some((m) => m.status === 'proposed' || m.status === 'claimed')) {
303
326
  state = 'queued';
304
327
  } else {
305
328
  state = 'resting';
@@ -312,13 +335,22 @@ function renderView(s, start, owner) {
312
335
  confidence = 'answered';
313
336
  } else if (latest && latest.passed) {
314
337
  let streak = 0;
315
- for (let i = runs.length - 1; i >= 0 && runs[i].passed; i--) streak++;
338
+ for (let i = runs.length - 1; i >= 0 && streak < 3 && runs[i].passed; i--) streak++;
316
339
  confidence = streak >= 3 ? 'stronger' : 'leaning';
317
340
  } else if (latest) {
318
341
  confidence = latest.confidence || 'open';
319
342
  }
320
343
 
321
344
  const reservedCents = [...s.reservations.values()].reduce((sum, value) => sum + value, 0);
345
+ const budget = {
346
+ total: dollars(s.totalCents),
347
+ spent: dollars(s.spentCents),
348
+ reserved: dollars(reservedCents),
349
+ available: dollars(s.totalCents - s.spentCents - reservedCents),
350
+ };
351
+ if (missionCounts) {
352
+ return { state, confidence, judge_runs: runs.length, budget, mission_counts: { ...missionCounts }, open_asks: s.asks.size };
353
+ }
322
354
  return {
323
355
  id: start.campaign_id,
324
356
  title: d.title,
@@ -336,12 +368,7 @@ function renderView(s, start, owner) {
336
368
  state,
337
369
  stop_reason: stopReason,
338
370
  answer: { text: latest ? latest.summary : null, confidence },
339
- budget: {
340
- total: dollars(s.totalCents),
341
- spent: dollars(s.spentCents),
342
- reserved: dollars(reservedCents),
343
- available: dollars(s.totalCents - s.spentCents - reservedCents),
344
- },
371
+ budget,
345
372
  asks: [...s.asks.values()].map((ask) => ({ ...ask })),
346
373
  // Only while a wait is saved, so the shared golden views stay as pinned.
347
374
  ...(s.wait ? { next_move: { action: 'wait', reason: 'waiting on the world', ...s.wait } } : {}),
@@ -392,4 +419,4 @@ function paysDollars(view) {
392
419
  return Boolean(view && view.basis === 'atris_cloud');
393
420
  }
394
421
 
395
- module.exports = { cents, compareEvents, decide, fold, foldWithPrefixes, paysDollars };
422
+ module.exports = { cents, compareEvents, decide, fold, foldWithPrefixes, foldWithSummaryPrefixes, paysDollars };
@@ -8,10 +8,11 @@
8
8
  const path = require('path');
9
9
  const crypto = require('crypto');
10
10
  const { spawn } = require('child_process');
11
- const { decide } = require('./campaign-core');
11
+ const { compareEvents, decide, fold } = require('./campaign-core');
12
+ const { validateEvent } = require('./campaign-schema');
12
13
  const { getCampaign, openCampaignStore } = require('./campaign-store');
13
14
  const { defaultReserve, missionCost, reconcileCampaign } = require('./campaign-missions');
14
- const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
15
+ const { confirmedReading, judgeRanData, latestJudgeOutput } = require('./campaign-reading');
15
16
  const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion, openLaneAsks } = require('./campaign-lanes');
16
17
 
17
18
  const TERMINAL = new Set(['stopped', 'complete']);
@@ -36,7 +37,7 @@ function proposalId(objective) {
36
37
  return `m-${slug || 'mission'}-${crypto.randomBytes(3).toString('hex')}`;
37
38
  }
38
39
 
39
- function rankCampaignMembers(view, rsiSummary = null) {
40
+ function rankCampaignMembers(view, rsiSummary = null, events = null) {
40
41
  const seats = Array.isArray(view && view.seats) ? view.seats.map((s) => s.member).filter(Boolean) : [];
41
42
  const candidates = [...new Set([view && view.owner, ...seats].filter(Boolean))];
42
43
  const rsiMap = new Map();
@@ -45,7 +46,7 @@ function rankCampaignMembers(view, rsiSummary = null) {
45
46
  if (row && row.member) rsiMap.set(row.member, row);
46
47
  }
47
48
  }
48
- const missions = Array.isArray(view && view.missions) ? view.missions : [];
49
+ const missions = completionOrderedMissions(view, events);
49
50
  const raw = candidates.map((member) => {
50
51
  const info = rsiMap.get(member) || null;
51
52
  const overall = info && typeof info.overall === 'number' ? info.overall : 75;
@@ -74,11 +75,33 @@ function rankCampaignMembers(view, rsiSummary = null) {
74
75
  });
75
76
  }
76
77
 
78
+ // Callers supply the complete history behind this view. Coverage checks can
79
+ // reject a mismatch, but cannot prove that a partial history is complete.
80
+ function completionOrderedMissions(view, events) {
81
+ const missions = Array.isArray(view && view.missions) ? view.missions : [];
82
+ if (!Array.isArray(events)) return missions;
83
+ const seen = new Set();
84
+ const ends = new Map();
85
+ let started = false;
86
+ for (const event of events) {
87
+ if (!validateEvent(event).ok || event.campaign_id !== (view && view.id)) return missions;
88
+ if (seen.has(event.id)) continue;
89
+ seen.add(event.id);
90
+ if (event.type === 'campaign_started') started = true;
91
+ if (event.type !== 'mission_ended') continue;
92
+ const previous = ends.get(event.data.mission_id);
93
+ if (!previous || compareEvents(previous, event) < 0) ends.set(event.data.mission_id, event);
94
+ }
95
+ const ended = missions.filter((m) => m && m.status === 'ended');
96
+ if (!started || ends.size !== ended.length || ended.some((m) => !ends.has(m.id) || ends.get(m.id).data.outcome !== m.outcome)) return missions;
97
+ return ended.sort((a, b) => compareEvents(ends.get(a.id), ends.get(b.id)));
98
+ }
99
+
77
100
  // What the owner's engine reads before proposing: the goal, how it is judged,
78
101
  // what has been tried, and how it went.
79
- function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null) {
102
+ function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null, events = null) {
80
103
  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);
104
+ const ranked = rankCampaignMembers(view, rsiSummary, events);
82
105
  const memberLine = ranked.map((r) => {
83
106
  if (r.benched) return `${r.member} (${r.overall} ovr, benched after ${r.consecutiveFailures || 'repeat'} misses)`;
84
107
  return rsiSummary ? `${r.member} (${r.overall} ovr, ${r.overallLabel})` : r.member;
@@ -108,7 +131,7 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummar
108
131
  ].filter((line) => line !== '').join('\n');
109
132
  }
110
133
 
111
- function parseProposals(text, view, count, rsiSummary = null) {
134
+ function parseProposals(text, view, count, rsiSummary = null, events = null) {
112
135
  const match = String(text || '').match(/\[[\s\S]*\]/);
113
136
  if (!match) return [];
114
137
  let rows;
@@ -118,7 +141,7 @@ function parseProposals(text, view, count, rsiSummary = null) {
118
141
  return [];
119
142
  }
120
143
  if (!Array.isArray(rows)) return [];
121
- const ranked = rankCampaignMembers(view, rsiSummary);
144
+ const ranked = rankCampaignMembers(view, rsiSummary, events);
122
145
  const activeMembers = new Set(ranked.filter((r) => !r.benched).map((r) => r.member));
123
146
  const fallbackMember = (ranked.find((r) => !r.benched) || ranked[0] || { member: view.owner }).member;
124
147
  return rows.slice(0, count)
@@ -195,8 +218,8 @@ function makeLoop(root, options = {}, deps = {}) {
195
218
  const append = (id, type, data, as = actor) => store.append({ campaign_id: id, type, actor: as, data });
196
219
 
197
220
  async function propose(id, count = parallel) {
198
- const current = view(id);
199
221
  const events = store.read(id);
222
+ const current = events.length ? fold(events) : null;
200
223
  let rsiSummary = null;
201
224
  try {
202
225
  const { missionRewardMap, summarizeStoreRsi } = require('./campaign-rsi');
@@ -208,9 +231,9 @@ function makeLoop(root, options = {}, deps = {}) {
208
231
  });
209
232
  rsiSummary = summarizeStoreRsi(store);
210
233
  } catch {}
211
- const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary);
234
+ const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary, events);
212
235
  const answer = await ask(root, current, prompt);
213
- const proposals = parseProposals(answer.text, current, count, rsiSummary);
236
+ const proposals = parseProposals(answer.text, current, count, rsiSummary, events);
214
237
  // Planning costs money too. It rides on the first mission it produced as a
215
238
  // step, so it is counted when that mission ends without making the judge
216
239
  // due. A plan that produced nothing is not failed work: the step saves a
@@ -247,7 +270,12 @@ function makeLoop(root, options = {}, deps = {}) {
247
270
  '--max-wall', String(missionMinutes * 60), '--json',
248
271
  ]);
249
272
  if (started.code !== 0) {
250
- const why = (started.out + started.err).trim().split('\n').pop();
273
+ let why = (started.out + started.err).trim().split('\n').pop();
274
+ try {
275
+ const error = JSON.parse(started.out);
276
+ const detail = [error?.detail, error?.error].find(value => typeof value === 'string' && value.trim());
277
+ if (detail) why = detail.trim();
278
+ } catch {}
251
279
  log(` ${work.mission_id} did not start: ${why}`);
252
280
  append(id, 'mission_vetoed', { mission_id: work.mission_id, reason: why.slice(0, 200) }, current.owner);
253
281
  return { mission_id: work.mission_id, started: false };
@@ -295,8 +323,10 @@ function makeLoop(root, options = {}, deps = {}) {
295
323
 
296
324
  // Run the judge and record its reading. waitFor(data) returns how long to
297
325
  // wait before the next check (0 for no wait); a passing reading never waits.
326
+ // A pass that would finish the campaign must pass a second, fresh run first.
298
327
  async function readJudge(id, current, waitFor = () => 0) {
299
- const reading = await judge(current.judge);
328
+ const run = () => judge(current.judge);
329
+ const reading = current.standing ? await run() : await confirmedReading(run);
300
330
  const data = judgeRanData(reading);
301
331
  const waitMs = data.passed ? 0 : waitFor(data);
302
332
  if (waitMs > 0) data.wait = { wake_at: now() + waitMs, wait_ms: waitMs };
@@ -360,6 +390,7 @@ function makeLoop(root, options = {}, deps = {}) {
360
390
  const proposals = await propose(id, parallel);
361
391
  if (!proposals.length) {
362
392
  const data = await readJudge(id, current, () => WAIT_FIRST_MS);
393
+ if (data.passed) return { stop: false, did: 'judge' };
363
394
  const next = data.wait ? `; next check at ${clockTime(data.wait.wake_at)}` : '';
364
395
  return { ok: false, stop: true, reason: `${current.owner} proposed nothing usable${next}` };
365
396
  }
@@ -380,7 +411,7 @@ function makeLoop(root, options = {}, deps = {}) {
380
411
  // An answer pulled just now can unblock a step that stopped for a person.
381
412
  const pulled = await sync(id);
382
413
  if (result.stop && pulled > 0) continue;
383
- if (result.stop) return { steps: i, reason: result.reason, campaign: view(id) };
414
+ if (result.stop) return { ...(result.ok === false ? { ok: false } : {}), steps: i, reason: result.reason, campaign: view(id) };
384
415
  }
385
416
  return { steps: maxSteps, reason: `stopped after ${maxSteps} steps`, campaign: view(id) };
386
417
  }
@@ -136,6 +136,19 @@ function judgeRanData(result) {
136
136
  return data;
137
137
  }
138
138
 
139
+ // One pass can be luck: a flaky test, a race, a grader that rolls dice. A pass
140
+ // only counts when a second, fresh run of the same judge passes too. A failed
141
+ // second run is recorded as the failure it is, naming the pass it overturned.
142
+ async function confirmedReading(run) {
143
+ const first = await run();
144
+ if (!first.passed) return first;
145
+ const second = await run();
146
+ const seconds = (Number(first.seconds) || 0) + (Number(second.seconds) || 0);
147
+ if (second.passed) return { ...second, seconds };
148
+ const said = second.summary || lastSaidLine(second.output_tail) || `exit ${second.exit}`;
149
+ return { ...second, seconds, summary: `passed once, then failed a fresh run: ${said}` };
150
+ }
151
+
139
152
  // The output_tail of the latest judge run in fold order, or null.
140
153
  function latestJudgeOutput(events) {
141
154
  const { compareEvents } = require('./campaign-core');
@@ -144,4 +157,4 @@ function latestJudgeOutput(events) {
144
157
  return last && typeof last.data.output_tail === 'string' && last.data.output_tail ? last.data.output_tail : null;
145
158
  }
146
159
 
147
- module.exports = { judgeReading, judgeRanData, latestJudgeOutput, outputTail };
160
+ module.exports = { confirmedReading, judgeReading, judgeRanData, latestJudgeOutput, outputTail };
@@ -7,6 +7,7 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
  const { buildTrace } = require('./campaign-trace');
10
+ const { compareEvents, fold } = require('./campaign-core');
10
11
  const { getCampaign, listCampaigns, openCampaignStore } = require('./campaign-store');
11
12
  const rsiRecord = require('./rsi-record');
12
13
  const { verifyCodeProvenance } = require('./mission-code-provenance');
@@ -39,7 +40,7 @@ function dedupeLessons(lines) {
39
40
  return out;
40
41
  }
41
42
 
42
- function aggregateMembers(missions) {
43
+ function aggregateMembers(missions, failureOrder = missions) {
43
44
  const byMember = new Map();
44
45
  for (const m of missions) {
45
46
  const name = m.member || 'unknown';
@@ -54,6 +55,7 @@ function aggregateMembers(missions) {
54
55
  repeat_failures: 0,
55
56
  failure_streak: 0,
56
57
  reward: 0,
58
+ reward_micros: 0,
57
59
  cost_usd: 0,
58
60
  wasted_cost_usd: 0,
59
61
  reward_per_dollar: null,
@@ -66,26 +68,31 @@ function aggregateMembers(missions) {
66
68
  entry.cost_usd = round(entry.cost_usd + cost, 4);
67
69
  if (m.outcome === 'kept') {
68
70
  entry.kept += 1;
69
- entry.failure_streak = 0;
70
71
  } else if (m.outcome) {
71
72
  entry.failed += 1;
72
73
  if (m.outcome === 'reverted') entry.reverted += 1;
73
- entry.failure_streak += 1;
74
- if (entry.failure_streak >= 2) entry.repeat_failures += 1;
75
74
  entry.wasted_cost_usd = round(entry.wasted_cost_usd + cost, 4);
76
75
  }
77
76
  if (typeof m.reward === 'number' && Number.isFinite(m.reward)) {
78
- entry.reward = round(entry.reward + m.reward, 4);
77
+ entry.reward_micros += Math.round(m.reward * 1000000);
79
78
  if (m.reward > 0) entry.judge_passed += 1;
80
- } else if (m.reading_after === 1 && m.outcome === 'kept') {
81
- entry.judge_passed += 1;
82
79
  }
83
80
  if (m.outcome === 'kept' && m.reward !== null && m.reward <= 0) {
84
81
  entry.zero_reward_kept += 1;
85
82
  }
86
83
  }
84
+ for (const m of failureOrder) {
85
+ const entry = byMember.get(m.member || 'unknown');
86
+ if (m.outcome === 'kept') entry.failure_streak = 0;
87
+ else if (m.outcome) {
88
+ entry.failure_streak += 1;
89
+ if (entry.failure_streak >= 2) entry.repeat_failures += 1;
90
+ }
91
+ }
87
92
  const members = [...byMember.values()].map((entry) => {
88
- const { failure_streak, ...clean } = entry;
93
+ const { failure_streak, reward_micros, ...clean } = entry;
94
+ // Trace shares have six decimals; round the complete member sum only once.
95
+ clean.reward = Math.round(reward_micros / 100) / 10000;
89
96
  const ovr = computeMemberOverall(clean);
90
97
  return {
91
98
  ...clean,
@@ -265,7 +272,13 @@ function summarizeStoreRsi(store, campaignId = null) {
265
272
  const total_spent_usd = round(summaries.reduce((sum, s) => sum + s.spent_usd, 0), 4);
266
273
  const reward_per_dollar = total_spent_usd > 0 ? round(total_reward / total_spent_usd, 4) : null;
267
274
  const allMissions = summaries.flatMap((s) => s.missions);
268
- const mergedMembers = aggregateMembers(allMissions);
275
+ // Only streaks change order; totals and the public list use the original list.
276
+ // Campaign IDs break cross-campaign ties; stable sorting keeps local order.
277
+ const failureOrder = allMissions.slice().sort((a, b) => compareEvents(
278
+ { at: a.finished_at, origin: a.campaign_id, seq: 0, id: '' },
279
+ { at: b.finished_at, origin: b.campaign_id, seq: 0, id: '' }
280
+ ));
281
+ const mergedMembers = aggregateMembers(allMissions, failureOrder);
269
282
  const dedupedLessons = dedupeLessons(summaries.flatMap((s) => s.lessons));
270
283
 
271
284
  return {
@@ -535,11 +548,15 @@ function selectNextCampaigns(store, rsiSummary = null) {
535
548
  const activeStates = new Set(['queued', 'running', 'resting']);
536
549
  const open = listCampaigns(store).filter((v) => v && !v.error && activeStates.has(v.state));
537
550
  const rows = open.map((v) => {
538
- const trace = summarizeTrace(store.read(v.id));
551
+ const events = store.read(v.id);
552
+ const trace = summarizeTrace(events);
539
553
  const reading = typeof trace.reading === 'number'
540
554
  ? trace.reading
541
555
  : (v.last_judge && typeof v.last_judge.reading === 'number' ? v.last_judge.reading : 0);
542
- const ranked = rankCampaignMembers(v, rsi);
556
+ const current = fold(events);
557
+ const ranked = current.error || current.id !== v.id
558
+ ? rankCampaignMembers(v, rsi)
559
+ : rankCampaignMembers(current, rsi, events);
543
560
  const benched = ranked.filter((r) => r.benched).map((r) => r.member);
544
561
  const warm = ranked.find((r) => !r.benched) || ranked[0] || { member: v.owner, overall: 75 };
545
562
  const failedCount = (v.missions || []).filter((m) => m.status === 'ended' && m.outcome !== 'kept').length;
@@ -0,0 +1,124 @@
1
+ // atris campaign schedule: a person says yes once, and the campaign starts on
2
+ // its own at set times until its budget is spent. The schedule lives beside the
3
+ // campaign's other files, not in its events, so other surfaces fold unchanged.
4
+ // `atris campaign wake` (the keep timer) and `atris campaign tick` fire it.
5
+ // Spec: atris/features/campaign/schedule.md.
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const MIN = 60 * 1000;
13
+ const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
14
+ const DAY_SETS = { daily: [0, 1, 2, 3, 4, 5, 6], weekdays: [1, 2, 3, 4, 5], weekends: [0, 6] };
15
+ const DEFAULT_STEPS = 4;
16
+
17
+ const HOW = 'say when like "weekdays 09:00", "daily 18:30", "mon,thu 07:00", or "30m" / "2h"';
18
+
19
+ // "30m", "2h", "daily 09:00", "weekdays 09:00", "mon,wed 14:30", or "09:00"
20
+ // (every day). Clock times are local to the computer that runs the timer.
21
+ function parseEvery(text) {
22
+ const value = String(text || '').trim().toLowerCase();
23
+ if (!value) return { error: `a schedule needs a time: ${HOW}` };
24
+ const interval = /^(\d+)\s*(m|min|mins|minutes?|h|hr|hrs|hours?)$/.exec(value);
25
+ if (interval) {
26
+ const ms = Number(interval[1]) * (interval[2].startsWith('h') ? 60 : 1) * MIN;
27
+ if (ms < MIN) return { error: 'a schedule runs at most once a minute' };
28
+ return { rule: { kind: 'interval', ms }, every: value };
29
+ }
30
+ const parts = value.split(/\s+/);
31
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(parts[parts.length - 1]);
32
+ if (!clock || parts.length > 2) return { error: `cannot read "${text}": ${HOW}` };
33
+ const hour = Number(clock[1]);
34
+ const minute = Number(clock[2]);
35
+ if (hour > 23 || minute > 59) return { error: `"${parts[parts.length - 1]}" is not a time of day` };
36
+ let days = DAY_SETS.daily;
37
+ if (parts.length === 2) {
38
+ days = DAY_SETS[parts[0]];
39
+ if (!days) {
40
+ days = [];
41
+ for (const name of parts[0].split(',')) {
42
+ const day = DAY_NAMES.indexOf(name.slice(0, 3));
43
+ if (day < 0 || (name.length > 3 && !name.startsWith(DAY_NAMES[day]))) return { error: `"${name}" is not a day: ${HOW}` };
44
+ if (!days.includes(day)) days.push(day);
45
+ }
46
+ days.sort();
47
+ }
48
+ }
49
+ return { rule: { kind: 'clock', days, hour, minute }, every: value };
50
+ }
51
+
52
+ // The next slot after `after` (epoch ms), on a whole minute so it is due at the
53
+ // minute it is shown, and the one-minute timer never runs it a minute late.
54
+ function nextAt(rule, after) {
55
+ if (rule.kind === 'interval') return Math.floor((after + rule.ms) / MIN) * MIN;
56
+ const at = new Date(after);
57
+ at.setSeconds(0, 0);
58
+ at.setHours(rule.hour, rule.minute);
59
+ for (let i = 0; i < 8; i++) {
60
+ if (at.getTime() > after && rule.days.includes(at.getDay())) return at.getTime();
61
+ at.setDate(at.getDate() + 1);
62
+ at.setHours(rule.hour, rule.minute, 0, 0);
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function scheduleFile(home, campaignId) {
68
+ return path.join(home, 'campaigns', campaignId, 'schedule.json');
69
+ }
70
+
71
+ function readSchedule(home, campaignId) {
72
+ if (!home) return null;
73
+ try {
74
+ const saved = JSON.parse(fs.readFileSync(scheduleFile(home, campaignId), 'utf8'));
75
+ return saved && saved.rule && Number.isFinite(saved.next_at) ? saved : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function writeSchedule(home, campaignId, schedule) {
82
+ const file = scheduleFile(home, campaignId);
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ const tmp = `${file}.${process.pid}.tmp`;
85
+ fs.writeFileSync(tmp, `${JSON.stringify(schedule, null, 2)}\n`);
86
+ fs.renameSync(tmp, file);
87
+ return schedule;
88
+ }
89
+
90
+ function saveSchedule(home, campaignId, { every, rule, steps = DEFAULT_STEPS, by, now }) {
91
+ return writeSchedule(home, campaignId, {
92
+ campaign_id: campaignId, every, rule, steps, approved_by: by,
93
+ approved_at: new Date(now).toISOString(), last_fired_at: null, next_at: nextAt(rule, now),
94
+ });
95
+ }
96
+
97
+ function removeSchedule(home, campaignId) {
98
+ const file = scheduleFile(home, campaignId);
99
+ if (!fs.existsSync(file)) return false;
100
+ fs.unlinkSync(file);
101
+ return true;
102
+ }
103
+
104
+ function isDue(schedule, now) {
105
+ return Boolean(schedule) && now >= schedule.next_at;
106
+ }
107
+
108
+ // Use up the slot before the run starts, so a run that crashes does not fire
109
+ // again every minute. A slot missed while the computer slept fires once.
110
+ function markFired(home, campaignId, schedule, now) {
111
+ return writeSchedule(home, campaignId, { ...schedule, last_fired_at: new Date(now).toISOString(), next_at: nextAt(schedule.rule, now) });
112
+ }
113
+
114
+ function whenText(ms) {
115
+ const at = new Date(ms);
116
+ const day = at.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
117
+ return `${day} ${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
118
+ }
119
+
120
+ function describe(schedule) {
121
+ return `${schedule.every}, up to ${schedule.steps} step${schedule.steps === 1 ? '' : 's'} each time; next ${whenText(schedule.next_at)}`;
122
+ }
123
+
124
+ module.exports = { DEFAULT_STEPS, describe, isDue, markFired, nextAt, parseEvery, readSchedule, removeSchedule, saveSchedule, scheduleFile, whenText };
@@ -267,6 +267,20 @@ function createMemoryStore(options = {}) {
267
267
  const missions = new Map();
268
268
  const seqByOrigin = new Map();
269
269
  const meta = new Map();
270
+ const eventIds = new Set();
271
+ const firstByOriginSeq = new Map();
272
+ let lookupIndexed = true;
273
+ const { isProxy } = require('node:util').types;
274
+ const indexable = (event) => {
275
+ // Reflection must not add caller Proxy traps or evaluate any getters.
276
+ if (isProxy(event) || event === null || typeof event !== 'object' || Object.getPrototypeOf(event) !== Object.prototype) return false;
277
+ const id = Object.getOwnPropertyDescriptor(event, 'id');
278
+ const origin = Object.getOwnPropertyDescriptor(event, 'origin');
279
+ const seq = Object.getOwnPropertyDescriptor(event, 'seq');
280
+ return id && Object.hasOwn(id, 'value') && typeof id.value === 'string'
281
+ && origin && Object.hasOwn(origin, 'value') && typeof origin.value === 'string'
282
+ && seq && Object.hasOwn(seq, 'value') && Number.isInteger(seq.value) && seq.value >= 1;
283
+ };
270
284
 
271
285
  const clone = (value) => JSON.parse(JSON.stringify(value));
272
286
  const count = (campaignId) => events.filter((e) => e.campaign_id === campaignId).length;
@@ -283,6 +297,18 @@ function createMemoryStore(options = {}) {
283
297
  if (missionId && !missions.has(missionId)) missions.set(missionId, event.campaign_id);
284
298
  seqByOrigin.set(event.origin, Math.max(seqByOrigin.get(event.origin) || 0, event.seq));
285
299
  events.push(clone(event));
300
+ if (lookupIndexed) {
301
+ // Index the row actually stored, after any reentrant toJSON commits.
302
+ const stored = events[events.length - 1];
303
+ if (!indexable(stored)) {
304
+ lookupIndexed = false;
305
+ } else {
306
+ eventIds.add(stored.id);
307
+ let sequences = firstByOriginSeq.get(stored.origin);
308
+ if (!sequences) firstByOriginSeq.set(stored.origin, sequences = new Map());
309
+ if (!sequences.has(stored.seq)) sequences.set(stored.seq, stored);
310
+ }
311
+ }
286
312
  };
287
313
 
288
314
  return {
@@ -306,8 +332,14 @@ function createMemoryStore(options = {}) {
306
332
  ingest(event) {
307
333
  const check = validateEvent(event);
308
334
  if (!check.ok) throw storeError('invalid_event', `invalid ${event && event.type}: ${check.errors.join(', ')}`);
309
- if (events.some((e) => e.id === event.id)) return false;
310
- const clash = events.find((e) => e.origin === event.origin && e.seq === event.seq);
335
+ let clash;
336
+ if (lookupIndexed && indexable(event)) {
337
+ if (eventIds.has(event.id)) return false;
338
+ clash = firstByOriginSeq.get(event.origin)?.get(event.seq);
339
+ } else {
340
+ if (events.some((e) => e.id === event.id)) return false;
341
+ clash = events.find((e) => e.origin === event.origin && e.seq === event.seq);
342
+ }
311
343
  if (clash) throw storeError('seq_conflict', `origin ${event.origin} seq ${event.seq} is already ${clash.id}`);
312
344
  checkMission(event);
313
345
  if (event.type === 'campaign_started' && !campaigns.has(event.campaign_id)) campaigns.set(event.campaign_id, event.at);