atris 3.77.0 → 3.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/commands/brief.js +8 -2
- package/commands/engine.js +14 -4
- package/commands/gm.js +3 -0
- package/commands/mission.js +23 -2
- package/commands/rating.js +7 -1
- package/lib/builder-pick.js +888 -0
- package/lib/claude-spend-guard.js +24 -0
- package/lib/fleet.js +145 -19
- package/lib/gm-season.js +33 -4
- package/lib/held-autosweep.js +559 -0
- package/lib/held-pull-requests.js +42 -15
- package/lib/landing-check.js +296 -0
- package/lib/runner-command.js +78 -38
- package/lib/task-db.js +105 -30
- package/package.json +1 -1
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The rating picks the builder. The fleet used to deal engines to jobs in turn, whatever each engine had
|
|
4
|
+
// landed. Now each job's candidates are ordered by a draw from the engine's real results: the same plays
|
|
5
|
+
// the rating counts (lib/gm-season.js), read from the same task store. The flight says why in one plain
|
|
6
|
+
// line under the job and keeps that line in its receipt.
|
|
7
|
+
//
|
|
8
|
+
// How a draw works. For one job, a candidate has landed some of its plays and missed others since steering
|
|
9
|
+
// began. Plays on jobs in this job's lane count in full and plays on other jobs count half. The candidate's
|
|
10
|
+
// score is one draw from Beta(1 + landed, 1 + missed), so a strong record leads most days, a weak one rarely
|
|
11
|
+
// does, and an engine nobody has tried yet gets its turn, which is how it earns a record. The highest draw
|
|
12
|
+
// leads. The draw is seeded by the job's row id and the UTC day: the same job on the same day with the same
|
|
13
|
+
// results always gets the same order, and every candidate has its own stream, so adding or dropping one
|
|
14
|
+
// candidate never reshuffles the others.
|
|
15
|
+
//
|
|
16
|
+
// What counts, beyond what the rating counts:
|
|
17
|
+
// - A landing counts only when it belongs to its job (lib/landing-check.js): its commit is on origin's
|
|
18
|
+
// default branch, is no older than the claim it lands (five minutes of clock skew allowed), and no other claim
|
|
19
|
+
// recorded the same commit first. A commit counts once, for the first claim that recorded it: not again for a
|
|
20
|
+
// later claim on the same job (reopened and claimed again, by the same engine or another) and not for another
|
|
21
|
+
// job. "First" is read from every landing record in the whole store (every workspace that shares it, every review
|
|
22
|
+
// pass of a job), each filed under the claim it was made under, in the order the store recorded them, and a tie
|
|
23
|
+
// in time goes to the record inserted first. A commit nobody recorded as a landing can still be claimed by a job
|
|
24
|
+
// that did not make it; that is accepted. Misses always count.
|
|
25
|
+
// - A landing pays the engine that finished it. A flight whose engine died hands the retry in under the engine
|
|
26
|
+
// that finished it (lib/task-db.js readyTask records handed_in_by and built_with), and that engine is credited,
|
|
27
|
+
// not the one that died. A landing whose hand-in names more than one engine and does not say which finished
|
|
28
|
+
// counts for no engine's steering. The rating's own display still credits the claim holder.
|
|
29
|
+
// - Where the results come from, in this order, for the folder the jobs are in:
|
|
30
|
+
// One. No git, no origin, or no origin/HEAD: no landing can be checked. Jobs are dealt in turn, with one line.
|
|
31
|
+
// Two. An engine's own landing is on record: origin's default branch is fetched once, so a merge from a minute
|
|
32
|
+
// ago is known. If the fetch fails, jobs are dealt in turn, with one line. If it works, the landings are
|
|
33
|
+
// checked and the draw steers.
|
|
34
|
+
// Three. Only misses are on record: they steer by themselves, with no fetch. An engine nobody has tried gets
|
|
35
|
+
// its turn, and the engine that missed gets fewer jobs.
|
|
36
|
+
// Counting only the misses because the landings could not be checked would punish every engine for not being
|
|
37
|
+
// checkable, so in One and Two nothing is counted.
|
|
38
|
+
// - Everything git does before a pick shares one budget of five seconds and never prompts a person.
|
|
39
|
+
// - A play's lane is the lane its job was in when the claim was made. The claim saves the job's title and tags
|
|
40
|
+
// on itself (lib/task-db.js claimTask and the claim `task do` makes), so tagging or renaming a finished job
|
|
41
|
+
// later changes nothing. A claim without that counts at the outside-lane weight, on the landed side and the
|
|
42
|
+
// missed side alike, and is never rebuilt from the job's history.
|
|
43
|
+
//
|
|
44
|
+
// What it leaves alone: explicit choices are resolved first and are never picked for. A job that names its
|
|
45
|
+
// engine, a job assigned to a member (that member's own engine, by the resolver every member command uses), and
|
|
46
|
+
// a roster's backup order all stand, whether or not anyone has a result yet and whether or not that engine is
|
|
47
|
+
// resting. An engine list a caller names is never filtered, on the first pick or on a retry. A choice the flight
|
|
48
|
+
// cannot give (an engine, or the engine a member's roster resolves to, that is not in this flight's roster) is
|
|
49
|
+
// never given to someone else: the job is left out of the flight, with one line naming the job and why. It is taken
|
|
50
|
+
// out before the flight is staffed, so it takes no slot and holds no file or blind place against another job. The
|
|
51
|
+
// words the pick went on (the member a job is assigned to and the engine it names) are asked again when the flight
|
|
52
|
+
// claims the job, inside the claim's own transaction: a job that says something else by then is not claimed for the
|
|
53
|
+
// old choice, and is left for the next flight with one line. Otherwise
|
|
54
|
+
// steering never picks an engine that is resting (out of credit, cooling after a stall) or that the Claude spend
|
|
55
|
+
// guard would stop in an unattended run: judged on the --model of the launch the fleet itself makes
|
|
56
|
+
// (lib/runner-command.js launchFor), in the environment the worker will really run in. A launch template somebody
|
|
57
|
+
// wrote is never read for its model; that counts as paid, and the line says the model could not be told. When
|
|
58
|
+
// steering is off, or nobody has a result yet, jobs are dealt in turn as they always were.
|
|
59
|
+
|
|
60
|
+
const crypto = require('crypto');
|
|
61
|
+
const fs = require('fs');
|
|
62
|
+
const path = require('path');
|
|
63
|
+
const { tally, isLandingRecord } = require('./gm-season');
|
|
64
|
+
const { taskTagTokens } = require('./task-decision');
|
|
65
|
+
const { normalizeActor } = require('./review-integrity');
|
|
66
|
+
const { commitsOf, createLandingCheck } = require('./landing-check');
|
|
67
|
+
const { unattendedStop } = require('./claude-spend-guard');
|
|
68
|
+
|
|
69
|
+
// Steering starts everyone even on this day. The last 30 days of plays hold old-flow damage: 27 jobs
|
|
70
|
+
// closed upstream were counted as one engine's misses on 10-13, and releases were forced before 3.74.28.
|
|
71
|
+
// Counting that history would steer by damage, not by work, so only plays whose claim began at or after
|
|
72
|
+
// this start count. ATRIS_PICK_SINCE (an ISO date) overrides it, for tests and for a reset.
|
|
73
|
+
const STEERING_START = '2026-11-01T00:00:00Z';
|
|
74
|
+
|
|
75
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
76
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
77
|
+
const OTHER_LANE_WEIGHT = 0.5;
|
|
78
|
+
|
|
79
|
+
function dayLabel(ms) {
|
|
80
|
+
const date = new Date(ms);
|
|
81
|
+
return `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The start steering counts from: { ms, iso, label }. A date nobody can read falls back to the named day.
|
|
85
|
+
function steeringStart(env = process.env) {
|
|
86
|
+
const text = String((env && env.ATRIS_PICK_SINCE) || '').trim();
|
|
87
|
+
// An ISO date, with or without a time. Anything else is not read: Date.parse guesses at odd text.
|
|
88
|
+
let ms = /^\d{4}-\d{2}-\d{2}(?:[T ]|$)/.test(text) ? Date.parse(text) : NaN;
|
|
89
|
+
if (!Number.isFinite(ms)) ms = Date.parse(STEERING_START);
|
|
90
|
+
return { ms, iso: new Date(ms).toISOString(), label: dayLabel(ms) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// "turns" or "rating", whatever the case; anything else is not a pick.
|
|
94
|
+
function parsePickFlag(value) {
|
|
95
|
+
const text = String(value == null ? '' : value).trim().toLowerCase();
|
|
96
|
+
return text === 'turns' || text === 'rating' ? text : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Steering is on unless someone turns it off. An explicit flag is a choice and beats the environment, a habit.
|
|
100
|
+
function pickMode(flag, env = process.env) {
|
|
101
|
+
const flagged = parsePickFlag(flag);
|
|
102
|
+
if (flagged) return flagged;
|
|
103
|
+
return parsePickFlag(env && env.ATRIS_PICK) === 'turns' ? 'turns' : 'rating';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The job's row id plus the UTC day: every hour of one day is the same draw.
|
|
107
|
+
function pickSeed(id, now = Date.now()) {
|
|
108
|
+
return `${id}|${new Date(now).toISOString().slice(0, 10)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A job's lane, read the way the protected-lane check reads its tags (lib/fleet.js protectedLaneOfTask):
|
|
112
|
+
// the tag, tags, metadata tags and #hashtags in the title, then the todo tags it was imported with.
|
|
113
|
+
function laneTokens(task) {
|
|
114
|
+
if (!task || typeof task !== 'object') return [];
|
|
115
|
+
const todoTags = task.metadata && Array.isArray(task.metadata.todo_tags) ? task.metadata.todo_tags : [];
|
|
116
|
+
const tokens = [...taskTagTokens(task), ...todoTags.filter(Boolean).map((tag) => String(tag).trim().toLowerCase())];
|
|
117
|
+
return [...new Set(tokens.filter(Boolean))];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// The draw.
|
|
122
|
+
|
|
123
|
+
// A small seeded generator (mulberry32). One stream per seed and candidate.
|
|
124
|
+
function streamFor(seed, name) {
|
|
125
|
+
let state = crypto.createHash('sha256').update(`${seed}|${name}`).digest().readUInt32BE(0);
|
|
126
|
+
return () => {
|
|
127
|
+
state = (state + 0x6D2B79F5) >>> 0;
|
|
128
|
+
let t = state;
|
|
129
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
130
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
131
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalDraw(rand) {
|
|
136
|
+
let u = 0;
|
|
137
|
+
while (u === 0) u = rand();
|
|
138
|
+
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rand());
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Marsaglia and Tsang. Every shape here is at least 1 (the prior is 1), the case this method covers.
|
|
142
|
+
function gammaDraw(shape, rand) {
|
|
143
|
+
const d = shape - 1 / 3;
|
|
144
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
145
|
+
for (;;) {
|
|
146
|
+
let x;
|
|
147
|
+
let v;
|
|
148
|
+
do {
|
|
149
|
+
x = normalDraw(rand);
|
|
150
|
+
v = 1 + c * x;
|
|
151
|
+
} while (v <= 0);
|
|
152
|
+
v = v * v * v;
|
|
153
|
+
const u = rand();
|
|
154
|
+
if (u < 1 - 0.0331 * x * x * x * x) return d * v;
|
|
155
|
+
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function betaDraw(alpha, beta, rand) {
|
|
160
|
+
const a = gammaDraw(alpha, rand);
|
|
161
|
+
const b = gammaDraw(beta, rand);
|
|
162
|
+
return a / (a + b);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// What counts.
|
|
167
|
+
|
|
168
|
+
function unique(list) {
|
|
169
|
+
return [...new Set((list || []).map((item) => String(item == null ? '' : item).trim()).filter(Boolean))];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The plays one engine made since the start, whose claim began at or after it. A play with no claim time
|
|
173
|
+
// belongs to a claim older than the history, so it cannot be dated and is left out.
|
|
174
|
+
function playsOf(plays, name, since) {
|
|
175
|
+
const member = normalizeActor(`fleet-${name}`);
|
|
176
|
+
return (plays || []).filter((play) => play && normalizeActor(play.member) === member
|
|
177
|
+
&& play.claimed_at != null && Number(play.claimed_at) >= since);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Landed and missed come from the rating's own count (gm-season tally): landed is a landing, finished is
|
|
181
|
+
// landed plus every miss the rating counts. Held, could-not-start and not-needed plays are neither.
|
|
182
|
+
function record(plays, name, since, lane) {
|
|
183
|
+
const own = playsOf(plays, name, since);
|
|
184
|
+
const inLane = (play) => {
|
|
185
|
+
// A lane history could not vouch for (null) is outside the job's lane, whatever the job: it counts at the
|
|
186
|
+
// outside weight on the landed side and on the missed side alike.
|
|
187
|
+
if (play.lane === null) return false;
|
|
188
|
+
return lane.size > 0
|
|
189
|
+
? (play.lane || []).some((token) => lane.has(token))
|
|
190
|
+
// A job with no tags is its own lane: plays on other untagged jobs are the jobs most like it.
|
|
191
|
+
: !(play.lane || []).length;
|
|
192
|
+
};
|
|
193
|
+
const all = tally(own);
|
|
194
|
+
const here = tally(own.filter(inLane));
|
|
195
|
+
const elsewhere = tally(own.filter((play) => !inLane(play)));
|
|
196
|
+
return {
|
|
197
|
+
name,
|
|
198
|
+
landed: all.landed,
|
|
199
|
+
counted: all.finished,
|
|
200
|
+
laneLanded: here.landed,
|
|
201
|
+
laneMissed: here.finished - here.landed,
|
|
202
|
+
otherLanded: elsewhere.landed,
|
|
203
|
+
otherMissed: elsewhere.finished - elsewhere.landed,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function haveResults(plays, names, since) {
|
|
208
|
+
return names.some((name) => tally(playsOf(plays, name, since)).finished > 0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// The pick and its line.
|
|
213
|
+
|
|
214
|
+
function ofRecord(r) {
|
|
215
|
+
return r.counted > 0 ? `${r.landed} of ${r.counted}` : 'new';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pickLine({ records, picked, lane, since, full }) {
|
|
219
|
+
const mine = records.find((r) => r.name === picked);
|
|
220
|
+
const laneName = lane.length ? String(lane[0]).slice(0, 24) : '';
|
|
221
|
+
const results = mine.counted > 0 ? `landed ${mine.landed} of ${mine.counted} since ${dayLabel(since)}` : `no results yet since ${dayLabel(since)}`;
|
|
222
|
+
// The lane's own record is quoted only when it says something the overall record does not.
|
|
223
|
+
const laneCounted = mine.laneLanded + mine.laneMissed;
|
|
224
|
+
const inLane = lane.length && laneCounted > 0 && !(laneCounted === mine.counted && mine.laneLanded === mine.landed)
|
|
225
|
+
? `, ${mine.laneLanded} of ${laneCounted} on jobs like it` : '';
|
|
226
|
+
const others = records.filter((r) => r.name !== picked)
|
|
227
|
+
.map((r) => `fleet-${r.name} ${ofRecord(r)}${full.includes(r.name) ? ' but has its share' : ''}`);
|
|
228
|
+
return `picked fleet-${picked} for this ${laneName ? `${laneName} ` : ''}job: ${results}${inLane}${others.length ? ` (${others.join(', ')})` : ''}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Order one job's candidates by a draw from what each has done. `candidates` arrive in the order the fleet
|
|
232
|
+
// would use today (every health, cooldown and roster check already applied) and keep that order for ties.
|
|
233
|
+
// `full` names the engines with no room left: the order stays the draw, and `picked` is the first with room.
|
|
234
|
+
function pickBuilders({ candidates = [], job = {}, plays = [], since = steeringStart().ms, seed = '', full = [] } = {}) {
|
|
235
|
+
const names = unique(candidates);
|
|
236
|
+
const lane = new Set(Array.isArray(job && job.lane) ? job.lane : []);
|
|
237
|
+
const records = names.map((name) => record(plays, name, since, lane));
|
|
238
|
+
const scored = records.map((r, index) => {
|
|
239
|
+
const rand = streamFor(seed, r.name);
|
|
240
|
+
const alpha = 1 + r.laneLanded + OTHER_LANE_WEIGHT * r.otherLanded;
|
|
241
|
+
const beta = 1 + r.laneMissed + OTHER_LANE_WEIGHT * r.otherMissed;
|
|
242
|
+
return { r, index, score: betaDraw(alpha, beta, rand) };
|
|
243
|
+
}).sort((a, b) => b.score - a.score || a.index - b.index);
|
|
244
|
+
const order = scored.map((entry) => entry.r.name);
|
|
245
|
+
const noRoom = unique(full);
|
|
246
|
+
const picked = order.find((name) => !noRoom.includes(name)) || order[0] || null;
|
|
247
|
+
return {
|
|
248
|
+
order,
|
|
249
|
+
picked,
|
|
250
|
+
line: picked ? pickLine({ records, picked, lane: [...lane], since, full: noRoom }) : '',
|
|
251
|
+
records: records.map((r) => ({ ...r, score: scored.find((entry) => entry.r === r).score })),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// The rating's own plays, from the task store.
|
|
257
|
+
|
|
258
|
+
function parseJson(text) {
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(text);
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- what each play knew when it was made -----------------------------------------------------------------
|
|
267
|
+
//
|
|
268
|
+
// The lane of a play is the lane its job was in when the claim was made, not the one it has now: a finished job
|
|
269
|
+
// that is retagged or renamed later must not change what the engine's record says. The claim saved the job's
|
|
270
|
+
// title and tags on itself when it was made (job_at_claim, lib/task-db.js claimTask), and that is what the lane is
|
|
271
|
+
// read from. A claim without it (made before it was saved) has no lane that anyone can vouch for: it counts at the
|
|
272
|
+
// outside-lane weight on the landed side and the missed side alike, and is never rebuilt from the job's notes or
|
|
273
|
+
// later events.
|
|
274
|
+
|
|
275
|
+
function claimedLane(claim) {
|
|
276
|
+
const job = claim && typeof claim === 'object' ? claim.job_at_claim : null;
|
|
277
|
+
if (!job || typeof job !== 'object' || Array.isArray(job) || typeof job.title !== 'string') return null;
|
|
278
|
+
return laneTokens({ title: job.title, tag: job.tag, metadata: { tags: job.tags, todo_tags: job.todo_tags } });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function chunksOf(list, size = 400) {
|
|
282
|
+
const out = [];
|
|
283
|
+
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// What the picker reads from the store beside the rating's plays, in one read-only pass:
|
|
288
|
+
// claims by claim event id: what the claim saved (the whole claimed payload) and where it sits in its job's history
|
|
289
|
+
// handIns by job: the hand-ins that name who handed the work in and who else built it, in the order they were made
|
|
290
|
+
// records every landing record in the whole store that names a commit, in the order they were recorded (by the
|
|
291
|
+
// record's own time, and by the order they were inserted in where the time is the same): every workspace
|
|
292
|
+
// that shares the store, and every review pass of a job. Each says which claim it was made under
|
|
293
|
+
// (claim_id), read the way the rating reads it: the latest claim of its job before the record, or
|
|
294
|
+
// `<job>:before` when there is none. The rating keeps one landing per claim, and one workspace, so its
|
|
295
|
+
// plays cannot say which claim recorded a commit first. Read in one query, and only when a landing is to
|
|
296
|
+
// be judged (`records: true`).
|
|
297
|
+
// A store that cannot be read throws.
|
|
298
|
+
function readFacts({ claimIds, taskIds, records: wantRecords = false, dbPath }) {
|
|
299
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
300
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
301
|
+
try {
|
|
302
|
+
const marks = (chunk) => chunk.map(() => '?').join(',');
|
|
303
|
+
const claims = new Map();
|
|
304
|
+
for (const chunk of chunksOf(unique(claimIds))) {
|
|
305
|
+
for (const row of db.prepare(`SELECT event_id, version, payload FROM task_events WHERE event_type = 'claimed' AND event_id IN (${marks(chunk)})`).all(...chunk)) {
|
|
306
|
+
claims.set(row.event_id, { version: Number(row.version), payload: parseJson(row.payload) });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const handIns = new Map();
|
|
310
|
+
for (const chunk of chunksOf(unique(taskIds))) {
|
|
311
|
+
const rows = db.prepare(`SELECT task_id, version, payload FROM task_events WHERE event_type = 'proof_ready' AND task_id IN (${marks(chunk)}) AND (payload LIKE '%handed_in_by%' OR payload LIKE '%built_with%')`).all(...chunk);
|
|
312
|
+
for (const row of rows) {
|
|
313
|
+
const body = parseJson(row.payload);
|
|
314
|
+
if (!body || typeof body !== 'object') continue;
|
|
315
|
+
const engineOf = (name) => {
|
|
316
|
+
const actor = typeof name === 'string' ? normalizeActor(name) : '';
|
|
317
|
+
return actor.startsWith('fleet-') ? actor : '';
|
|
318
|
+
};
|
|
319
|
+
const hand = { version: Number(row.version), by: engineOf(body.handed_in_by), builtWith: unique((Array.isArray(body.built_with) ? body.built_with : []).map(engineOf)) };
|
|
320
|
+
if (!handIns.has(row.task_id)) handIns.set(row.task_id, []);
|
|
321
|
+
handIns.get(row.task_id).push(hand);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const records = [];
|
|
325
|
+
if (wantRecords) {
|
|
326
|
+
const scan = db.prepare(`SELECT event_id, task_id, version, event_type, payload FROM task_events
|
|
327
|
+
WHERE event_type IN ('proof_ready', 'landing', 'completed') AND (payload LIKE '%merge_commit%' OR payload LIKE '%merge_sha%')
|
|
328
|
+
ORDER BY created_at, rowid`);
|
|
329
|
+
for (const row of scan.iterate ? scan.iterate() : scan.all()) {
|
|
330
|
+
const body = parseJson(row.payload);
|
|
331
|
+
if (!isLandingRecord(row.event_type, body)) continue;
|
|
332
|
+
const commits = commitsOf(body);
|
|
333
|
+
if (commits.length) records.push({ event_id: row.event_id, task_id: row.task_id, version: Number(row.version), commits, claim_id: '' });
|
|
334
|
+
}
|
|
335
|
+
// The claim each record was made under: the job's latest claim before it, by version (gm-season playEventsSql).
|
|
336
|
+
// Read for the jobs that have a record, on the job index, so it grows with the records and not with the store.
|
|
337
|
+
const claimsOf = new Map();
|
|
338
|
+
for (const chunk of chunksOf(unique(records.map((record) => record.task_id)))) {
|
|
339
|
+
for (const row of db.prepare(`SELECT task_id, version, event_id FROM task_events WHERE event_type = 'claimed' AND task_id IN (${marks(chunk)})`).all(...chunk)) {
|
|
340
|
+
if (!claimsOf.has(row.task_id)) claimsOf.set(row.task_id, []);
|
|
341
|
+
claimsOf.get(row.task_id).push({ version: Number(row.version), event_id: row.event_id });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
for (const record of records) {
|
|
345
|
+
let made = null;
|
|
346
|
+
for (const claim of claimsOf.get(record.task_id) || []) {
|
|
347
|
+
if (claim.version < record.version && (!made || claim.version > made.version)) made = claim;
|
|
348
|
+
}
|
|
349
|
+
record.claim_id = made ? made.event_id : `${record.task_id}:before`;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { claims, handIns, records };
|
|
353
|
+
} finally {
|
|
354
|
+
try { db.close(); } catch { /* closed */ }
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The spellings of a commit worth asking git about: the ones our landings name, and those of any other record that
|
|
359
|
+
// could name the same commit (one spelling is the start of the other). A record that cannot is no rival for any of
|
|
360
|
+
// them, so git is never asked to read it, and the questions grow with the landings being judged, not with the store.
|
|
361
|
+
function relatedSpellings(ours, records) {
|
|
362
|
+
const byStart = new Map();
|
|
363
|
+
for (const name of ours) {
|
|
364
|
+
const key = name.slice(0, 7);
|
|
365
|
+
if (!byStart.has(key)) byStart.set(key, []);
|
|
366
|
+
byStart.get(key).push(name);
|
|
367
|
+
}
|
|
368
|
+
const wanted = new Set(ours);
|
|
369
|
+
for (const record of records) {
|
|
370
|
+
for (const name of record.commits) {
|
|
371
|
+
if ((byStart.get(name.slice(0, 7)) || []).some((mine) => mine.startsWith(name) || name.startsWith(mine))) wanted.add(name);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return [...wanted];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// The engine a landing pays. The rating credits the claim holder, and the rating's own display stays that way. A
|
|
378
|
+
// flight whose engine died hands the retry in under the engine that finished it (handed_in_by), with the engines
|
|
379
|
+
// that ran before it named beside it (built_with), so the last hand-in made under this claim and before this landing
|
|
380
|
+
// says who finished. When no engine is named as the one that finished, the landing pays the claim holder, unless the
|
|
381
|
+
// hand-in names another engine beside it: then nobody can say who finished, and it counts for no engine ('').
|
|
382
|
+
function finisherOf(play, record, facts) {
|
|
383
|
+
const holder = normalizeActor(play.member);
|
|
384
|
+
const claim = facts.claims.get(play.claim_id);
|
|
385
|
+
const from = claim ? claim.version : 0;
|
|
386
|
+
let last = null;
|
|
387
|
+
for (const handIn of facts.handIns.get(play.task_id) || []) {
|
|
388
|
+
if (handIn.version > from && handIn.version <= record.version && (!last || handIn.version > last.version)) last = handIn;
|
|
389
|
+
}
|
|
390
|
+
if (last && last.by) return last.by;
|
|
391
|
+
const named = new Set([holder, ...(last ? last.builtWith : [])]);
|
|
392
|
+
return named.size > 1 ? '' : holder;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Every fleet engine play since `since`, as the rating builds them (gm-season loadOutcomes, the plays of
|
|
396
|
+
// loadSeason without the board around them), each with its lane at the claim. Returns { plays, notes, cannot }.
|
|
397
|
+
//
|
|
398
|
+
// A landing counts only when it belongs to its job (lib/landing-check.js): its commit is on origin's default branch,
|
|
399
|
+
// is no older than its claim (five minutes of clock skew allowed), and no other job recorded the same commit first,
|
|
400
|
+
// judged on every landing record in the store (readFacts). It pays the engine that finished it (finisherOf). Misses
|
|
401
|
+
// stay as they are. The folder is judged in this order:
|
|
402
|
+
// One. No git, no origin, or no origin/HEAD for this folder: no landing can be checked, whatever is on record. `cannot`
|
|
403
|
+
// says why and `plays` is empty, so the jobs are dealt in turn with one line.
|
|
404
|
+
// Two. An engine's own landing is on record: origin's default branch is fetched once, so a merge from a minute ago is
|
|
405
|
+
// known. A fetch that fails is the same case as One. One that works checks the landings, every commit in one
|
|
406
|
+
// batched git call, and the plays steer.
|
|
407
|
+
// Three. Only misses are on record: they steer by themselves, without a fetch. A newcomer is tried and the engine
|
|
408
|
+
// that missed gets fewer jobs.
|
|
409
|
+
// `notes` are plain sentences the flight says once. No task store, or one with no history, is no plays. A store that
|
|
410
|
+
// cannot be read throws.
|
|
411
|
+
function loadPlays(root, { since, now = Date.now(), dbPath, landing } = {}) {
|
|
412
|
+
const taskDb = require('./task-db');
|
|
413
|
+
const start = Number.isFinite(since) ? since : steeringStart().ms;
|
|
414
|
+
const workspace = taskDb.workspaceRoot(root);
|
|
415
|
+
const days = Math.max(1, Math.ceil((now - start) / DAY_MS));
|
|
416
|
+
const season = require('./gm-season').loadOutcomes(workspace, { now, days, dbPath });
|
|
417
|
+
if (season.error) throw new Error(season.error);
|
|
418
|
+
const mine = (season.outcomes || []).filter((play) => normalizeActor(play.member).startsWith('fleet-')
|
|
419
|
+
&& play.claimed_at != null && Number(play.claimed_at) >= start);
|
|
420
|
+
if (!mine.length) return { plays: [], notes: [], cannot: '' };
|
|
421
|
+
|
|
422
|
+
const landings = mine.filter((play) => play.kind === 'landed');
|
|
423
|
+
const check = landing || createLandingCheck(workspace, landings.length ? { since: Math.min(...landings.map((play) => Number(play.claimed_at))) } : {});
|
|
424
|
+
const notes = () => (typeof check.notes === 'function' ? check.notes() : []);
|
|
425
|
+
|
|
426
|
+
// One. Can any landing be checked in this folder? Asked even when only misses are on record, so a folder that
|
|
427
|
+
// cannot be checked deals in turn whatever the plays are.
|
|
428
|
+
// Two. An engine's own landing needs origin brought up to date, once.
|
|
429
|
+
let cannot = check.cannotCheck();
|
|
430
|
+
if (!cannot && landings.length) cannot = check.refresh();
|
|
431
|
+
if (cannot) return { plays: [], notes: notes(), cannot };
|
|
432
|
+
|
|
433
|
+
const facts = readFacts({
|
|
434
|
+
claimIds: mine.map((play) => play.claim_id),
|
|
435
|
+
taskIds: landings.map((play) => play.task_id),
|
|
436
|
+
records: landings.length > 0,
|
|
437
|
+
dbPath: dbPath || taskDb.getDbPath(),
|
|
438
|
+
});
|
|
439
|
+
const withLane = (play) => ({ ...play, lane: claimedLane((facts.claims.get(play.claim_id) || {}).payload) });
|
|
440
|
+
// Three. Only misses: they steer by themselves.
|
|
441
|
+
if (!landings.length) return { plays: mine.map(withLane), notes: notes(), cannot: '' };
|
|
442
|
+
|
|
443
|
+
// The first claim to record each commit keeps it, and no claim after it gets it again: not a later claim on the same
|
|
444
|
+
// job (reopened, claimed again by the same engine or another) and not another job's. A short spelling is read to the
|
|
445
|
+
// commit it starts, so the same commit under two spellings is one commit.
|
|
446
|
+
const byEvent = new Map(facts.records.map((record) => [record.event_id, record]));
|
|
447
|
+
const ours = unique(landings.flatMap((play) => (byEvent.get(play.event_id) || { commits: [] }).commits));
|
|
448
|
+
const seen = check.read(relatedSpellings(ours, facts.records));
|
|
449
|
+
const first = new Map();
|
|
450
|
+
for (const record of facts.records) {
|
|
451
|
+
for (const name of record.commits) {
|
|
452
|
+
const sighting = seen.get(name);
|
|
453
|
+
if (sighting && !first.has(sighting.oid)) first.set(sighting.oid, record.claim_id);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const plays = [];
|
|
458
|
+
for (const play of mine) {
|
|
459
|
+
if (play.kind !== 'landed') {
|
|
460
|
+
plays.push(withLane(play));
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const record = byEvent.get(play.event_id);
|
|
464
|
+
if (!record) continue;
|
|
465
|
+
const member = finisherOf(play, record, facts);
|
|
466
|
+
if (!member) continue;
|
|
467
|
+
const counts = check.landed(record.commits, {
|
|
468
|
+
claimedAt: Number(play.claimed_at),
|
|
469
|
+
owns: (oid) => !first.has(oid) || first.get(oid) === record.claim_id,
|
|
470
|
+
});
|
|
471
|
+
if (counts) plays.push(withLane({ ...play, member }));
|
|
472
|
+
}
|
|
473
|
+
// Git can stop answering partway. What was judged before that is not a verdict: nothing counts.
|
|
474
|
+
cannot = check.cannotCheck();
|
|
475
|
+
if (cannot) return { plays: [], notes: notes(), cannot };
|
|
476
|
+
return { plays, notes: notes(), cannot: '' };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Explicit choices, and engines that are resting.
|
|
481
|
+
|
|
482
|
+
function rowId(task) {
|
|
483
|
+
return String((task && (task.id || task.display_id)) || '');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function assigneeOf(task) {
|
|
487
|
+
const assigned = task && task.metadata && task.metadata.assigned_to;
|
|
488
|
+
return typeof assigned === 'string' ? assigned.trim() : '';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The engine a job names for itself, if it does, spelled the way the fleet spells its engines.
|
|
492
|
+
function namedEngine(task) {
|
|
493
|
+
const raw = task && (task.executed_by || (task.metadata && (task.metadata.executed_by || task.metadata.engine)));
|
|
494
|
+
const name = String(raw || '').trim().toLowerCase();
|
|
495
|
+
if (name === 'cursor-agent' || name === 'cursor agent') return 'cursor';
|
|
496
|
+
if (name === 'claude-code') return 'claude';
|
|
497
|
+
return name;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// What a job's own words choose, as the pick reads them: the member it is assigned to and the engine it names, both
|
|
501
|
+
// spelled the way the pick spells them. A job read from the task list and the same job read from the store (its
|
|
502
|
+
// metadata parsed) give the same answer when nobody has changed them.
|
|
503
|
+
function choiceOf(task) {
|
|
504
|
+
return { member: assigneeOf(task), engine: namedEngine(task) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const sameChoice = (a, b) => a.member === b.member && a.engine === b.engine;
|
|
508
|
+
|
|
509
|
+
// The one line for a job the flight cannot give its choice to, said once however many times it is asked about.
|
|
510
|
+
const leftOutNote = (task, why) => `left out ${String((task && (task.display_id || task.id)) || 'a job')}: ${why}`;
|
|
511
|
+
|
|
512
|
+
// Why a claim was refused when the job's words changed after the pick (the reason claimTask answers with).
|
|
513
|
+
const CHOICE_CHANGED = 'choice_changed';
|
|
514
|
+
|
|
515
|
+
// Why an engine should not be picked right now, or ''. Only a state the registry knows is read: an engine it
|
|
516
|
+
// has no entry for is left alone, and an engine out of credit stays out until a person says otherwise.
|
|
517
|
+
function restingWhy(entry, now) {
|
|
518
|
+
const { engineReadyAt } = require('./engine-registry');
|
|
519
|
+
const status = entry && entry.health && entry.health.status;
|
|
520
|
+
if (status === 'credit_out') return 'out of credit';
|
|
521
|
+
if (status === 'cooling' && !engineReadyAt(entry, new Date(now))) return 'cooling';
|
|
522
|
+
if (status === 'error' && !engineReadyAt(entry, new Date(now))) return 'failed recently';
|
|
523
|
+
return '';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function restingEngines(names, root, now) {
|
|
527
|
+
let view = [];
|
|
528
|
+
try {
|
|
529
|
+
view = require('./engine-registry').readEngineRegistry(root, { persist: false }).engines || [];
|
|
530
|
+
} catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
const resting = [];
|
|
534
|
+
for (const name of names) {
|
|
535
|
+
const why = restingWhy(view.find((entry) => entry.id === name), now);
|
|
536
|
+
if (why) resting.push({ name, why });
|
|
537
|
+
}
|
|
538
|
+
return resting;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The program a launch runs: its last path segment, so "/opt/bin/claude" and "claude.exe" are both claude.
|
|
542
|
+
function programOf(bin) {
|
|
543
|
+
return String(bin || '').split(/[\\/]/).pop().replace(/\.exe$/i, '').toLowerCase();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// The engines the Claude spend guard would stop in an unattended run, with a few plain words each. The hook that
|
|
547
|
+
// `atris spend guard install` adds stays the real guard on every unattended claude run; this only avoids picking an
|
|
548
|
+
// engine that would be refused, so wherever it is unsure it leaves claude out. Two environments are in play, and
|
|
549
|
+
// they are not always the same one.
|
|
550
|
+
// launchEnv where the launch is built. It decides the program and the --model the command carries, through the
|
|
551
|
+
// resolver the fleet builds its commands with (lib/runner-command.js launchFor): the legacy
|
|
552
|
+
// ATRIS_CLAUDE_MODEL, a runner profile, and the runner's own fall back to the cheap model all count.
|
|
553
|
+
// workerEnv where the worker runs. It decides the guard's verdict, because that is what the guard's hook reads:
|
|
554
|
+
// a sealed one-lap worker has no spend approval, no pay-per-use key and no model variable, whatever
|
|
555
|
+
// the launching side has.
|
|
556
|
+
// The rule, and it guesses nothing:
|
|
557
|
+
// - The fleet's own launch is judged by the --model the fleet itself passes, resolved through
|
|
558
|
+
// ANTHROPIC_DEFAULT_*_MODEL in workerEnv. With no --model, by ANTHROPIC_MODEL in workerEnv, else the paid default.
|
|
559
|
+
// No settings file is read, from any home (unattendedStop).
|
|
560
|
+
// - A launch template somebody wrote is never read for its model (launch.custom). It counts as a model nobody can
|
|
561
|
+
// tell, which counts as possibly paid: the engine sits out unless ATRIS_CLAUDE_SPEND_OK in workerEnv covers the
|
|
562
|
+
// paid default, and the line says the model could not be told.
|
|
563
|
+
// Only a launch of the claude program is asked. A launch that cannot be worked out leaves the engine alone.
|
|
564
|
+
const CANNOT_TELL = 'could not tell which model its custom launch template uses, so it counts as paid, and there is no spend approval';
|
|
565
|
+
|
|
566
|
+
function spendStops(names, launchEnv, workerEnv) {
|
|
567
|
+
const { launchFor } = require('./runner-command');
|
|
568
|
+
const stops = [];
|
|
569
|
+
for (const name of names) {
|
|
570
|
+
try {
|
|
571
|
+
const launch = launchFor(name, { env: launchEnv });
|
|
572
|
+
if (!launch || programOf(launch.bin) !== 'claude') continue;
|
|
573
|
+
// A model nobody can tell is asked about as the paid default, never as a model the worker's environment names.
|
|
574
|
+
const stop = unattendedStop({ model: launch.custom ? 'default' : launch.model, env: workerEnv });
|
|
575
|
+
if (stop) stops.push({ name, why: launch.custom && stop.reason === 'paid_model' ? CANNOT_TELL : stop.why });
|
|
576
|
+
} catch {
|
|
577
|
+
// an engine whose launch cannot be worked out is left alone
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return stops;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// One flight's steering.
|
|
585
|
+
|
|
586
|
+
// Steering for one flight. Nothing is read until a job needs a pick, and when steering is off nothing is ever
|
|
587
|
+
// read. `plays` stands in for the task store (tests). `env` is the environment the launches are built in. `workerEnv`
|
|
588
|
+
// is the environment the workers will run in when it is not the same one (a sealed one-lap worker is given far
|
|
589
|
+
// less); the Claude spend guard's verdict is given for it. `named` says the caller named the engines: a list a
|
|
590
|
+
// caller names is never filtered, on the first pick or on a retry. The pieces the flights use:
|
|
591
|
+
// rest(names) which of these engines are ready to be picked, and a note naming those left out
|
|
592
|
+
// deal(entries, roster) the fleet's turns, steered: explicit choices first, then an engine for every other job
|
|
593
|
+
// next(job, candidates) the best of these for one job's retry, or null when today's rule stands
|
|
594
|
+
// notes() what steering has to say that nobody has printed yet (deal already hands over its own)
|
|
595
|
+
// gives(roster) a test for staffing: false for a job whose chosen engine this roster cannot give, which is
|
|
596
|
+
// then left out (with its line) before it can take a slot or hold another job's files
|
|
597
|
+
// guardClaim(task) the check a claim makes on a job this flight picked for, inside the claim's own transaction
|
|
598
|
+
function startSteering({ root = process.cwd(), pick, env = process.env, workerEnv = env, now = Date.now(), plays = null, named = false } = {}) {
|
|
599
|
+
const on = pickMode(pick, env) === 'rating';
|
|
600
|
+
const since = steeringStart(env);
|
|
601
|
+
const header = `steering on since ${since.label}`;
|
|
602
|
+
let loaded = null;
|
|
603
|
+
const pending = [];
|
|
604
|
+
const told = new Set();
|
|
605
|
+
// Each note is said once per flight, however many jobs or retries meet it.
|
|
606
|
+
const tell = (note) => {
|
|
607
|
+
if (note && !told.has(note)) {
|
|
608
|
+
told.add(note);
|
|
609
|
+
pending.push(note);
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
// The results, read once. When no landing can be checked in this folder, `cannot` says why and the line that says
|
|
613
|
+
// so is told here, whoever reads the results first (the first pick or a retry): the jobs are dealt in turn.
|
|
614
|
+
const history = () => {
|
|
615
|
+
if (!loaded) {
|
|
616
|
+
try {
|
|
617
|
+
const got = plays ? { plays, notes: [], cannot: '' } : loadPlays(root, { since: since.ms, now });
|
|
618
|
+
loaded = { plays: got.plays, error: '', cannot: got.cannot || '' };
|
|
619
|
+
if (loaded.cannot) tell(`${header}: dealing in turn: landings cannot be checked here (${loaded.cannot})`);
|
|
620
|
+
for (const note of got.notes) tell(`${header}: ${note}`);
|
|
621
|
+
} catch (err) {
|
|
622
|
+
loaded = { plays: [], error: String(err && err.message || err).split('\n')[0].slice(0, 120), cannot: '' };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return loaded;
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// Which of these engines are ready to be picked: not resting, and not one the spend guard would stop. Always
|
|
629
|
+
// asks. `rest` is the same question for a caller that may have named its engines.
|
|
630
|
+
function screen(names) {
|
|
631
|
+
const list = unique(names);
|
|
632
|
+
if (!on) return { ready: list, resting: [], note: '' };
|
|
633
|
+
const resting = restingEngines(list, root, now);
|
|
634
|
+
for (const stop of spendStops(list, env, workerEnv)) {
|
|
635
|
+
if (!resting.some((entry) => entry.name === stop.name)) resting.push(stop);
|
|
636
|
+
}
|
|
637
|
+
const ready = list.filter((name) => !resting.some((entry) => entry.name === name));
|
|
638
|
+
return {
|
|
639
|
+
ready,
|
|
640
|
+
resting,
|
|
641
|
+
note: resting.length ? `left out, not ready: ${resting.map((entry) => `${entry.name} (${entry.why})`).join(', ')}` : '',
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// A list the caller named is never filtered: everything on it stays, whatever state the engine is in.
|
|
646
|
+
function rest(names) {
|
|
647
|
+
return named ? { ready: unique(names), resting: [], note: '' } : screen(names);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// The engine of a member, when the roster decided it. Asked once per member. This is the resolver every member
|
|
651
|
+
// command uses (lib/member-engine.js memberRosterEngine, called as they call it), so the job follows the roster
|
|
652
|
+
// exactly as `atris member` does, including the engine it falls back to when the member's own is out of credit.
|
|
653
|
+
const memberEngines = new Map();
|
|
654
|
+
function memberEngine(member) {
|
|
655
|
+
if (!memberEngines.has(member)) {
|
|
656
|
+
let id = '';
|
|
657
|
+
try {
|
|
658
|
+
const own = require('./member-engine').memberRosterEngine(member, root);
|
|
659
|
+
id = own && own.engine && own.engine.id ? String(own.engine.id) : '';
|
|
660
|
+
} catch {
|
|
661
|
+
id = '';
|
|
662
|
+
}
|
|
663
|
+
memberEngines.set(member, id);
|
|
664
|
+
}
|
|
665
|
+
return memberEngines.get(member);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// What a job's own words choose, judged against the whole roster before anything is filtered out. Null when the
|
|
669
|
+
// job chooses nothing. Otherwise one of:
|
|
670
|
+
// { engine } the engine the job goes to. It is '' for a member the roster does not decide, and then the job
|
|
671
|
+
// keeps the engine the turns gave it, which is all there is to give.
|
|
672
|
+
// { out } the job is left out of the flight, and this is why in plain words: the person chose an engine, or a
|
|
673
|
+
// member whose roster engine is one, that this flight does not have. Nothing else may take their place.
|
|
674
|
+
// The engine a job names decides: it goes there when the flight has it, and an owner's name (a member) stands for
|
|
675
|
+
// the engine its roster gives it. A job that names nothing goes to the engine of the member it is assigned to.
|
|
676
|
+
function explicitChoice(task, full) {
|
|
677
|
+
const namedBy = namedEngine(task);
|
|
678
|
+
const assignee = assigneeOf(task);
|
|
679
|
+
if (!namedBy && !assignee) return null;
|
|
680
|
+
const pool = `(${full.join(', ')})`;
|
|
681
|
+
if (namedBy) {
|
|
682
|
+
if (full.includes(namedBy)) return { engine: namedBy };
|
|
683
|
+
const own = memberEngine(namedBy);
|
|
684
|
+
if (own && full.includes(own)) return { engine: own };
|
|
685
|
+
return { out: own ? `it names ${namedBy}, whose engine is ${own}, which this flight does not have ${pool}` : `it names ${namedBy}, which this flight does not have ${pool}` };
|
|
686
|
+
}
|
|
687
|
+
const own = memberEngine(assignee);
|
|
688
|
+
if (!own) return { engine: '' };
|
|
689
|
+
if (full.includes(own)) return { engine: own };
|
|
690
|
+
return { out: `it is assigned to ${assignee}, whose engine is ${own}, which this flight does not have ${pool}` };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// The fleet's turns, steered. `entries` are the jobs with the engine the turns gave each over the whole
|
|
694
|
+
// `roster`. Explicit choices are resolved first, before any filter or early return: a job that names its
|
|
695
|
+
// engine goes there, a job assigned to a member goes to that member's own engine, and a choice the roster
|
|
696
|
+
// cannot decide (a member it has no engine for) keeps the engine the turns gave it (unless that engine is
|
|
697
|
+
// resting or stopped, and then the job is picked like any other). A choice the flight cannot give at all,
|
|
698
|
+
// an engine or a member's engine that is not in the roster, is never given to someone else: the job is left out
|
|
699
|
+
// of this flight, with one line naming it and why. Resting and spend-stopped engines are left out of the pool
|
|
700
|
+
// after that, and only for the jobs nobody chose for. `filter: false` keeps every roster engine in the pool, and
|
|
701
|
+
// it is the default for a list the caller named.
|
|
702
|
+
// Returns { entries, ready, notes, picked }: ready is the pool, notes are plain sentences to print, and entries
|
|
703
|
+
// are the jobs that stay, each with the engine the turns or the pick gave it (a job left out is not in it).
|
|
704
|
+
function deal(entries, roster, { filter = !named } = {}) {
|
|
705
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
706
|
+
if (!on) return { entries: list, ready: Array.isArray(roster) ? roster : [], notes: [], picked: 0 };
|
|
707
|
+
const full = unique(roster);
|
|
708
|
+
const sitOut = filter ? screen(full) : { ready: full, note: '' };
|
|
709
|
+
tell(sitOut.note);
|
|
710
|
+
const ready = sitOut.ready;
|
|
711
|
+
|
|
712
|
+
const used = new Map(ready.map((name) => [name, 0]));
|
|
713
|
+
const dealt = new Array(list.length).fill(null);
|
|
714
|
+
const leftOut = new Set();
|
|
715
|
+
list.forEach((entry, i) => {
|
|
716
|
+
const choice = explicitChoice(entry && entry.task, full);
|
|
717
|
+
if (!choice) return;
|
|
718
|
+
if (choice.out) {
|
|
719
|
+
leftOut.add(i);
|
|
720
|
+
tell(leftOutNote(entry && entry.task, choice.out));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
// A choice the roster cannot decide leaves the job on its turn, which is exactly what the turns would do,
|
|
724
|
+
// unless that engine cannot be used right now. Then nobody has chosen for this job after all.
|
|
725
|
+
if (!choice.engine && !ready.includes(entry.engine)) return;
|
|
726
|
+
const engine = choice.engine || entry.engine;
|
|
727
|
+
if (used.has(engine)) used.set(engine, used.get(engine) + 1);
|
|
728
|
+
dealt[i] = engine === entry.engine ? entry : { ...entry, engine };
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
const free = list.map((entry, i) => i).filter((i) => !dealt[i] && !leftOut.has(i));
|
|
732
|
+
let picked = 0;
|
|
733
|
+
const turn = (i) => ({ ...list[i], engine: ready[i % ready.length] });
|
|
734
|
+
if (free.length && ready.length) {
|
|
735
|
+
const known = ready.length >= 2 ? history() : null;
|
|
736
|
+
if (!known) {
|
|
737
|
+
// One engine is ready: nothing to choose between.
|
|
738
|
+
free.forEach((i) => { dealt[i] = turn(i); });
|
|
739
|
+
} else if (known.error) {
|
|
740
|
+
tell(`${header}: could not read results (${known.error}), so jobs are dealt in turn`);
|
|
741
|
+
free.forEach((i) => { dealt[i] = turn(i); });
|
|
742
|
+
} else if (known.cannot) {
|
|
743
|
+
// history() has said why. Nothing that landed can be told from nothing here, so no engine is ahead.
|
|
744
|
+
free.forEach((i) => { dealt[i] = turn(i); });
|
|
745
|
+
} else if (!haveResults(known.plays, ready, since.ms)) {
|
|
746
|
+
tell(`${header}: no results yet, so jobs are dealt in turn`);
|
|
747
|
+
free.forEach((i) => { dealt[i] = turn(i); });
|
|
748
|
+
} else {
|
|
749
|
+
// The share is a spread, not a capacity limit. It keeps one engine from taking a whole flight while the
|
|
750
|
+
// others sit idle: the draw still orders every candidate, and the job goes to the first with room.
|
|
751
|
+
// Explicit choices count against it but are never refused by it, and when every engine is full the
|
|
752
|
+
// draw's own order stands. A job left out is not dealt to anyone, so it counts toward nobody's share.
|
|
753
|
+
const share = Math.ceil((list.length - leftOut.size) / ready.length);
|
|
754
|
+
free.forEach((i) => {
|
|
755
|
+
const task = list[i] && list[i].task;
|
|
756
|
+
const id = rowId(task);
|
|
757
|
+
const result = pickBuilders({
|
|
758
|
+
candidates: ready,
|
|
759
|
+
job: { id, lane: laneTokens(task) },
|
|
760
|
+
plays: known.plays,
|
|
761
|
+
since: since.ms,
|
|
762
|
+
seed: pickSeed(id, now),
|
|
763
|
+
full: ready.filter((name) => used.get(name) >= share),
|
|
764
|
+
});
|
|
765
|
+
used.set(result.picked, used.get(result.picked) + 1);
|
|
766
|
+
picked += 1;
|
|
767
|
+
dealt[i] = { ...list[i], engine: result.picked, pick: result.line };
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return { entries: dealt.filter(Boolean), ready, notes: notes(), picked };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// The jobs a flight may staff, asked before it is staffed. A job whose own words choose an engine this roster
|
|
775
|
+
// cannot give (an engine it names, or the engine of the member it is assigned to, that the roster does not have) is
|
|
776
|
+
// left out, with the same line deal says, and is never given to someone else. Asked of each job the staffing looks at,
|
|
777
|
+
// in order, so a job that cannot go takes no slot and holds no file or blind place against the jobs after it. A job
|
|
778
|
+
// that chooses nothing, or something the roster can give, may be staffed. When steering is off, or the roster is empty,
|
|
779
|
+
// every job may.
|
|
780
|
+
function gives(roster) {
|
|
781
|
+
const full = unique(roster);
|
|
782
|
+
// A roster with no engine has nothing to give anyone and nobody to staff: no job is named as left out for it.
|
|
783
|
+
if (!on || !full.length) return () => true;
|
|
784
|
+
return (task) => {
|
|
785
|
+
const choice = explicitChoice(task, full);
|
|
786
|
+
if (!choice || !choice.out) return true;
|
|
787
|
+
tell(leftOutNote(task, choice.out));
|
|
788
|
+
return false;
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// The check a claim makes, inside its own transaction, on a job this flight picked an engine for. The pick went on
|
|
793
|
+
// who the job is assigned to and the engine it names, as the task list showed them; the claim comes later, and the
|
|
794
|
+
// job may have been given to someone else, or named another engine, in between. The returned function is asked
|
|
795
|
+
// with the job as it stands in the store when the claim is made, and answers with the reason to refuse (a job whose
|
|
796
|
+
// words changed is not claimed for the old choice) or ''. Null when steering is off: the turns went on neither.
|
|
797
|
+
function guardClaim(task) {
|
|
798
|
+
if (!on) return null;
|
|
799
|
+
const used = choiceOf(task);
|
|
800
|
+
return (current) => (sameChoice(used, choiceOf(current)) ? '' : CHOICE_CHANGED);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// The best of these candidates for one job's retry on another engine, with the line that says why. Null
|
|
804
|
+
// when steering is off or has no result to go on (or no landing can be checked here): then today's rule stands.
|
|
805
|
+
function next(job, candidates) {
|
|
806
|
+
if (!on) return null;
|
|
807
|
+
const names = unique(candidates);
|
|
808
|
+
if (!names.length) return null;
|
|
809
|
+
const known = history();
|
|
810
|
+
if (known.error || known.cannot || !haveResults(known.plays, names, since.ms)) return null;
|
|
811
|
+
const id = rowId(job);
|
|
812
|
+
const result = pickBuilders({ candidates: names, job: { id, lane: laneTokens(job) }, plays: known.plays, since: since.ms, seed: pickSeed(id, now) });
|
|
813
|
+
return result.picked ? { engine: result.picked, pick: result.line } : null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// The notes steering has to say that nobody has printed yet. Call after next(): reading the results for the
|
|
817
|
+
// first time is when a landing problem comes to light.
|
|
818
|
+
const notes = () => pending.splice(0);
|
|
819
|
+
|
|
820
|
+
return { on, since, rest, deal, next, notes, gives, guardClaim };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// atris rating: how many jobs steering has picked, counted from the flights' receipts.
|
|
825
|
+
|
|
826
|
+
function pickedJobs(receipt) {
|
|
827
|
+
const jobs = new Set();
|
|
828
|
+
for (const row of Array.isArray(receipt.staffed) ? receipt.staffed : []) {
|
|
829
|
+
if (row && row.pick) jobs.add(String(row.task));
|
|
830
|
+
}
|
|
831
|
+
for (const row of Array.isArray(receipt.results) ? receipt.results : []) {
|
|
832
|
+
if (row && row.restaffed && row.restaffed.pick) jobs.add(String(row.task));
|
|
833
|
+
}
|
|
834
|
+
return jobs.size;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function countPicked(root, sinceMs) {
|
|
838
|
+
const dir = path.join(root, 'atris', 'runs');
|
|
839
|
+
let names;
|
|
840
|
+
try {
|
|
841
|
+
names = fs.readdirSync(dir);
|
|
842
|
+
} catch {
|
|
843
|
+
return 0;
|
|
844
|
+
}
|
|
845
|
+
let total = 0;
|
|
846
|
+
for (const name of names) {
|
|
847
|
+
if (!/^(fleet|dispatch)-.*\.json$/.test(name)) continue;
|
|
848
|
+
const file = path.join(dir, name);
|
|
849
|
+
let receipt;
|
|
850
|
+
try {
|
|
851
|
+
if (fs.statSync(file).mtimeMs < sinceMs) continue;
|
|
852
|
+
receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
853
|
+
} catch {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (!receipt || !(Date.parse(receipt.at) >= sinceMs)) continue;
|
|
857
|
+
total += pickedJobs(receipt);
|
|
858
|
+
}
|
|
859
|
+
return total;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// What `atris rating` says about steering: nothing but { on: false } when it is off, else the start, the jobs
|
|
863
|
+
// it has picked since, and the one line. A job counts once per flight, however many times it was picked.
|
|
864
|
+
function steeringSummary({ root = process.cwd(), env = process.env } = {}) {
|
|
865
|
+
if (pickMode(undefined, env) !== 'rating') return { on: false };
|
|
866
|
+
const since = steeringStart(env);
|
|
867
|
+
const picked = countPicked(root, since.ms);
|
|
868
|
+
return {
|
|
869
|
+
on: true,
|
|
870
|
+
since: since.iso,
|
|
871
|
+
picked,
|
|
872
|
+
line: `steering on since ${since.label}: picked ${picked} ${picked === 1 ? 'job' : 'jobs'}`,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
module.exports = {
|
|
877
|
+
CHOICE_CHANGED,
|
|
878
|
+
STEERING_START,
|
|
879
|
+
steeringStart,
|
|
880
|
+
parsePickFlag,
|
|
881
|
+
pickMode,
|
|
882
|
+
pickSeed,
|
|
883
|
+
laneTokens,
|
|
884
|
+
pickBuilders,
|
|
885
|
+
loadPlays,
|
|
886
|
+
startSteering,
|
|
887
|
+
steeringSummary,
|
|
888
|
+
};
|