@bongos/core 1.19.876 → 1.19.878
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/.bongos-core.json +110 -40
- package/.gitattributes +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -1
- package/docs/api/openapi.json +149 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +7 -6
- package/docs/copy-registry.json +16 -7
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/autobongos-windows-host.md +163 -0
- package/docs/recipes/instance-deploy.md +29 -3
- package/modules/autonomy/cadence.js +276 -0
- package/modules/autonomy/db.js +47 -0
- package/modules/autonomy/digest.js +344 -0
- package/modules/autonomy/migrations/autonomy_002_runner_heartbeat.sql +93 -0
- package/modules/autonomy/routes/autonomy.js +69 -0
- package/modules/autonomy/runner-health.js +97 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/gate.html +4 -1
- package/modules/hall-ui/public/gate.js +51 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-digest.js +123 -0
- package/scripts/gds/autobongos-run.js +375 -11
- package/scripts/gds/autobongos-service.cmd +38 -0
- package/scripts/gds/autobongos-service.ps1 +135 -0
- package/scripts/gds/deploy-main-audit-gate.sh +90 -0
- package/scripts/gds/main-audit.js +26 -4
- package/scripts/gds/provision-core-upgrade.js +23 -3
- package/scripts/gds/run-unit-tests.js +16 -1
- package/src/module-api.js +1 -1
- package/tests/autobongos_cadence.mjs +602 -0
- package/tests/autonomy_digest.mjs +302 -0
- package/tests/autonomy_runner_health.mjs +135 -0
- package/tests/bump_version.mjs +12 -1
- package/tests/cli_package.mjs +11 -1
- package/tests/core_upgrade_runner.mjs +43 -1
- package/tests/deploy_audit_gate.mjs +154 -0
- package/tests/deploy_divergence_line.mjs +47 -1
- package/tests/helpers.mjs +61 -0
- package/tests/main_audit_db_roster.mjs +97 -0
- package/tests/publish_watch.mjs +9 -0
- package/tests/setup_device_flow_fallback.mjs +2 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/autonomy/cadence.js — the runner has no terminal stop, only speeds
|
|
4
|
+
// (task 1003904, ADR 0307 §5). Pure: no I/O, no clock, no timers. The caller
|
|
5
|
+
// reports what happened and is told how fast to go and how long to wait.
|
|
6
|
+
//
|
|
7
|
+
// THE FLAW THIS FIXES, in the owner's words: "there is no loop to reboot once we
|
|
8
|
+
// stop and hit the failures." The first draft of this supervisor exited on
|
|
9
|
+
// anything that was not a completed piece of work. On a laptop that is fine —
|
|
10
|
+
// someone sees the prompt come back. On an always-on machine nobody is watching,
|
|
11
|
+
// a stopped daemon is indistinguishable from a working one, and stays that way
|
|
12
|
+
// until a person happens to wonder. Silence has to mean "idle", never "dead".
|
|
13
|
+
//
|
|
14
|
+
// SO THERE ARE ONLY SPEEDS. `full` works task after task. Three straight failures
|
|
15
|
+
// drop to `probing`: no new work, a growing wait, then exactly ONE task — a
|
|
16
|
+
// deliberately cheap question, asked over and over, so the runner costs almost
|
|
17
|
+
// nothing while broken and resumes the moment someone fixes the cause without
|
|
18
|
+
// being told. One success returns it to `full`.
|
|
19
|
+
//
|
|
20
|
+
// WHY CLASSIFY AT ALL, when every class ends up backing off. Because the log is
|
|
21
|
+
// read by a person at breakfast, and "the instance was unreachable for 40
|
|
22
|
+
// minutes" and "your token expired six hours ago" want different actions from
|
|
23
|
+
// them even though they want the same action from the machine. The classes are
|
|
24
|
+
// also the honest answer to a question the naive design gets wrong:
|
|
25
|
+
//
|
|
26
|
+
// · TRANSIENT (instance unreachable, grader outage, a rejected push) heals by
|
|
27
|
+
// waiting. Backing off IS the fix.
|
|
28
|
+
// · SYSTEMIC (expired token, full disk) will NOT heal by waiting. Backing off
|
|
29
|
+
// is not a fix, but cheap probing costs nothing while it waits for a human,
|
|
30
|
+
// and resumes by itself the moment one acts. It is called out separately so
|
|
31
|
+
// the digest can say "this one needs you".
|
|
32
|
+
// · UNWORKABLE_QUEUE — every task in the allowlist halts the matrix. This is
|
|
33
|
+
// NOT a runner failure and must never be reported as breakage: the machine is
|
|
34
|
+
// working perfectly and there is nothing for it to do. Reporting it as a
|
|
35
|
+
// failure is how a healthy runner gets "fixed" into a broken one.
|
|
36
|
+
// · QUIET — no capacity, nothing claimable, the fence closed. Not a failure at
|
|
37
|
+
// all, and deliberately does NOT trip the backoff: three quiet nights in a
|
|
38
|
+
// row must not leave the runner probing on a morning when work appears.
|
|
39
|
+
// · STOPPED — the server-side kill switch, the only true stop. Even this does
|
|
40
|
+
// not exit: the runner keeps asking, cheaply, so flipping the switch back on
|
|
41
|
+
// is enough to start it without touching the machine.
|
|
42
|
+
// · PROGRESS — work landed. Resets everything.
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Classification
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
const CLASSES = ['progress', 'quiet', 'stopped', 'transient', 'systemic', 'unworkable_queue'];
|
|
49
|
+
|
|
50
|
+
// Only these advance the failure count toward `probing`.
|
|
51
|
+
const COUNTS_AS_FAILURE = new Set(['transient', 'systemic']);
|
|
52
|
+
|
|
53
|
+
// SYSTEMIC_MARKERS — substrings that mean waiting will not help.
|
|
54
|
+
//
|
|
55
|
+
// A heuristic over an error string, which is a thing to be uneasy about, so the
|
|
56
|
+
// bound is deliberate: getting this WRONG in either direction costs only the
|
|
57
|
+
// digest's wording, never the machine's behaviour. Transient and systemic back
|
|
58
|
+
// off identically — the classes differ in what a person is told, not in what the
|
|
59
|
+
// runner does. That is why a heuristic is acceptable here and would not be if it
|
|
60
|
+
// gated an action.
|
|
61
|
+
const SYSTEMIC_MARKERS = [
|
|
62
|
+
'401', 'unauthorized', 'token', 'expired', 'forbidden', '403',
|
|
63
|
+
'enospc', 'no space', 'disk full', 'quota', 'eacces', 'permission denied',
|
|
64
|
+
'not a git repository', 'command not found', 'enoent',
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function looksSystemic(text) {
|
|
68
|
+
const s = String(text || '').toLowerCase();
|
|
69
|
+
return SYSTEMIC_MARKERS.some((m) => s.includes(m));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// classifyEvent — one run-log row becomes one class.
|
|
73
|
+
//
|
|
74
|
+
// The row shapes are autobongos-run.js's own `event`/`code` vocabulary; an event
|
|
75
|
+
// this does not recognise is TRANSIENT, not quiet. An unknown failure that read
|
|
76
|
+
// as "nothing to do" would let a broken runner look idle forever, which is the
|
|
77
|
+
// exact failure this whole file exists against.
|
|
78
|
+
function classifyEvent(row = {}) {
|
|
79
|
+
const event = String(row.event || '');
|
|
80
|
+
const code = String(row.code || '');
|
|
81
|
+
const reason = `${row.reason || ''} ${row.release_error || ''}`;
|
|
82
|
+
|
|
83
|
+
if (event === 'worked') {
|
|
84
|
+
// A worked task is progress only if the LEDGER agreed (task 1003903). A
|
|
85
|
+
// worker that claimed a ship the ledger never saw is not progress, and
|
|
86
|
+
// counting it as such would hold a broken runner at full speed indefinitely.
|
|
87
|
+
if (row.verified === true) return { class: 'progress', detail: 'a task shipped and the ledger confirms it' };
|
|
88
|
+
if (row.ledger_shape === 'undetermined') return { class: 'transient', detail: 'the ledger could not be read back' };
|
|
89
|
+
// It ran, it did not ship. Not breakage — a blocker was filed and the task is
|
|
90
|
+
// out of the pool — but not progress either. Quiet keeps the runner at full
|
|
91
|
+
// speed so it moves on to the next task, which is the right response.
|
|
92
|
+
return { class: 'quiet', detail: `the worker finished but the task came back ${row.ledger_shape || 'unshipped'}` };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (event === 'dry_run') return { class: 'quiet', detail: 'dry run' };
|
|
96
|
+
|
|
97
|
+
if (event === 'hold') {
|
|
98
|
+
// Out of capacity, or the gauge could not be vouched for. Neither is a fault
|
|
99
|
+
// and neither is fixed by probing — the gauge already says when to come back.
|
|
100
|
+
return { class: 'quiet', detail: row.reason || 'no capacity in this window', sleepUntil: row.sleep_until ?? null };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (event === 'fenced') {
|
|
104
|
+
if (code === 'kill_switch') return { class: 'stopped', detail: row.reason || 'an owner stopped the runner' };
|
|
105
|
+
if (code === 'grader_unavailable') return { class: 'transient', detail: 'the grader is unavailable, and it is the only review there is' };
|
|
106
|
+
if (code === 'fence_unreadable') return { class: 'transient', detail: 'the fence could not be read' };
|
|
107
|
+
// no_allowlist / goal_not_allowlisted / protected_path: the fence is working
|
|
108
|
+
// and there is nothing it permits right now. Configuration, not breakage.
|
|
109
|
+
return { class: 'quiet', detail: row.reason || 'the fence permits nothing right now' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (event === 'nothing_claimable') {
|
|
113
|
+
// THE DISTINCTION THAT MATTERS. An empty queue and a queue where every task
|
|
114
|
+
// halts look identical from here — both yield no task — and calling the
|
|
115
|
+
// second one "idle" is how a goal full of blocked work sits untouched for a
|
|
116
|
+
// week with the runner reporting itself healthy.
|
|
117
|
+
const skipped = Array.isArray(row.skipped) ? row.skipped : [];
|
|
118
|
+
if (skipped.length) {
|
|
119
|
+
return {
|
|
120
|
+
class: 'unworkable_queue',
|
|
121
|
+
detail: `${skipped.length} task${skipped.length === 1 ? '' : 's'} in the allowlist, every one of them halted by the matrix`,
|
|
122
|
+
skipped,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { class: 'quiet', detail: 'nothing claimable' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (event === 'verify_halt') return { class: 'transient', detail: 'the ledger could not be read back after a worker' };
|
|
129
|
+
|
|
130
|
+
if (event === 'pick_failed' || event === 'worktree_failed' || event === 'claim_failed') {
|
|
131
|
+
return {
|
|
132
|
+
class: looksSystemic(reason) ? 'systemic' : 'transient',
|
|
133
|
+
detail: `${event}: ${String(row.reason || '').slice(0, 300)}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { class: 'transient', detail: `unrecognised run event '${event || '(none)'}'` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// The state machine
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const DEFAULTS = Object.freeze({
|
|
145
|
+
// Three straight failures, per ADR 0307 §5.
|
|
146
|
+
failuresBeforeProbing: 3,
|
|
147
|
+
// The growing wait, in seconds. Reasoned, not magic: a minute covers a service
|
|
148
|
+
// restart, half an hour covers a person noticing at breakfast, and the cap
|
|
149
|
+
// exists so a runner broken overnight still asks twice an hour rather than
|
|
150
|
+
// drifting into an interval nobody would call responsive.
|
|
151
|
+
backoffS: [60, 120, 300, 600, 1200, 1800],
|
|
152
|
+
// How long to idle when there is simply nothing to do. Short, because the cost
|
|
153
|
+
// of asking is one cheap request and the cost of missing new work is an hour of
|
|
154
|
+
// an always-on machine doing nothing.
|
|
155
|
+
idleS: 300,
|
|
156
|
+
// A closed fence is checked less often than an idle queue: flipping it is a
|
|
157
|
+
// deliberate human act, and nobody expects the runner back within the minute.
|
|
158
|
+
stoppedS: 600,
|
|
159
|
+
// An unworkable queue needs a person to unblock something. Same reasoning as a
|
|
160
|
+
// closed fence, and deliberately NOT the backoff ladder — it is not a failure
|
|
161
|
+
// and must not escalate as though it were.
|
|
162
|
+
unworkableS: 900,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const START = Object.freeze({ mode: 'full', consecutiveFailures: 0, backoffStep: 0 });
|
|
166
|
+
|
|
167
|
+
// nextCadence — given where the runner is and what just happened, where does it
|
|
168
|
+
// go and how long does it wait?
|
|
169
|
+
//
|
|
170
|
+
// Returns a WHOLE new state rather than mutating, so the caller can log both and
|
|
171
|
+
// a test can assert a sequence. `waitS` is the wait BEFORE the next iteration;
|
|
172
|
+
// `takeWork: false` is the probing promise that the runner claims nothing new.
|
|
173
|
+
function nextCadence(state = START, classification = {}, cfg = {}) {
|
|
174
|
+
const c = { ...DEFAULTS, ...cfg };
|
|
175
|
+
const prev = { ...START, ...state };
|
|
176
|
+
const kind = CLASSES.includes(classification.class) ? classification.class : 'transient';
|
|
177
|
+
|
|
178
|
+
// PROGRESS resets everything. One shipped task is proof the whole path works.
|
|
179
|
+
if (kind === 'progress') {
|
|
180
|
+
return { mode: 'full', consecutiveFailures: 0, backoffStep: 0, waitS: 0, takeWork: true, why: 'a task shipped — full speed' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// QUIET does not touch the failure count. Three quiet nights must not leave the
|
|
184
|
+
// runner probing on the morning work appears. It also does not RESET the count:
|
|
185
|
+
// a transient failure followed by an idle window is still one failure toward
|
|
186
|
+
// probing, and forgiving it there would let a runner that fails every other
|
|
187
|
+
// iteration never reach a backoff.
|
|
188
|
+
if (kind === 'quiet') {
|
|
189
|
+
const waitS = Number.isFinite(Number(classification.sleepUntilS)) ? Number(classification.sleepUntilS) : c.idleS;
|
|
190
|
+
return { ...prev, waitS, takeWork: prev.mode === 'full', why: classification.detail || 'nothing to do' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (kind === 'stopped') {
|
|
194
|
+
// Not a failure, and not an exit. The runner keeps asking so that flipping the
|
|
195
|
+
// switch back on is enough — nobody should have to log into the machine to
|
|
196
|
+
// restart something an owner turned off from a web page.
|
|
197
|
+
return { ...prev, waitS: c.stoppedS, takeWork: false, why: classification.detail || 'stopped by an owner' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (kind === 'unworkable_queue') {
|
|
201
|
+
// Explicitly NOT the failure ladder. The machine is healthy; the queue is not.
|
|
202
|
+
return { ...prev, waitS: c.unworkableS, takeWork: prev.mode === 'full', why: classification.detail || 'every task in the queue halts' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// transient | systemic — the only two that count toward probing.
|
|
206
|
+
const consecutiveFailures = prev.consecutiveFailures + 1;
|
|
207
|
+
const mode = consecutiveFailures >= c.failuresBeforeProbing ? 'probing' : prev.mode;
|
|
208
|
+
const backoffStep = mode === 'probing' ? Math.min(prev.backoffStep + 1, c.backoffS.length) : 0;
|
|
209
|
+
const waitS = mode === 'probing' ? c.backoffS[Math.max(0, backoffStep - 1)] : 0;
|
|
210
|
+
return {
|
|
211
|
+
mode, consecutiveFailures, backoffStep, waitS,
|
|
212
|
+
// The probing promise: one task at a time, and only when the wait is over.
|
|
213
|
+
takeWork: mode === 'full',
|
|
214
|
+
probeOnly: mode === 'probing',
|
|
215
|
+
why: `${kind}: ${classification.detail || 'no detail'}`
|
|
216
|
+
+ (mode === 'probing' ? ` — failure ${consecutiveFailures}, probing every ${waitS}s` : ` — failure ${consecutiveFailures} of ${c.failuresBeforeProbing}`),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Jumping the wait
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
// signalFingerprint — the cheap facts whose CHANGE means the world moved.
|
|
225
|
+
//
|
|
226
|
+
// Deliberately a string rather than a structured diff: the only question the
|
|
227
|
+
// waiter asks is "is this different from last time", and a fingerprint answers it
|
|
228
|
+
// without the caller having to know which of the three moved. What is in it is
|
|
229
|
+
// what ADR 0307 §5 names: main moved, the allowlist changed, a blocker resolved
|
|
230
|
+
// (which shows up as the claimable count changing).
|
|
231
|
+
function signalFingerprint({ head = null, fenceUpdatedAt = null, claimableCount = null } = {}) {
|
|
232
|
+
// `-` is the ONLY spelling of "this probe could not answer", and each field
|
|
233
|
+
// has to reach it deliberately. `Number(null)` is 0 — finite — so a plain
|
|
234
|
+
// isFinite check turned an unread claimable count into the literal string
|
|
235
|
+
// "null" and made a blind probe look like a reading. Caught by the test that
|
|
236
|
+
// asserts a failed probe is not a signal.
|
|
237
|
+
const n = claimableCount === null || claimableCount === undefined || claimableCount === ''
|
|
238
|
+
? null : Number(claimableCount);
|
|
239
|
+
return [
|
|
240
|
+
head ? String(head).slice(0, 12) : '-',
|
|
241
|
+
fenceUpdatedAt ? String(fenceUpdatedAt) : '-',
|
|
242
|
+
n !== null && Number.isFinite(n) ? String(n) : '-',
|
|
243
|
+
].join('|');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// shouldJumpWait — has anything changed that makes waiting pointless?
|
|
247
|
+
//
|
|
248
|
+
// COMPARED FIELD BY FIELD, and only where BOTH sides know the answer. A whole-
|
|
249
|
+
// string comparison looks right and is wrong in the case that matters: the probes
|
|
250
|
+
// fail independently, so a flaky `git ls-remote` flips the fingerprint between
|
|
251
|
+
// `abc|t1|3` and `-|t1|3` on alternate polls, and every one of those reads as a
|
|
252
|
+
// change. That turns a thirty-minute backoff into a busy loop hammering the
|
|
253
|
+
// instance it is backing off from — the precise opposite of the intent, and
|
|
254
|
+
// worst exactly when things are already broken.
|
|
255
|
+
//
|
|
256
|
+
// So a `-` on EITHER side means "not evidence" for that field, and a comparison
|
|
257
|
+
// with no usable field at all never jumps.
|
|
258
|
+
function shouldJumpWait(before, after) {
|
|
259
|
+
if (typeof before !== 'string' || typeof after !== 'string') return false;
|
|
260
|
+
const a = before.split('|');
|
|
261
|
+
const b = after.split('|');
|
|
262
|
+
if (a.length !== b.length) return false;
|
|
263
|
+
let comparable = 0;
|
|
264
|
+
let changed = false;
|
|
265
|
+
for (let i = 0; i < a.length; i++) {
|
|
266
|
+
if (a[i] === '-' || b[i] === '-') continue; // one side could not look
|
|
267
|
+
comparable += 1;
|
|
268
|
+
if (a[i] !== b[i]) changed = true;
|
|
269
|
+
}
|
|
270
|
+
return comparable > 0 && changed;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
CLASSES, COUNTS_AS_FAILURE, SYSTEMIC_MARKERS, DEFAULTS, START,
|
|
275
|
+
looksSystemic, classifyEvent, nextCadence, signalFingerprint, shouldJumpWait,
|
|
276
|
+
};
|
package/modules/autonomy/db.js
CHANGED
|
@@ -114,9 +114,56 @@ async function disallowGoal(goalId) {
|
|
|
114
114
|
return { removed: rowCount > 0 };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// ── the runner heartbeat (task 1003905) ──────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
// recordRunnerHeartbeat — upsert one host's check-in. ON CONFLICT so the normal
|
|
120
|
+
// case is one write, and `host` is the key so a second runner appears as a second
|
|
121
|
+
// row rather than silently overwriting the first.
|
|
122
|
+
async function recordRunnerHeartbeat({ builderId, host, pid = null, startedAt = null, mode = null, consecutiveFailures = null, lastEvent = null, workingTaskId = null }) {
|
|
123
|
+
const { rows } = await pool.query(
|
|
124
|
+
`INSERT INTO autonomy_runner_heartbeat
|
|
125
|
+
(builder_id, host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at, updated_at)
|
|
126
|
+
VALUES ($8, $1, $2, $3, $4, $5, $6, $7, now(), now())
|
|
127
|
+
ON CONFLICT (builder_id, host) DO UPDATE SET
|
|
128
|
+
pid = EXCLUDED.pid,
|
|
129
|
+
started_at = COALESCE(EXCLUDED.started_at, autonomy_runner_heartbeat.started_at),
|
|
130
|
+
mode = EXCLUDED.mode,
|
|
131
|
+
consecutive_failures = EXCLUDED.consecutive_failures,
|
|
132
|
+
last_event = EXCLUDED.last_event,
|
|
133
|
+
working_task_id = EXCLUDED.working_task_id,
|
|
134
|
+
last_seen_at = now(),
|
|
135
|
+
updated_at = now()
|
|
136
|
+
RETURNING host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at`,
|
|
137
|
+
[String(host).toLowerCase().slice(0, 255), pid, startedAt, mode, consecutiveFailures, lastEvent, workingTaskId, builderId]
|
|
138
|
+
);
|
|
139
|
+
return rows[0] || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// readRunnerHeartbeats — every host, most recently heard from first. Returns an
|
|
143
|
+
// EMPTY ARRAY when nothing has ever checked in, which the pure describeRunner
|
|
144
|
+
// reads as `never_seen` — a different answer from "it died", and the reason the
|
|
145
|
+
// caller must not paper over an empty read with a fabricated row.
|
|
146
|
+
// SCOPED TO ONE BUILDER. A heartbeat row names a machine, its hostname and a pid;
|
|
147
|
+
// that is the caller's own infrastructure and nobody else's business, and scoping
|
|
148
|
+
// the read is also what keeps a stranger's forged-looking row out of the owner's
|
|
149
|
+
// liveness view entirely (grader finding, task 1003905).
|
|
150
|
+
async function readRunnerHeartbeats(builderId) {
|
|
151
|
+
const { rows } = await pool.query(
|
|
152
|
+
`SELECT host, pid, started_at, mode, consecutive_failures, last_event, working_task_id, last_seen_at
|
|
153
|
+
FROM autonomy_runner_heartbeat
|
|
154
|
+
WHERE builder_id = $1
|
|
155
|
+
ORDER BY last_seen_at DESC
|
|
156
|
+
LIMIT 20`,
|
|
157
|
+
[builderId]
|
|
158
|
+
);
|
|
159
|
+
return rows.map((r) => ({ ...r, working_task_id: r.working_task_id ? String(r.working_task_id) : null }));
|
|
160
|
+
}
|
|
161
|
+
|
|
117
162
|
module.exports = {
|
|
118
163
|
recordAutonomyRun,
|
|
119
164
|
lastAutonomyHead,
|
|
165
|
+
recordRunnerHeartbeat,
|
|
166
|
+
readRunnerHeartbeats,
|
|
120
167
|
readFence,
|
|
121
168
|
setFenceEnabled,
|
|
122
169
|
allowGoal,
|