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.
@@ -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 fallback = nextInstalledFleetEngine(engine, { root, installedEngines });
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
- function staffFlight(tasks, { slots = 3 } = {}) {
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 first, rank second: every task staffFlight returns already cleared
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 is staffed there is no task to rank for and
3283
- // the flag stays off.
3284
- const staffedTasks = staffFlight(readProjectionTasks(root), { slots });
3285
- const rosterDetail = engines ? null : (() => {
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: staffedTasks.length > 0 });
3364
+ return rankFleetEnginesDetailed(installed, root, { lowStakes: anythingToStaff });
3291
3365
  })();
3292
- const roster = engines || rosterDetail.candidates;
3293
-
3294
- const staffed = assignEngines(staffedTasks, roster);
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 s of staffed) log(` ${s.engine.padEnd(8)} → ${s.task.display_id} ${String(s.task.title || '').slice(0, 80)}`);
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) {
@@ -3326,6 +3413,12 @@ async function runFleetFlight({
3326
3413
  const lowDisk = lowDiskRefusal({ root, agent: 'fleet', task: 'flight', freeSpace, spaceGuard });
3327
3414
  if (lowDisk) return stopForLowDisk(flight, lowDisk, staffed.map((s) => ({ task: s.task.display_id, engine: s.engine })), log);
3328
3415
 
3416
+ // A person's merge pays the builder with no command: the held-job check runs here, never before the disk look
3417
+ // above (a flight that refuses writes nothing to any job) and never on a dry run: a dry run, and a flight with
3418
+ // nothing staffed, end before this point. A pick that reads the results after this point (a retry choosing a
3419
+ // fallback builder, say) sees what the check credited or released, and so does any pick in a later flight.
3420
+ require('./held-autosweep').autoSweepHeld({ root });
3421
+
3329
3422
  const liveLogPath = startLiveFlight(root, receiptPath, flight);
3330
3423
 
3331
3424
  // Claim + cut a worktree per assignment, then dispatch all in parallel.
@@ -3394,7 +3487,15 @@ async function runFleetFlight({
3394
3487
  // The row the list named is the row this claims, leases, proves and lets go of: named once,
3395
3488
  // by its row id, at every step (see jobRef).
3396
3489
  const rowRef = jobRef(entry.task, entry.task.display_id);
3397
- const claimed = claimJob({ cli, taskDb: leaseDb, db: leaseConnection, root, ref: rowRef, actor: `fleet-${entry.engine}` });
3490
+ // The pick went on the words the list showed, who the job is assigned to and the engine it names. The claim asks
3491
+ // the store for them again, inside its own transaction (see claimJob), and does not claim a job that now says
3492
+ // something else: it is left for the next flight.
3493
+ const claimed = claimJob({ cli, taskDb: leaseDb, db: leaseConnection, root, ref: rowRef, actor: `fleet-${entry.engine}`, refuseIf: steer.guardClaim(entry.task) });
3494
+ if (claimed && claimed.choiceChanged) {
3495
+ flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'choice_changed', detail: CHOICE_CHANGED_DETAIL });
3496
+ log(choiceChangedLine(entry.task.display_id));
3497
+ continue;
3498
+ }
3398
3499
  if (!claimed || claimed.status !== 0) {
3399
3500
  const detail = String(claimed && (claimed.stderr || claimed.stdout) || 'claim failed').trim().slice(0, 300);
3400
3501
  flight.paused.push({ task: entry.task.display_id, engine: entry.engine, stage: 'claim', detail });
@@ -3478,6 +3579,7 @@ async function runFleetFlight({
3478
3579
  defaultPin: fleetPrepPin(entry.engine, root),
3479
3580
  prepAsk,
3480
3581
  beforeEngine: markStarted,
3582
+ steer,
3481
3583
  }).then((result) => {
3482
3584
  if (result.not_started) letGoNotStarted(entry, result);
3483
3585
  return { entry, result };
@@ -3511,6 +3613,7 @@ async function runFleetFlight({
3511
3613
  const landingEntry = { ...entry, engine: activeEngine };
3512
3614
  if (result.restaffed) log(` restaffed ${entry.task.display_id}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.detail || result.restaffed.reason})`);
3513
3615
  if (result.handover) log(` handed over ${entry.task.display_id}: ${result.handover_reason}`);
3616
+ logPicks(log, result);
3514
3617
  const buildFailure = detectDeadEngineDispatch(result);
3515
3618
  if (buildFailure) {
3516
3619
  const paused = {
@@ -3760,7 +3863,13 @@ function storeHoldsJob(taskDb, db, ref) {
3760
3863
  // It answers like the task command (status 0, or status 1 and why), with eventId when it claimed in process. A
3761
3864
  // claim that landed but came back with no name is refused: the flight cannot tell later whether the job is
3762
3865
  // still its own, so it builds nothing on it and leaves it alone.
3763
- function claimJob({ cli, taskDb, db, root, ref, actor, minutes = 60 }) {
3866
+ //
3867
+ // refuseIf is the flight's check on the job as it stands when the claim is made (see claimTask): asked inside the
3868
+ // claim's own transaction, so a job whose assigned member or named engine changed after the pick is not claimed for
3869
+ // the old choice. The answer then carries choiceChanged, and the task list files are drawn again from the store, so
3870
+ // 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
3871
+ // claimed through the task command, as it always was: there is no row to ask.
3872
+ function claimJob({ cli, taskDb, db, root, ref, actor, minutes = 60, refuseIf = null }) {
3764
3873
  if (!storeHoldsJob(taskDb, db, ref)) {
3765
3874
  return cli(['task', 'claim', ref, '--as', actor, '--lease-minutes', String(minutes), '--lease-pid', String(process.pid)]);
3766
3875
  }
@@ -3779,12 +3888,14 @@ function claimJob({ cli, taskDb, db, root, ref, actor, minutes = 60 }) {
3779
3888
  taskDb.sweepExpiredLeases();
3780
3889
  let result;
3781
3890
  try {
3782
- result = taskDb.claimTask(db, { id: ref, claimedBy: actor, leaseMinutes: minutes, leasePid: process.pid });
3891
+ result = taskDb.claimTask(db, { id: ref, claimedBy: actor, leaseMinutes: minutes, leasePid: process.pid, refuseIf });
3783
3892
  } catch (err) {
3784
3893
  return { status: 1, stdout: '', stderr: `claim failed: ${String(err?.message ?? err).trim() || 'unknown'}` };
3785
3894
  }
3786
3895
  if (!result.claimed) {
3787
- return { status: 1, stdout: '', stderr: `claim failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}` };
3896
+ const changed = Boolean(refuseIf) && result.reason === CHOICE_CHANGED;
3897
+ if (changed) redrawTaskFiles({ taskDb, db, root });
3898
+ return { status: 1, stdout: '', stderr: `claim failed: ${result.reason}${result.claimed_by ? ` (held by ${result.claimed_by})` : ''}`, ...(changed ? { choiceChanged: true } : {}) };
3788
3899
  }
3789
3900
  redrawTaskFiles({ taskDb, db, root });
3790
3901
  const eventId = result.event && result.event.event_id;
@@ -3795,6 +3906,11 @@ function claimJob({ cli, taskDb, db, root, ref, actor, minutes = 60 }) {
3795
3906
  // The one plain line for a job another run has claimed since this flight made its own claim.
3796
3907
  const claimTakenOverLine = (task) => ` left ${task} alone: another run claimed it after this one did`;
3797
3908
 
3909
+ // The one plain line for a job whose assigned member or named engine changed after this flight picked an engine for it:
3910
+ // the claim was refused, and the job waits for the next flight, which reads it as it is now.
3911
+ const CHOICE_CHANGED_DETAIL = 'who it is assigned to, or the engine it names, changed after this flight read the list';
3912
+ const choiceChangedLine = (task) => ` left ${task} for the next flight: ${CHOICE_CHANGED_DETAIL}`;
3913
+
3798
3914
  // The one plain line for a job whose claim this flight no longer holds when it is about to ask the engine.
3799
3915
  const LOST_CLAIM_DETAIL = 'this run no longer holds the claim, so the engine was not started and the job was left alone';
3800
3916
  const claimLostLine = (task) => ` ${task}: ${LOST_CLAIM_DETAIL}`;
@@ -4141,6 +4257,9 @@ async function runDispatchFlight({
4141
4257
  // The disk check before anything is claimed (see lowDiskRefusal). Tests inject both.
4142
4258
  freeSpace = undefined,
4143
4259
  spaceGuard = undefined,
4260
+ // --pick: rating (the default) or turns. The engine asked first is the caller's choice and stays; steering
4261
+ // orders what comes after a dead or stalled engine.
4262
+ pick = '',
4144
4263
  } = {}) {
4145
4264
  if (!engine) throw new Error('runDispatchFlight: engine is required');
4146
4265
  if (!DISPATCH_CAPABLE.includes(engine)) {
@@ -4205,6 +4324,10 @@ async function runDispatchFlight({
4205
4324
  // the flight writes anything into its folder.
4206
4325
  const workspace = leaseDb.workspaceRoot(root);
4207
4326
  try { leaseDb.expireLeases(leaseConnection); } catch (err) { if (process.env.ATRIS_DEBUG) process.stderr.write(`lease sweep failed: ${String(err?.message ?? err).replace(/[\r\n]+/g, ' ')}\n`); }
4327
+ // A person's merge pays the builder with no command: the held-job check runs here, never before the disk look
4328
+ // above. A pick that reads the results after this point (the picker is made below, and a retry choosing a
4329
+ // fallback builder reads them) sees what the check credited or released, and so does any pick in a later flight.
4330
+ require('./held-autosweep').autoSweepHeld({ root });
4208
4331
  // Cut worktrees from, and check self-landing against, the workspace's real
4209
4332
  // protected branch, not a hard-coded origin/master (CLI-1298).
4210
4333
  const localBase = reviewOnly ? '' : localDispatchBase(root);
@@ -4416,6 +4539,7 @@ async function runDispatchFlight({
4416
4539
  }));
4417
4540
  }));
4418
4541
  const restaffState = { used: false };
4542
+ const steer = steeringFor({ root, pick, named: Boolean(installedEngines), sealed: enforceRemoteBoundary });
4419
4543
 
4420
4544
  if (engine === 'fable' && prepared.length) {
4421
4545
  log(` fable handoff started: receipt ${path.relative(root, receiptPath)}`);
@@ -4470,6 +4594,7 @@ async function runDispatchFlight({
4470
4594
  job: rosterJob,
4471
4595
  prepAsk,
4472
4596
  beforeEngine: markStarted,
4597
+ steer,
4473
4598
  }).then((result) => {
4474
4599
  if (result.not_started) letGoNotStarted(entry, result);
4475
4600
  const completedAtMs = Date.now();
@@ -4624,6 +4749,7 @@ async function runDispatchFlight({
4624
4749
  const landingEntry = { ...entry, engine: activeEngine, candidate };
4625
4750
  if (result.restaffed) log(` restaffed ${entry.taskId}: ${result.restaffed.from} -> ${result.restaffed.to} (${result.restaffed.detail || result.restaffed.reason})`);
4626
4751
  if (result.handover) log(` handed over ${entry.taskId}: ${result.handover_reason}`);
4752
+ logPicks(log, result);
4627
4753
  const buildFailure = detectDeadEngineDispatch(result);
4628
4754
  if (buildFailure) {
4629
4755
  const paused = {
package/lib/gm-season.js CHANGED
@@ -374,10 +374,7 @@ function outcomeFromEvent(row) {
374
374
  } else if (row.event_type === 'claim_expired') {
375
375
  kind = 'dropped';
376
376
  member = payload.claimed_by;
377
- } else if ((row.event_type === 'proof_ready' || row.event_type === 'landing') && hasStructuredMerge(payload)) {
378
- kind = 'landed';
379
- member = holder;
380
- } else if (row.event_type === 'completed' && KEPT_ACTIONS.has(payload.action)) {
377
+ } else if (isLandingRecord(row.event_type, payload)) {
381
378
  kind = 'landed';
382
379
  member = holder;
383
380
  } else if (row.event_type === 'revision_requested' || row.event_type === 'reopened') {
@@ -410,6 +407,15 @@ function outcomeFromEvent(row) {
410
407
  };
411
408
  }
412
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
+
413
419
  function hasStructuredMerge(payload) {
414
420
  const objects = [payload, payload.landing, payload.pull_request, payload.pr].filter(x => x && typeof x === 'object' && !Array.isArray(x));
415
421
  return objects.some(x => {
@@ -683,6 +689,27 @@ function openReleasePlays(db, workspaceRoot) {
683
689
  return plays;
684
690
  }
685
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
+
686
713
  // The one loader: last 30 days of plays for a workspace, plus open claims,
687
714
  // who is asleep, and what is up next. Opens the db read-only.
688
715
  // `lastSeen` pins the recap to a session's opening look; without it the
@@ -1332,6 +1359,7 @@ function loadReplay({ workspaceRoot = null, since, now = Date.now(), dbPath, lea
1332
1359
  module.exports = {
1333
1360
  ageLabel,
1334
1361
  closeNames,
1362
+ isLandingRecord,
1335
1363
  isSafeMemberName,
1336
1364
  rosterEngine,
1337
1365
  loadCard,
@@ -1353,6 +1381,7 @@ module.exports = {
1353
1381
  clockLabel,
1354
1382
  form,
1355
1383
  lastPlays,
1384
+ loadOutcomes,
1356
1385
  loadSeason,
1357
1386
  memberRatings,
1358
1387
  missReasons,