atris 3.76.0 → 3.78.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 +2 -0
- package/commands/brief.js +219 -36
- package/commands/engine.js +14 -4
- package/commands/mission.js +23 -2
- package/commands/rating.js +7 -10
- package/lib/builder-pick.js +888 -0
- package/lib/claude-spend-guard.js +24 -0
- package/lib/fleet.js +135 -19
- package/lib/gm-season.js +110 -4
- package/lib/landing-check.js +296 -0
- package/lib/owner-brief.js +1060 -0
- package/lib/runner-command.js +78 -38
- package/lib/task-db.js +35 -3
- package/package.json +1 -1
|
@@ -100,6 +100,28 @@ function checkClaudeSpend({ model, job = '', env = process.env, checkKey = false
|
|
|
100
100
|
return { ok: false, reason: 'paid_model', model: resolved, message: blockMessage(resolved, job) };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// Whether an unattended `claude` run on this model would be stopped, for a caller that only wants to know
|
|
104
|
+
// (the fleet's builder picker leaves such an engine out). The same rule the hook applies, with the key check
|
|
105
|
+
// on, but nothing is written to the ledger: asking is not a block. Null when the run may start, else
|
|
106
|
+
// { reason, model, why }, where why is a few plain words.
|
|
107
|
+
//
|
|
108
|
+
// The run has not started, so no settings file is read, from any home: that file belongs to the home the run will
|
|
109
|
+
// get, which this caller may not know, and reading the wrong one can say Haiku about a run that is not. With no
|
|
110
|
+
// model the answer is ANTHROPIC_MODEL in `env`, else the account default, which is paid. (The hook itself reads
|
|
111
|
+
// the settings of the run it is stopping, and checkClaudeSpend still does.)
|
|
112
|
+
function unattendedStop({ model, env = process.env } = {}) {
|
|
113
|
+
const asked = String(model || '').trim() || String(env.ANTHROPIC_MODEL || '').trim() || 'default';
|
|
114
|
+
const verdict = checkClaudeSpend({ model: asked, job: 'claude spend guard', env, checkKey: true, log: false });
|
|
115
|
+
if (verdict.ok) return null;
|
|
116
|
+
return {
|
|
117
|
+
reason: verdict.reason,
|
|
118
|
+
model: verdict.model,
|
|
119
|
+
why: verdict.reason === 'paid_key'
|
|
120
|
+
? 'a pay-per-use Claude key is set'
|
|
121
|
+
: `paid model ${verdict.model}, no spend approval`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
103
125
|
// A copy of env safe to hand a `claude` child: no pay-per-use key, so the run
|
|
104
126
|
// bills the plan login. ATRIS_CLAUDE_KEY_OK=1 keeps the key (servers only).
|
|
105
127
|
function claudeChildEnv(env = process.env) {
|
|
@@ -277,8 +299,10 @@ function uninstallGuard({ env = process.env } = {}) {
|
|
|
277
299
|
module.exports = {
|
|
278
300
|
APPROVAL_ENV,
|
|
279
301
|
checkClaudeSpend,
|
|
302
|
+
unattendedStop,
|
|
280
303
|
claudeChildEnv,
|
|
281
304
|
hookDecision,
|
|
305
|
+
modelFromArgv,
|
|
282
306
|
guardStatus,
|
|
283
307
|
installGuard,
|
|
284
308
|
uninstallGuard,
|
package/lib/fleet.js
CHANGED
|
@@ -29,6 +29,7 @@ const { engineRunsView } = require('./roster-models');
|
|
|
29
29
|
const { resolveDefaultVerifier } = require('./default-verifier');
|
|
30
30
|
const { taskCheck } = require('./member-task-pick');
|
|
31
31
|
const { rankEnginesDetailed } = require('./router-brain');
|
|
32
|
+
const { startSteering, CHOICE_CHANGED } = require('./builder-pick');
|
|
32
33
|
const {
|
|
33
34
|
buildOneLapValidatorPrompt,
|
|
34
35
|
parseOneLapValidatorVerdict,
|
|
@@ -815,6 +816,52 @@ function nextInstalledFleetEngine(current, { root = process.cwd(), installedEngi
|
|
|
815
816
|
return rankFleetEngines(candidates, root)[0] || '';
|
|
816
817
|
}
|
|
817
818
|
|
|
819
|
+
// What the Claude spend guard reads in the environment a sealed worker (a one-lap build) is given: no approval for
|
|
820
|
+
// a paid model, no pay-per-use key, and no model variable (ANTHROPIC_MODEL and the ANTHROPIC_DEFAULT_*_MODEL
|
|
821
|
+
// remaps). Its home is new and has no settings file, which is why the guard is never asked to read one for it.
|
|
822
|
+
// reviewOnlyEngineEnvironment builds that environment from scratch, and only where the sandbox exists, so this is
|
|
823
|
+
// its spend-guard view; test/fleet-subscription-only.test.js holds the two together. A plain worker is not sealed:
|
|
824
|
+
// it inherits this process's environment, approval, key, model variables and all.
|
|
825
|
+
const SEALED_WORKER_GUARD_ENV = Object.freeze({
|
|
826
|
+
ANTHROPIC_API_KEY: '',
|
|
827
|
+
ANTHROPIC_AUTH_TOKEN: '',
|
|
828
|
+
ANTHROPIC_MODEL: '',
|
|
829
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: '',
|
|
830
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: '',
|
|
831
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: '',
|
|
832
|
+
ATRIS_CLAUDE_SPEND_OK: '',
|
|
833
|
+
ATRIS_CLAUDE_KEY_OK: '',
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// Steering for one flight. `named` says the caller named the engines: that list is never filtered, on the first
|
|
837
|
+
// pick or on a retry. `sealed` says the workers run sealed, so the Claude spend guard is asked for the
|
|
838
|
+
// environment they will really have, not the one this process has. The models are judged on the launch built here.
|
|
839
|
+
function steeringFor({ root, pick, named = false, sealed = false } = {}) {
|
|
840
|
+
return startSteering({ root, pick, named, ...(sealed ? { workerEnv: SEALED_WORKER_GUARD_ENV } : {}) });
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// The engine a retry goes to. Today's rule, unless steering is on and some candidate has a result since
|
|
844
|
+
// steering began: then the best draw among the engines that are ready (lib/builder-pick.js), and the line
|
|
845
|
+
// that says why. Steering with no results yet keeps today's rule, over the ready engines. `notes` are plain
|
|
846
|
+
// sentences steering has to say that nobody has printed yet.
|
|
847
|
+
function retryEngine(current, { root = process.cwd(), installedEngines = null, steer = null, task = null } = {}) {
|
|
848
|
+
if (!steer || !steer.on) return { engine: nextInstalledFleetEngine(current, { root, installedEngines }) };
|
|
849
|
+
const listed = installedEngines ? normalizeInstalledEngines(installedEngines) : installedFleetEngines(root);
|
|
850
|
+
const { ready } = steer.rest(listed.filter((name) => name !== current));
|
|
851
|
+
const picked = steer.next(task, ready);
|
|
852
|
+
const notes = steer.notes();
|
|
853
|
+
if (picked) return { ...picked, notes };
|
|
854
|
+
return { engine: nextInstalledFleetEngine(current, { root, installedEngines: listed.filter((name) => name === current || ready.includes(name)) }), notes };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// What steering said for a retry, right under the line that says it happened.
|
|
858
|
+
function logPicks(log, result) {
|
|
859
|
+
const retry = result.restaffed;
|
|
860
|
+
if (!retry) return;
|
|
861
|
+
for (const note of retry.notes || []) log(` ${note}`);
|
|
862
|
+
if (retry.pick) log(` ${retry.pick}`);
|
|
863
|
+
}
|
|
864
|
+
|
|
818
865
|
function normalizeDispatchResult(result, engineName) {
|
|
819
866
|
const normalized = { ...(result || {}) };
|
|
820
867
|
normalized.engine = normalized.engine || engineName;
|
|
@@ -953,10 +1000,11 @@ async function dispatchEntryWithRestaff({
|
|
|
953
1000
|
job = 'build',
|
|
954
1001
|
prepAsk = undefined,
|
|
955
1002
|
beforeEngine = null,
|
|
1003
|
+
steer = null,
|
|
956
1004
|
}) {
|
|
957
1005
|
const attempts = [];
|
|
958
1006
|
try {
|
|
959
|
-
const result = await dispatchEntryAttempts({ entry, engine, root, dispatch, installedEngines, restaffState, scoutAsk, clock, defaultPin, attempts, job, prepAsk, beforeEngine });
|
|
1007
|
+
const result = await dispatchEntryAttempts({ entry, engine, root, dispatch, installedEngines, restaffState, scoutAsk, clock, defaultPin, attempts, job, prepAsk, beforeEngine, steer });
|
|
960
1008
|
return { ...result, attempts };
|
|
961
1009
|
} catch (err) {
|
|
962
1010
|
// One job's attempt blowing up (a retry brief that cannot be written, a store that throws) is that job's
|
|
@@ -1006,6 +1054,9 @@ async function dispatchEntryAttempts({
|
|
|
1006
1054
|
// after a dead one, a handover to the next worker (all after any scout, prep, and brief), so the flight can
|
|
1007
1055
|
// check it still holds the claim and mark it started. If it throws, that engine is not asked.
|
|
1008
1056
|
beforeEngine = null,
|
|
1057
|
+
// Steering for this flight (lib/builder-pick.js): which engine a retry goes to. A roster's handover order is
|
|
1058
|
+
// the owner's and steering never reorders it. Null keeps today's rules.
|
|
1059
|
+
steer = null,
|
|
1009
1060
|
}) {
|
|
1010
1061
|
const basePrompt = entry.prompt || buildFleetPrompt(entry.task, { worktreePath: entry.worktreePath, yolo: entry.yolo });
|
|
1011
1062
|
const prepFor = prepPassCache({ entry, root, clock, prepAsk, job });
|
|
@@ -1099,7 +1150,8 @@ async function dispatchEntryAttempts({
|
|
|
1099
1150
|
return { ...first, deadEngine: { ...outage, skipped: 'already_restaffed' } };
|
|
1100
1151
|
}
|
|
1101
1152
|
|
|
1102
|
-
const
|
|
1153
|
+
const retry = retryEngine(engine, { root, installedEngines, steer, task: entry.task });
|
|
1154
|
+
const fallback = retry.engine;
|
|
1103
1155
|
if (!fallback) {
|
|
1104
1156
|
return { ...first, deadEngine: { ...outage, skipped: 'no_fallback_engine' } };
|
|
1105
1157
|
}
|
|
@@ -1117,6 +1169,8 @@ async function dispatchEntryAttempts({
|
|
|
1117
1169
|
to: fallback,
|
|
1118
1170
|
reason: deadEngine.reason,
|
|
1119
1171
|
...(deadEngine.detail ? { detail: deadEngine.detail } : {}),
|
|
1172
|
+
...(retry.pick ? { pick: retry.pick } : {}),
|
|
1173
|
+
...(retry.notes && retry.notes.length ? { notes: retry.notes } : {}),
|
|
1120
1174
|
failed_legs: [failedDispatchLeg(first, engine)],
|
|
1121
1175
|
},
|
|
1122
1176
|
};
|
|
@@ -1148,6 +1202,8 @@ async function handOverStalledDispatch({ entry, engine, first, root, runOnce, jo
|
|
|
1148
1202
|
// Once every worker is benched the roster stops deciding; that is the
|
|
1149
1203
|
// team running out, not a missing roster.
|
|
1150
1204
|
const decided = Boolean(team && rosterDecided(team.source));
|
|
1205
|
+
// The roster's backup order is the owner's explicit choice: the first worker listed that is still open
|
|
1206
|
+
// takes over, whatever the results say.
|
|
1151
1207
|
const next = decided
|
|
1152
1208
|
? team.team.find((worker) => worker && !tried.has(worker.id) && DISPATCH_CAPABLE.includes(worker.id))
|
|
1153
1209
|
: null;
|
|
@@ -1456,13 +1512,18 @@ function surfacesOverlap(a, b) {
|
|
|
1456
1512
|
|
|
1457
1513
|
// Pick up to `slots` open, safe-lane, mutually file-disjoint tasks. Tasks
|
|
1458
1514
|
// whose surface cannot be guessed are allowed one at a time (the unknown
|
|
1459
|
-
// surface could overlap anything).
|
|
1460
|
-
|
|
1515
|
+
// surface could overlap anything). `eligible` is asked of each task the loop
|
|
1516
|
+
// looks at, before anything else is: a task it turns down is not in the
|
|
1517
|
+
// flight, takes no slot, and holds neither a file nor the blind place against
|
|
1518
|
+
// the tasks after it (the fleet turns down the jobs whose chosen engine the
|
|
1519
|
+
// flight cannot give, see steering's gives).
|
|
1520
|
+
function staffFlight(tasks, { slots = 3, eligible = null } = {}) {
|
|
1461
1521
|
const open = (tasks || []).filter((t) => t && t.status === 'open' && isSafeLane(t));
|
|
1462
1522
|
const picked = [];
|
|
1463
1523
|
let blindPicked = false;
|
|
1464
1524
|
for (const task of open) {
|
|
1465
1525
|
if (picked.length >= slots) break;
|
|
1526
|
+
if (eligible && !eligible(task)) continue;
|
|
1466
1527
|
const surface = fileSurface(task);
|
|
1467
1528
|
if (surface.length === 0) {
|
|
1468
1529
|
if (blindPicked) continue;
|
|
@@ -1749,6 +1810,8 @@ module.exports = {
|
|
|
1749
1810
|
markClaimStarted,
|
|
1750
1811
|
releaseOwnClaim,
|
|
1751
1812
|
reviewOnlyEngineEnvironment,
|
|
1813
|
+
SEALED_WORKER_GUARD_ENV,
|
|
1814
|
+
steeringFor,
|
|
1752
1815
|
runInReapedProcessGroup,
|
|
1753
1816
|
shipWithRetry,
|
|
1754
1817
|
shipFailureDetail,
|
|
@@ -3257,6 +3320,10 @@ async function runFleetFlight({
|
|
|
3257
3320
|
root = process.cwd(),
|
|
3258
3321
|
slots = 3,
|
|
3259
3322
|
engines = null,
|
|
3323
|
+
// --engine: every job goes to this one engine, and the picker is not asked.
|
|
3324
|
+
engine = null,
|
|
3325
|
+
// --pick: rating (the default) or turns. Turns deals engines to jobs in turn, as the fleet always did.
|
|
3326
|
+
pick = '',
|
|
3260
3327
|
dryRun = false,
|
|
3261
3328
|
log = console.log,
|
|
3262
3329
|
ownCli = null,
|
|
@@ -3274,24 +3341,40 @@ async function runFleetFlight({
|
|
|
3274
3341
|
} = {}) {
|
|
3275
3342
|
const cli = ownCli || defaultOwnCli(root);
|
|
3276
3343
|
const landTarget = resolveDispatchLandTarget(root, checkoutBase);
|
|
3277
|
-
// Staff
|
|
3344
|
+
// Staff, rank, then staff for real. Every task staffFlight returns already cleared
|
|
3278
3345
|
// the safe-lane filter (not a decision row, no denied tags), so the rank
|
|
3279
3346
|
// that pairs engines to those tasks is low stakes and the stretch zone
|
|
3280
3347
|
// rule may trade the strongest engine for the cheapest learnable one.
|
|
3281
3348
|
// Protected or denied work never reaches staffFlight's output, so it never
|
|
3282
|
-
// gets the flag; when nothing
|
|
3283
|
-
// the flag stays off.
|
|
3284
|
-
|
|
3285
|
-
|
|
3349
|
+
// gets the flag; when nothing can be staffed there is no task to rank for and
|
|
3350
|
+
// the flag stays off. That first question is asked of the list as it stands:
|
|
3351
|
+
// the rank gives the roster, and the roster is what tells the jobs the flight
|
|
3352
|
+
// can give an engine to from the ones it cannot, so no job is turned down yet.
|
|
3353
|
+
if (engine && !FLEET_CAPABLE.includes(engine)) {
|
|
3354
|
+
throw new Error(`runFleetFlight: engine "${engine}" cannot build in a fleet (capable: ${FLEET_CAPABLE.join(', ')})`);
|
|
3355
|
+
}
|
|
3356
|
+
const steer = steeringFor({ root, pick: engine ? 'turns' : pick, named: Boolean(engines) });
|
|
3357
|
+
const listed = readProjectionTasks(root);
|
|
3358
|
+
const anythingToStaff = staffFlight(listed, { slots }).length > 0;
|
|
3359
|
+
const rosterDetail = engines || engine ? null : (() => {
|
|
3286
3360
|
const { roster: fullRoster } = require('../commands/engine');
|
|
3287
3361
|
const installed = fullRoster(root)
|
|
3288
3362
|
.filter((e) => e.installed && FLEET_CAPABLE.includes(e.name))
|
|
3289
3363
|
.map((e) => e.name);
|
|
3290
|
-
return rankFleetEnginesDetailed(installed, root, { lowStakes:
|
|
3364
|
+
return rankFleetEnginesDetailed(installed, root, { lowStakes: anythingToStaff });
|
|
3291
3365
|
})();
|
|
3292
|
-
const
|
|
3293
|
-
|
|
3294
|
-
|
|
3366
|
+
const wholeRoster = engine ? [engine] : (engines || rosterDetail.candidates);
|
|
3367
|
+
// The jobs whose chosen engine the roster cannot give are taken out first, each with its one line, and the slots and
|
|
3368
|
+
// the file check are filled from what is left: a job left out takes no slot, and holds no file against a job that can go.
|
|
3369
|
+
const staffedTasks = staffFlight(listed, { slots, eligible: steer.gives(wholeRoster) });
|
|
3370
|
+
// The jobs are dealt in turn over the whole roster first: that is exactly what `--pick turns` gives. Steering
|
|
3371
|
+
// then resolves every explicit choice against that whole roster (a job that names its engine, a job assigned
|
|
3372
|
+
// to a member, whether or not that engine is resting) and picks for the rest from the engines that are ready:
|
|
3373
|
+
// an engine that is out of credit, cooling after a stall, or one the Claude spend guard would stop sits this
|
|
3374
|
+
// flight out. A caller that named its engines keeps every one of them in the pool.
|
|
3375
|
+
const dealt = steer.deal(assignEngines(staffedTasks, wholeRoster), wholeRoster);
|
|
3376
|
+
const roster = dealt.ready;
|
|
3377
|
+
const staffed = dealt.entries;
|
|
3295
3378
|
// The receipt path is decided BEFORE landings so each task's ready-proof
|
|
3296
3379
|
// can cite it, the proof policy certifies receipt-backed proofs agent-side.
|
|
3297
3380
|
const receiptPath = path.join(root, 'atris', 'runs', `fleet-${nowStamp()}.json`);
|
|
@@ -3300,7 +3383,7 @@ async function runFleetFlight({
|
|
|
3300
3383
|
root,
|
|
3301
3384
|
slots,
|
|
3302
3385
|
roster,
|
|
3303
|
-
staffed: staffed.map((s) => ({ task: s.task.display_id, title: String(s.task.title || '').slice(0, 140), engine: s.engine, surface: s.surface })),
|
|
3386
|
+
staffed: staffed.map((s) => ({ task: s.task.display_id, title: String(s.task.title || '').slice(0, 140), engine: s.engine, surface: s.surface, ...(s.pick ? { pick: s.pick } : {}) })),
|
|
3304
3387
|
stretch_zone_pick: rosterDetail && rosterDetail.stretch_zone_pick ? rosterDetail.stretch_zone_pick : null,
|
|
3305
3388
|
results: [],
|
|
3306
3389
|
landed: [],
|
|
@@ -3312,7 +3395,11 @@ async function runFleetFlight({
|
|
|
3312
3395
|
|
|
3313
3396
|
log('');
|
|
3314
3397
|
log(` fleet, ${roster.length} engine${roster.length === 1 ? '' : 's'} ready, ${staffed.length} task${staffed.length === 1 ? '' : 's'} staffed`);
|
|
3315
|
-
for (const
|
|
3398
|
+
for (const note of dealt.notes) log(` ${note}`);
|
|
3399
|
+
for (const s of staffed) {
|
|
3400
|
+
log(` ${s.engine.padEnd(8)} → ${s.task.display_id} ${String(s.task.title || '').slice(0, 80)}`);
|
|
3401
|
+
if (s.pick) log(` ${s.pick}`);
|
|
3402
|
+
}
|
|
3316
3403
|
log('');
|
|
3317
3404
|
|
|
3318
3405
|
if (dryRun || staffed.length === 0) {
|
|
@@ -3394,7 +3481,15 @@ async function runFleetFlight({
|
|
|
3394
3481
|
// The row the list named is the row this claims, leases, proves and lets go of: named once,
|
|
3395
3482
|
// by its row id, at every step (see jobRef).
|
|
3396
3483
|
const rowRef = jobRef(entry.task, entry.task.display_id);
|
|
3397
|
-
|
|
3484
|
+
// The pick went on the words the list showed, who the job is assigned to and the engine it names. The claim asks
|
|
3485
|
+
// the store for them again, inside its own transaction (see claimJob), and does not claim a job that now says
|
|
3486
|
+
// something else: it is left for the next flight.
|
|
3487
|
+
const claimed = claimJob({ cli, taskDb: leaseDb, db: leaseConnection, root, ref: rowRef, actor: `fleet-${entry.engine}`, refuseIf: steer.guardClaim(entry.task) });
|
|
3488
|
+
if (claimed && claimed.choiceChanged) {
|
|
3489
|
+
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'choice_changed', detail: CHOICE_CHANGED_DETAIL });
|
|
3490
|
+
log(choiceChangedLine(entry.task.display_id));
|
|
3491
|
+
continue;
|
|
3492
|
+
}
|
|
3398
3493
|
if (!claimed || claimed.status !== 0) {
|
|
3399
3494
|
const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
|
|
3400
3495
|
flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'claim', detail });
|
|
@@ -3478,6 +3573,7 @@ async function runFleetFlight({
|
|
|
3478
3573
|
defaultPin: fleetPrepPin(entry.engine, root),
|
|
3479
3574
|
prepAsk,
|
|
3480
3575
|
beforeEngine: markStarted,
|
|
3576
|
+
steer,
|
|
3481
3577
|
}).then((result) => {
|
|
3482
3578
|
if (result.not_started) letGoNotStarted(entry, result);
|
|
3483
3579
|
return { entry, result };
|
|
@@ -3511,6 +3607,7 @@ async function runFleetFlight({
|
|
|
3511
3607
|
const landingEntry = { ...entry, engine: activeEngine };
|
|
3512
3608
|
if (result.restaffed) log(` restaffed ${entry.task.display_id}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.detail || result.restaffed.reason})`);
|
|
3513
3609
|
if (result.handover) log(` handed over ${entry.task.display_id}: ${result.handover_reason}`);
|
|
3610
|
+
logPicks(log, result);
|
|
3514
3611
|
const buildFailure = detectDeadEngineDispatch(result);
|
|
3515
3612
|
if (buildFailure) {
|
|
3516
3613
|
const paused = {
|
|
@@ -3760,7 +3857,13 @@ function storeHoldsJob(taskDb, db, ref) {
|
|
|
3760
3857
|
// It answers like the task command (status 0, or status 1 and why), with eventId when it claimed in process. A
|
|
3761
3858
|
// claim that landed but came back with no name is refused: the flight cannot tell later whether the job is
|
|
3762
3859
|
// still its own, so it builds nothing on it and leaves it alone.
|
|
3763
|
-
|
|
3860
|
+
//
|
|
3861
|
+
// refuseIf is the flight's check on the job as it stands when the claim is made (see claimTask): asked inside the
|
|
3862
|
+
// claim's own transaction, so a job whose assigned member or named engine changed after the pick is not claimed for
|
|
3863
|
+
// the old choice. The answer then carries choiceChanged, and the task list files are drawn again from the store, so
|
|
3864
|
+
// the next flight reads the job as it is now and not as the old list had it. A job the store holds no row for is
|
|
3865
|
+
// claimed through the task command, as it always was: there is no row to ask.
|
|
3866
|
+
function claimJob({ cli, taskDb, db, root, ref, actor, minutes = 60, refuseIf = null }) {
|
|
3764
3867
|
if (!storeHoldsJob(taskDb, db, ref)) {
|
|
3765
3868
|
return cli(['task', 'claim', ref, '--as', actor, '--lease-minutes', String(minutes), '--lease-pid', String(process.pid)]);
|
|
3766
3869
|
}
|
|
@@ -3779,12 +3882,14 @@ function claimJob({ cli, taskDb, db, root, ref, actor, minutes = 60 }) {
|
|
|
3779
3882
|
taskDb.sweepExpiredLeases();
|
|
3780
3883
|
let result;
|
|
3781
3884
|
try {
|
|
3782
|
-
result = taskDb.claimTask(db, { id: ref, claimedBy: actor, leaseMinutes: minutes, leasePid: process.pid });
|
|
3885
|
+
result = taskDb.claimTask(db, { id: ref, claimedBy: actor, leaseMinutes: minutes, leasePid: process.pid, refuseIf });
|
|
3783
3886
|
} catch (err) {
|
|
3784
3887
|
return { status: 1, stdout: '', stderr: `claim failed: ${String(err?.message ?? err).trim() || 'unknown'}` };
|
|
3785
3888
|
}
|
|
3786
3889
|
if (!result.claimed) {
|
|
3787
|
-
|
|
3890
|
+
const changed = Boolean(refuseIf) && result.reason === CHOICE_CHANGED;
|
|
3891
|
+
if (changed) redrawTaskFiles({ taskDb, db, root });
|
|
3892
|
+
return { status: 1, stdout: '', stderr: `claim failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}`, ...(changed ? { choiceChanged: true } : {}) };
|
|
3788
3893
|
}
|
|
3789
3894
|
redrawTaskFiles({ taskDb, db, root });
|
|
3790
3895
|
const eventId = result.event && result.event.event_id;
|
|
@@ -3795,6 +3900,11 @@ function claimJob({ cli, taskDb, db, root, ref, actor, minutes = 60 }) {
|
|
|
3795
3900
|
// The one plain line for a job another run has claimed since this flight made its own claim.
|
|
3796
3901
|
const claimTakenOverLine = (task) => ` left ${task} alone: another run claimed it after this one did`;
|
|
3797
3902
|
|
|
3903
|
+
// The one plain line for a job whose assigned member or named engine changed after this flight picked an engine for it:
|
|
3904
|
+
// the claim was refused, and the job waits for the next flight, which reads it as it is now.
|
|
3905
|
+
const CHOICE_CHANGED_DETAIL = 'who it is assigned to, or the engine it names, changed after this flight read the list';
|
|
3906
|
+
const choiceChangedLine = (task) => ` left ${task} for the next flight: ${CHOICE_CHANGED_DETAIL}`;
|
|
3907
|
+
|
|
3798
3908
|
// The one plain line for a job whose claim this flight no longer holds when it is about to ask the engine.
|
|
3799
3909
|
const LOST_CLAIM_DETAIL = 'this run no longer holds the claim, so the engine was not started and the job was left alone';
|
|
3800
3910
|
const claimLostLine = (task) => ` ${task}: ${LOST_CLAIM_DETAIL}`;
|
|
@@ -4141,6 +4251,9 @@ async function runDispatchFlight({
|
|
|
4141
4251
|
// The disk check before anything is claimed (see lowDiskRefusal). Tests inject both.
|
|
4142
4252
|
freeSpace = undefined,
|
|
4143
4253
|
spaceGuard = undefined,
|
|
4254
|
+
// --pick: rating (the default) or turns. The engine asked first is the caller's choice and stays; steering
|
|
4255
|
+
// orders what comes after a dead or stalled engine.
|
|
4256
|
+
pick = '',
|
|
4144
4257
|
} = {}) {
|
|
4145
4258
|
if (!engine) throw new Error('runDispatchFlight: engine is required');
|
|
4146
4259
|
if (!DISPATCH_CAPABLE.includes(engine)) {
|
|
@@ -4416,6 +4529,7 @@ async function runDispatchFlight({
|
|
|
4416
4529
|
}));
|
|
4417
4530
|
}));
|
|
4418
4531
|
const restaffState = { used: false };
|
|
4532
|
+
const steer = steeringFor({ root, pick, named: Boolean(installedEngines), sealed: enforceRemoteBoundary });
|
|
4419
4533
|
|
|
4420
4534
|
if (engine === 'fable' && prepared.length) {
|
|
4421
4535
|
log(` fable handoff started: receipt ${path.relative(root, receiptPath)}`);
|
|
@@ -4470,6 +4584,7 @@ async function runDispatchFlight({
|
|
|
4470
4584
|
job: rosterJob,
|
|
4471
4585
|
prepAsk,
|
|
4472
4586
|
beforeEngine: markStarted,
|
|
4587
|
+
steer,
|
|
4473
4588
|
}).then((result) => {
|
|
4474
4589
|
if (result.not_started) letGoNotStarted(entry, result);
|
|
4475
4590
|
const completedAtMs = Date.now();
|
|
@@ -4624,6 +4739,7 @@ async function runDispatchFlight({
|
|
|
4624
4739
|
const landingEntry = { ...entry, engine: activeEngine, candidate };
|
|
4625
4740
|
if (result.restaffed) log(` restaffed ${entry.taskId}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.detail || result.restaffed.reason})`);
|
|
4626
4741
|
if (result.handover) log(` handed over ${entry.taskId}: ${result.handover_reason}`);
|
|
4742
|
+
logPicks(log, result);
|
|
4627
4743
|
const buildFailure = detectDeadEngineDispatch(result);
|
|
4628
4744
|
if (buildFailure) {
|
|
4629
4745
|
const paused = {
|
package/lib/gm-season.js
CHANGED
|
@@ -89,6 +89,20 @@ function memberRatings(outcomes = []) {
|
|
|
89
89
|
return new Map([...groups].map(([name, list]) => [name, { ovr: ovr(list), finished: tally(list).finished }]));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// The words a member's misses came with, most often given first: the reason on
|
|
93
|
+
// its dropped, sent back, and abandoned plays. atris rating and the owner brief
|
|
94
|
+
// both show these, so both read them here.
|
|
95
|
+
function missReasons(outcomes, member, limit = 3) {
|
|
96
|
+
const reasons = new Map();
|
|
97
|
+
for (const play of outcomes || []) {
|
|
98
|
+
if (play.member === member && ['dropped', 'sent_back', 'abandoned'].includes(play.kind) && play.reason) {
|
|
99
|
+
reasons.set(play.reason, (reasons.get(play.reason) || 0) + 1);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...reasons].map(([reason, count]) => ({ reason, count }))
|
|
103
|
+
.sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)).slice(0, limit);
|
|
104
|
+
}
|
|
105
|
+
|
|
92
106
|
// Last n finished jobs, oldest on the left, newest on the right.
|
|
93
107
|
function form(outcomes, n = 5) {
|
|
94
108
|
return [...(outcomes || [])]
|
|
@@ -360,10 +374,7 @@ function outcomeFromEvent(row) {
|
|
|
360
374
|
} else if (row.event_type === 'claim_expired') {
|
|
361
375
|
kind = 'dropped';
|
|
362
376
|
member = payload.claimed_by;
|
|
363
|
-
} else if ((row.event_type
|
|
364
|
-
kind = 'landed';
|
|
365
|
-
member = holder;
|
|
366
|
-
} else if (row.event_type === 'completed' && KEPT_ACTIONS.has(payload.action)) {
|
|
377
|
+
} else if (isLandingRecord(row.event_type, payload)) {
|
|
367
378
|
kind = 'landed';
|
|
368
379
|
member = holder;
|
|
369
380
|
} else if (row.event_type === 'revision_requested' || row.event_type === 'reopened') {
|
|
@@ -396,6 +407,15 @@ function outcomeFromEvent(row) {
|
|
|
396
407
|
};
|
|
397
408
|
}
|
|
398
409
|
|
|
410
|
+
// Whether an event records a landing: a proof or landing event that names its merge, or a completion marked kept.
|
|
411
|
+
// The rating counts each of these as a landed play, and the builder picker (lib/builder-pick.js) reads every one of
|
|
412
|
+
// them, in every workspace of the store, to find which job recorded a commit first.
|
|
413
|
+
function isLandingRecord(eventType, payload) {
|
|
414
|
+
const body = payload && typeof payload === 'object' ? payload : {};
|
|
415
|
+
return ((eventType === 'proof_ready' || eventType === 'landing') && hasStructuredMerge(body)) ||
|
|
416
|
+
(eventType === 'completed' && KEPT_ACTIONS.has(body.action));
|
|
417
|
+
}
|
|
418
|
+
|
|
399
419
|
function hasStructuredMerge(payload) {
|
|
400
420
|
const objects = [payload, payload.landing, payload.pull_request, payload.pr].filter(x => x && typeof x === 'object' && !Array.isArray(x));
|
|
401
421
|
return objects.some(x => {
|
|
@@ -610,6 +630,86 @@ function playEventsSql(extraWhere = '') {
|
|
|
610
630
|
ORDER BY e.created_at ASC`;
|
|
611
631
|
}
|
|
612
632
|
|
|
633
|
+
// A workspace's plays as they stood at an earlier time: the same plays loadSeason
|
|
634
|
+
// would have found on that day, from the events that existed by then and nothing
|
|
635
|
+
// after. Working out all of the history and cutting the plays by date afterwards is
|
|
636
|
+
// not the same thing. A landing that came later replaces an earlier miss, and a
|
|
637
|
+
// later "not needed" note clears one, so the miss would be gone from a rating that
|
|
638
|
+
// was printed with it. Every other fact a play is built from (the claim before it,
|
|
639
|
+
// whether the engine had started) is an earlier event in the same job, so cutting
|
|
640
|
+
// the events is enough.
|
|
641
|
+
//
|
|
642
|
+
// The job's own row is the one thing that is not an earlier event. It says who holds
|
|
643
|
+
// the job now, and today's rating falls back on that when a play has no claim on
|
|
644
|
+
// record before it. For a day in the past that is the wrong answer: a job nobody held
|
|
645
|
+
// that day would be handed to whoever took it later, and sent back to them. So the
|
|
646
|
+
// holder and the claim time are cleared here. A play with no claim on record at or
|
|
647
|
+
// before the day has no member and is not counted, the way a job nobody held was not
|
|
648
|
+
// counted on that day. (Only the title is still read from the row, and it is only
|
|
649
|
+
// shown.) `db` is an open task store.
|
|
650
|
+
function outcomesAsOf(db, workspaceRoot, asOf, days = SEASON_DAYS) {
|
|
651
|
+
const since = asOf - days * DAY_MS;
|
|
652
|
+
const rows = db.prepare(playEventsSql('AND e.workspace_root = ? AND e.created_at <= ?')).all(since, workspaceRoot, asOf)
|
|
653
|
+
.map(row => ({ ...row, claimed_by: null, claimed_at: null }));
|
|
654
|
+
return outcomesFromEvents(rows, since).filter(play => play.at <= asOf);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// The jobs open right now whose latest release is one the rating reads as held (the
|
|
658
|
+
// work is done and its pull request waits for a person, but the proof could not be
|
|
659
|
+
// saved) or as could_not_start (the build machine never ran the job), and that nobody
|
|
660
|
+
// has claimed or landed since, and that has not been archived or reopened since: a
|
|
661
|
+
// release older than the job's latest archive or reopen is not what the job is waiting
|
|
662
|
+
// on, because it was closed and then opened again for new work. There is no age limit:
|
|
663
|
+
// loadSeason only looks back 30 days, and a job left open by a release older than that
|
|
664
|
+
// is still where it was left.
|
|
665
|
+
// Each release is read by the rating's own query and rules (the member, the kind, the
|
|
666
|
+
// engine_started mark), so this cannot disagree with atris rating about what a release
|
|
667
|
+
// was. Returns one play per job, in the shape outcomesFromEvents makes. `db` is an open
|
|
668
|
+
// task store.
|
|
669
|
+
function openReleasePlays(db, workspaceRoot) {
|
|
670
|
+
const found = db.prepare(`
|
|
671
|
+
SELECT r.event_id AS event_id, r.task_id AS task_id, r.version AS version
|
|
672
|
+
FROM task_events r
|
|
673
|
+
JOIN tasks t ON t.id = r.task_id
|
|
674
|
+
WHERE t.workspace_root = ? AND t.status = 'open' AND r.event_type = 'claim_released'
|
|
675
|
+
-- a first cut on the words, so only the few releases of these kinds are read in full below
|
|
676
|
+
AND (r.payload LIKE '%proof_not_saved%' OR r.payload LIKE '%could_not_start%')
|
|
677
|
+
AND r.version = (SELECT MAX(x.version) FROM task_events x WHERE x.task_id = r.task_id AND x.event_type = 'claim_released')
|
|
678
|
+
AND NOT EXISTS (SELECT 1 FROM task_events later WHERE later.task_id = r.task_id AND later.version > r.version AND later.event_type IN ('claimed', 'archived', 'reopened'))`)
|
|
679
|
+
.all(workspaceRoot);
|
|
680
|
+
const release = db.prepare(playEventsSql('AND e.event_id = ?'));
|
|
681
|
+
const after = db.prepare(playEventsSql('AND e.task_id = ? AND e.version > ?'));
|
|
682
|
+
const plays = [];
|
|
683
|
+
for (const { event_id: eventId, task_id: taskId, version } of found) {
|
|
684
|
+
const play = outcomesFromEvents(release.all(0, eventId)).find(made => made.event_id === eventId);
|
|
685
|
+
if (!play || (play.kind !== 'held' && play.kind !== 'could_not_start')) continue;
|
|
686
|
+
if (outcomesFromEvents(after.all(0, taskId, version)).some(made => made.kind === 'landed')) continue;
|
|
687
|
+
plays.push(play);
|
|
688
|
+
}
|
|
689
|
+
return plays;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Only the plays: the same rows and the same outcome builder as loadSeason, without the board around them
|
|
693
|
+
// (open tasks, reviews, claims, targets, who is asleep). A reader that only counts plays uses this, because the
|
|
694
|
+
// rest of loadSeason costs more the more tasks the workspace has ever held. Opens the db read-only.
|
|
695
|
+
function loadOutcomes(workspaceRoot, { now = Date.now(), days = SEASON_DAYS, dbPath } = {}) {
|
|
696
|
+
const target = dbPath || require('./task-db').getDbPath();
|
|
697
|
+
if (!fs.existsSync(target)) return { outcomes: [], no_history: true };
|
|
698
|
+
let db;
|
|
699
|
+
try {
|
|
700
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
701
|
+
db = new DatabaseSync(target, { readOnly: true });
|
|
702
|
+
const since = now - days * DAY_MS;
|
|
703
|
+
return { outcomes: outcomesFromEvents(db.prepare(playEventsSql('AND e.workspace_root = ?')).all(since, workspaceRoot), since) };
|
|
704
|
+
} catch (err) {
|
|
705
|
+
return { outcomes: [], error: err && err.message ? err.message : String(err) };
|
|
706
|
+
} finally {
|
|
707
|
+
if (db) {
|
|
708
|
+
try { db.close(); } catch {}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
613
713
|
// The one loader: last 30 days of plays for a workspace, plus open claims,
|
|
614
714
|
// who is asleep, and what is up next. Opens the db read-only.
|
|
615
715
|
// `lastSeen` pins the recap to a session's opening look; without it the
|
|
@@ -1259,6 +1359,7 @@ function loadReplay({ workspaceRoot = null, since, now = Date.now(), dbPath, lea
|
|
|
1259
1359
|
module.exports = {
|
|
1260
1360
|
ageLabel,
|
|
1261
1361
|
closeNames,
|
|
1362
|
+
isLandingRecord,
|
|
1262
1363
|
isSafeMemberName,
|
|
1263
1364
|
rosterEngine,
|
|
1264
1365
|
loadCard,
|
|
@@ -1280,13 +1381,18 @@ module.exports = {
|
|
|
1280
1381
|
clockLabel,
|
|
1281
1382
|
form,
|
|
1282
1383
|
lastPlays,
|
|
1384
|
+
loadOutcomes,
|
|
1283
1385
|
loadSeason,
|
|
1284
1386
|
memberRatings,
|
|
1387
|
+
missReasons,
|
|
1388
|
+
openReleasePlays,
|
|
1389
|
+
outcomesAsOf,
|
|
1285
1390
|
outcomesFromEvents,
|
|
1286
1391
|
ovr,
|
|
1287
1392
|
ownerMood,
|
|
1288
1393
|
readLastSeen,
|
|
1289
1394
|
rosterRows,
|
|
1395
|
+
SEASON_DAYS,
|
|
1290
1396
|
seasonBoard,
|
|
1291
1397
|
sinceLastLook,
|
|
1292
1398
|
stillStuck,
|