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