@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,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-
|
|
84
|
+
<script src="/builders/gate.js?v=2026-09-19-runner"></script>
|
|
82
85
|
</body>
|
|
83
86
|
</html>
|