@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,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
+ };
@@ -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,