@bongos/core 1.19.875 → 1.19.877
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 +103 -43
- 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 +138 -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/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/modules/security/tests/artifact_scan.mjs +32 -9
- package/modules/security/tests/mirror.mjs +26 -4
- package/modules/security/tests/sanitize_rebuild.mjs +25 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-run.js +368 -11
- package/scripts/gds/autobongos-service.cmd +38 -0
- package/scripts/gds/autobongos-service.ps1 +129 -0
- package/scripts/gds/deploy-main-audit-gate.sh +90 -0
- package/scripts/gds/main-audit.js +26 -4
- package/scripts/gds/run-unit-tests.js +46 -5
- package/src/module-api.js +1 -1
- package/tests/autobongos_cadence.mjs +602 -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/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
- package/tests/skip_is_not_pass.mjs +121 -0
- package/tests/tool_scan_engine.mjs +25 -4
- package/tests/unit_runner_teardown_abort.mjs +37 -0
|
@@ -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,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
-- autonomy_002_runner_heartbeat.sql — a dead runner must be VISIBLE, not silent
|
|
2
|
+
-- (task 1003905, ADR 0307 §5).
|
|
3
|
+
--
|
|
4
|
+
-- WHY THE SERVER HAS TO HOLD THIS. autonomy_001 gave the owner a switch the
|
|
5
|
+
-- machine can read and cannot write. This is the same argument pointed the other
|
|
6
|
+
-- way: the owner needs to see the machine, and a status written only on the
|
|
7
|
+
-- machine is invisible on precisely the day it matters — the day the machine is
|
|
8
|
+
-- off, unplugged, or wedged. The runner writes a local heartbeat file too
|
|
9
|
+
-- (scripts/gds/autobongos-run.js, the guard against two runners fighting over one
|
|
10
|
+
-- claim), but that file cannot be read from a web page in another country. This
|
|
11
|
+
-- table can.
|
|
12
|
+
--
|
|
13
|
+
-- SILENCE IS THE SIGNAL, which is why the row records a TIME and not a state.
|
|
14
|
+
-- A "status: running" column would be written by a healthy runner and then left
|
|
15
|
+
-- untouched by a dead one, so it would read "running" forever — exactly the class
|
|
16
|
+
-- of lie provisioning_006 was written to end ("status records what the runner DID,
|
|
17
|
+
-- not what the instance actually does"). Only `last_seen_at` can go stale on its
|
|
18
|
+
-- own, so only `last_seen_at` can tell the truth without being told.
|
|
19
|
+
--
|
|
20
|
+
-- NO ROW MEANS NEVER SEEN, and that is a third answer, not a missing one. A
|
|
21
|
+
-- runner that has never checked in and a runner that checked in yesterday and
|
|
22
|
+
-- stopped need different words from the hall — "not set up yet" versus "it died".
|
|
23
|
+
-- Same distinction provisioning_006 draws between NULL and false.
|
|
24
|
+
--
|
|
25
|
+
-- KEYED BY (builder_id, host), AND THE BUILDER HALF IS A SECURITY BOUNDARY, not
|
|
26
|
+
-- bookkeeping. With `host` alone as the key, any authenticated builder could POST
|
|
27
|
+
-- a heartbeat naming the real runner's hostname and keep the liveness signal
|
|
28
|
+
-- reading "alive" forever — defeating the exact property this table exists for,
|
|
29
|
+
-- which is that a dead machine holding broad spawn permissions becomes VISIBLE.
|
|
30
|
+
-- An ownership check in the handler would also close it, and would be one `if`
|
|
31
|
+
-- away from being removed by a later edit that looked reasonable. Putting the
|
|
32
|
+
-- caller in the KEY makes the forgery structurally impossible instead: a stranger
|
|
33
|
+
-- writing "winbox" writes THEIR OWN row and cannot touch the owner's. (Found by
|
|
34
|
+
-- the grader's security worker on task 1003905.)
|
|
35
|
+
--
|
|
36
|
+
-- A singleton was the other option and is worse for a second reason: one row per
|
|
37
|
+
-- host means a second runner shows up as a second row instead of silently
|
|
38
|
+
-- overwriting the first, which is the failure a singleton discovers at the worst
|
|
39
|
+
-- possible moment.
|
|
40
|
+
--
|
|
41
|
+
-- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
|
|
42
|
+
-- object is autonomy_-prefixed, there is no down-migration, and the filename is
|
|
43
|
+
-- autonomy_-prefixed (ADR 0108 §5). Idempotent: safe to re-run.
|
|
44
|
+
|
|
45
|
+
BEGIN;
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS autonomy_runner_heartbeat (
|
|
48
|
+
-- WHO is reporting. Half the primary key — see the header: this is what makes a
|
|
49
|
+
-- forged heartbeat impossible rather than merely checked for.
|
|
50
|
+
builder_id bigint NOT NULL,
|
|
51
|
+
-- The machine's own name for itself (os.hostname()). Lowercased by the writer
|
|
52
|
+
-- so one host cannot appear twice under two spellings.
|
|
53
|
+
host text NOT NULL,
|
|
54
|
+
pid integer,
|
|
55
|
+
-- THE ONLY LOAD-BEARING COLUMN. Everything else is colour for the hall.
|
|
56
|
+
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
|
57
|
+
-- When this process started, so the hall can say "up 3 days" and a restart loop
|
|
58
|
+
-- is visible as a start time that keeps moving.
|
|
59
|
+
started_at timestamptz,
|
|
60
|
+
-- The cadence, verbatim from modules/autonomy/cadence.js: 'full' | 'probing'.
|
|
61
|
+
-- Free text rather than an enum: the vocabulary lives in that file, and a CHECK
|
|
62
|
+
-- here would mean a schema migration every time it gains a word.
|
|
63
|
+
mode text,
|
|
64
|
+
consecutive_failures integer,
|
|
65
|
+
-- The last run-log event, so "last seen 4 minutes ago" can be followed by WHAT
|
|
66
|
+
-- it was doing. A runner quiet because it is mid-worker and one quiet because it
|
|
67
|
+
-- is wedged look identical without this.
|
|
68
|
+
last_event text,
|
|
69
|
+
-- The task a worker is building, when there is one. A long gap is EXPECTED while
|
|
70
|
+
-- a worker runs (they may take an hour and a half), and without this the hall
|
|
71
|
+
-- would cry dead every time the runner did its job.
|
|
72
|
+
working_task_id bigint,
|
|
73
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
74
|
+
PRIMARY KEY (builder_id, host)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
-- ON RE-RUNNING THIS AGAINST A TABLE THAT ALREADY EXISTS IN A DIFFERENT SHAPE:
|
|
78
|
+
-- it would not be corrected, and that is deliberate rather than overlooked. An
|
|
79
|
+
-- earlier cut of this same file keyed the table on `host` alone, and the obvious
|
|
80
|
+
-- repair -- detect the old key, DROP CONSTRAINT, re-add -- is DESTRUCTIVE DDL,
|
|
81
|
+
-- which a module migration may not use (ADR 0083 section Decision #5, enforced by
|
|
82
|
+
-- fitness.js: "disabling a module stops applying, it never drops"). It was
|
|
83
|
+
-- written, it was caught by that gate, and the gate is right: a module that can
|
|
84
|
+
-- drop things on its way in is a module that can take a host's data with it.
|
|
85
|
+
--
|
|
86
|
+
-- The reason it is safe to leave is narrower than "nobody will hit it". This
|
|
87
|
+
-- table is NEW in an UNRELEASED task -- no published core version contains it, so
|
|
88
|
+
-- no instance can have applied any shape of it. The only databases that ever saw
|
|
89
|
+
-- the host-only version were throwaway probes on the author's machine, since
|
|
90
|
+
-- deleted. If that ever stops being true, the fix is a NEW additive migration,
|
|
91
|
+
-- not an edit here. (Grader finding, task 1003905.)
|
|
92
|
+
|
|
93
|
+
COMMIT;
|
|
@@ -28,6 +28,7 @@ const auth = api;
|
|
|
28
28
|
// neither core internals nor a sibling module, so importing it here is allowed.
|
|
29
29
|
const autonomyGate = require('../../../scripts/gds/autonomy-gate');
|
|
30
30
|
const db = require('../db');
|
|
31
|
+
const runnerHealth = require('../runner-health');
|
|
31
32
|
// The doorway's namespaced logger and strict body validator. Raw console.* is
|
|
32
33
|
// ratcheted repo-wide (fitness.js console_call_count) and unstructured besides.
|
|
33
34
|
const log = api.logger('autonomy');
|
|
@@ -143,5 +144,73 @@ module.exports = function buildAutonomyRouter() {
|
|
|
143
144
|
}
|
|
144
145
|
});
|
|
145
146
|
|
|
147
|
+
// ── the runner heartbeat (task 1003905) ────────────────────────────────────
|
|
148
|
+
//
|
|
149
|
+
// A dead runner must be VISIBLE, not silent. The runner also writes a heartbeat
|
|
150
|
+
// FILE on its own host — that one stops two runners fighting over a claim — but
|
|
151
|
+
// a file on the machine is unreadable on exactly the day it matters, the day
|
|
152
|
+
// the machine is off. This pair is the half the owner can see from a web page.
|
|
153
|
+
|
|
154
|
+
// The runner's check-in, once per loop. ANY-BUILDER is safe because the row is
|
|
155
|
+
// keyed by the CALLER: a stranger reporting "winbox" writes their own row and
|
|
156
|
+
// cannot touch the owner's, so the liveness signal cannot be forged into reading
|
|
157
|
+
// alive. Keeping the bar low matters — the failure mode of a refused heartbeat
|
|
158
|
+
// is a healthy runner reported dead.
|
|
159
|
+
router.post('/autonomy/heartbeat', auth.requireBuilder, async (req, res) => {
|
|
160
|
+
if (validateOrRespond(req, res, {
|
|
161
|
+
host: { required: true, type: 'string', maxLength: 255, minLength: 1 },
|
|
162
|
+
pid: { type: 'number' },
|
|
163
|
+
started_at: { type: 'string', maxLength: 64 },
|
|
164
|
+
mode: { type: 'string', maxLength: 32 },
|
|
165
|
+
consecutive_failures: { type: 'number' },
|
|
166
|
+
last_event: { type: 'string', maxLength: 64 },
|
|
167
|
+
working_task_id: { type: 'number' },
|
|
168
|
+
})) return;
|
|
169
|
+
const b = req.body || {};
|
|
170
|
+
try {
|
|
171
|
+
const row = await db.recordRunnerHeartbeat({
|
|
172
|
+
// From the SESSION, never the body. A caller cannot name someone else's
|
|
173
|
+
// runner, because the row they write is keyed by who they are.
|
|
174
|
+
builderId: req.builder.id,
|
|
175
|
+
host: b.host,
|
|
176
|
+
pid: Number.isInteger(b.pid) ? b.pid : null,
|
|
177
|
+
startedAt: b.started_at || null,
|
|
178
|
+
mode: b.mode || null,
|
|
179
|
+
consecutiveFailures: Number.isInteger(b.consecutive_failures) ? b.consecutive_failures : null,
|
|
180
|
+
lastEvent: b.last_event || null,
|
|
181
|
+
workingTaskId: Number.isInteger(b.working_task_id) ? b.working_task_id : null,
|
|
182
|
+
});
|
|
183
|
+
res.json({ ok: true, heartbeat: row });
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// FAIL SOFT, and deliberately unlike the fence's GET. A heartbeat that
|
|
186
|
+
// cannot be written is a monitoring gap; turning it into an error the runner
|
|
187
|
+
// has to handle would let the watching mechanism take down the thing it
|
|
188
|
+
// watches. The gap shows up on its own — as silence, which is the signal.
|
|
189
|
+
log.error('[gds] POST /autonomy/heartbeat', err);
|
|
190
|
+
res.fail('heartbeat_write_failed', { status: 500, message: 'internal error' });
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// What the hall renders: the CALLER'S OWN runners, with the pure verdict attached
|
|
195
|
+
// so the page and any future alert cannot disagree about what "dead" means. A
|
|
196
|
+
// row names a machine, its hostname and a pid — the caller's infrastructure, and
|
|
197
|
+
// nobody else's business.
|
|
198
|
+
router.get('/autonomy/runners', auth.requireBuilder, async (req, res) => {
|
|
199
|
+
try {
|
|
200
|
+
const rows = await db.readRunnerHeartbeats(req.builder.id);
|
|
201
|
+
const nowMs = Date.now();
|
|
202
|
+
res.json({
|
|
203
|
+
// An EMPTY list is the honest answer for "nobody has ever checked in" —
|
|
204
|
+
// never_seen is a third state, not a missing one, so no row is fabricated
|
|
205
|
+
// to stand in for it.
|
|
206
|
+
runners: rows.map((r) => ({ ...r, health: runnerHealth.describeRunner(r, { nowMs }) })),
|
|
207
|
+
none: runnerHealth.describeRunner(null, { nowMs }),
|
|
208
|
+
});
|
|
209
|
+
} catch (err) {
|
|
210
|
+
log.error('[gds] GET /autonomy/runners', err);
|
|
211
|
+
res.fail('runners_unreadable', { status: 503, message: 'could not read the runner heartbeats' });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
146
215
|
return router;
|
|
147
216
|
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/autonomy/runner-health.js — is the runner alive? (task 1003905.)
|
|
4
|
+
// Pure: no I/O, no clock of its own. `now` is injected.
|
|
5
|
+
//
|
|
6
|
+
// THE QUESTION THIS ANSWERS IS ASYMMETRIC, and that is the whole design. A
|
|
7
|
+
// running machine can say so; a dead one cannot say anything. So the signal is
|
|
8
|
+
// not a status the runner writes — a dead runner leaves "running" on the screen
|
|
9
|
+
// forever — but the AGE of the last thing it said. Silence is the only report a
|
|
10
|
+
// dead process can file, so silence is what this reads.
|
|
11
|
+
//
|
|
12
|
+
// FOUR STATES, because three of them are different kinds of quiet:
|
|
13
|
+
// · never_seen — no row at all. Not a failure: nobody has set the host up yet,
|
|
14
|
+
// and telling an owner their runner "died" before it ever ran is how a status
|
|
15
|
+
// light trains people to ignore it.
|
|
16
|
+
// · alive — heard from within the window.
|
|
17
|
+
// · late — quiet longer than a loop, but not longer than a WORKER. The
|
|
18
|
+
// runner legitimately goes quiet for up to the worker ceiling while doing
|
|
19
|
+
// exactly what it is for, and crying dead every time it works is worse than
|
|
20
|
+
// saying nothing.
|
|
21
|
+
// · dead — quiet longer than any legitimate explanation.
|
|
22
|
+
|
|
23
|
+
// Reasoned from the runner's own numbers, not picked. The loop beats at the top of
|
|
24
|
+
// every pass and again on each poll inside a wait (every 60s), so a healthy runner
|
|
25
|
+
// that is merely idle or backing off is never quiet for more than about a minute.
|
|
26
|
+
const ALIVE_S = 15 * 60;
|
|
27
|
+
// The worker wall-clock ceiling is 90 minutes (DEFAULT_WORKER_TIMEOUT_MS), and a
|
|
28
|
+
// worker holds the loop for its whole run. Two hours is that ceiling plus room for
|
|
29
|
+
// the ship, the grade and the verify that follow it.
|
|
30
|
+
const LATE_S = 2 * 60 * 60;
|
|
31
|
+
|
|
32
|
+
function ageS(lastSeenAt, nowMs) {
|
|
33
|
+
const t = Date.parse(lastSeenAt || '');
|
|
34
|
+
if (!Number.isFinite(t) || !Number.isFinite(nowMs)) return null;
|
|
35
|
+
return Math.max(0, Math.round((nowMs - t) / 1000));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// humanAge — the same shape strand-watch.js uses, so two surfaces describing a
|
|
39
|
+
// duration do not describe it two ways.
|
|
40
|
+
function humanAge(s) {
|
|
41
|
+
if (!Number.isFinite(s)) return 'unknown';
|
|
42
|
+
if (s < 90) return `${s}s`;
|
|
43
|
+
if (s < 5400) return `${Math.round(s / 60)}m`;
|
|
44
|
+
if (s < 172800) return `${Math.round(s / 3600)}h`;
|
|
45
|
+
return `${Math.round(s / 86400)}d`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// describeRunner — one heartbeat row becomes one sentence the hall can print.
|
|
49
|
+
//
|
|
50
|
+
// `heartbeat` is null/undefined for "no row", which is never_seen. An UNPARSEABLE
|
|
51
|
+
// last_seen_at is `dead`, not never_seen: a row that exists but cannot be read is
|
|
52
|
+
// a runner we heard from once and cannot account for now, and the fail direction
|
|
53
|
+
// on a liveness check is to raise the alarm rather than to explain it away.
|
|
54
|
+
function describeRunner(heartbeat, { nowMs = Date.now(), aliveS = ALIVE_S, lateS = LATE_S } = {}) {
|
|
55
|
+
if (!heartbeat || typeof heartbeat !== 'object') {
|
|
56
|
+
return {
|
|
57
|
+
state: 'never_seen', ok: true, ageS: null, age: null,
|
|
58
|
+
detail: 'no runner has ever checked in from this instance',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const age = ageS(heartbeat.last_seen_at, nowMs);
|
|
63
|
+
if (age === null) {
|
|
64
|
+
return { state: 'dead', ok: false, ageS: null, age: 'unknown', detail: 'the runner’s last check-in cannot be read' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const host = typeof heartbeat.host === 'string' ? heartbeat.host : null;
|
|
68
|
+
const mode = heartbeat.mode === 'probing' ? 'probing' : (heartbeat.mode === 'full' ? 'full' : null);
|
|
69
|
+
const working = heartbeat.working_task_id ? String(heartbeat.working_task_id) : null;
|
|
70
|
+
const where = host ? ` on ${host}` : '';
|
|
71
|
+
|
|
72
|
+
if (age <= aliveS) {
|
|
73
|
+
return {
|
|
74
|
+
state: 'alive', ok: true, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
|
|
75
|
+
detail: working
|
|
76
|
+
? `working task ${working}${where}`
|
|
77
|
+
: `${mode === 'probing' ? 'probing after failures' : 'running'}${where}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (age <= lateS) {
|
|
82
|
+
return {
|
|
83
|
+
state: 'late', ok: true, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
|
|
84
|
+
// Deliberately NOT an alarm. This is what a runner doing its job looks like.
|
|
85
|
+
detail: working
|
|
86
|
+
? `quiet for ${humanAge(age)} — expected while task ${working} builds`
|
|
87
|
+
: `quiet for ${humanAge(age)} — longer than a loop, still within a worker's run`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
state: 'dead', ok: false, ageS: age, age: humanAge(age), host, mode, workingTaskId: working,
|
|
93
|
+
detail: `no check-in for ${humanAge(age)}${where} — longer than any worker takes, so the runner is not running`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { ALIVE_S, LATE_S, ageS, humanAge, describeRunner };
|
|
@@ -180,8 +180,12 @@ function createClient(opts = {}) {
|
|
|
180
180
|
postAutonomyFenceGoals: (args) => request("POST", "/autonomy/fence/goals", { hasBody: true }, args),
|
|
181
181
|
// DELETE /autonomy/fence/goals/{goalId} — rank: archon — DELETE /autonomy/fence/goals/:goalId
|
|
182
182
|
deleteAutonomyFenceGoalsGoalId: (args) => request("DELETE", "/autonomy/fence/goals/{goalId}", { hasBody: false }, args),
|
|
183
|
+
// POST /autonomy/heartbeat — rank: any-builder — POST /autonomy/heartbeat
|
|
184
|
+
postAutonomyHeartbeat: (args) => request("POST", "/autonomy/heartbeat", { hasBody: true }, args),
|
|
183
185
|
// GET /autonomy/precheck — rank: metic+archon — GET /autonomy/precheck
|
|
184
186
|
getAutonomyPrecheck: (args) => request("GET", "/autonomy/precheck", { hasBody: false }, args),
|
|
187
|
+
// GET /autonomy/runners — rank: any-builder — GET /autonomy/runners
|
|
188
|
+
getAutonomyRunners: (args) => request("GET", "/autonomy/runners", { hasBody: false }, args),
|
|
185
189
|
},
|
|
186
190
|
"backup": {
|
|
187
191
|
// GET /backup/status — rank: metic+archon — GET /backup/status
|
|
@@ -52,6 +52,9 @@
|
|
|
52
52
|
<section class="scroll" id="fence-panel" aria-labelledby="fence-h" hidden>
|
|
53
53
|
<h2 class="scroll__h" id="fence-h">Autobongos runner</h2>
|
|
54
54
|
<p class="scroll__lede">The unattended runner works only the goals listed here, and only while it is on. This is the switch — it lives on the server, so the machine can read it and cannot change it.</p>
|
|
55
|
+
<!-- Liveness first (task 1003905): "is it running at all" is the
|
|
56
|
+
question before "is it allowed to run". -->
|
|
57
|
+
<div id="fence-runner"><span class="kit-skel"></span></div>
|
|
55
58
|
<div id="fence-state"><span class="kit-skel"></span></div>
|
|
56
59
|
<div id="fence-goals"></div>
|
|
57
60
|
</section>
|
|
@@ -78,6 +81,6 @@
|
|
|
78
81
|
<script src="/builders/palette.js" defer></script>
|
|
79
82
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
80
83
|
<script src="/builders/hall-kit.js"></script>
|
|
81
|
-
<script src="/builders/gate.js?v=2026-09-19-
|
|
84
|
+
<script src="/builders/gate.js?v=2026-09-19-runner"></script>
|
|
82
85
|
</body>
|
|
83
86
|
</html>
|