atris 3.77.0 → 3.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/commands/brief.js +8 -2
- package/commands/engine.js +14 -4
- package/commands/gm.js +3 -0
- package/commands/mission.js +23 -2
- package/commands/rating.js +7 -1
- package/lib/builder-pick.js +888 -0
- package/lib/claude-spend-guard.js +24 -0
- package/lib/fleet.js +145 -19
- package/lib/gm-season.js +33 -4
- package/lib/held-autosweep.js +559 -0
- package/lib/held-pull-requests.js +42 -15
- package/lib/landing-check.js +296 -0
- package/lib/runner-command.js +78 -38
- package/lib/task-db.js +105 -30
- package/package.json +1 -1
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Held jobs, swept on their own. A held job is work that is done and handed in, waiting in Review under its
|
|
4
|
+
// builder's name for a person to land its pull request. When the person merges it, nothing in the task store
|
|
5
|
+
// hears about it: `atris task sweep --held` is the check that asks GitHub (lib/held-pull-requests.js), and
|
|
6
|
+
// nobody ran it, so a merged pull request sat for hours with its builder still rated 25. The commands that
|
|
7
|
+
// show the rating or start work now run that same check themselves: atris brief, atris rating and atris gm at
|
|
8
|
+
// their start, and both flights (the fleet flight and the dispatch flight) once they are past their disk look.
|
|
9
|
+
// So a merge is usually credited within the hour, by one of those that reaches GitHub, and `atris task sweep --held`
|
|
10
|
+
// does it right away. Only what always holds is said about the rest. Order: the check never runs before a flight's
|
|
11
|
+
// low-disk look (a flight that refuses writes nothing to any job) and never on a dry run, and any pick of a builder
|
|
12
|
+
// that reads the results after the check sees what the check credited or released, in that flight or in a later one.
|
|
13
|
+
//
|
|
14
|
+
// It is a favor to the command around it, never a cost:
|
|
15
|
+
// - Only this workspace, and only when it has a held job, which reads of the task store tell. The first is one
|
|
16
|
+
// plain question about every workspace, with no call to git: with no job held anywhere, which is the usual
|
|
17
|
+
// case, that is the end of it, and nothing is asked of GitHub or of git or written anywhere. Only when some
|
|
18
|
+
// job is held somewhere is git asked which checkouts are linked to this workspace (a held job may sit in
|
|
19
|
+
// one), and that call is held to what is left of the eight seconds and ended outright if it does not answer.
|
|
20
|
+
// - A run that GitHub gave a readable answer in, and that did not fail, is written down, and holds off the runs
|
|
21
|
+
// that would start within the hour after it. Its time is one small file in the CLI's state folder
|
|
22
|
+
// (state/held-sweep, one file for each workspace), never in the task store or the repository. Any other run
|
|
23
|
+
// writes nothing, so it holds nothing off.
|
|
24
|
+
// - One command at a time. A run first makes a small lock file beside that record, with the exclusive flag,
|
|
25
|
+
// so of two commands that start together one asks GitHub and the other goes on without a word. A lock
|
|
26
|
+
// more than a minute old belongs to a run that died, and is taken over under a second small file made the
|
|
27
|
+
// same way, so that only one run at a time removes it and a late second command cannot remove the new lock
|
|
28
|
+
// the first has made; a run that loses any step of that goes on without a word.
|
|
29
|
+
// - Eight seconds bound the waiting, not the whole run. They are counted from the start of the run and cover git and
|
|
30
|
+
// GitHub: git gets two seconds to tell the linked checkouts and two to say where the workspace came from; one
|
|
31
|
+
// question to GitHub gets four, and none is started with under a second and a half left. Once the eight are used
|
|
32
|
+
// up the run stops asking. The task store is opened with a wait of about a second for other writers. So a slow or
|
|
33
|
+
// missing gh, a hung git or a locked store holds the command for eight seconds at most. Reads of the local task
|
|
34
|
+
// list are not cut short, and on the real store they take well under a second. Known limit: the run lists the
|
|
35
|
+
// workspace's tasks once and never numbers them, but each credit numbers the list inside the task store to write
|
|
36
|
+
// the journal line, as the manual sweep does, which can take seconds on a very large list. Nothing else starts
|
|
37
|
+
// git. The sweep would, when it refuses a pull request that another job has and names that job in that job's own
|
|
38
|
+
// workspace (listing a workspace's tasks asks git for its linked checkouts), and that can be a workspace this run
|
|
39
|
+
// was never asked about. The run says nothing of a refusal, so it names that job by its id and looks at no other
|
|
40
|
+
// workspace.
|
|
41
|
+
// - gh missing, gh that cannot be run, gh stopped by a signal or timed out, not signed in, GitHub down, git that
|
|
42
|
+
// cannot answer (it hung, could not be run, or exited with an error), a store that stays locked: the run stops,
|
|
43
|
+
// says nothing and is not written down, so it holds nothing off. A job that cannot be settled does not stop the
|
|
44
|
+
// run, but the run is not written down either. All of that holds after GitHub has answered too. The jobs it
|
|
45
|
+
// settled before it stopped stay settled. A run is written down when GitHub gave it at least one readable
|
|
46
|
+
// answer and nothing in it failed, and using up the eight seconds is not a failure (the rule is stated where the
|
|
47
|
+
// record is written), so a run that asked nothing, or understood nothing, or failed, holds nothing off.
|
|
48
|
+
// - Silent unless it acts. It says one plain line on stderr for each job it credits or lets go, as soon as
|
|
49
|
+
// that job is settled, and nothing on stdout, so --json output is untouched. A job it cannot settle costs
|
|
50
|
+
// one short line, and the run goes on to the next job. A line that cannot be written (nobody reads stderr,
|
|
51
|
+
// or the pipe is full) is lost where it is written, or cut short when the pipe had room for only part of it,
|
|
52
|
+
// and the command ends as it would have without the check. On POSIX a pipe that is full is not waited for; a
|
|
53
|
+
// terminal that is paused holds this write as it holds every other line the command prints.
|
|
54
|
+
// - It never breaks the command around it: a failure of its own costs one short line on stderr.
|
|
55
|
+
// - ATRIS_HELD_SWEEP=off skips it entirely.
|
|
56
|
+
//
|
|
57
|
+
// The check is the manual sweep itself (sweepHeldJobs), called for one job at a time under the name the manual
|
|
58
|
+
// sweep records when it is not told one, so the rating reads a credit the same whichever way it was written.
|
|
59
|
+
// The workspace's task list is read once for the whole run and never numbered, since the run prints no task number, and
|
|
60
|
+
// each call is handed its job from that.
|
|
61
|
+
// The manual sweep takes no lock, so it can be running at the same moment. That is safe: a job is settled only
|
|
62
|
+
// while its row is still what the sweep read, and one pull request lands once, so the second run finds
|
|
63
|
+
// nothing left to do.
|
|
64
|
+
|
|
65
|
+
const fs = require('fs');
|
|
66
|
+
const os = require('os');
|
|
67
|
+
const path = require('path');
|
|
68
|
+
const crypto = require('crypto');
|
|
69
|
+
const { spawnSync } = require('child_process');
|
|
70
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
71
|
+
const taskDb = require('./task-db');
|
|
72
|
+
const { atrisHome } = require('./state-home');
|
|
73
|
+
const { pullRequestOfLink } = require('./pull-request-link');
|
|
74
|
+
const { askGitHub, askDefaultBranch, heldJobsAmong, originRepoOf, sweepHeldJobs } = require('./held-pull-requests');
|
|
75
|
+
|
|
76
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
77
|
+
// TOTAL_MS bounds the waiting, not the whole run. It is counted from the start of the run, and the run stops asking
|
|
78
|
+
// once it is used up. Reads of the local task list are not cut short. One question to GitHub gets CALL_MS of it and is
|
|
79
|
+
// not started with less than LEAST_CALL_MS left, which could not answer it in time; each of git's questions (which
|
|
80
|
+
// checkouts are linked to the workspace, and where the workspace came from) gets GIT_MS and is not started with less
|
|
81
|
+
// than LEAST_GIT_MS left.
|
|
82
|
+
const TOTAL_MS = 8000;
|
|
83
|
+
const CALL_MS = 4000;
|
|
84
|
+
const LEAST_CALL_MS = 1500;
|
|
85
|
+
const GIT_MS = 2000;
|
|
86
|
+
const LEAST_GIT_MS = 500;
|
|
87
|
+
// A lock this far from now, either way, belongs to a run that died.
|
|
88
|
+
const STALE_LOCK_MS = 60 * 1000;
|
|
89
|
+
const OFF = /^(off|0|false|no)$/i;
|
|
90
|
+
// One plain question to the task store, about every workspace and with no call to git: is any job held? It asks
|
|
91
|
+
// for more than the check settles (a job in review with a claim and a pull request on its row, whether or not that
|
|
92
|
+
// pull request is still open) and never for less, so a no is a no for every workspace.
|
|
93
|
+
const ANY_HELD_JOB = "SELECT 1 FROM tasks WHERE status = 'review' AND claimed_by IS NOT NULL AND claimed_by <> '' AND metadata LIKE '%\"pull_request\"%' LIMIT 1";
|
|
94
|
+
|
|
95
|
+
// What gh says when it is not signed in, and when GitHub cannot answer right now. The sweep already stops on
|
|
96
|
+
// a gh that is missing or that times out; these are the other ways gh fails for the whole run rather than for
|
|
97
|
+
// one pull request. Anything else gh says (a pull request it cannot find, a repository it may not read) is an
|
|
98
|
+
// answer about that one job, and the run goes on.
|
|
99
|
+
const SIGNED_OUT = /gh auth login|bad credentials|HTTP 401|not logged in|authentication (?:is )?required|requires authentication|GH_TOKEN|GITHUB_TOKEN/i;
|
|
100
|
+
const GITHUB_DOWN = /HTTP 5\d\d|HTTP 429|bad gateway|service unavailable|gateway time-?out|internal server error|rate limit|error connecting to|check your internet|githubstatus|could not resolve host|no such host|dial tcp|network is unreachable|connection (?:refused|reset|timed out)|\bE(?:CONNREFUSED|CONNRESET|NOTFOUND|AI_AGAIN|TIMEDOUT)\b|i\/o timeout|TLS handshake|x509/i;
|
|
101
|
+
// The one way gh says, in so many words, that GitHub has no pull request with the number it was asked about, and the
|
|
102
|
+
// repository itself is there. gh 2.74.2 prints exactly this on its error output, nothing on its output, and exits 1:
|
|
103
|
+
// GraphQL: Could not resolve to a PullRequest with the number of 99999999. (repository.pullRequest)
|
|
104
|
+
// Nothing else is that, and a 404 least of all, since GitHub answers 404 for a repository the token cannot read too.
|
|
105
|
+
// A repository it cannot read ("Could not resolve to a Repository"), a message it has not shown before and a failure
|
|
106
|
+
// with no words at all say nothing about the pull request. So the match is the whole error output, less the one line
|
|
107
|
+
// break gh ends it with, and that is that message and nothing but it, for the very number asked about: the same
|
|
108
|
+
// sentence for another number, the sentence inside a longer message (words in front of it, another error behind it,
|
|
109
|
+
// other lines around it), the sentence with a space or a blank line in front or a second line break or spaces behind,
|
|
110
|
+
// a 404 or a longer status like "HTTP 4040" are all some other reply, which cannot be read. Another wording of the
|
|
111
|
+
// sentence, such as "GraphQL error:" in front of it and no path behind it, is not taken for it either: if a gh words
|
|
112
|
+
// it so, a deleted link is no answer, and holds nothing off, which is the safe side.
|
|
113
|
+
function noSuchPullRequestMessage(number) {
|
|
114
|
+
return `GraphQL: Could not resolve to a PullRequest with the number of ${number}. (repository.pullRequest)`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// What is left of the eight seconds of waiting, in milliseconds.
|
|
118
|
+
function timeLeft(now, began) {
|
|
119
|
+
return TOTAL_MS - (now() - began);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// One line on stderr, written on the spot, and lost where it is written if it cannot be. A stream's write on a pipe is
|
|
123
|
+
// finished later, and a pipe whose reader has gone then fails with EPIPE as an error event that no try around the
|
|
124
|
+
// write can catch: it ends a command that goes on running (atris gm, a flight) with a different exit code, after a
|
|
125
|
+
// job was settled. So the line goes straight to the file (descriptor 2), and any error (EPIPE when nobody reads,
|
|
126
|
+
// EAGAIN when the pipe is full) costs the line and nothing else. No listener is added to process.stderr: that would
|
|
127
|
+
// answer for every other write the command makes as well.
|
|
128
|
+
//
|
|
129
|
+
// The line before the write makes Node create process.stderr first. On POSIX Node sets a pipe to not wait for room
|
|
130
|
+
// when it creates that stream, and until then the descriptor is whatever the parent handed down, usually one that
|
|
131
|
+
// waits. A write to a pipe that is full and that nobody reads would then never return, and would hold the command and
|
|
132
|
+
// the lock for as long as nobody read it, after a job was settled: the eight seconds bound the waiting on git and
|
|
133
|
+
// GitHub, not a write. With the stream made, the same write fails at once with EAGAIN, which the catch swallows. A
|
|
134
|
+
// write can also go through in part when the pipe has a little room. It is not tried again, so on a full pipe a line
|
|
135
|
+
// can be cut short or lost. A terminal stays as Node sets it (it waits for room), so a paused terminal holds this
|
|
136
|
+
// write exactly as it holds every other line the command prints.
|
|
137
|
+
function writeLine(line) {
|
|
138
|
+
try {
|
|
139
|
+
void process.stderr;
|
|
140
|
+
fs.writeSync(2, `${line}\n`);
|
|
141
|
+
} catch {
|
|
142
|
+
// The line is lost. The command goes on.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function oneLine(text, max) {
|
|
147
|
+
const flat = String(text === undefined || text === null ? '' : text).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
148
|
+
return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Who `atris task sweep --held` records when it is not told with --as (DEFAULT_OWNER in commands/task.js).
|
|
152
|
+
// The rating reads a name with "auto" in it as an autoland, so a credit written here must carry that same
|
|
153
|
+
// name, never one of its own.
|
|
154
|
+
function actorOf(env) {
|
|
155
|
+
const osName = () => { try { return os.userInfo().username; } catch { return ''; } };
|
|
156
|
+
return String(env.ATRIS_AGENT_ID || env.USER || osName() || 'unknown');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Where the time of the last written-down run for a workspace is kept: one small file named by the workspace's path,
|
|
160
|
+
// so two workspaces never share a record and looking at one never writes into its repository.
|
|
161
|
+
function recordPath(workspace, env) {
|
|
162
|
+
const name = crypto.createHash('sha1').update(path.resolve(String(workspace))).digest('hex');
|
|
163
|
+
return path.join(atrisHome(env), 'state', 'held-sweep', `${name}.json`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function lastRunAt(file) {
|
|
167
|
+
try {
|
|
168
|
+
const at = Number(JSON.parse(fs.readFileSync(file, 'utf8')).last_run_at);
|
|
169
|
+
return Number.isFinite(at) && at > 0 ? at : null;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Whether the check ran within the last hour. A last run in the future (the clock was set back) does not hold
|
|
176
|
+
// the sweep off.
|
|
177
|
+
function ranRecently(file, now) {
|
|
178
|
+
const last = lastRunAt(file);
|
|
179
|
+
return last !== null && now() >= last && now() - last < HOUR_MS;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Written to a temp file then renamed, so a reader never sees half a file and two runs never clobber each other.
|
|
183
|
+
function writeRecord(file, workspace, at) {
|
|
184
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
185
|
+
try {
|
|
186
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ workspace, last_run_at: at, last_run_iso: new Date(at).toISOString() })}\n`);
|
|
187
|
+
fs.renameSync(tmp, file);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
try { fs.unlinkSync(tmp); } catch { /* nothing left to clean */ }
|
|
190
|
+
throw err;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Makes the lock file with the exclusive flag and puts the run's token in it. False when the file is already there.
|
|
195
|
+
function makeLock(file, token) {
|
|
196
|
+
let fd;
|
|
197
|
+
try {
|
|
198
|
+
fd = fs.openSync(file, 'wx');
|
|
199
|
+
} catch (err) {
|
|
200
|
+
if (err && err.code === 'EEXIST') return false;
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
fs.writeSync(fd, `${token}\n`);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
fs.closeSync(fd);
|
|
207
|
+
try { fs.unlinkSync(file); } catch { /* nothing left to clean */ }
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
fs.closeSync(fd);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// When a file was made (its own time), or null when there is no such file.
|
|
215
|
+
function madeAtOf(file) {
|
|
216
|
+
try {
|
|
217
|
+
return fs.statSync(file).mtimeMs;
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err && err.code === 'ENOENT') return null;
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A file this far from now, either way, belongs to a run that died.
|
|
225
|
+
const isDead = (madeAt, now) => Math.abs(now() - madeAt) >= STALE_LOCK_MS;
|
|
226
|
+
|
|
227
|
+
// The right to run the check, as a small file made with the exclusive flag: of two commands that start together
|
|
228
|
+
// only one can make it. Returns { release }, or null when another run holds it.
|
|
229
|
+
//
|
|
230
|
+
// A lock more than STALE_LOCK_MS old belongs to a run that died, and is taken over. Removing a lock by its name
|
|
231
|
+
// is not safe on its own: two runs can look at the same dead lock, and the slower one would then remove the new
|
|
232
|
+
// lock the faster one has made since. So the removal is a step one run at a time may take, guarded by a second
|
|
233
|
+
// small file made the same way beside the lock (its name and .takeover). The run that makes it looks at the lock
|
|
234
|
+
// again, removes it only if it is still that old, and makes the new lock. A run that finds the guard taken, finds
|
|
235
|
+
// the lock new after all, or loses the making of the new lock to another run, gives the check up. No run removes
|
|
236
|
+
// a lock it has not just seen to be dead, while holding the guard that keeps every other run from removing one.
|
|
237
|
+
// A guard more than STALE_LOCK_MS old is a run that died in the middle of a takeover: it is removed, once.
|
|
238
|
+
function takeLock(file, now) {
|
|
239
|
+
const token = crypto.randomBytes(8).toString('hex');
|
|
240
|
+
const mine = { release: () => letGo(file, token) };
|
|
241
|
+
if (makeLock(file, token)) return mine;
|
|
242
|
+
const madeAt = madeAtOf(file);
|
|
243
|
+
// The run that held it let it go in the moment between: it is free to make, once.
|
|
244
|
+
if (madeAt === null) return makeLock(file, token) ? mine : null;
|
|
245
|
+
if (!isDead(madeAt, now)) return null;
|
|
246
|
+
const guard = `${file}.takeover`;
|
|
247
|
+
if (!makeLock(guard, token)) {
|
|
248
|
+
const guardedAt = madeAtOf(guard);
|
|
249
|
+
if (guardedAt === null || !isDead(guardedAt, now)) return null;
|
|
250
|
+
try { fs.unlinkSync(guard); } catch (err) { if (!err || err.code !== 'ENOENT') throw err; }
|
|
251
|
+
if (!makeLock(guard, token)) return null;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const nowMadeAt = madeAtOf(file);
|
|
255
|
+
if (nowMadeAt !== null) {
|
|
256
|
+
if (!isDead(nowMadeAt, now)) return null;
|
|
257
|
+
try { fs.unlinkSync(file); } catch (err) { if (!err || err.code !== 'ENOENT') throw err; }
|
|
258
|
+
}
|
|
259
|
+
return makeLock(file, token) ? mine : null;
|
|
260
|
+
} finally {
|
|
261
|
+
letGo(guard, token);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Lets the lock go, if it is still this run's: one taken over as dead is not removed by the run that lost it.
|
|
266
|
+
function letGo(file, token) {
|
|
267
|
+
try {
|
|
268
|
+
if (fs.readFileSync(file, 'utf8').trim() === token) fs.unlinkSync(file);
|
|
269
|
+
} catch { /* already gone */ }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Reads the task store for the question `ask`, on a connection that opens nothing for writing and creates nothing,
|
|
273
|
+
// as the sweep of expired claims does. A store that cannot be read holds no job this check can settle (the
|
|
274
|
+
// command's own views report it): `unread` is the answer then.
|
|
275
|
+
function readStore({ env, say, ask, unread }) {
|
|
276
|
+
try {
|
|
277
|
+
const target = taskDb.getDbPath();
|
|
278
|
+
if (!fs.existsSync(target)) return unread;
|
|
279
|
+
const reader = new DatabaseSync(target, { readOnly: true });
|
|
280
|
+
try {
|
|
281
|
+
return ask(reader);
|
|
282
|
+
} finally {
|
|
283
|
+
reader.close();
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (env.ATRIS_DEBUG) say(`held jobs: could not read the task list (${oneLine(err && err.message, 120)})`);
|
|
287
|
+
return unread;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Whether any job is held, in any workspace: one plain question, with no call to git.
|
|
292
|
+
function anyJobHeld({ env, say }) {
|
|
293
|
+
return readStore({ env, say, unread: false, ask: (reader) => Boolean(reader.prepare(ANY_HELD_JOB).get()) });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Whether this workspace has a job held for a pull request: held (it has), none (it has not) or blocked (git could
|
|
297
|
+
// not tell which checkouts are linked to the workspace, in the time there was). The workspace's jobs include those
|
|
298
|
+
// held in checkouts linked to it, and telling them is a call to git, held to what is left of the eight seconds and
|
|
299
|
+
// ended outright if it does not answer. Not asked at all when no job is held anywhere (see anyJobHeld).
|
|
300
|
+
function workspaceHolds(workspace, { env, say, left }) {
|
|
301
|
+
return readStore({
|
|
302
|
+
env,
|
|
303
|
+
say,
|
|
304
|
+
unread: 'none',
|
|
305
|
+
ask: (reader) => {
|
|
306
|
+
const time = Math.floor(Math.min(GIT_MS, left()));
|
|
307
|
+
if (time < LEAST_GIT_MS) return 'blocked';
|
|
308
|
+
if (taskDb.lookUpLinkedWorktrees(workspace, { timeoutMs: time }).failed) return 'blocked';
|
|
309
|
+
return taskDb.listTasks(reader, { workspaceRoot: workspace, status: 'review' }).some((row) => taskDb.heldPullRequestOf(row)) ? 'held' : 'none';
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Whether what gh did says it cannot be used for any pull request right now, as an outage the sweep stops on:
|
|
315
|
+
// it could not be launched at all (a file that cannot be run, no memory, a runner that threw, a wrapper that
|
|
316
|
+
// exits 126), it was stopped by a signal before it said anything, it is not signed in, or GitHub is not
|
|
317
|
+
// answering.
|
|
318
|
+
function troubleWith(heard) {
|
|
319
|
+
if (!heard) return null;
|
|
320
|
+
if (heard.error) {
|
|
321
|
+
// Output past the buffer is a trouble with that one answer, not with gh. It comes first because the runner
|
|
322
|
+
// ends gh with a signal then, too.
|
|
323
|
+
return heard.error.code === 'ENOBUFS' ? null : { kind: 'gh_unusable', detail: 'gh could not be run, so nothing could be checked' };
|
|
324
|
+
}
|
|
325
|
+
// A gh with no exit status was ended by a signal, and with no error to say why it has told us nothing about the
|
|
326
|
+
// pull request, as one that timed out has not: the run stops, and nothing is written down.
|
|
327
|
+
if (heard.status === null) return { kind: 'gh_stopped', detail: 'gh was stopped before it answered, so nothing could be checked' };
|
|
328
|
+
if (typeof heard.status !== 'number' || heard.status === 0) return null;
|
|
329
|
+
if (heard.status === 126) return { kind: 'gh_unusable', detail: 'gh could not be run, so nothing could be checked' };
|
|
330
|
+
const said = String(heard.stderr || '').slice(0, 4000);
|
|
331
|
+
if (heard.status === 4 || SIGNED_OUT.test(said)) return { kind: 'not_signed_in', detail: 'gh is not signed in to GitHub, so nothing could be checked' };
|
|
332
|
+
if (GITHUB_DOWN.test(said)) return { kind: 'github_down', detail: 'GitHub is not answering, so nothing could be checked' };
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Whether what gh did is GitHub saying that there is no pull request with this number, the one the question was about:
|
|
337
|
+
// gh exited 1 and its whole error output, less the one line break at the end ("\n", or "\r\n"), is the message above
|
|
338
|
+
// for this number. Only that one break is let go: a space or a blank line in front, a second line break behind, spaces
|
|
339
|
+
// anywhere around the line make it some other reply. (The pattern's "$" is the very end of the text here, since it has
|
|
340
|
+
// no "m" flag, so it never lets go of a break in the middle.) GitHub has answered then, though not with a pull request
|
|
341
|
+
// state. With no number to check against (the question was not about one pull request) nothing is.
|
|
342
|
+
function saysNoSuchPullRequest(heard, number) {
|
|
343
|
+
if (!heard || heard.status !== 1 || !Number.isSafeInteger(number) || number <= 0) return false;
|
|
344
|
+
return String(heard.stderr || '').replace(/\r?\n$/, '') === noSuchPullRequestMessage(number);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// What the sweep asks of GitHub and of git, each held to its own limit and to what is left of the eight seconds,
|
|
348
|
+
// and each asked once for the whole run (the sweep is called job by job, and would ask again every time).
|
|
349
|
+
// It counts the readable answers GitHub gave (answered), which is half of what a run is written down for; the other
|
|
350
|
+
// half is that nothing failed. When the time is spent GitHub is not asked, and the sweep stops on that outage: it is no
|
|
351
|
+
// answer, it is not a failure, and it does not undo the answers before it. Blocked is the other kind of stop, for a gh
|
|
352
|
+
// or a git that cannot be used at all, on any question: that is a failure, and an answer before it does not save the run.
|
|
353
|
+
function limited({ run, now, began }) {
|
|
354
|
+
let blocked = false;
|
|
355
|
+
let answered = 0;
|
|
356
|
+
let heard = null;
|
|
357
|
+
const left = () => timeLeft(now, began);
|
|
358
|
+
// Runs a command and keeps what it did. A runner that throws did not run the command.
|
|
359
|
+
const listen = (command, args, options) => {
|
|
360
|
+
try {
|
|
361
|
+
heard = run(command, args, options) || {};
|
|
362
|
+
} catch (error) {
|
|
363
|
+
heard = { error };
|
|
364
|
+
}
|
|
365
|
+
return heard;
|
|
366
|
+
};
|
|
367
|
+
const asked = new Map();
|
|
368
|
+
const askOnce = (key, ask) => {
|
|
369
|
+
if (!asked.has(key)) asked.set(key, ask());
|
|
370
|
+
return asked.get(key);
|
|
371
|
+
};
|
|
372
|
+
const github = (key, ask, number) => askOnce(key, () => {
|
|
373
|
+
const time = left();
|
|
374
|
+
if (time < LEAST_CALL_MS) return { outage: { kind: 'out_of_time', detail: 'the check ran out of time' } };
|
|
375
|
+
heard = null;
|
|
376
|
+
const answer = ask({ run: listen, timeoutMs: Math.floor(Math.min(CALL_MS, time)) });
|
|
377
|
+
const trouble = answer.outage || (answer.unknown ? troubleWith(heard) : null);
|
|
378
|
+
if (!trouble) {
|
|
379
|
+
// A reply the sweep parsed into a pull request state, or GitHub saying there is no pull request with the number
|
|
380
|
+
// the question was about. The default branch of a repository (askBranch) is neither, and is only asked after a
|
|
381
|
+
// merge has been read and counted.
|
|
382
|
+
if (answer.answer || (answer.unknown && saysNoSuchPullRequest(heard, number))) answered += 1;
|
|
383
|
+
return answer;
|
|
384
|
+
}
|
|
385
|
+
blocked = true;
|
|
386
|
+
return { outage: trouble };
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
// The number is the one in the link, which is the one gh is asked about and the only one it can name in its reply.
|
|
390
|
+
ask: (url) => github(`pull request ${url}`, (options) => askGitHub(url, options), (pullRequestOfLink(url) || {}).number),
|
|
391
|
+
askBranch: (repoLink) => github(`repository ${repoLink.toLowerCase()}`, (options) => askDefaultBranch(repoLink, options)),
|
|
392
|
+
// Where the workspace came from. With too little time left git is not asked, and the job is left as it is.
|
|
393
|
+
// A git that did not exit with 0 (it hung, could not be run, exited with an error, or was stopped
|
|
394
|
+
// by a signal) has not answered, and says nothing about the origin, so the run stops.
|
|
395
|
+
askOrigin: (root) => askOnce(`origin ${root}`, () => {
|
|
396
|
+
const time = left();
|
|
397
|
+
if (time < LEAST_GIT_MS) return null;
|
|
398
|
+
heard = null;
|
|
399
|
+
const origin = originRepoOf(root, { run: listen, timeoutMs: Math.floor(Math.min(GIT_MS, time)) });
|
|
400
|
+
if (!heard || (!heard.error && heard.status === 0)) return origin;
|
|
401
|
+
blocked = true;
|
|
402
|
+
return null;
|
|
403
|
+
}),
|
|
404
|
+
blocked: () => blocked,
|
|
405
|
+
answered: () => answered,
|
|
406
|
+
left,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The pull request a job names, in words: "pull request 71", or "its pull request" when the row gives no number.
|
|
411
|
+
function pullRequestWords(pullRequest) {
|
|
412
|
+
const number = pullRequest.number || (pullRequestOfLink(pullRequest.url) || {}).number;
|
|
413
|
+
return number ? `pull request ${number}` : 'its pull request';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// One plain line for each job the sweep settled: what was done for the builder, the pull request, what happened.
|
|
417
|
+
function linesFor(report) {
|
|
418
|
+
const lines = [];
|
|
419
|
+
for (const result of report.results) {
|
|
420
|
+
const named = pullRequestWords(result.pull_request);
|
|
421
|
+
const builder = oneLine(result.builder, 60);
|
|
422
|
+
if (result.action === 'landed') lines.push(`credited ${builder}: ${named} merged`);
|
|
423
|
+
else if (result.action === 'released') lines.push(`released ${builder}: ${named} closed without merging`);
|
|
424
|
+
}
|
|
425
|
+
return lines;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// The workspace's held jobs, oldest first, one call of the sweep for each, and each job's line said as soon as
|
|
429
|
+
// that job is settled. A job the sweep throws on costs one short line and the run goes on to the next. A store
|
|
430
|
+
// that is busy ends the run (the error goes up to autoSweepHeld) with the lines already said standing.
|
|
431
|
+
// The task list is read once, before the first job, and nothing is numbered: the run's lines name the builder and the
|
|
432
|
+
// pull request, never a task number, and the sweep is called job by job. Each call is handed its job, read again by its
|
|
433
|
+
// id first, so that a job someone settled since is not asked about. Reading the list can take the time there was; then
|
|
434
|
+
// there is none to ask with, and nothing is asked, said or written down.
|
|
435
|
+
// Returns one word: ran (GitHub gave at least one readable answer, and nothing failed: running out of time after an
|
|
436
|
+
// answer is not a failure), blocked (gh, git or GitHub could not be used, on any question and whatever was answered
|
|
437
|
+
// before, or there was no time left to ask and nothing had been answered), failed (a job could not be settled) or idle
|
|
438
|
+
// (no readable answer came back and nothing stopped the run: the last held job was settled by someone else since the
|
|
439
|
+
// first look, no job had a link to ask about, or every reply was unreadable). Only a ran is written down (see sweep).
|
|
440
|
+
function checkJobs({ db, workspace, actor, limits, say }) {
|
|
441
|
+
let failed = false;
|
|
442
|
+
let outOfTime = false;
|
|
443
|
+
const held = heldJobsAmong(taskDb, taskDb.listTasks(db, { workspaceRoot: workspace }));
|
|
444
|
+
if (!held.length) return 'idle';
|
|
445
|
+
if (limits.left() < LEAST_CALL_MS) return 'blocked';
|
|
446
|
+
for (const job of held) {
|
|
447
|
+
if (limits.blocked()) break;
|
|
448
|
+
if (limits.left() < LEAST_CALL_MS) {
|
|
449
|
+
outOfTime = true;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
let report;
|
|
453
|
+
try {
|
|
454
|
+
const current = taskDb.getTask(db, job.id);
|
|
455
|
+
report = sweepHeldJobs(taskDb, db, {
|
|
456
|
+
workspaceRoot: workspace,
|
|
457
|
+
actor,
|
|
458
|
+
only: job.id,
|
|
459
|
+
listing: current ? [current] : [],
|
|
460
|
+
ask: limits.ask,
|
|
461
|
+
askBranch: limits.askBranch,
|
|
462
|
+
askOrigin: limits.askOrigin,
|
|
463
|
+
// The run says nothing of a refusal, and the sweep's own way of naming the other job lists that job's
|
|
464
|
+
// workspace, which can start git outside the eight seconds. The id is name enough.
|
|
465
|
+
nameOf: (id) => String(id),
|
|
466
|
+
});
|
|
467
|
+
} catch (err) {
|
|
468
|
+
if (taskDb.isBusyError(err)) throw err;
|
|
469
|
+
failed = true;
|
|
470
|
+
say(`held jobs: could not check ${oneLine(job.claimed_by, 60)}: ${pullRequestWords(taskDb.heldPullRequestOf(job))} (${oneLine(err && err.message, 100) || 'unknown error'})`);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
for (const line of linesFor(report)) say(line);
|
|
474
|
+
// A question can find the time gone though the look above did not: the default branch is asked after the pull
|
|
475
|
+
// request has been read.
|
|
476
|
+
if (report.outage && report.outage.kind === 'out_of_time') {
|
|
477
|
+
outOfTime = true;
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (limits.blocked()) return 'blocked';
|
|
482
|
+
if (failed) return 'failed';
|
|
483
|
+
if (limits.answered() > 0) return 'ran';
|
|
484
|
+
return outOfTime ? 'blocked' : 'idle';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// The check itself, on a connection to the task store of its own that waits about a second for other writers.
|
|
488
|
+
function check({ workspace, env, now, run, say, began }) {
|
|
489
|
+
const db = taskDb.open(undefined, { shortWait: true });
|
|
490
|
+
try {
|
|
491
|
+
return checkJobs({ db, workspace, actor: actorOf(env), limits: limited({ run, now, began }), say });
|
|
492
|
+
} finally {
|
|
493
|
+
try { db.close(); } catch { /* nothing left to close */ }
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function sweep({ root, env, now, run, say }) {
|
|
498
|
+
if (OFF.test(String(env.ATRIS_HELD_SWEEP || '').trim())) return 'off';
|
|
499
|
+
const began = now();
|
|
500
|
+
const workspace = taskDb.workspaceRoot(root);
|
|
501
|
+
// One plain question first, about every workspace and with no call to git: with no job held anywhere, which is
|
|
502
|
+
// the usual case, that is all.
|
|
503
|
+
if (!anyJobHeld({ env, say })) return 'idle';
|
|
504
|
+
const file = recordPath(workspace, env);
|
|
505
|
+
if (ranRecently(file, now)) return 'recent';
|
|
506
|
+
const here = workspaceHolds(workspace, { env, say, left: () => timeLeft(now, began) });
|
|
507
|
+
if (here === 'blocked') return 'blocked';
|
|
508
|
+
if (here === 'none') return 'idle';
|
|
509
|
+
// A run that cannot be written down would start again with every command, so it does not start: the folder
|
|
510
|
+
// for the record, which holds the lock too, is made first.
|
|
511
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
512
|
+
const lock = takeLock(file.replace(/\.json$/, '.lock'), now);
|
|
513
|
+
if (!lock) return 'running';
|
|
514
|
+
try {
|
|
515
|
+
// A command that started with this one may have run the check in the time between the look above and the lock.
|
|
516
|
+
if (ranRecently(file, now)) return 'recent';
|
|
517
|
+
const outcome = check({ workspace, env, now, run, say, began });
|
|
518
|
+
// The rule for the record, and the only one: a run is written down when GitHub gave at least one readable answer in
|
|
519
|
+
// it and nothing in the run failed. Running out of time is not a failure.
|
|
520
|
+
// - A readable answer is a reply the sweep parsed into a pull request state (open, merged or closed), or GitHub
|
|
521
|
+
// saying in so many words that there is no pull request with the number asked about: gh's whole message
|
|
522
|
+
// "Could not resolve to a PullRequest with the number of N", for that very N, which it says only once the
|
|
523
|
+
// repository itself resolved. A 404, a repository gh cannot read, another number, the sentence inside a longer
|
|
524
|
+
// message and any other words are no answer. Answers are counted, not jobs or calls.
|
|
525
|
+
// - A failure is a gh or a git that could not be used (missing, could not be launched, killed, timed out, not
|
|
526
|
+
// signed in, GitHub not answering), on any question, the one about the default branch included; a task store that
|
|
527
|
+
// stayed busy; or a job that could not be settled. After a failure nothing is written down, even if GitHub had
|
|
528
|
+
// answered before it, because a prompt retry is what a real failure wants: nothing holds the retry off. What the
|
|
529
|
+
// run settled before it failed stays settled. A single reply that cannot be read (nothing, something that is not
|
|
530
|
+
// a pull request, too much, gh failing in some other way for that one job) is no failure of the run, only no
|
|
531
|
+
// answer, and the run goes on to the next job.
|
|
532
|
+
// - Using up the eight seconds is not a failure. Once an answer has come back, the time running out later still
|
|
533
|
+
// counts as a run, and the jobs not asked are left as they are. With no answer there is no run, and nothing is
|
|
534
|
+
// held off: no time was left to ask, every reply was unreadable, or the only jobs had no link.
|
|
535
|
+
// Written before the lock is let go, so a run that takes the lock after this one finds the record.
|
|
536
|
+
if (outcome === 'ran') writeRecord(file, workspace, now());
|
|
537
|
+
return outcome;
|
|
538
|
+
} finally {
|
|
539
|
+
lock.release();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Runs the held-job sweep for the workspace the command is in, if it is due. Never throws. Returns what
|
|
544
|
+
// happened as one word: off, idle (no held job, or no readable answer from GitHub), recent (ran within the hour),
|
|
545
|
+
// running (another command has the check), blocked (gh, git or GitHub could not be used, there was no time left to
|
|
546
|
+
// ask, or the store stayed locked), ran (at least one readable answer and nothing failed: written down), or failed (a
|
|
547
|
+
// job could not be settled). `env`, `now`, `run` and `say` are for tests.
|
|
548
|
+
function autoSweepHeld({ root = process.cwd(), env = process.env, now = Date.now, run = spawnSync, say = writeLine } = {}) {
|
|
549
|
+
try {
|
|
550
|
+
return sweep({ root, env, now, run, say });
|
|
551
|
+
} catch (err) {
|
|
552
|
+
// A store that stays busy past its short wait is not worth a line: nothing is written down.
|
|
553
|
+
if (taskDb.isBusyError(err)) return 'blocked';
|
|
554
|
+
try { say(`held jobs: automatic check failed (${oneLine(err && err.message, 120) || 'unknown error'})`); } catch { /* the command goes on */ }
|
|
555
|
+
return 'failed';
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
module.exports = { autoSweepHeld };
|