@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.
Files changed (47) hide show
  1. package/.bongos-core.json +110 -40
  2. package/.gitattributes +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +12 -1
  9. package/docs/api/openapi.json +149 -3
  10. package/docs/api-reference.md +4 -2
  11. package/docs/copy-inventory.md +7 -6
  12. package/docs/copy-registry.json +16 -7
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/recipes/autobongos-windows-host.md +163 -0
  15. package/docs/recipes/instance-deploy.md +29 -3
  16. package/modules/autonomy/cadence.js +276 -0
  17. package/modules/autonomy/db.js +47 -0
  18. package/modules/autonomy/digest.js +344 -0
  19. package/modules/autonomy/migrations/autonomy_002_runner_heartbeat.sql +93 -0
  20. package/modules/autonomy/routes/autonomy.js +69 -0
  21. package/modules/autonomy/runner-health.js +97 -0
  22. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  23. package/modules/hall-ui/public/gate.html +4 -1
  24. package/modules/hall-ui/public/gate.js +51 -2
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/autobongos-digest.js +123 -0
  28. package/scripts/gds/autobongos-run.js +375 -11
  29. package/scripts/gds/autobongos-service.cmd +38 -0
  30. package/scripts/gds/autobongos-service.ps1 +135 -0
  31. package/scripts/gds/deploy-main-audit-gate.sh +90 -0
  32. package/scripts/gds/main-audit.js +26 -4
  33. package/scripts/gds/provision-core-upgrade.js +23 -3
  34. package/scripts/gds/run-unit-tests.js +16 -1
  35. package/src/module-api.js +1 -1
  36. package/tests/autobongos_cadence.mjs +602 -0
  37. package/tests/autonomy_digest.mjs +302 -0
  38. package/tests/autonomy_runner_health.mjs +135 -0
  39. package/tests/bump_version.mjs +12 -1
  40. package/tests/cli_package.mjs +11 -1
  41. package/tests/core_upgrade_runner.mjs +43 -1
  42. package/tests/deploy_audit_gate.mjs +154 -0
  43. package/tests/deploy_divergence_line.mjs +47 -1
  44. package/tests/helpers.mjs +61 -0
  45. package/tests/main_audit_db_roster.mjs +97 -0
  46. package/tests/publish_watch.mjs +9 -0
  47. package/tests/setup_device_flow_fallback.mjs +2 -1
@@ -0,0 +1,344 @@
1
+ 'use strict';
2
+
3
+ // modules/autonomy/digest.js — the night, readable afterwards (task 1003906,
4
+ // ADR 0307). Pure: rows in, a digest out. No I/O, no clock of its own.
5
+ //
6
+ // THE OWNER IS NOT AT THE KEYBOARD. That is the whole premise, and it means the
7
+ // run log — an append-only JSONL nobody will ever open — is not an answer. One
8
+ // page has to answer five questions, and it has to answer them without anyone
9
+ // reading a log: what shipped, what is on hold and why, what each worker decided
10
+ // that its task description did not determine, what it cost, and where the two
11
+ // limit windows stand.
12
+ //
13
+ // THE THIRD QUESTION IS THE ONE THAT COMPOUNDS, and it is worth saying why it is
14
+ // here at all. A worker will NOT reliably stop to ask: tested on an ambiguous
15
+ // task, it made four genuine judgement calls and wrote them straight into the
16
+ // file, which is what a competent engineer does with an under-specified brief.
17
+ // So the decisions happen either way. What the digest changes is whether anyone
18
+ // finds out. And every entry is also a sentence the task description SHOULD have
19
+ // contained — which makes this list a spec-quality signal, not just an audit
20
+ // trail: the goals whose decisions pile up are the goals whose specs are thin,
21
+ // and fixing those is what lowers tomorrow's halt count.
22
+ //
23
+ // WHAT COUNTS AS SHIPPED IS THE LEDGER'S ANSWER, never the worker's. A `worked`
24
+ // row with `verified: false` is a task that ran and did not ship, and putting it
25
+ // under "shipped" because a session ended would reintroduce the exact confusion
26
+ // task 1003903 exists to end.
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const isRow = (r) => r && typeof r === 'object' && typeof r.event === 'string';
33
+
34
+ // num — a number, or null for "not a number I can use".
35
+ //
36
+ // THE GUARD ON null IS LOAD-BEARING, and it is not defensive noise:
37
+ // `Number(null)` is 0 and 0 is finite, so the obvious one-liner turns every
38
+ // absent cost into a free worker and every absent duration into an instant one.
39
+ // The same trap cost a bug in modules/autonomy/cadence.js's fingerprint, where an
40
+ // unread claimable count became the literal string "null" for the same reason.
41
+ // Empty string behaves identically. Both are spelled out rather than trusted to
42
+ // Number().
43
+ const num = (v) => {
44
+ if (v === null || v === undefined || v === '') return null;
45
+ const n = Number(v);
46
+ return Number.isFinite(n) ? n : null;
47
+ };
48
+
49
+ function parseAt(row) {
50
+ const t = Date.parse(row && row.at);
51
+ return Number.isFinite(t) ? t : null;
52
+ }
53
+
54
+ // humanDuration — minutes and hours, the way a person says them.
55
+ function humanDuration(s) {
56
+ const n = num(s);
57
+ if (n === null || n < 0) return null;
58
+ if (n < 90) return `${Math.round(n)}s`;
59
+ if (n < 5400) return `${Math.round(n / 60)}m`;
60
+ return `${(n / 3600).toFixed(1)}h`;
61
+ }
62
+
63
+ function usd(n) {
64
+ const v = num(n);
65
+ return v === null ? null : `$${v.toFixed(2)}`;
66
+ }
67
+
68
+ // windowsFromGauge — question five, from the gauge file's own shape.
69
+ //
70
+ // Reports what it can SEE and says so when it cannot. A digest that silently
71
+ // omits a window reads as "nothing to report", and the one morning that matters
72
+ // is the morning the gauge stopped being written.
73
+ // STALENESS IS GAUGE.JS'S RULE, NOT A SECOND ONE. modules/autonomy/gauge.js holds
74
+ // a window UNKNOWN when its `observed_at` is older than staleAfterS (30 minutes
75
+ // by default), and the runner HOLDS on unknown. A digest that applied a laxer
76
+ // test would print "41% used, resets in 2h" about a window the runner had already
77
+ // decided it could not vouch for — two surfaces disagreeing about one file, with
78
+ // the reader believing the one that is wrong. Same threshold, same conclusion.
79
+ // (Grader finding, task 1003906.)
80
+ function windowsFromGauge(gauge, nowEpochS, { staleAfterS = null } = {}) {
81
+ const limit = staleAfterS === null ? require('./gauge.js').DEFAULT_STALE_AFTER_S : staleAfterS;
82
+ const writtenAt = gauge && typeof gauge === 'object' ? num(gauge.written_at) : null;
83
+ const one = (key, label) => {
84
+ const w = gauge && typeof gauge === 'object' ? gauge[key] : null;
85
+ const used = w ? num(w.used_percentage) : null;
86
+ const resets = w ? num(w.resets_at) : null;
87
+ // Per-window observed_at, falling back to the file's written_at — exactly the
88
+ // shape gauge.js reads, because one window can go unreported while the other
89
+ // is carried forward.
90
+ const observedAt = w ? (num(w.observed_at) ?? writtenAt) : null;
91
+ if (used === null) return { key, label, known: false, detail: 'not reported' };
92
+
93
+ const ageS = observedAt !== null && Number.isFinite(nowEpochS) ? nowEpochS - observedAt : null;
94
+ if (ageS !== null && ageS > limit) {
95
+ return {
96
+ key, label, known: false, stale: true, usedPct: Math.round(used), resetsAt: resets, ageS,
97
+ detail: `last read ${Math.round(used)}% used ${humanDuration(ageS)} ago — older than the runner trusts, so it is holding`,
98
+ };
99
+ }
100
+ const ahead = resets !== null && Number.isFinite(nowEpochS) ? resets - nowEpochS : null;
101
+
102
+ // A RESET TIME IN THE PAST MEANS THE READING IS STALE, not that the window is
103
+ // about to turn over. The percentage was true of a window that has since
104
+ // rolled, so printing it as a live figure is worse than printing nothing —
105
+ // "41% used, resets in 0s" invites a decision about headroom that no longer
106
+ // exists. Say the reading is old and let the reader go look.
107
+ if (ahead !== null && ahead <= 0) {
108
+ return {
109
+ key, label, known: false, stale: true, usedPct: Math.round(used), resetsAt: resets, resetsInS: 0,
110
+ detail: `last read ${Math.round(used)}% used, but that window has since reset — the gauge is stale`,
111
+ };
112
+ }
113
+ return {
114
+ key, label, known: true, usedPct: Math.round(used),
115
+ resetsAt: resets, resetsInS: ahead,
116
+ detail: `${Math.round(used)}% used${ahead !== null ? `, resets in ${humanDuration(ahead)}` : ''}`,
117
+ };
118
+ };
119
+ return [one('five_hour', 'Five-hour window'), one('seven_day', 'Seven-day window')];
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // The digest
124
+ // ---------------------------------------------------------------------------
125
+
126
+ // buildDigest — every question answered from the rows, or explicitly unanswered.
127
+ //
128
+ // `rows` is the run log for the period, oldest first. `gauge` is the raw gauge
129
+ // file (the same shape modules/autonomy/gauge.js reads). Unrecognised rows are
130
+ // COUNTED rather than dropped: a digest that silently ignores what it does not
131
+ // understand is how a new failure mode stays invisible for a week.
132
+ function buildDigest(rows = [], { gauge = null, nowEpochS = null, sinceMs = null, untilMs = null, staleAfterS = null } = {}) {
133
+ const all = (Array.isArray(rows) ? rows : []).filter(isRow);
134
+ const worked = all.filter((r) => r.event === 'worked');
135
+
136
+ // 1. WHAT SHIPPED — the ledger's answer.
137
+ const shipped = worked.filter((r) => r.verified === true).map((r) => ({
138
+ taskId: String(r.task_id ?? ''), title: r.title || null, goalId: r.goal_id ?? null,
139
+ durationS: num(r.duration_s), costUsd: num(r.cost_usd),
140
+ }));
141
+
142
+ // 2. WHAT IS ON HOLD, AND WHY. Four different shapes of "did not ship", kept
143
+ // apart because the owner's next action differs for each.
144
+ const ranButDidNotShip = worked.filter((r) => r.verified !== true).map((r) => ({
145
+ taskId: String(r.task_id ?? ''), title: r.title || null,
146
+ shape: r.ledger_shape || 'unknown',
147
+ // THE LEDGER'S REASON FIRST, and the worker's only as a fallback. `row.reason`
148
+ // is verdict.reason — the WORKER's account of itself — and quoting that in the
149
+ // "did not ship" section is the precise inversion this whole goal exists to
150
+ // undo: a worker that stopped dead needing a decision reports success. The
151
+ // ledger's explanation ("the grade passed and the land never finished") is the
152
+ // one a person can act on. Older rows predate ledger_reason, so the fallback
153
+ // stays rather than leaving them blank.
154
+ reason: r.ledger_reason || r.reason || null,
155
+ remedy: r.ledger_remedy || null,
156
+ workerSaid: r.reason || null,
157
+ blockerId: r.blocker_id ?? null,
158
+ parked: !!r.task_parked,
159
+ claimReleased: !!r.claim_released,
160
+ }));
161
+
162
+ const fenced = all.filter((r) => r.event === 'fenced').map((r) => ({
163
+ code: r.code || 'unknown', reason: r.reason || null, taskId: r.task_id ? String(r.task_id) : null,
164
+ }));
165
+
166
+ // An UNWORKABLE QUEUE is not breakage, and the digest must not present it as
167
+ // such: the machine is healthy and there is nothing it may work on.
168
+ const unworkable = [];
169
+ for (const r of all) {
170
+ if (r.event !== 'nothing_claimable') continue;
171
+ for (const sk of (Array.isArray(r.skipped) ? r.skipped : [])) {
172
+ unworkable.push({ taskId: String(sk.id ?? ''), goalId: sk.goal_id ?? null, reasons: sk.reasons || [] });
173
+ }
174
+ }
175
+
176
+ const held = all.filter((r) => r.event === 'hold').map((r) => ({ reason: r.reason || null, sleepUntil: r.sleep_until ?? null }));
177
+
178
+ // 3. WHAT EACH WORKER DECIDED that its spec did not determine — grouped by task,
179
+ // because the SHAPE of this list is the signal. One task with six decisions is a
180
+ // thin spec; six tasks with one each is a normal night.
181
+ const decisions = [];
182
+ for (const r of worked) {
183
+ const list = Array.isArray(r.undetermined_decisions) ? r.undetermined_decisions : [];
184
+ if (!list.length) continue;
185
+ decisions.push({
186
+ taskId: String(r.task_id ?? ''), title: r.title || null, goalId: r.goal_id ?? null,
187
+ shipped: r.verified === true,
188
+ decisions: list.map((d) => String(d)),
189
+ });
190
+ }
191
+ const decisionCount = decisions.reduce((n, d) => n + d.decisions.length, 0);
192
+ // The thinnest spec of the night, named. A list is browsable; a name is
193
+ // actionable, and this one is the sentence the description should have carried.
194
+ const thinnestSpec = decisions.slice().sort((a, b) => b.decisions.length - a.decisions.length)[0] || null;
195
+
196
+ // 4. WHAT IT COST. Nulls are EXCLUDED and counted, never coerced to zero — a
197
+ // worker whose cost was not reported must not make the night look cheaper.
198
+ const costs = worked.map((r) => num(r.cost_usd));
199
+ const knownCosts = costs.filter((c) => c !== null);
200
+ const cost = {
201
+ totalUsd: knownCosts.reduce((a, b) => a + b, 0),
202
+ reported: knownCosts.length,
203
+ unreported: costs.length - knownCosts.length,
204
+ workerRuns: worked.length,
205
+ totalDurationS: worked.reduce((a, r) => a + (num(r.duration_s) || 0), 0),
206
+ };
207
+
208
+ // 5. THE TWO LIMIT WINDOWS.
209
+ const windows = windowsFromGauge(gauge, nowEpochS, { staleAfterS });
210
+
211
+ // How the runner spent the night, so "nothing shipped" can be told apart from
212
+ // "nothing ran".
213
+ const KNOWN = new Set(['worked', 'fenced', 'hold', 'nothing_claimable', 'dry_run',
214
+ 'pick_failed', 'worktree_failed', 'claim_failed', 'verify_halt', 'cadence', 'boot_reconcile']);
215
+ const byEvent = {};
216
+ let unrecognised = 0;
217
+ for (const r of all) {
218
+ byEvent[r.event] = (byEvent[r.event] || 0) + 1;
219
+ if (!KNOWN.has(r.event)) unrecognised += 1;
220
+ }
221
+
222
+ const cadenceRows = all.filter((r) => r.event === 'cadence' && r.mode);
223
+ const endedProbing = cadenceRows.length ? cadenceRows[cadenceRows.length - 1].mode === 'probing' : false;
224
+
225
+ // reduce, not Math.min(...times): `--all` on a long-lived log can hold more
226
+ // timestamps than the engine will accept as call arguments, and a reporting CLI
227
+ // that throws on a big log fails exactly when the log is most worth reading.
228
+ const times = all.map(parseAt).filter((t) => t !== null);
229
+ const minAt = times.length ? times.reduce((a, b) => (b < a ? b : a)) : null;
230
+ const maxAt = times.length ? times.reduce((a, b) => (b > a ? b : a)) : null;
231
+ return {
232
+ period: {
233
+ fromMs: sinceMs ?? minAt,
234
+ toMs: untilMs ?? maxAt,
235
+ rows: all.length,
236
+ },
237
+ shipped,
238
+ onHold: { ranButDidNotShip, fenced, unworkable, held },
239
+ decisions, decisionCount, thinnestSpec,
240
+ cost,
241
+ windows,
242
+ runner: { byEvent, unrecognised, endedProbing, bootReconciles: byEvent.boot_reconcile || 0 },
243
+ };
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Rendering
248
+ // ---------------------------------------------------------------------------
249
+
250
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
251
+
252
+ // renderDigest — the five questions, in the order they are asked, as plain text a
253
+ // person reads over coffee. Deliberately not a table: the widths are unknowable
254
+ // and a wrapped table is worse than prose.
255
+ //
256
+ // EVERY SECTION PRINTS, even when empty. A digest whose quiet sections vanish
257
+ // teaches its reader that absence means "fine", and then the one morning a
258
+ // section is missing because the DATA is missing reads exactly the same.
259
+ function renderDigest(d, { title = 'Autobongos — overnight' } = {}) {
260
+ const L = [];
261
+ const when = d.period.fromMs && d.period.toMs
262
+ ? `${new Date(d.period.fromMs).toISOString().slice(0, 16).replace('T', ' ')} → ${new Date(d.period.toMs).toISOString().slice(11, 16)} UTC`
263
+ : 'no runs recorded';
264
+ L.push(title, '='.repeat(title.length), when, '');
265
+
266
+ // 1
267
+ L.push(`SHIPPED — ${plural(d.shipped.length, 'task', 'tasks')}`);
268
+ if (!d.shipped.length) L.push(' nothing shipped.');
269
+ for (const s of d.shipped) {
270
+ L.push(` ${s.taskId} ${s.title || '(untitled)'}`);
271
+ const bits = [humanDuration(s.durationS), usd(s.costUsd)].filter(Boolean);
272
+ if (bits.length) L.push(` ${bits.join(' · ')}`);
273
+ }
274
+ L.push('');
275
+
276
+ // 2
277
+ const holdTotal = d.onHold.ranButDidNotShip.length + d.onHold.fenced.length + d.onHold.unworkable.length + d.onHold.held.length;
278
+ L.push(`ON HOLD — ${plural(holdTotal, 'thing', 'things')}`);
279
+ if (!holdTotal) L.push(' nothing is waiting on you.');
280
+ for (const r of d.onHold.ranButDidNotShip) {
281
+ L.push(` ${r.taskId} ran but did not ship — ${r.shape}`);
282
+ if (r.reason) L.push(` ${String(r.reason).slice(0, 160)}`);
283
+ if (r.remedy) L.push(` next: ${String(r.remedy).slice(0, 160)}`);
284
+ // Printed only when it ADDS something. A worker that agreed with the ledger
285
+ // tells the reader nothing twice; one that claimed success against a ledger
286
+ // that says otherwise is the single most useful line on the page.
287
+ if (r.workerSaid && r.workerSaid !== r.reason) L.push(` the worker said: ${String(r.workerSaid).slice(0, 160)}`);
288
+ if (r.blockerId) L.push(` blocker ${r.blockerId}${r.parked ? ' (task parked, out of the queue)' : ''}`);
289
+ }
290
+ // Fenced refusals are collapsed by code: twenty identical lines say nothing the
291
+ // first one did not.
292
+ const byCode = {};
293
+ for (const f of d.onHold.fenced) (byCode[f.code] = byCode[f.code] || []).push(f);
294
+ for (const [code, list] of Object.entries(byCode)) {
295
+ L.push(` fence: ${code} ×${list.length}`);
296
+ if (list[0].reason) L.push(` ${String(list[0].reason).slice(0, 160)}`);
297
+ }
298
+ if (d.onHold.unworkable.length) {
299
+ L.push(` queue: ${plural(d.onHold.unworkable.length, 'task', 'tasks')} in the allowlist, every one halted`);
300
+ L.push(' (this is not a runner failure — it has nothing it may work on)');
301
+ for (const u of d.onHold.unworkable.slice(0, 5)) L.push(` ${u.taskId}: ${(u.reasons || []).join(', ') || 'halted'}`);
302
+ }
303
+ if (d.onHold.held.length) L.push(` capacity: held ${plural(d.onHold.held.length, 'time', 'times')} — ${d.onHold.held[0].reason || 'no headroom'}`);
304
+ L.push('');
305
+
306
+ // 3 — the one that compounds.
307
+ L.push(`DECIDED FOR YOU — ${plural(d.decisionCount, 'decision', 'decisions')} across ${plural(d.decisions.length, 'task', 'tasks')}`);
308
+ if (!d.decisionCount) {
309
+ L.push(' no worker had to fill a gap in its spec.');
310
+ } else {
311
+ L.push(' Each line is a sentence the task description should have contained.');
312
+ for (const t of d.decisions) {
313
+ L.push(` ${t.taskId} ${t.title || '(untitled)'}${t.shipped ? '' : ' [did not ship]'}`);
314
+ for (const one of t.decisions) L.push(` · ${one}`);
315
+ }
316
+ if (d.thinnestSpec && d.thinnestSpec.decisions.length > 1) {
317
+ L.push('');
318
+ L.push(` Thinnest spec: ${d.thinnestSpec.taskId} (${d.thinnestSpec.decisions.length} decisions).`);
319
+ L.push(' Tightening that description is what lowers tomorrow’s halts.');
320
+ }
321
+ }
322
+ L.push('');
323
+
324
+ // 4
325
+ L.push('COST');
326
+ L.push(` ${usd(d.cost.totalUsd)} across ${plural(d.cost.workerRuns, 'worker run', 'worker runs')}` +
327
+ (d.cost.unreported ? ` (${d.cost.unreported} did not report a cost)` : ''));
328
+ if (d.cost.totalDurationS) L.push(` ${humanDuration(d.cost.totalDurationS)} of worker time`);
329
+ L.push('');
330
+
331
+ // 5
332
+ L.push('LIMITS');
333
+ for (const w of d.windows) L.push(` ${w.label.padEnd(18)} ${w.detail}`);
334
+ L.push('');
335
+
336
+ // The footer answers "did it run at all", which none of the five questions do.
337
+ const events = Object.entries(d.runner.byEvent).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(' · ');
338
+ L.push(`RUNNER ${events || 'no rows'}`);
339
+ if (d.runner.endedProbing) L.push(' Ended the night PROBING — it hit repeated failures and slowed itself down.');
340
+ if (d.runner.unrecognised) L.push(` ${plural(d.runner.unrecognised, 'row', 'rows')} of a kind this digest does not know about.`);
341
+ return L.join('\n');
342
+ }
343
+
344
+ module.exports = { buildDigest, renderDigest, windowsFromGauge, humanDuration };
@@ -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-fence"></script>
84
+ <script src="/builders/gate.js?v=2026-09-19-runner"></script>
82
85
  </body>
83
86
  </html>