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.
- package/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +7 -1
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +86 -13
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/member.js +28 -3
- package/commands/mission.js +72 -23
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +15 -5
- package/lib/campaign-core.js +44 -17
- package/lib/campaign-loop.js +45 -14
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +28 -11
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +40 -14
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/qr.js +338 -0
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +511 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +395 -0
- package/package.json +1 -1
package/lib/campaign-cadence.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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 };
|
package/lib/campaign-core.js
CHANGED
|
@@ -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(
|
|
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 (
|
|
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 };
|
package/lib/campaign-loop.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/lib/campaign-reading.js
CHANGED
|
@@ -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 };
|
package/lib/campaign-rsi.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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 };
|
package/lib/campaign-store.js
CHANGED
|
@@ -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
|
-
|
|
310
|
-
|
|
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);
|