@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,602 @@
1
+ // tests/autobongos_cadence.mjs — the runner has no terminal stop (task 1003904).
2
+ //
3
+ // The done-when is three physical faults: pulling the network, expiring the
4
+ // token, and cutting power mid-task must each leave NO LOCKED CLAIM and must each
5
+ // resume by themselves once the cause clears. None of those can be staged in a
6
+ // unit test, so each is reduced to the shape it presents to this code — a failing
7
+ // read, a 401 in a reason string, a claim held by a session that no longer exists
8
+ // — and the three are driven end to end through the real loop below.
9
+ //
10
+ // The property underneath all of them: NOTHING returns. A machine nobody is
11
+ // watching must never be able to look idle while it is dead.
12
+
13
+ import assert from 'node:assert/strict';
14
+ import { test } from 'node:test';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ // POINT THE HEARTBEAT FILE AT A THROWAWAY PATH before requiring the runner.
20
+ // anotherRunnerIsAlive() reads a file on disk, so without this the suite's verdict
21
+ // depends on what happens to be in the real ~/.config path — a heartbeat left by a
22
+ // previous run, or by the developer's own runner, makes reconciliation tests pass
23
+ // or fail by luck. Set here rather than per-test because the failure mode is a
24
+ // test that reads it BEFORE anyone remembered to.
25
+ process.env.AUTOBONGOS_HEARTBEAT_FILE = require('node:path').join(
26
+ require('node:fs').mkdtempSync(require('node:path').join(require('node:os').tmpdir(), 'ab-cadence-')),
27
+ 'beat.json',
28
+ );
29
+
30
+ const cadence = require('../modules/autonomy/cadence.js');
31
+ const runner = require('../scripts/gds/autobongos-run.js');
32
+
33
+ const { classifyEvent, nextCadence, START, DEFAULTS, signalFingerprint, shouldJumpWait } = cadence;
34
+
35
+ // Drive a sequence of classes through the machine and return every state.
36
+ const walk = (classes, cfg) => {
37
+ let s = { ...START };
38
+ return classes.map((c) => { s = nextCadence(s, typeof c === 'string' ? { class: c } : c, cfg); return s; });
39
+ };
40
+
41
+ // ── classification ──────────────────────────────────────────────────────────────
42
+
43
+ test('a worked task counts as progress only when the LEDGER agreed', () => {
44
+ assert.equal(classifyEvent({ event: 'worked', verified: true }).class, 'progress');
45
+ // A worker's claim the ledger never saw is not progress; counting it would hold
46
+ // a broken runner at full speed indefinitely.
47
+ assert.equal(classifyEvent({ event: 'worked', verified: false, ledger_shape: 'still_held' }).class, 'quiet');
48
+ assert.equal(classifyEvent({ event: 'worked', verified: false, ledger_shape: 'undetermined' }).class, 'transient');
49
+ });
50
+
51
+ test('an empty queue and a queue where everything halts are different facts', () => {
52
+ assert.equal(classifyEvent({ event: 'nothing_claimable', skipped: [] }).class, 'quiet');
53
+ const blocked = classifyEvent({ event: 'nothing_claimable', skipped: [{ id: 1, reasons: ['needs_migration'] }, { id: 2, reasons: ['deps_unmet'] }] });
54
+ assert.equal(blocked.class, 'unworkable_queue');
55
+ assert.match(blocked.detail, /every one of them halted/);
56
+ });
57
+
58
+ test('an unworkable queue is never reported as breakage', () => {
59
+ // The machine is healthy and there is nothing for it to do. Escalating this
60
+ // through the failure ladder is how a working runner gets "fixed" into a broken one.
61
+ const states = walk(Array(5).fill({ class: 'unworkable_queue' }));
62
+ for (const s of states) {
63
+ assert.equal(s.mode, 'full');
64
+ assert.equal(s.consecutiveFailures, 0);
65
+ assert.equal(s.waitS, DEFAULTS.unworkableS);
66
+ }
67
+ });
68
+
69
+ test('the kill switch is a speed, not an exit', () => {
70
+ const c = classifyEvent({ event: 'fenced', code: 'kill_switch', reason: 'owner stopped it' });
71
+ assert.equal(c.class, 'stopped');
72
+ const s = nextCadence(START, c);
73
+ assert.equal(s.takeWork, false, 'a stopped runner claims nothing');
74
+ assert.ok(s.waitS > 0, 'but it keeps asking, so flipping the switch back on is enough');
75
+ assert.equal(s.consecutiveFailures, 0, 'being told to stop is not a failure');
76
+ });
77
+
78
+ test('a grader outage and an unreadable fence are transient; a closed allowlist is not', () => {
79
+ assert.equal(classifyEvent({ event: 'fenced', code: 'grader_unavailable' }).class, 'transient');
80
+ assert.equal(classifyEvent({ event: 'fenced', code: 'fence_unreadable' }).class, 'transient');
81
+ assert.equal(classifyEvent({ event: 'fenced', code: 'no_allowlist' }).class, 'quiet');
82
+ assert.equal(classifyEvent({ event: 'fenced', code: 'protected_path' }).class, 'quiet');
83
+ });
84
+
85
+ test('an expired token reads as systemic; an unreachable instance as transient', () => {
86
+ const token = classifyEvent({ event: 'claim_failed', reason: 'claim failed (401): unauthorized' });
87
+ assert.equal(token.class, 'systemic', 'waiting will not renew a token');
88
+ const net = classifyEvent({ event: 'pick_failed', reason: 'goal 1000119: fetch failed ECONNREFUSED' });
89
+ assert.equal(net.class, 'transient');
90
+ assert.equal(classifyEvent({ event: 'worktree_failed', reason: 'ENOSPC: no space left on device' }).class, 'systemic');
91
+ });
92
+
93
+ test('an unrecognised event is a failure, never silence', () => {
94
+ // An unknown failure that read as "nothing to do" would let a broken runner
95
+ // look idle forever — the exact thing this file exists against.
96
+ for (const event of ['', 'something_new', undefined]) {
97
+ assert.equal(classifyEvent({ event }).class, 'transient', String(event));
98
+ }
99
+ assert.equal(classifyEvent({}).class, 'transient');
100
+ });
101
+
102
+ // ── the state machine ───────────────────────────────────────────────────────────
103
+
104
+ test('three straight failures drop to probing, and not two', () => {
105
+ const s = walk(['transient', 'transient', 'transient']);
106
+ assert.equal(s[0].mode, 'full');
107
+ assert.equal(s[1].mode, 'full');
108
+ assert.equal(s[2].mode, 'probing');
109
+ assert.equal(s[2].takeWork, false, 'probing takes no new work');
110
+ assert.equal(s[2].probeOnly, true);
111
+ });
112
+
113
+ test('the wait grows while probing, and stops growing at the cap', () => {
114
+ const s = walk(Array(12).fill('transient'));
115
+ const waits = s.slice(2).map((x) => x.waitS);
116
+ assert.deepEqual(waits.slice(0, DEFAULTS.backoffS.length), DEFAULTS.backoffS);
117
+ for (const w of waits) assert.ok(w <= DEFAULTS.backoffS[DEFAULTS.backoffS.length - 1]);
118
+ assert.equal(waits[waits.length - 1], DEFAULTS.backoffS[DEFAULTS.backoffS.length - 1], 'a broken runner still asks twice an hour');
119
+ });
120
+
121
+ test('one success returns it to full speed from anywhere', () => {
122
+ let s = { ...START };
123
+ for (const c of Array(9).fill('transient')) s = nextCadence(s, { class: c });
124
+ assert.equal(s.mode, 'probing');
125
+ s = nextCadence(s, { class: 'progress' });
126
+ assert.deepEqual(
127
+ { mode: s.mode, consecutiveFailures: s.consecutiveFailures, backoffStep: s.backoffStep, waitS: s.waitS, takeWork: s.takeWork },
128
+ { mode: 'full', consecutiveFailures: 0, backoffStep: 0, waitS: 0, takeWork: true },
129
+ );
130
+ });
131
+
132
+ test('quiet neither trips nor forgives the failure count', () => {
133
+ // Three idle nights must not leave it probing on the morning work appears...
134
+ const idle = walk(Array(6).fill('quiet'));
135
+ for (const s of idle) assert.equal(s.mode, 'full');
136
+ // ...and an idle window between failures must not reset the count either, or a
137
+ // runner that fails every other iteration would never back off at all.
138
+ const mixed = walk(['transient', 'quiet', 'transient', 'quiet', 'transient']);
139
+ assert.equal(mixed[mixed.length - 1].mode, 'probing');
140
+ });
141
+
142
+ test('every class produces a decision — no input leaves the runner without one', () => {
143
+ for (const cls of [...cadence.CLASSES, 'nonsense', undefined]) {
144
+ const s = nextCadence(START, { class: cls });
145
+ assert.ok(['full', 'probing'].includes(s.mode), String(cls));
146
+ assert.ok(Number.isFinite(s.waitS) && s.waitS >= 0, `${cls} must name a wait`);
147
+ assert.ok(typeof s.why === 'string' && s.why.length, `${cls} must say why`);
148
+ }
149
+ });
150
+
151
+ // ── jumping the wait ────────────────────────────────────────────────────────────
152
+
153
+ test('a new signal jumps the wait; an unchanged world does not', () => {
154
+ const a = signalFingerprint({ head: 'abc123def456', fenceUpdatedAt: 't1', claimableCount: 3 });
155
+ assert.equal(shouldJumpWait(a, a), false);
156
+ for (const moved of [
157
+ { head: 'fff000111222', fenceUpdatedAt: 't1', claimableCount: 3 }, // main moved
158
+ { head: 'abc123def456', fenceUpdatedAt: 't2', claimableCount: 3 }, // the allowlist changed
159
+ { head: 'abc123def456', fenceUpdatedAt: 't1', claimableCount: 4 }, // a blocker resolved
160
+ ]) {
161
+ assert.equal(shouldJumpWait(a, signalFingerprint(moved)), true, JSON.stringify(moved));
162
+ }
163
+ });
164
+
165
+ test('a FAILED probe is not a signal', () => {
166
+ // Otherwise an unreachable instance turns the backoff into a busy loop
167
+ // hammering it — the exact opposite of backing off.
168
+ const known = signalFingerprint({ head: 'abc123def456', fenceUpdatedAt: 't1', claimableCount: 3 });
169
+ const blind = signalFingerprint({});
170
+ assert.equal(shouldJumpWait(known, blind), false);
171
+ assert.equal(shouldJumpWait(blind, known), false);
172
+ assert.equal(shouldJumpWait(blind, blind), false);
173
+ });
174
+
175
+ // ── boot recovery: no locked claim survives a run ───────────────────────────────
176
+
177
+ const meWith = (claims) => ({ me: { getMe: async () => ({ ok: true, data: { active_claims: claims } }) } });
178
+
179
+ test('a claim left by a previous run is reconciled before any new work', async () => {
180
+ const verified = [];
181
+ const row = await runner.reconcileLeftoverClaims({
182
+ anotherRunnerIsAlive: () => false, // this test is about reconciling, not about the guard
183
+ api: meWith([{ task_id: '4242', worktree_name: 'autobongos-4242-abc123', title: 'T' }]),
184
+ record: (r) => r,
185
+ run: async () => ({ ok: true, code: 0, stdout: '', stderr: '' }),
186
+ verifyShip: async (what, o) => {
187
+ verified.push(what.taskId);
188
+ assert.equal(typeof o.release, 'function', 'reconciliation must be able to release');
189
+ return { shape: 'still_held', released: true, blocker: { blockerId: 7 } };
190
+ },
191
+ });
192
+ assert.equal(row.event, 'boot_reconcile');
193
+ assert.equal(row.ok, true);
194
+ assert.equal(row.leftover, 1);
195
+ assert.deepEqual(verified, ['4242']);
196
+ assert.equal(row.resolved[0].released, true);
197
+ });
198
+
199
+ test('reconciliation touches ONLY trees the runner itself made', async () => {
200
+ // FOUND THE HARD WAY, twice. A loose `autobongos-` prefix reads as "ours" and is
201
+ // not: a person naming a worktree `autobongos-resume-1003904-d29f25` while
202
+ // building this very feature matched it, and reconciliation released their live
203
+ // claim out from under them — during this task, to this task.
204
+ const verified = [];
205
+ const row = await runner.reconcileLeftoverClaims({
206
+ anotherRunnerIsAlive: () => false,
207
+ api: meWith([
208
+ { task_id: '1', worktree_name: 'autobongos-1-aaa111' }, // the generator's shape
209
+ { task_id: '2', worktree_name: 'goal-1000086-completion-6a672f' }, // a session tree
210
+ { task_id: '3', worktree_name: 'my-hand-made-tree' }, // a person's
211
+ { task_id: '4', worktree_name: 'autobongos-resume-1003904-d29f25' }, // the one that bit us
212
+ { task_id: '5', worktree_name: 'autobongos-fence-1003908-12cef3' }, // and its sibling
213
+ ]),
214
+ record: (r) => r,
215
+ run: async () => ({ ok: true }),
216
+ verifyShip: async (w) => { verified.push(w.taskId); return { shape: 'shipped' }; },
217
+ });
218
+ assert.deepEqual(verified, ['1']);
219
+ assert.equal(row.leftover, 1);
220
+ });
221
+
222
+ test('the tree matcher tracks the generator, so the two cannot drift apart', () => {
223
+ // If worktreeName's format changes and this regex does not, reconciliation
224
+ // silently matches NOTHING and every crashed claim stays locked forever — a
225
+ // failure with no symptom, which is the whole class this task exists against.
226
+ for (let i = 0; i < 20; i++) {
227
+ const name = runner.worktreeName(1000000 + i);
228
+ assert.equal(runner.isRunnerTree(name), true, `worktreeName produced ${name}, which the matcher rejects`);
229
+ }
230
+ for (const nope of ['autobongos-', 'autobongos-42', 'autobongos-42-ABC123', 'autobongos-x-abc123', 'xautobongos-42-abc123', '']) {
231
+ assert.equal(runner.isRunnerTree(nope), false, nope);
232
+ }
233
+ });
234
+
235
+ test('an unreadable /me is NOT "nothing to reconcile"', async () => {
236
+ // Starting work on top of an unknown lock is how a claim gets held forever.
237
+ for (const api of [
238
+ { me: { getMe: async () => ({ ok: false, status: 401 }) } },
239
+ { me: { getMe: async () => { throw new Error('ECONNREFUSED'); } } },
240
+ ]) {
241
+ const row = await runner.reconcileLeftoverClaims({ anotherRunnerIsAlive: () => false, api, record: (r) => r, run: async () => ({ ok: true }) });
242
+ assert.equal(row.event, 'boot_reconcile');
243
+ assert.equal(row.ok, false, 'an unreadable ledger must not read as a clean boot');
244
+ assert.ok(row.reason);
245
+ }
246
+ });
247
+
248
+ // ── the three physical faults, driven through the real loop ─────────────────────
249
+
250
+ // A forever-loop harness: no clock, no network, no worker.
251
+ function loopHarness({ rows, maxLoops = 6 }) {
252
+ const emitted = [];
253
+ const recorded = [];
254
+ let i = 0;
255
+ const deps = {
256
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true, leftover: 0 }),
257
+ iteration: async () => rows[Math.min(i++, rows.length - 1)],
258
+ waitOrJump: async (waitS) => { emitted.push({ waitS }); return { waited: waitS, jumped: false }; },
259
+ record: (r) => { recorded.push(r); return r; },
260
+ emit: () => {},
261
+ // BOTH HEARTBEAT HALVES, or these tests hit the NETWORK. forever() beats at
262
+ // boot, per loop and around every worker; an uninjected postHeartbeat falls
263
+ // through to the real one, which builds a CLI client and makes an HTTP request
264
+ // per beat. That was fast enough on a laptop with a session to notice, and in
265
+ // CI — no session, no route — the suite ran for minutes and was killed, taking
266
+ // an unrelated pull request's `unit` check down with it (2026-09-19).
267
+ //
268
+ // The rule this encodes: when forever() gains a new outward call, the harness
269
+ // gains its stub in the SAME change. A DB-free unit suite that quietly makes a
270
+ // network call is not a unit suite, it is a flake waiting for a slow day.
271
+ writeHeartbeat: () => true,
272
+ postHeartbeat: async () => true,
273
+ };
274
+ return { deps, waits: emitted, recorded, opts: { goals: [], maxTasks: 3, maxLoops } };
275
+ }
276
+
277
+ test('PULLING THE NETWORK: the loop backs off and never returns', async () => {
278
+ const h = loopHarness({ rows: [{ event: 'pick_failed', reason: 'fetch failed ECONNREFUSED' }] });
279
+ await runner.forever(h.opts, h.deps);
280
+ const modes = h.recorded.filter((r) => r.event === 'cadence').map((r) => r.mode);
281
+ assert.ok(modes.includes('probing'), 'it must reach probing');
282
+ assert.ok(h.waits.some((w) => w.waitS > 0), 'and actually wait');
283
+ assert.ok(h.waits.length >= 5, 'and keep going — the loop does not exit on failure');
284
+ });
285
+
286
+ test('EXPIRING THE TOKEN: reported as systemic, and still probing rather than dead', async () => {
287
+ const h = loopHarness({ rows: [{ event: 'claim_failed', reason: 'claim failed (401): unauthorized' }] });
288
+ await runner.forever(h.opts, h.deps);
289
+ const cad = h.recorded.filter((r) => r.event === 'cadence');
290
+ assert.ok(cad.some((r) => r.class === 'systemic'), 'a person needs to know waiting will not fix this');
291
+ assert.equal(cad[cad.length - 1].mode, 'probing');
292
+ // Cheap probing means it resumes the moment someone re-authenticates.
293
+ assert.ok(cad[cad.length - 1].wait_s >= DEFAULTS.backoffS[0]);
294
+ });
295
+
296
+ test('CUTTING POWER MID-TASK: the next boot reconciles the claim before doing anything', async () => {
297
+ const order = [];
298
+ const h = loopHarness({ rows: [{ event: 'nothing_claimable', skipped: [] }], maxLoops: 1 });
299
+ h.deps.reconcileLeftoverClaims = async () => { order.push('reconcile'); return { event: 'boot_reconcile', ok: true, leftover: 1 }; };
300
+ const iterate = h.deps.iteration;
301
+ h.deps.iteration = async (...a) => { order.push('iterate'); return iterate(...a); };
302
+ await runner.forever(h.opts, h.deps);
303
+ assert.deepEqual(order, ['reconcile', 'iterate'], 'a leftover claim is resolved BEFORE new work is taken');
304
+ });
305
+
306
+ test('recovery: once the cause clears, it returns to full speed by itself', async () => {
307
+ // Three failures, then the world heals. Nobody restarts anything.
308
+ const h = loopHarness({
309
+ rows: [
310
+ { event: 'pick_failed', reason: 'ECONNREFUSED' },
311
+ { event: 'pick_failed', reason: 'ECONNREFUSED' },
312
+ { event: 'pick_failed', reason: 'ECONNREFUSED' },
313
+ { event: 'worked', verified: true, task_id: 1 },
314
+ { event: 'worked', verified: true, task_id: 2 },
315
+ ],
316
+ maxLoops: 5,
317
+ });
318
+ await runner.forever(h.opts, h.deps);
319
+ const cad = h.recorded.filter((r) => r.event === 'cadence');
320
+ assert.equal(cad[2].mode, 'probing');
321
+ assert.equal(cad[cad.length - 1].mode, 'full', 'a success returns it to full speed with no intervention');
322
+ assert.equal(cad[cad.length - 1].wait_s, 0);
323
+ });
324
+
325
+ test('the loop never returns early, whatever it is fed', async () => {
326
+ // The single property the owner asked for: "there is no loop to reboot once we
327
+ // stop and hit the failures."
328
+ for (const row of [
329
+ { event: 'fenced', code: 'kill_switch', reason: 'stopped' },
330
+ { event: 'claim_failed', reason: '401 unauthorized' },
331
+ { event: 'verify_halt' },
332
+ { event: 'nothing_claimable', skipped: [{ id: 1, reasons: ['needs_migration'] }] },
333
+ { event: 'wat' },
334
+ ]) {
335
+ const h = loopHarness({ rows: [row], maxLoops: 4 });
336
+ await runner.forever(h.opts, h.deps);
337
+ assert.equal(h.waits.length, 4, `${row.event}/${row.code || ''} must run every loop, not exit`);
338
+ }
339
+ });
340
+
341
+ test('a probing run takes exactly one task per wake, not a burst', async () => {
342
+ const h = loopHarness({ rows: [{ event: 'pick_failed', reason: 'boom' }], maxLoops: 6 });
343
+ let iterations = 0;
344
+ const iterate = h.deps.iteration;
345
+ h.deps.iteration = async (...a) => { iterations += 1; return iterate(...a); };
346
+ await runner.forever(h.opts, h.deps);
347
+ // maxTasks is 3, but a failure ends the burst immediately and probing caps the
348
+ // budget at 1 — so six loops is six iterations, never eighteen.
349
+ assert.equal(iterations, 6);
350
+ });
351
+
352
+ test('a full-speed burst continues only while work is landing', async () => {
353
+ const h = loopHarness({ rows: [{ event: 'worked', verified: true }], maxLoops: 1 });
354
+ let iterations = 0;
355
+ const iterate = h.deps.iteration;
356
+ h.deps.iteration = async (...a) => { iterations += 1; return iterate(...a); };
357
+ await runner.forever(h.opts, h.deps);
358
+ assert.equal(iterations, 3, 'maxTasks bounds the burst');
359
+ });
360
+
361
+ // ── waitOrJump ──────────────────────────────────────────────────────────────────
362
+
363
+ test('waitOrJump wakes early when the world moved, and sleeps through when it did not', async () => {
364
+ const slept = [];
365
+ const sleep = async (ms) => { slept.push(ms); };
366
+ let n = 0;
367
+
368
+ const still = await runner.waitOrJump(300, { sleep, pollSignals: async () => 'same|t1|3' }, { pollEveryS: 60 });
369
+ assert.equal(still.jumped, false);
370
+ assert.equal(still.waited, 300);
371
+
372
+ slept.length = 0;
373
+ const moved = await runner.waitOrJump(1800, {
374
+ sleep,
375
+ pollSignals: async () => (++n > 2 ? 'NEW|t1|3' : 'same|t1|3'),
376
+ }, { pollEveryS: 60 });
377
+ assert.equal(moved.jumped, true);
378
+ assert.ok(moved.waited < 1800, 'it must not sit out the rest of a half-hour backoff');
379
+ });
380
+
381
+ test('a zero wait returns immediately without polling', async () => {
382
+ let polled = 0;
383
+ const r = await runner.waitOrJump(0, { sleep: async () => {}, pollSignals: async () => { polled += 1; return 'x'; } });
384
+ assert.deepEqual(r, { waited: 0, jumped: false });
385
+ assert.equal(polled, 0);
386
+ });
387
+
388
+ test('a PARTIALLY failed probe does not jump either — the flaky-field trap', () => {
389
+ // The bug a whole-string comparison has and this does not: the three probes fail
390
+ // independently, so a flaky `git ls-remote` flips the fingerprint between
391
+ // `abc|t1|3` and `-|t1|3` on alternate polls. Every one of those reads as a
392
+ // change, and a half-hour backoff becomes a busy loop hammering the instance it
393
+ // is backing off from — worst exactly when things are already broken.
394
+ const full = signalFingerprint({ head: 'abc123def456', fenceUpdatedAt: 't1', claimableCount: 3 });
395
+ const noHead = signalFingerprint({ fenceUpdatedAt: 't1', claimableCount: 3 });
396
+ assert.equal(shouldJumpWait(full, noHead), false);
397
+ assert.equal(shouldJumpWait(noHead, full), false);
398
+
399
+ // But a field that BOTH sides can see, and that moved, still jumps even when
400
+ // another field went blind.
401
+ const noHeadMoved = signalFingerprint({ fenceUpdatedAt: 't2', claimableCount: 3 });
402
+ assert.equal(shouldJumpWait(full, noHeadMoved), true);
403
+ });
404
+
405
+ // ── the heartbeat: a reconciler must not release a LIVE claim ───────────────────
406
+ //
407
+ // FOUND BY RUNNING IT, not by reading it. The first live run of
408
+ // reconcileLeftoverClaims released the claim held by the very session writing the
409
+ // code — right by its own rules (an `autobongos-` tree, a task at `active`), and
410
+ // that is the point: the LEDGER CANNOT TELL A LIVE RUN FROM A DEAD ONE. Guessing
411
+ // wrong one way leaves a task locked forever; the other way yanks a claim out
412
+ // from under a worker mid-build and strands what it committed.
413
+
414
+ test('reconciliation refuses while another runner is beating', async () => {
415
+ let looked = false;
416
+ const row = await runner.reconcileLeftoverClaims({
417
+ anotherRunnerIsAlive: () => true,
418
+ api: { me: { getMe: async () => { looked = true; return { ok: true, data: { active_claims: [] } }; } } },
419
+ record: (r) => r,
420
+ });
421
+ assert.equal(row.skipped, 'another runner is alive');
422
+ assert.equal(looked, false, 'it must not even ask — a live claim is not a leftover');
423
+ });
424
+
425
+ test('a STALE heartbeat does not veto recovery forever', () => {
426
+ // A power cut leaves a heartbeat file behind. If that vetoed reconciliation,
427
+ // the crash this whole mechanism exists to recover from would be unrecoverable.
428
+ const now = Date.parse('2026-09-19T12:00:00Z');
429
+ const stale = { pid: process.pid + 1, at: now - runner.HEARTBEAT_STALE_MS - 1000 };
430
+ assert.equal(runner.anotherRunnerIsAlive({ now, hb: stale }), false);
431
+ });
432
+
433
+ test('a heartbeat whose process is gone is litter, not a veto', () => {
434
+ const now = Date.now();
435
+ // A pid that cannot exist: fresh timestamp, dead process.
436
+ assert.equal(runner.anotherRunnerIsAlive({ now, hb: { pid: 2147483646, at: now } }), false);
437
+ });
438
+
439
+ test('our OWN heartbeat never vetoes us', () => {
440
+ const now = Date.now();
441
+ assert.equal(runner.anotherRunnerIsAlive({ now, hb: { pid: process.pid, at: now } }), false);
442
+ });
443
+
444
+ test('a fresh heartbeat from a live OTHER process does veto', () => {
445
+ const now = Date.now();
446
+ // process.ppid is a real, live process that is not us.
447
+ assert.equal(runner.pidAlive(process.ppid), true);
448
+ assert.equal(runner.anotherRunnerIsAlive({ now, hb: { pid: process.ppid, at: now } }), true);
449
+ });
450
+
451
+ test('no heartbeat at all is the common case and never vetoes', () => {
452
+ for (const hb of [null, undefined, {}, { pid: 1 }, { at: Date.now() }, 'nonsense']) {
453
+ assert.equal(runner.anotherRunnerIsAlive({ now: Date.now(), hb }), false, JSON.stringify(hb));
454
+ }
455
+ });
456
+
457
+ test('the forever loop claims the machine BEFORE it reconciles', async () => {
458
+ const order = [];
459
+ const h = loopHarness({ rows: [{ event: 'nothing_claimable', skipped: [] }], maxLoops: 1 });
460
+ h.deps.writeHeartbeat = () => { order.push('beat'); return true; };
461
+ h.deps.reconcileLeftoverClaims = async () => { order.push('reconcile'); return { event: 'boot_reconcile', ok: true }; };
462
+ await runner.forever(h.opts, h.deps);
463
+ assert.equal(order[0], 'beat', 'a second runner starting a moment later must see this one alive');
464
+ assert.equal(order[1], 'reconcile');
465
+ });
466
+
467
+ test('the heartbeat is rewritten at least once a loop, so a crash goes stale on its own', async () => {
468
+ const loops = 4;
469
+ let beats = 0;
470
+ const h = loopHarness({ rows: [{ event: 'nothing_claimable', skipped: [] }], maxLoops: loops });
471
+ h.deps.writeHeartbeat = () => { beats += 1; return true; };
472
+ h.deps.postHeartbeat = async () => true;
473
+ await runner.forever(h.opts, h.deps);
474
+ // A count, not an exact number: the runner beats at boot, per loop and around
475
+ // each worker, and pinning the arithmetic makes the test fail every time the
476
+ // loop gains an honest beat. The property is that it never goes a loop silent.
477
+ assert.ok(beats >= loops + 1, `expected at least one beat per loop plus boot, got ${beats}`);
478
+ });
479
+
480
+ test('writeHeartbeat honours AUTOBONGOS_HEARTBEAT_FILE and never throws', async () => {
481
+ const os = require('node:os');
482
+ const fs = require('node:fs');
483
+ const path = require('node:path');
484
+ const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ab-hb-')), 'nested', 'beat.json');
485
+ const prev = process.env.AUTOBONGOS_HEARTBEAT_FILE;
486
+ process.env.AUTOBONGOS_HEARTBEAT_FILE = p;
487
+ try {
488
+ assert.equal(runner.heartbeatPath(), p, 'the env knob must actually be read');
489
+ assert.equal(runner.writeHeartbeat(), true);
490
+ const hb = runner.readHeartbeat();
491
+ assert.equal(hb.pid, process.pid);
492
+ assert.ok(Number.isFinite(hb.at));
493
+ // An unwritable path loses the guard, never the run.
494
+ //
495
+ // NOT /proc/... — that is what hung CI for every PR (task 1004052). writeHeartbeat
496
+ // does mkdirSync(dir, { recursive: true }), and on LINUX that call never returns
497
+ // under /proc: measured 10s+ with no result, where the same call throws EACCES
498
+ // instantly under /sys or /, and the non-recursive form throws ENOENT instantly
499
+ // under /proc itself. macOS has no /proc at all, so it failed fast there and the
500
+ // suite was green on every machine anyone tested it on while dying at the 90s
501
+ // per-test budget on the Ubuntu runner — with zero output, because node:test
502
+ // buffers a file's report until it finishes.
503
+ //
504
+ // A regular file as the PARENT is the portable way to be unwritable: mkdir under
505
+ // it is ENOTDIR immediately, on every platform, and it does not depend on
506
+ // permissions (so it still holds when the tests run as root, where a chmod 0o500
507
+ // directory would not).
508
+ const notADir = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ab-hb-file-')), 'iam-a-file');
509
+ fs.writeFileSync(notADir, 'not a directory');
510
+ process.env.AUTOBONGOS_HEARTBEAT_FILE = path.join(notADir, 'nested', 'beat.json');
511
+ assert.equal(runner.writeHeartbeat(), false);
512
+ assert.equal(runner.readHeartbeat(), null);
513
+ } finally {
514
+ if (prev === undefined) delete process.env.AUTOBONGOS_HEARTBEAT_FILE;
515
+ else process.env.AUTOBONGOS_HEARTBEAT_FILE = prev;
516
+ }
517
+ });
518
+
519
+ // ── the server heartbeat: a dead runner is visible, not silent (task 1003905) ────
520
+
521
+ test('the runner tells the SERVER it is alive at boot, per loop, and around work', async () => {
522
+ const posts = [];
523
+ const h = loopHarness({ rows: [{ event: 'worked', verified: true, task_id: 4242 }], maxLoops: 2 });
524
+ h.deps.writeHeartbeat = () => true;
525
+ h.deps.postHeartbeat = async (fields) => { posts.push(fields); return true; };
526
+ await runner.forever(h.opts, h.deps);
527
+
528
+ assert.equal(posts[0].last_event, 'boot', 'the first thing it does is say it is up');
529
+ assert.ok(posts.some((p) => p.last_event === 'loop'));
530
+ // The task id is what lets the hall tell "quiet because it is building" from
531
+ // "quiet because it is dead" — a worker may hold the loop for ninety minutes.
532
+ const working = posts.find((p) => p.working_task_id === 4242);
533
+ assert.ok(working, 'a beat around a worker must carry the task it is building');
534
+ });
535
+
536
+ test('a failed heartbeat never takes down the runner it is watching', async () => {
537
+ // Letting the watching mechanism break the watched thing is strictly worse than
538
+ // a gap in the log — and the gap reports itself, because silence is the signal.
539
+ const h = loopHarness({ rows: [{ event: 'nothing_claimable', skipped: [] }], maxLoops: 3 });
540
+ h.deps.writeHeartbeat = () => false;
541
+ h.deps.postHeartbeat = async () => { throw new Error('instance unreachable'); };
542
+ await assert.doesNotReject(() => runner.forever(h.opts, h.deps));
543
+ });
544
+
545
+ test('postHeartbeat swallows an unreachable instance rather than throwing', async () => {
546
+ const api = { autonomy: { postAutonomyHeartbeat: async () => { throw new Error('ECONNREFUSED'); } } };
547
+ assert.equal(await runner.postHeartbeat({ last_event: 'loop' }, { api }), false);
548
+ });
549
+
550
+ test('postHeartbeat sends the host, the pid and a stable start time', async () => {
551
+ const sent = [];
552
+ const api = { autonomy: { postAutonomyHeartbeat: async (a) => { sent.push(a.body); return { ok: true }; } } };
553
+ assert.equal(await runner.postHeartbeat({ last_event: 'loop', mode: 'full' }, { api }), true);
554
+ assert.equal(sent[0].pid, process.pid);
555
+ assert.ok(sent[0].host && typeof sent[0].host === 'string');
556
+ assert.equal(sent[0].started_at, runner.RUNNER_STARTED_AT);
557
+ assert.equal(sent[0].last_event, 'loop');
558
+ // A second beat carries the SAME start time, so a restart loop shows up in the
559
+ // hall as a start time that keeps moving rather than one that never does.
560
+ await runner.postHeartbeat({ last_event: 'loop' }, { api });
561
+ assert.equal(sent[1].started_at, sent[0].started_at);
562
+ });
563
+
564
+ test('a long wait still beats, so backing off does not look like dying', async () => {
565
+ const beats = [];
566
+ await runner.waitOrJump(300, {
567
+ sleep: async () => {},
568
+ pollSignals: async () => 'same|t1|3',
569
+ beat: async (f) => { beats.push(f); },
570
+ }, { pollEveryS: 60 });
571
+ assert.ok(beats.length >= 4, 'a thirty-minute backoff is the runner working, not a corpse');
572
+ assert.equal(beats[0].last_event, 'waiting');
573
+ });
574
+
575
+ // ── the harness must not reach the network (2026-09-19) ─────────────────────────
576
+
577
+ test('loopHarness stubs every outward call forever() makes', () => {
578
+ // THE INCIDENT. Task 1003905 gave forever() a server heartbeat — at boot, per
579
+ // loop and around every worker — and stubbed it only in the NEW tests. The older
580
+ // forever() tests fell through to the real postHeartbeat, which builds a CLI
581
+ // client and makes an HTTP request per beat. On a laptop with a session that was
582
+ // merely slow (6-7s for this file); in CI, with no session and no route, the
583
+ // suite ran until it was killed and took an unrelated pull request's `unit`
584
+ // check down with it. A DB-free unit suite that quietly makes a network call is
585
+ // not a unit suite, it is a flake waiting for a slow day.
586
+ //
587
+ // This asserts the stubs exist rather than trusting the next editor to remember.
588
+ // It cannot prove no network call happens — that would need a socket hook — but
589
+ // it does fail the moment someone deletes a default, which is how the gap opened.
590
+ const { deps } = loopHarness({ rows: [{ event: 'nothing_claimable', skipped: [] }] });
591
+ for (const outward of ['reconcileLeftoverClaims', 'iteration', 'waitOrJump', 'writeHeartbeat', 'postHeartbeat']) {
592
+ assert.equal(typeof deps[outward], 'function', `loopHarness must stub ${outward} — forever() calls it, and unstubbed it goes to the network`);
593
+ }
594
+ });
595
+
596
+ test('the suite never reads the real heartbeat file', () => {
597
+ // anotherRunnerIsAlive() reads a path on disk, so without the override at the top
598
+ // of this file the reconciliation tests pass or fail by whatever happens to be in
599
+ // the developer's own ~/.config — including a heartbeat their own runner is
600
+ // writing right now.
601
+ assert.match(runner.heartbeatPath(), /ab-cadence-/, 'the suite must point at a throwaway path, not the real one');
602
+ });