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.
@@ -234,15 +234,16 @@ const GIT_REPOSITORY_VARIABLES = [
234
234
 
235
235
  // Which repository a workspace came from: the owner and name its origin remote points at, asked of
236
236
  // git in the workspace's own folder. Null when git cannot say (no folder, no origin) or the remote
237
- // does not read as a GitHub repository.
238
- function originRepoOf(root, { run = spawnSync } = {}) {
237
+ // does not read as a GitHub repository. `run` and `timeoutMs` (10 seconds unless told) are for the
238
+ // caller that holds git to a limit of its own, and for tests.
239
+ function originRepoOf(root, { run = spawnSync, timeoutMs = 10000 } = {}) {
239
240
  if (!root) return null;
240
241
  const env = { ...process.env };
241
242
  for (const key of GIT_REPOSITORY_VARIABLES) delete env[key];
242
243
  const done = run('git', ['-C', String(root), 'remote', 'get-url', 'origin'], {
243
244
  encoding: 'utf8',
244
245
  env,
245
- timeout: 10000,
246
+ timeout: timeoutMs,
246
247
  killSignal: 'SIGKILL',
247
248
  stdio: ['ignore', 'pipe', 'ignore'],
248
249
  }) || {};
@@ -289,6 +290,14 @@ function leftAloneDetail(reason) {
289
290
  return 'it changed while GitHub was being asked, so it was left alone';
290
291
  }
291
292
 
293
+ // The jobs among `rows` that wait on a pull request, oldest first (by when they were made, then by row id).
294
+ // The order is the order the sweep goes through them in.
295
+ function heldJobsAmong(taskStore, rows) {
296
+ return rows
297
+ .filter((row) => row.status === 'review' && taskStore.heldPullRequestOf(row))
298
+ .sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id).localeCompare(String(b.id)));
299
+ }
300
+
292
301
  // Check every held job against GitHub and, unless dryRun, write down what happened.
293
302
  //
294
303
  // taskStore lib/task-db
@@ -298,6 +307,21 @@ function leftAloneDetail(reason) {
298
307
  // ask how to ask GitHub about a link (askGitHub); asked once per link per run
299
308
  // askBranch how to ask GitHub for a repository's default branch (askDefaultBranch); asked
300
309
  // once per repository per run, and only for a pull request that was merged
310
+ // askOrigin how to read the repository a workspace came from (originRepoOf); asked once per
311
+ // workspace per run, and only for a pull request that was merged or closed
312
+ // only a row id: sweep that one job and none of the others (the automatic run goes job
313
+ // by job, to say each result as it is made)
314
+ // listing task rows already read. Given, this call lists and numbers nothing itself, and `only`
315
+ // still picks from it. A job in it is named by its number if the row has one (display_id or
316
+ // legacy_ref) and by its id if it has not. The automatic run reads the workspace once for the
317
+ // whole run and hands each call its job, because it prints no task number and so has none to
318
+ // make. Left out, as the manual sweep leaves it, this call lists and numbers the workspace
319
+ // itself.
320
+ // nameOf how a refusal names the other job that has the pull request, given its row id. Left
321
+ // out, the name is the one that job has in its own workspace's numbering, which lists
322
+ // that workspace's tasks and so can start git when checkouts are linked to it. The
323
+ // automatic run, which says nothing of a refusal and keeps to a time limit, gives the
324
+ // row id itself.
301
325
  //
302
326
  // Returns { ok, action, dry_run, scope, actor, outage, summary, results }. ok is false only on
303
327
  // an outage. Each result is { task_id, ref, title, builder, workspace_root, pull_request,
@@ -305,15 +329,18 @@ function leftAloneDetail(reason) {
305
329
  // unknown or not_asked, and action is what was done: landed, released, would_land,
306
330
  // would_release, none, left_alone, or refused (the pull request is not this job's build: nothing
307
331
  // was written, and detail is the plain line saying why).
308
- function sweepHeldJobs(taskStore, db, { workspaceRoot = null, actor, dryRun = false, ask = askGitHub, askBranch = askDefaultBranch } = {}) {
332
+ function sweepHeldJobs(taskStore, db, { workspaceRoot = null, actor, dryRun = false, ask = askGitHub, askBranch = askDefaultBranch, askOrigin = originRepoOf, only = null, nameOf = null, listing = null } = {}) {
309
333
  if (!actor) throw new Error('actor required');
310
334
  const scope = workspaceRoot ? 'workspace' : 'everywhere';
311
- // No task list yet means nothing is held.
312
- const inScope = db ? taskStore.listTasks(db, { workspaceRoot: workspaceRoot || null }) : [];
313
- const held = inScope
314
- .filter((row) => row.status === 'review' && taskStore.heldPullRequestOf(row))
315
- .sort((a, b) => (Number(a.created_at || 0) - Number(b.created_at || 0)) || String(a.id).localeCompare(String(b.id)));
316
- const named = taskStore.withTaskDisplayRefs(held, inScope);
335
+ let named;
336
+ if (listing) {
337
+ named = heldJobsAmong(taskStore, listing).filter((row) => !only || row.id === only);
338
+ } else {
339
+ // No task list yet means nothing is held.
340
+ const inScope = db ? taskStore.listTasks(db, { workspaceRoot: workspaceRoot || null }) : [];
341
+ const held = heldJobsAmong(taskStore, inScope).filter((row) => !only || row.id === only);
342
+ named = taskStore.withTaskDisplayRefs(held, inScope);
343
+ }
317
344
 
318
345
  // Each question goes to GitHub once per run, whatever the answer: a failure is not asked again.
319
346
  const answers = new Map();
@@ -347,17 +374,17 @@ function sweepHeldJobs(taskStore, db, { workspaceRoot = null, actor, dryRun = fa
347
374
  // A workspace's origin is read once per run, and only if a pull request needs it.
348
375
  const origins = new Map();
349
376
  const originOf = (root) => {
350
- if (!origins.has(root)) origins.set(root, originRepoOf(root));
377
+ if (!origins.has(root)) origins.set(root, askOrigin(root));
351
378
  return origins.get(root);
352
379
  };
353
380
 
354
- // The name a person reads for a job, in its own workspace's numbering.
355
- const refOfJob = (id) => {
381
+ // The name a person reads for a job, in its own workspace's numbering, unless the caller names jobs itself.
382
+ const refOfJob = nameOf || ((id) => {
356
383
  const task = taskStore.getTask(db, id);
357
384
  if (!task) return String(id);
358
385
  const [shown] = taskStore.withTaskDisplayRefs([task], taskStore.listTasks(db, { workspaceRoot: task.workspace_root || null }));
359
386
  return (shown && (shown.display_id || shown.legacy_ref || shown.id)) || String(id);
360
- };
387
+ });
361
388
  // Why this pull request is not this job's to settle, when another job has it.
362
389
  const takenReason = (taken, answer) => (taken.how === 'landed'
363
390
  ? `pull request ${answer.number} in ${answer.repo} already landed with ${refOfJob(taken.id)}`
@@ -586,4 +613,4 @@ function describeHeldSweep(report) {
586
613
  return lines;
587
614
  }
588
615
 
589
- module.exports = { ASK_TIMEOUT_MS, askGitHub, askDefaultBranch, branchNamesJob, originRepoOf, sweepHeldJobs, describeHeldSweep };
616
+ module.exports = { ASK_TIMEOUT_MS, askGitHub, askDefaultBranch, branchNamesJob, heldJobsAmong, originRepoOf, sweepHeldJobs, describeHeldSweep };
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+
3
+ // Whether a landing really landed. The rating counts a job as landed when its record says it merged. The
4
+ // builder picker steers by that count, so it asks more of each landing, and it asks git, on this computer:
5
+ //
6
+ // One. The commit the record names is on origin's default branch. The branch is listed once (git rev-list, back to
7
+ // a day before the earliest claim the caller will ask about), and every commit is looked up in that list.
8
+ // However many landings there are, git is asked the same few questions, and no process is started for any one
9
+ // landing.
10
+ // Two. The commit is no older than the claim it lands, allowing five minutes for clocks that disagree. Work
11
+ // that was already on the branch before the job began is not what the job landed. Every commit is read
12
+ // in one batched call (git rev-list --stdin), however many landings there are.
13
+ // Three. No other job recorded the same commit first. The check does not know jobs: the caller passes
14
+ // `owns(commit)`, and a landing counts only when one of its commits is the caller's to claim.
15
+ //
16
+ // The default branch is the one origin/HEAD names, and nothing else: origin/main and origin/master are never
17
+ // guessed, because a repository whose default branch is something else would be checked against the wrong
18
+ // branch without a word. When origin/HEAD is not set, or names a branch this folder does not have, no landing can be
19
+ // checked here and the reason names the fix (git remote set-head origin --auto). The same goes for a folder
20
+ // that is not a git repository, a computer without git, and a folder with no origin.
21
+ //
22
+ // Before anything is counted, refresh() fetches origin's default branch once (and only that branch), so a merge
23
+ // that happened a minute ago is known. The fetch names where the branch goes (+refs/heads/<branch>:refs/remotes/
24
+ // origin/<branch>), so the ref that is checked is the ref that was fetched, whatever the repository's own fetch
25
+ // settings say. If the fetch fails or takes too long, no landing can be checked, and the reason says so. Everything
26
+ // else asks only about this computer's repository; the fetch is the one question that goes out, and it is marked so
27
+ // a stand-in for git can tell it apart.
28
+ //
29
+ // Everything git does before a pick shares one budget of five seconds, counted from the first question: each
30
+ // question is given what is left of it, the question about the person's ssh settings and the fetch included. When it
31
+ // is gone nothing more is asked, and no landing can be checked. Git never prompts a person: its standard input is
32
+ // closed, GIT_TERMINAL_PROMPT is 0, and the fetch runs ssh in batch mode (-o BatchMode=yes). A person's own ssh
33
+ // command (GIT_SSH_COMMAND, GIT_SSH or core.sshCommand) is not replaced: the fetch runs it as it is, the five
34
+ // seconds still end it, and notes() says so in one line.
35
+ //
36
+ // A landing with no commit, a commit git cannot find or that sits on a side branch only, a commit made before the
37
+ // claim, or one another job recorded first: none of these count as landed. The check changes nothing the rating
38
+ // counts. One check lives for one run: it reads the default branch once, reads each commit once, and looks up
39
+ // each commit in what it read. If git stops answering in time (a hung disk, a lock) or the budget runs out, the
40
+ // check stops asking and cannotCheck() says so: nothing more counts as landed.
41
+ //
42
+ // The limit of listing only the window: `git rev-list --max-age` skips a commit older than the limit and does not
43
+ // walk on to its parents, so a branch whose commit dates run backwards by more than a day (a clock set wrong, a
44
+ // rebase on a machine far behind) can hide commits behind such a commit. A hidden commit is not landed, which is
45
+ // the safe answer. The day on top of the five minutes of skew is what that allowance is.
46
+
47
+ const { spawnSync } = require('child_process');
48
+
49
+ const HEX = /^[0-9a-f]{7,64}$/i;
50
+ const COMMIT_KEYS = ['merge_commit', 'merge_commit_sha', 'merge_sha'];
51
+
52
+ // A folder's own git, not the repository of whatever hook or script started this run.
53
+ const GIT_POINTERS = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
54
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE', 'GIT_SHALLOW_FILE', 'GIT_PREFIX'];
55
+
56
+ // All of git's work before a pick shares this long, as short as the fetch a worktree start makes
57
+ // (commands/worktree.js refreshRemoteRef): it runs before a flight's first job is dealt, and a slow network is
58
+ // answered by dealing in turn, not by waiting.
59
+ const BUDGET_MS = 5000;
60
+ // A commit made up to this long before its claim still counts: two computers' clocks are never quite together.
61
+ const SKEW_MS = 5 * 60 * 1000;
62
+ // The default branch is listed back to this much before the skew allowance, for commits whose dates are out of order.
63
+ const WINDOW_MARGIN_MS = 24 * 60 * 60 * 1000;
64
+ const FIX = 'git remote set-head origin --auto';
65
+ const ORIGIN_BRANCH = /^refs\/remotes\/origin\/([^\s:-][^\s:]*)$/;
66
+ // Run ssh so that it cannot wait on a person (no password, no host-key question).
67
+ const BATCH_SSH = 'ssh -o BatchMode=yes';
68
+
69
+ // The commits one landing record names, from the same places the rating looks for a merge (the record itself,
70
+ // its landing, its pull request). A merged pull request's merge commit is checked like any other commit.
71
+ function commitsOf(payload) {
72
+ const record = payload && typeof payload === 'object' ? payload : {};
73
+ const places = [record, record.landing, record.pull_request, record.pr]
74
+ .filter((place) => place && typeof place === 'object' && !Array.isArray(place));
75
+ const found = [];
76
+ for (const place of places) {
77
+ for (const key of COMMIT_KEYS) {
78
+ if (typeof place[key] === 'string' && HEX.test(place[key])) found.push(place[key].toLowerCase());
79
+ }
80
+ }
81
+ return [...new Set(found)];
82
+ }
83
+
84
+ function cleanEnv(base) {
85
+ const env = { ...base, GIT_TERMINAL_PROMPT: '0' };
86
+ for (const key of GIT_POINTERS) delete env[key];
87
+ return env;
88
+ }
89
+
90
+ // Start git in `root`. Standard input is closed unless there is text for it, `timeout` is what is left of the budget,
91
+ // and git never prompts. `input` is text for git's standard input. `network` marks the one question that goes out; it
92
+ // is given `ssh` (the ssh command to run, when the check chose one) and keeps the repository's own network settings.
93
+ function runGit(spawn, base, root, args, { input, network = false, ssh = '', timeout } = {}) {
94
+ const env = cleanEnv(base);
95
+ if (!network) env.GIT_NO_LAZY_FETCH = '1';
96
+ if (ssh) env.GIT_SSH_COMMAND = ssh;
97
+ const done = spawn('git', ['-C', root, ...args], {
98
+ env,
99
+ encoding: 'utf8',
100
+ timeout,
101
+ killSignal: 'SIGKILL',
102
+ maxBuffer: 64 * 1024 * 1024,
103
+ ...(input === undefined ? {} : { input }),
104
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'],
105
+ });
106
+ if (done.error) return { status: -1, stdout: '', missing: done.error.code === 'ENOENT', timedOut: done.error.code === 'ETIMEDOUT' };
107
+ return { status: done.status, stdout: String(done.stdout || ''), missing: false, timedOut: false };
108
+ }
109
+
110
+ // `run(root, args, { input, network, ssh, timeout })` stands in for git in tests, and `spawn` for the operating
111
+ // system's way of starting it. They answer { status, stdout, missing, timedOut }. `env` is the environment git
112
+ // runs in (this process's by default), `now` the clock the budget is counted on, and `since` (milliseconds) the
113
+ // earliest claim any landing asked about could have: the default branch is listed back to a day before it.
114
+ function createLandingCheck(root, { run, spawn = spawnSync, env = process.env, now = Date.now, budgetMs = BUDGET_MS, since } = {}) {
115
+ const gitRun = run || ((dir, args, options) => runGit(spawn, env, dir, args, options));
116
+ let startedAt = null; // the first question starts the budget
117
+ let spent = false; // the budget is used up, or git did not answer in time: no more questions are asked
118
+ let target = null; // { ref, branch }, or { problem }: looked up once
119
+ let fetched = null; // null until refresh() has run, then '' or why it could not fetch
120
+ let unreadable = ''; // why the commits, or the default branch, could not be read
121
+ const told = []; // what the check did that a person should hear about
122
+ const sightings = new Map(); // a commit as spelled (lower case) -> { oid, time } or null
123
+ let onBranch = null; // the full ids of the commits on the default branch inside the window
124
+
125
+ // Every question goes through here, with what is left of the budget. Once git has not answered in time, or the
126
+ // budget is gone, no more are asked.
127
+ function ask(args, options = {}) {
128
+ const silent = { status: -1, stdout: '', missing: false, timedOut: true };
129
+ if (spent) return silent;
130
+ if (startedAt === null) startedAt = now();
131
+ const left = budgetMs - (now() - startedAt);
132
+ if (left <= 0) {
133
+ spent = true;
134
+ return silent;
135
+ }
136
+ const answer = gitRun(root, args, { ...options, timeout: left });
137
+ if (answer.timedOut) spent = true;
138
+ return answer;
139
+ }
140
+
141
+ // The branch to check against, or why there is none. Looked up once.
142
+ function look() {
143
+ if (target) return target;
144
+ const fail = (problem) => (target = { problem });
145
+ const inside = ask(['rev-parse', '--git-dir']);
146
+ if (inside.missing) return fail('git is not installed here');
147
+ if (inside.timedOut) return fail('git did not answer in time');
148
+ if (inside.status !== 0) return fail('this folder is not a git repository');
149
+ const head = ask(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
150
+ if (head.timedOut) return fail('git did not answer in time');
151
+ const named = head.status === 0 ? ORIGIN_BRANCH.exec(head.stdout.trim()) : null;
152
+ if (!named) {
153
+ // Not set. Say which of two things it is: no origin at all, or an origin whose default branch was never read.
154
+ const remote = ask(['remote', 'get-url', 'origin']);
155
+ if (remote.timedOut) return fail('git did not answer in time');
156
+ return fail(remote.status === 0 ? `origin's default branch is not set here, so there is no branch to check against; fix it with ${FIX}` : 'no origin branch');
157
+ }
158
+ const ref = head.stdout.trim();
159
+ const there = ask(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
160
+ if (there.timedOut) return fail('git did not answer in time');
161
+ if (there.status !== 0) return fail(`origin's default branch, origin/${named[1]}, is not in this folder; fix it with ${FIX}`);
162
+ return (target = { ref, branch: named[1] });
163
+ }
164
+
165
+ // Why no landing can be checked in this folder at all, or ''. A fetch that failed counts: a folder that cannot
166
+ // bring origin up to date does not vouch for anything. So does a git that stopped answering.
167
+ function cannotCheck() {
168
+ return look().problem || fetched || unreadable || '';
169
+ }
170
+
171
+ // The ssh command the fetch runs with, or '' to leave ssh as the repository has it. A person's own ssh command
172
+ // (GIT_SSH_COMMAND, GIT_SSH, or core.sshCommand in the repository's settings) is never replaced: it runs as it is, the
173
+ // five seconds still end the fetch, and the check says so. With none, ssh is told never to wait on a person.
174
+ function sshForFetch() {
175
+ let own = '';
176
+ if (String(env.GIT_SSH_COMMAND || '').trim()) own = 'GIT_SSH_COMMAND';
177
+ else if (String(env.GIT_SSH || '').trim()) own = 'GIT_SSH';
178
+ else {
179
+ const configured = ask(['config', '--get', 'core.sshCommand']);
180
+ if (configured.status === 0 && configured.stdout.trim()) own = 'core.sshCommand';
181
+ }
182
+ if (!own) return BATCH_SSH;
183
+ told.push(`your own ssh command (${own}) was left as it is, so ssh may stop to ask for a password; the fetch gives up after five seconds either way`);
184
+ return '';
185
+ }
186
+
187
+ // Fetch origin's default branch, once. Returns '' when it worked, else why it did not. Asked again, it answers the
188
+ // same and does not try again. A folder that cannot be checked is not fetched.
189
+ function refresh() {
190
+ const found = look();
191
+ if (found.problem) return found.problem;
192
+ if (fetched !== null) return fetched;
193
+ const ssh = sshForFetch();
194
+ const spec = `+refs/heads/${found.branch}:refs/remotes/origin/${found.branch}`;
195
+ const done = ask(['fetch', 'origin', spec], { network: true, ...(ssh ? { ssh } : {}) });
196
+ if (done.timedOut) fetched = 'could not fetch origin in time';
197
+ else if (done.status !== 0) fetched = 'could not fetch origin';
198
+ else fetched = '';
199
+ // What was unknown before the fetch may be known now.
200
+ if (!fetched) {
201
+ sightings.clear();
202
+ onBranch = null;
203
+ }
204
+ return fetched;
205
+ }
206
+
207
+ // The commits as git has them, in one batched call: a Map from each commit as spelled (lower case) to
208
+ // { oid, time } (the full id and the commit time in milliseconds), or to null when git does not have it, or when a
209
+ // short spelling starts more than one commit. What was read before is not read again.
210
+ function read(commits) {
211
+ const names = [...new Set((Array.isArray(commits) ? commits : []).map((commit) => String(commit == null ? '' : commit).trim().toLowerCase()).filter((commit) => HEX.test(commit)))];
212
+ const fresh = names.filter((name) => !sightings.has(name));
213
+ if (fresh.length) {
214
+ const found = look();
215
+ if (found.problem || spent || unreadable) {
216
+ for (const name of fresh) sightings.set(name, null);
217
+ } else {
218
+ // --ignore-missing goes first: git reads it before the names on standard input. With it, a name git does not
219
+ // have, or one that starts two commits, is left out instead of ending the whole batch.
220
+ const answer = ask(['rev-list', '--ignore-missing', '--no-walk=unsorted', '--stdin', '--format=%H %ct'], { input: `${fresh.join('\n')}\n` });
221
+ if (answer.timedOut) unreadable = 'git did not answer in time';
222
+ else if (answer.status !== 0) unreadable = 'git could not read the commits';
223
+ const commitsRead = [];
224
+ if (!unreadable) {
225
+ for (const line of answer.stdout.split('\n')) {
226
+ const one = /^([0-9a-f]{40,64}) (\d+)$/.exec(line.trim());
227
+ if (one) commitsRead.push({ oid: one[1], time: Number(one[2]) * 1000 });
228
+ }
229
+ }
230
+ // Each spelling is looked up in a map of the commits it could start, not compared against every commit: one
231
+ // entry for each commit and each length a spelling was given in (a spelling is 7 to 64 digits).
232
+ const lengths = [...new Set(fresh.map((name) => name.length))];
233
+ const starting = new Map();
234
+ for (const one of commitsRead) {
235
+ for (const length of lengths) {
236
+ if (length > one.oid.length) continue;
237
+ const prefix = one.oid.slice(0, length);
238
+ const held = starting.get(prefix);
239
+ if (held) held.push(one); else starting.set(prefix, [one]);
240
+ }
241
+ }
242
+ for (const name of fresh) {
243
+ const starts = starting.get(name) || [];
244
+ sightings.set(name, starts.length === 1 ? starts[0] : null);
245
+ }
246
+ // A commit asked for by a short spelling is also known by its full id, which is how a later landing may name it.
247
+ for (const one of commitsRead) if (!sightings.has(one.oid)) sightings.set(one.oid, one);
248
+ }
249
+ }
250
+ return new Map(names.map((name) => [name, sightings.get(name) || null]));
251
+ }
252
+
253
+ // The commits on the default branch, listed once: the full ids of everything reachable from it inside the window,
254
+ // or null when git could not list it (cannotCheck says why).
255
+ function branchCommits(ref) {
256
+ if (onBranch) return onBranch;
257
+ if (unreadable) return null;
258
+ const edge = Number.isFinite(since) ? since - SKEW_MS - WINDOW_MARGIN_MS : 0;
259
+ const window = edge > 0 ? [`--max-age=${Math.floor(edge / 1000)}`] : [];
260
+ const answer = ask(['rev-list', ...window, ref, '--']);
261
+ if (answer.timedOut) unreadable = 'git did not answer in time';
262
+ else if (answer.status !== 0) unreadable = 'git could not read the default branch';
263
+ if (unreadable) return null;
264
+ onBranch = new Set(answer.stdout.split('\n').map((line) => line.trim().toLowerCase()).filter(Boolean));
265
+ return onBranch;
266
+ }
267
+
268
+ return {
269
+ cannotCheck,
270
+ refresh,
271
+ read,
272
+ // What the check did that a person should hear about, in plain words: a line each, said once. Empty when it left
273
+ // nothing of theirs alone.
274
+ notes: () => told.slice(),
275
+ // True when one of these commits is on the default branch, is no older than `claimedAt` (a time in
276
+ // milliseconds, if given; five minutes of skew allowed) and is `owns(commit)`'s to claim (if given; it is
277
+ // handed the full id, and only for a commit that is already landed). No commits, or no branch to check, is false.
278
+ landed(commits, { claimedAt, owns } = {}) {
279
+ const found = look();
280
+ if (found.problem || fetched || unreadable) return false;
281
+ const seen = read(commits);
282
+ if (unreadable) return false;
283
+ const listed = [...seen.values()].some(Boolean) ? branchCommits(found.ref) : null;
284
+ if (!listed) return false;
285
+ return [...seen.keys()].some((name) => {
286
+ const sighting = seen.get(name);
287
+ if (!sighting) return false;
288
+ if (Number.isFinite(claimedAt) && sighting.time < claimedAt - SKEW_MS) return false;
289
+ if (!listed.has(sighting.oid)) return false;
290
+ return typeof owns === 'function' ? Boolean(owns(sighting.oid)) : true;
291
+ });
292
+ },
293
+ };
294
+ }
295
+
296
+ module.exports = { commitsOf, createLandingCheck };
@@ -191,20 +191,23 @@ function shellWord(value) {
191
191
  return `'${s.replace(/'/g, "'\\''")}'`;
192
192
  }
193
193
 
194
- function firstConfiguredEnv(names) {
194
+ // Every reader below takes the environment it reads from and defaults to this process's. A launch is built in
195
+ // this process, so it reads process.env; a caller that wants to know what a launch would carry (launchFor) hands
196
+ // in the environment the launch would be built in, and nothing here looks at this process then.
197
+ function firstConfiguredEnv(names, env = process.env) {
195
198
  for (const name of names) {
196
- const value = String(process.env[name] || '').trim();
199
+ const value = String(env[name] || '').trim();
197
200
  if (value) return value;
198
201
  }
199
202
  return '';
200
203
  }
201
204
 
202
- function resolveRunnerProfileName() {
203
- return String(process.env.ATRIS_RUNNER_PROFILE || '').trim();
205
+ function resolveRunnerProfileName(env = process.env) {
206
+ return String(env.ATRIS_RUNNER_PROFILE || '').trim();
204
207
  }
205
208
 
206
- function resolveRunnerProfile() {
207
- const name = resolveRunnerProfileName();
209
+ function resolveRunnerProfile(env = process.env) {
210
+ const name = resolveRunnerProfileName(env);
208
211
  if (!name) return null;
209
212
  const profile = RUNNER_PROFILES[name];
210
213
  if (!profile) {
@@ -213,8 +216,8 @@ function resolveRunnerProfile() {
213
216
  return profile;
214
217
  }
215
218
 
216
- function runnerProfileValue(key) {
217
- const profile = resolveRunnerProfile();
219
+ function runnerProfileValue(key, env = process.env) {
220
+ const profile = resolveRunnerProfile(env);
218
221
  return profile && profile[key] ? profile[key] : '';
219
222
  }
220
223
 
@@ -233,10 +236,10 @@ function isSpendGatedModel(model) {
233
236
 
234
237
  // `fallback` is the model to run instead. An engine that is not the claude cli
235
238
  // cannot take a claude model id, so it passes '' and rides its own default.
236
- function applySpendGate(model, { silent = false, fallback = SPEND_FALLBACK_MODEL } = {}) {
239
+ function applySpendGate(model, { silent = false, fallback = SPEND_FALLBACK_MODEL, env = process.env } = {}) {
237
240
  const value = String(model || '').trim();
238
241
  if (!value || !isSpendGatedModel(value)) return value;
239
- if (String(process.env.ATRIS_CLAUDE_SPEND_OK || '').trim() === value) return value;
242
+ if (String(env.ATRIS_CLAUDE_SPEND_OK || '').trim() === value) return value;
240
243
  if (!silent && !spendGateWarned.has(`${value}>${fallback}`)) {
241
244
  spendGateWarned.add(`${value}>${fallback}`);
242
245
  const instead = fallback || "that engine's own default model";
@@ -251,37 +254,37 @@ function resetSpendGateWarnings() {
251
254
 
252
255
  // Engines that run only their own vendor's models drop any claude model name
253
256
  // later, so the gate has nothing to stop there and stays quiet.
254
- function runnerDropsClaudeModels() {
255
- const name = resolveRunnerProfileName();
257
+ function runnerDropsClaudeModels(env = process.env) {
258
+ const name = resolveRunnerProfileName(env);
256
259
  const def = name ? RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name] : null;
257
260
  if (def && def.ownModelsOnly) return true;
258
- return Boolean(ownModelsOnlyEngineForBin(resolveClaudeRunnerBin()));
261
+ return Boolean(ownModelsOnlyEngineForBin(resolveClaudeRunnerBin(env)));
259
262
  }
260
263
 
261
264
  // Whether the launch goes through the claude cli, the only engine that can take
262
265
  // the cheap claude fallback by name.
263
- function runnerRidesClaudeCli() {
264
- const name = resolveRunnerProfileName();
266
+ function runnerRidesClaudeCli(env = process.env) {
267
+ const name = resolveRunnerProfileName(env);
265
268
  const canonical = name ? (RUNNER_PROFILE_ALIASES[name] || name) : '';
266
269
  return !canonical || canonical === 'claude' || canonical === 'fable' || canonical === 'haiku';
267
270
  }
268
271
 
269
272
  // The choke point: every unattended Claude launch (mission, autopilot, run,
270
273
  // pulse, fleet) resolves its model here, directly or via buildRunnerCommand.
271
- function resolveClaudeRunnerModel(mission) {
272
- const model = resolveClaudeRunnerModelUngated(mission);
273
- if (!isSpendGatedModel(model) || runnerDropsClaudeModels()) return model;
274
- return applySpendGate(model, { fallback: runnerRidesClaudeCli() ? SPEND_FALLBACK_MODEL : '' });
274
+ function resolveClaudeRunnerModel(mission, { env = process.env, silent = false } = {}) {
275
+ const model = resolveClaudeRunnerModelUngated(mission, env);
276
+ if (!isSpendGatedModel(model) || runnerDropsClaudeModels(env)) return model;
277
+ return applySpendGate(model, { fallback: runnerRidesClaudeCli(env) ? SPEND_FALLBACK_MODEL : '', env, silent });
275
278
  }
276
279
 
277
- function resolveClaudeRunnerModelUngated(mission) {
280
+ function resolveClaudeRunnerModelUngated(mission, env = process.env) {
278
281
  const explicit = mission && mission.model != null ? normalizeClaudeModelName(mission.model) : '';
279
282
  if (explicit) return explicit;
280
- const env = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_RUNNER_MODEL']));
281
- if (env) return env;
282
- const profileModel = normalizeClaudeModelName(runnerProfileValue('model'));
283
+ const fromEnv = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_RUNNER_MODEL'], env));
284
+ if (fromEnv) return fromEnv;
285
+ const profileModel = normalizeClaudeModelName(runnerProfileValue('model', env));
283
286
  if (profileModel) return profileModel;
284
- const legacyEnv = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_CLAUDE_MODEL']));
287
+ const legacyEnv = normalizeClaudeModelName(firstConfiguredEnv(['ATRIS_CLAUDE_MODEL'], env));
285
288
  if (legacyEnv) return legacyEnv;
286
289
  return DEFAULT_CLAUDE_RUNNER_MODEL;
287
290
  }
@@ -290,12 +293,12 @@ function resolveClaudeRunnerModelUngated(mission) {
290
293
  // mission model) or the profile's own pin. ATRIS_RUNNER_MODEL is left out on
291
294
  // purpose: it usually names a claude model, which devin or grok would reject.
292
295
  // '' means nothing is pinned, so the engine's CLI picks its own default.
293
- function resolvePinnedRunnerModel(mission) {
296
+ function resolvePinnedRunnerModel(mission, { env = process.env, silent = false } = {}) {
294
297
  const explicit = mission && mission.model != null ? String(mission.model).trim() : '';
295
- const pinned = explicit || runnerProfileValue('model');
298
+ const pinned = explicit || runnerProfileValue('model', env);
296
299
  // Cheap check first: an ungated pin never needs the profile looked up.
297
- if (!isSpendGatedModel(pinned) || runnerDropsClaudeModels()) return pinned;
298
- return applySpendGate(pinned, { fallback: runnerRidesClaudeCli() ? SPEND_FALLBACK_MODEL : '' });
300
+ if (!isSpendGatedModel(pinned) || runnerDropsClaudeModels(env)) return pinned;
301
+ return applySpendGate(pinned, { fallback: runnerRidesClaudeCli(env) ? SPEND_FALLBACK_MODEL : '', env, silent });
299
302
  }
300
303
 
301
304
  // The own-models-only engine a runner bin launches ("codex", "/opt/bin/grok"),
@@ -326,22 +329,22 @@ function runnerModelFor(engineId, model) {
326
329
  return isClaudeFamilyModel(value) ? '' : value;
327
330
  }
328
331
 
329
- function resolveClaudeRunnerBin() {
330
- const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
331
- if (env) return env;
332
- const profileBin = runnerProfileValue('bin');
332
+ function resolveClaudeRunnerBin(env = process.env) {
333
+ const fromEnv = firstConfiguredEnv(['ATRIS_RUNNER_BIN'], env);
334
+ if (fromEnv) return fromEnv;
335
+ const profileBin = runnerProfileValue('bin', env);
333
336
  if (profileBin) return profileBin;
334
- const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_BIN']);
337
+ const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_BIN'], env);
335
338
  if (legacyEnv) return legacyEnv;
336
339
  return DEFAULT_CLAUDE_RUNNER_BIN;
337
340
  }
338
341
 
339
- function resolveClaudeRunnerCommandTemplate() {
340
- const env = firstConfiguredEnv(['ATRIS_RUNNER_COMMAND_TEMPLATE']);
341
- if (env) return env;
342
- const profileTemplate = runnerProfileValue('commandTemplate');
342
+ function resolveClaudeRunnerCommandTemplate(env = process.env) {
343
+ const fromEnv = firstConfiguredEnv(['ATRIS_RUNNER_COMMAND_TEMPLATE'], env);
344
+ if (fromEnv) return fromEnv;
345
+ const profileTemplate = runnerProfileValue('commandTemplate', env);
343
346
  if (profileTemplate) return profileTemplate;
344
- return firstConfiguredEnv(['ATRIS_CLAUDE_COMMAND_TEMPLATE']);
347
+ return firstConfiguredEnv(['ATRIS_CLAUDE_COMMAND_TEMPLATE'], env);
345
348
  }
346
349
 
347
350
  function buildRunnerAvailabilityCommand() {
@@ -453,6 +456,42 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
453
456
  return cmd;
454
457
  }
455
458
 
459
+ // What the launch of one engine would carry, decided from `env` (this process's by default) and without launching
460
+ // anything or saying anything: { engine, bin, model }, or null for an engine nobody knows. `model` is the pin a
461
+ // caller would hand buildRunnerCommand (a roster pick). The model is the --model of the command buildRunnerCommand
462
+ // builds for this engine under ATRIS_RUNNER_PROFILE=<engine>, the way the fleet builds it: the same precedence,
463
+ // the same spend gate, and a claude model dropped by an engine that cannot take one. '' means the launch carries
464
+ // none. A caller that must judge the launch (the builder picker asks the Claude spend guard about it) asks here
465
+ // and never works the model out for itself.
466
+ //
467
+ // Only the fleet's own launch is read. A command template somebody wrote (ATRIS_RUNNER_COMMAND_TEMPLATE, or the
468
+ // older ATRIS_CLAUDE_COMMAND_TEMPLATE) can start anything with anything: a model in it may be a flag, a word of the
469
+ // prompt, another command's, or one the shell works out only when it runs. It is never read for its model. The
470
+ // answer is { engine, bin, model: '', custom: true }, and a caller that has to judge it counts the model as one it
471
+ // cannot tell. A template that is the engine's own is the fleet's launch like any other.
472
+ function launchFor(engineId, { env = process.env, model } = {}) {
473
+ const asked = String(engineId || '').trim().toLowerCase();
474
+ const name = RUNNER_PROFILE_ALIASES[asked] || asked;
475
+ if (!name || !RUNNER_PROFILE_DEFS[name]) return null;
476
+ const scoped = { ...env, ATRIS_RUNNER_PROFILE: name };
477
+ const bin = resolveClaudeRunnerBin(scoped);
478
+ const template = resolveClaudeRunnerCommandTemplate(scoped);
479
+ if (template && template !== runnerProfileValue('commandTemplate', scoped)) {
480
+ return { engine: name, bin, model: '', custom: true };
481
+ }
482
+ const mission = { model };
483
+ const binEngine = ownModelsOnlyEngineForBin(bin);
484
+ const modelFor = (value) => {
485
+ const forProfile = runnerModelFor(name, value);
486
+ return binEngine ? runnerModelFor(binEngine, forProfile) : forProfile;
487
+ };
488
+ const hasModelSlot = !template || /\{(?:model|modelFlag)\}/.test(template);
489
+ const hasPinSlot = Boolean(template) && /\{pinnedModelFlag\}/.test(template);
490
+ const resolved = hasModelSlot ? resolveClaudeRunnerModel(mission, { env: scoped, silent: true }) : '';
491
+ const pinned = hasPinSlot ? resolvePinnedRunnerModel(mission, { env: scoped, silent: true }) : '';
492
+ return { engine: name, bin, model: modelFor(hasModelSlot ? resolved : pinned) };
493
+ }
494
+
456
495
  // Whether the installed codex prints JSONL events and can write its final
457
496
  // answer to a file. Asked once per program; an older codex without the flags
458
497
  // keeps today's plain launch.
@@ -518,4 +557,5 @@ module.exports = {
518
557
  buildRunnerAvailabilityCommand,
519
558
  runnerAvailabilityFailureMessage,
520
559
  buildRunnerCommand,
560
+ launchFor,
521
561
  };