@bongos/core 1.20.36 → 1.20.38
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 +104 -49
- package/.claude/skills/backlog-review/SKILL.md +1 -1
- package/.claude/skills/bongos-feedback/SKILL.md +66 -0
- package/.claude/skills/collab-review/SKILL.md +1 -1
- package/.claude/skills/priority-session/SKILL.md +1 -1
- package/.claude/skills/session-handoff/SKILL.md +1 -1
- package/.claude/skills/status/SKILL.md +1 -1
- package/bin/bongos.js +1 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/adr/0136-update-channel-subscription-policy.md +1 -1
- package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +41 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +188 -2
- package/docs/api-reference.md +12 -4
- package/docs/architecture.md +2 -0
- package/docs/copy-inventory.md +9 -9
- package/docs/copy-registry.json +9 -9
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +2 -2
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/modules/autonomy/db.js +39 -8
- package/modules/autonomy/fence.js +26 -4
- package/modules/autonomy/migrations/autonomy_003_priority_goal.sql +32 -0
- package/modules/autonomy/routes/autonomy.js +28 -1
- package/modules/hall-ui/public/gate.js +33 -4
- package/modules/ideas/routing.js +8 -1
- package/modules/platform-identity/instance-feedback.js +142 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/sso.js +62 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +30 -0
- package/scripts/gds/autobongos-run.js +121 -16
- package/scripts/gds/feedback-send.js +131 -0
- package/scripts/gds/module-assess-tests.js +248 -0
- package/scripts/gds/update-channel.js +82 -0
- package/src/bongos/routes/feedback.js +110 -0
- package/src/bongos/routes.js +2 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_fence.mjs +62 -0
- package/tests/autobongos_loop.mjs +281 -0
- package/tests/autonomy_fence_priority.mjs +211 -0
- package/tests/bongos_cli.mjs +7 -1
- package/tests/filing_abuse_matrix.mjs +7 -2
- package/tests/idea_routing.mjs +1 -1
- package/tests/instance_feedback.mjs +327 -0
- package/tests/module_assess_tests.mjs +232 -0
- package/tests/update_channel_db.mjs +124 -0
|
@@ -1084,6 +1084,211 @@ test('a runner that just started will NOT upgrade-exit, so a failed fetch cannot
|
|
|
1084
1084
|
assert.equal(askedForDrift, false, 'and not even ask — the check costs a network call');
|
|
1085
1085
|
});
|
|
1086
1086
|
|
|
1087
|
+
// --- pull always, restart rarely (task 1004406) -----------------------------
|
|
1088
|
+
//
|
|
1089
|
+
// Owner-approved 2026-09-30: restart only when the supervisor's OWN code changes.
|
|
1090
|
+
// Every worker is a fresh process in its own tree, so it runs whatever is on disk
|
|
1091
|
+
// already; the only code a restart refreshes is what THIS process has loaded.
|
|
1092
|
+
// So on drift the checkout is updated in place, and the process exits for the
|
|
1093
|
+
// wrapper only when a change touches a file it holds in memory.
|
|
1094
|
+
|
|
1095
|
+
const loopDeps = (over = {}) => ({
|
|
1096
|
+
iteration: async () => ({ event: 'hold', reason: 'test' }),
|
|
1097
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
|
|
1098
|
+
waitOrJump: async () => ({ waited: 0, jumped: false }),
|
|
1099
|
+
writeHeartbeat: () => true,
|
|
1100
|
+
postHeartbeat: async () => true,
|
|
1101
|
+
emit: () => {},
|
|
1102
|
+
loadedHead: async () => 'a'.repeat(40),
|
|
1103
|
+
now: () => Date.now() + runner.MIN_UPTIME_BEFORE_UPGRADE_MS + 1000,
|
|
1104
|
+
codeDrifted: async () => ({ from: 'aaaaaaaa', to: 'cccccccc' }),
|
|
1105
|
+
loadedFiles: () => ['scripts/gds/autobongos-run.js', 'scripts/gds/sequence.js'],
|
|
1106
|
+
state: runner.newRunnerState(),
|
|
1107
|
+
...over,
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
test('main moving with none of the supervisor\'s code refreshes the checkout IN PLACE and does not restart', async () => {
|
|
1111
|
+
const rows = [];
|
|
1112
|
+
let refreshed = 0;
|
|
1113
|
+
const d = loopDeps({
|
|
1114
|
+
record: (r) => { rows.push(r); return r; },
|
|
1115
|
+
refreshCheckout: async () => { refreshed += 1; return { ok: true, to: 'c'.repeat(40), changed: ['docs/recipes/x.md', 'src/bongos/routes/tasks.js'] }; },
|
|
1116
|
+
});
|
|
1117
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, d);
|
|
1118
|
+
assert.equal(code, 0, 'no restart for code the supervisor does not run');
|
|
1119
|
+
assert.ok(refreshed >= 1, 'the checkout is brought current, so workers run the new code');
|
|
1120
|
+
assert.ok(rows.some((r) => r.event === 'code_refreshed'), 'and the refresh is logged');
|
|
1121
|
+
assert.ok(!rows.some((r) => r.event === 'upgrade_exit'));
|
|
1122
|
+
assert.equal(d.state.diskHead, 'c'.repeat(40), 'the on-disk commit is remembered apart from the loaded one');
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
test('a change to a file the supervisor has LOADED restarts, and says which file', async () => {
|
|
1126
|
+
const rows = [];
|
|
1127
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, loopDeps({
|
|
1128
|
+
record: (r) => { rows.push(r); return r; },
|
|
1129
|
+
refreshCheckout: async () => ({ ok: true, to: 'c'.repeat(40), changed: ['docs/x.md', 'scripts/gds/sequence.js'] }),
|
|
1130
|
+
}));
|
|
1131
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1132
|
+
const exit = rows.find((r) => r.event === 'upgrade_exit');
|
|
1133
|
+
assert.match(exit.reason, /scripts\/gds\/sequence\.js/);
|
|
1134
|
+
assert.ok(!/docs\/x\.md/.test(exit.reason), 'only the files that force the restart are named');
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
test('a change under the supervisor\'s own paths restarts even before that file was loaded', async () => {
|
|
1138
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, loopDeps({
|
|
1139
|
+
record: (r) => r,
|
|
1140
|
+
loadedFiles: () => [],
|
|
1141
|
+
refreshCheckout: async () => ({ ok: true, to: 'c'.repeat(40), changed: ['modules/autonomy/cadence.js'] }),
|
|
1142
|
+
}));
|
|
1143
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
test('an in-place refresh that fails falls back to the restart, which re-runs the wrapper\'s refresh', async () => {
|
|
1147
|
+
const rows = [];
|
|
1148
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, loopDeps({
|
|
1149
|
+
record: (r) => { rows.push(r); return r; },
|
|
1150
|
+
refreshCheckout: async () => ({ ok: false, reason: 'fetch failed' }),
|
|
1151
|
+
}));
|
|
1152
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1153
|
+
assert.match(rows.find((r) => r.event === 'upgrade_exit').reason, /fetch failed/);
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
test('the real --forever entry point is the one place that arms the in-place refresh', () => {
|
|
1157
|
+
// main() is the production call shape and is not driven by these tests (it
|
|
1158
|
+
// would run the real loop), so its one line is pinned by source. Without it the
|
|
1159
|
+
// refresh is dead code and every merge to main restarts the runner again.
|
|
1160
|
+
const src = readFileSync(new URL('../scripts/gds/autobongos-run.js', import.meta.url), 'utf8');
|
|
1161
|
+
const line = src.split('\n').find((l) => l.includes('if (opts.forever) return forever('));
|
|
1162
|
+
assert.ok(line, 'main() must still hand --forever to forever()');
|
|
1163
|
+
assert.ok(line.includes('refreshInPlace: true'), `and arm the refresh there: ${line}`);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test('without refreshInPlace, forever never touches the checkout — tests and one-shot runs stay safe', async () => {
|
|
1167
|
+
// refreshCheckout runs `git reset --hard`. A suite that reached it would wipe
|
|
1168
|
+
// the tree it is testing, in CI or on a laptop, so only main() arms it.
|
|
1169
|
+
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, loopDeps({
|
|
1170
|
+
record: (r) => r,
|
|
1171
|
+
refreshCheckout: async () => { throw new Error('refreshed a checkout it was never told it owns'); },
|
|
1172
|
+
}));
|
|
1173
|
+
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
test('after a refresh, drift is measured from the REFRESHED commit, so it does not refresh every loop', async () => {
|
|
1177
|
+
const tip = 'c'.repeat(40);
|
|
1178
|
+
const run = async () => ({ ok: true, stdout: `${tip}\trefs/heads/main\n` });
|
|
1179
|
+
assert.equal(await runner.codeDrifted({ run, loadedHead: async () => 'a'.repeat(40), state: { diskHead: tip } }), false);
|
|
1180
|
+
const moved = await runner.codeDrifted({ run, loadedHead: async () => 'a'.repeat(40), state: { diskHead: 'b'.repeat(40) } });
|
|
1181
|
+
assert.deepEqual(moved, { from: 'bbbbbbbb', to: 'cccccccc' });
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
test('supervisorTouched: loaded files and the supervisor\'s own paths count; nothing else does', () => {
|
|
1185
|
+
const loaded = ['scripts/gds/sequence.js'];
|
|
1186
|
+
assert.deepEqual(runner.supervisorTouched(['docs/a.md', 'src/x.js'], loaded), []);
|
|
1187
|
+
assert.deepEqual(runner.supervisorTouched(['scripts/gds/sequence.js'], loaded), ['scripts/gds/sequence.js']);
|
|
1188
|
+
assert.deepEqual(runner.supervisorTouched(['scripts/gds/autobongos-verify.js'], []), ['scripts/gds/autobongos-verify.js']);
|
|
1189
|
+
assert.deepEqual(runner.supervisorTouched(['modules/autonomy/fence.js'], []), ['modules/autonomy/fence.js']);
|
|
1190
|
+
assert.deepEqual(runner.supervisorTouched(['scripts/gds/claim.js'], loaded), [], 'claim.js runs as a child process, so it is always fresh');
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
test('loadedFiles lists this process\'s own repo modules, repo-relative with forward slashes', () => {
|
|
1194
|
+
const files = runner.loadedFiles();
|
|
1195
|
+
assert.ok(files.includes('scripts/gds/autobongos-run.js'));
|
|
1196
|
+
assert.ok(files.every((f) => !f.includes('\\') && !f.startsWith('/') && !f.includes('node_modules/')));
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
test('refreshCheckout refuses to reset anything but the MAIN checkout', async () => {
|
|
1200
|
+
const calls = [];
|
|
1201
|
+
// Everything else answers as a healthy main checkout would, so the ONLY thing
|
|
1202
|
+
// that can stop the reset is the worktree check itself.
|
|
1203
|
+
const run = async (bin, args) => {
|
|
1204
|
+
calls.push(args.join(' '));
|
|
1205
|
+
if (args[1] === '--git-dir') return { ok: true, stdout: '/repo/.git/worktrees/some-claim\n' };
|
|
1206
|
+
if (args[1] === '--git-common-dir') return { ok: true, stdout: '/repo/.git\n' };
|
|
1207
|
+
if (args[0] === 'rev-parse' && args[1] === 'FETCH_HEAD') return { ok: true, stdout: `${'c'.repeat(40)}\n` };
|
|
1208
|
+
if (args[0] === 'diff') return { ok: true, stdout: 'docs/a.md\n' };
|
|
1209
|
+
return { ok: true, stdout: '' };
|
|
1210
|
+
};
|
|
1211
|
+
const r = await runner.refreshCheckout({ run, loadedHead: async () => 'a'.repeat(40) });
|
|
1212
|
+
assert.equal(r.ok, false);
|
|
1213
|
+
assert.ok(!calls.some((c) => c.startsWith('reset')), 'a linked worktree is somebody\'s claim — never reset it');
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
test('refreshCheckout in the main checkout fetches, diffs from the LOADED commit, then resets', async () => {
|
|
1217
|
+
const calls = [];
|
|
1218
|
+
const tip = 'c'.repeat(40);
|
|
1219
|
+
const run = async (bin, args) => {
|
|
1220
|
+
calls.push(args.join(' '));
|
|
1221
|
+
if (args[1] === '--git-dir' || args[1] === '--git-common-dir') return { ok: true, stdout: '.git\n' };
|
|
1222
|
+
if (args[0] === 'rev-parse' && args[1] === 'FETCH_HEAD') return { ok: true, stdout: `${tip}\n` };
|
|
1223
|
+
if (args[0] === 'diff') return { ok: true, stdout: 'docs/a.md\r\nscripts/gds/sequence.js\n' };
|
|
1224
|
+
return { ok: true, stdout: '' };
|
|
1225
|
+
};
|
|
1226
|
+
const r = await runner.refreshCheckout({ run, loadedHead: async () => 'a'.repeat(40) });
|
|
1227
|
+
assert.deepEqual(r, { ok: true, to: tip, changed: ['docs/a.md', 'scripts/gds/sequence.js'] });
|
|
1228
|
+
const order = ['fetch', 'diff', 'reset'].map((v) => calls.findIndex((c) => c.startsWith(v)));
|
|
1229
|
+
assert.ok(order.every((i) => i >= 0) && order[0] < order[1] && order[1] < order[2], `fetch → diff → reset, got ${calls.join(' | ')}`);
|
|
1230
|
+
assert.ok(calls.find((c) => c.startsWith('diff')).includes('a'.repeat(40)), 'the diff is from what the process LOADED, so a change that landed several refreshes ago still counts');
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// Each way the refresh can bail must bail BEFORE the reset: a half-done refresh
|
|
1234
|
+
// that resets to an unread or undiffed commit would lose the restart decision.
|
|
1235
|
+
for (const [name, failAt] of [
|
|
1236
|
+
['the running commit is unknown', 'loaded'],
|
|
1237
|
+
['fetch failed', 'fetch'],
|
|
1238
|
+
['could not read the fetched commit', 'FETCH_HEAD'],
|
|
1239
|
+
['could not diff the running commit against main', 'diff'],
|
|
1240
|
+
]) {
|
|
1241
|
+
test(`refreshCheckout bails without resetting when: ${name}`, async () => {
|
|
1242
|
+
const calls = [];
|
|
1243
|
+
const run = async (bin, args) => {
|
|
1244
|
+
calls.push(args.join(' '));
|
|
1245
|
+
if (args[1] === '--git-dir' || args[1] === '--git-common-dir') return { ok: true, stdout: '.git\n' };
|
|
1246
|
+
if (args[0] === 'fetch') return { ok: failAt !== 'fetch', stdout: '' };
|
|
1247
|
+
if (args[0] === 'rev-parse' && args[1] === 'FETCH_HEAD') return { ok: true, stdout: failAt === 'FETCH_HEAD' ? 'not-a-sha\n' : `${'c'.repeat(40)}\n` };
|
|
1248
|
+
if (args[0] === 'diff') return { ok: failAt !== 'diff', stdout: 'docs/a.md\n' };
|
|
1249
|
+
return { ok: true, stdout: '' };
|
|
1250
|
+
};
|
|
1251
|
+
const r = await runner.refreshCheckout({ run, loadedHead: async () => (failAt === 'loaded' ? null : 'a'.repeat(40)) });
|
|
1252
|
+
assert.deepEqual(r, { ok: false, reason: name });
|
|
1253
|
+
assert.ok(!calls.some((c) => c.startsWith('reset')), `no reset after "${name}"`);
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
test('a refresh in place reaches the heartbeat FILE through the real beat — disk_head end to end', async () => {
|
|
1258
|
+
// The grader's catch: every forever() test stubbed writeHeartbeat, so the
|
|
1259
|
+
// state never had to travel from the refresh to the file. This one does not.
|
|
1260
|
+
const dir = mkdtempSync(join(tmpdir(), 'ab-diskhead-e2e-'));
|
|
1261
|
+
const prev = process.env.AUTOBONGOS_HEARTBEAT_FILE;
|
|
1262
|
+
process.env.AUTOBONGOS_HEARTBEAT_FILE = join(dir, 'beat.json');
|
|
1263
|
+
try {
|
|
1264
|
+
const d = loopDeps({
|
|
1265
|
+
record: (r) => r,
|
|
1266
|
+
refreshCheckout: async () => ({ ok: true, to: 'e'.repeat(40), changed: ['docs/a.md'] }),
|
|
1267
|
+
});
|
|
1268
|
+
delete d.writeHeartbeat;
|
|
1269
|
+
await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2, refreshInPlace: true }, d);
|
|
1270
|
+
const beat = JSON.parse(readFileSync(process.env.AUTOBONGOS_HEARTBEAT_FILE, 'utf8'));
|
|
1271
|
+
assert.equal(beat.disk_head, 'e'.repeat(40));
|
|
1272
|
+
} finally {
|
|
1273
|
+
if (prev === undefined) delete process.env.AUTOBONGOS_HEARTBEAT_FILE; else process.env.AUTOBONGOS_HEARTBEAT_FILE = prev;
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test('the heartbeat file names the on-disk commit when it differs from the running one', () => {
|
|
1278
|
+
const dir = mkdtempSync(join(tmpdir(), 'ab-diskhead-'));
|
|
1279
|
+
const prev = process.env.AUTOBONGOS_HEARTBEAT_FILE;
|
|
1280
|
+
process.env.AUTOBONGOS_HEARTBEAT_FILE = join(dir, 'beat.json');
|
|
1281
|
+
try {
|
|
1282
|
+
runner.writeHeartbeat(Date.now(), { diskHead: 'd'.repeat(40) });
|
|
1283
|
+
const beat = JSON.parse(readFileSync(process.env.AUTOBONGOS_HEARTBEAT_FILE, 'utf8'));
|
|
1284
|
+
assert.equal(beat.disk_head, 'd'.repeat(40), '"up to date on disk, old supervisor" must be visible');
|
|
1285
|
+
runner.writeHeartbeat(Date.now(), { diskHead: null });
|
|
1286
|
+
assert.equal(JSON.parse(readFileSync(process.env.AUTOBONGOS_HEARTBEAT_FILE, 'utf8')).disk_head, undefined);
|
|
1287
|
+
} finally {
|
|
1288
|
+
if (prev === undefined) delete process.env.AUTOBONGOS_HEARTBEAT_FILE; else process.env.AUTOBONGOS_HEARTBEAT_FILE = prev;
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1087
1292
|
test('past the uptime floor, a drift still exits', async () => {
|
|
1088
1293
|
const code = await runner.forever({ goals: [], maxTasks: 1, maxLoops: 2 }, {
|
|
1089
1294
|
iteration: async () => ({ event: 'hold', reason: 'test' }),
|
|
@@ -1098,3 +1303,79 @@ test('past the uptime floor, a drift still exits', async () => {
|
|
|
1098
1303
|
});
|
|
1099
1304
|
assert.equal(code, runner.UPGRADE_EXIT_CODE);
|
|
1100
1305
|
});
|
|
1306
|
+
|
|
1307
|
+
// ── the priority goal reaches the picker, the rows and the heartbeat (task 1004453) ──
|
|
1308
|
+
|
|
1309
|
+
const twoGoalFence = (priority) => async () => ({
|
|
1310
|
+
raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }, { goal_id: 1000090 }], priority_goal_id: priority },
|
|
1311
|
+
graderBypassed: false,
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test('the picker is handed the priority goal first', async () => {
|
|
1315
|
+
let sawGoals = null;
|
|
1316
|
+
const h = harness({
|
|
1317
|
+
readFence: twoGoalFence(1000090),
|
|
1318
|
+
pickTask: async (goals) => { sawGoals = goals; return { task: { ...aTask, goal_id: 1000090 }, goalId: 1000090, skipped: [] }; },
|
|
1319
|
+
});
|
|
1320
|
+
await runner.iteration({ ...opts, goals: [] }, h.deps);
|
|
1321
|
+
assert.deepEqual(sawGoals, [1000090, 1000119]);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
test('a worked row says its pick came from the priority goal', async () => {
|
|
1325
|
+
const h = harness({
|
|
1326
|
+
readFence: twoGoalFence(1000090),
|
|
1327
|
+
pickTask: async () => ({ task: { ...aTask, goal_id: 1000090 }, goalId: 1000090, skipped: [] }),
|
|
1328
|
+
});
|
|
1329
|
+
const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
|
|
1330
|
+
assert.equal(row.event, 'worked');
|
|
1331
|
+
assert.equal(row.goal_id, 1000090);
|
|
1332
|
+
assert.equal(row.priority, true);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
test('once the priority goal runs dry, a pick from the rest reads priority:false', async () => {
|
|
1336
|
+
const h = harness({
|
|
1337
|
+
readFence: twoGoalFence(1000090),
|
|
1338
|
+
pickTask: async () => ({ task: aTask, goalId: 1000119, skipped: [] }),
|
|
1339
|
+
});
|
|
1340
|
+
const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
|
|
1341
|
+
assert.equal(row.event, 'worked');
|
|
1342
|
+
assert.equal(row.goal_id, 1000119);
|
|
1343
|
+
assert.equal(row.priority, false);
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
test('with no priority set, no pick claims to be one', async () => {
|
|
1347
|
+
const h = harness({ readFence: twoGoalFence(null) });
|
|
1348
|
+
const row = await runner.iteration({ ...opts, goals: [] }, h.deps);
|
|
1349
|
+
assert.equal(row.priority, false);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test('a dry run and an empty queue both name the priority', async () => {
|
|
1353
|
+
const dry = await runner.iteration({ ...opts, goals: [], dryRun: true }, harness({
|
|
1354
|
+
readFence: twoGoalFence(1000119),
|
|
1355
|
+
}).deps);
|
|
1356
|
+
assert.equal(dry.event, 'dry_run');
|
|
1357
|
+
assert.equal(dry.priority, true);
|
|
1358
|
+
const none = await runner.iteration({ ...opts, goals: [] }, harness({
|
|
1359
|
+
readFence: twoGoalFence(1000119),
|
|
1360
|
+
pickTask: async () => ({ none: true, skipped: [] }),
|
|
1361
|
+
}).deps);
|
|
1362
|
+
assert.equal(none.event, 'nothing_claimable');
|
|
1363
|
+
assert.equal(none.priority_goal, 1000119);
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
test('the server heartbeat carries the goal the current task came from', async () => {
|
|
1367
|
+
const beats = [];
|
|
1368
|
+
await runner.forever({ goals: [], maxTasks: 1, maxLoops: 1 }, {
|
|
1369
|
+
iteration: async () => ({ event: 'worked', task_id: 4242, goal_id: 1000090, priority: true }),
|
|
1370
|
+
reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true }),
|
|
1371
|
+
waitOrJump: async () => ({ waited: 0, jumped: false }),
|
|
1372
|
+
writeHeartbeat: () => true,
|
|
1373
|
+
postHeartbeat: async (fields) => { beats.push(fields); return true; },
|
|
1374
|
+
emit: () => {},
|
|
1375
|
+
record: (r) => r,
|
|
1376
|
+
codeDrifted: async () => false,
|
|
1377
|
+
});
|
|
1378
|
+
const b = beats.find((x) => x.working_task_id === 4242);
|
|
1379
|
+
assert.ok(b, 'the beat after a worked row must name its task');
|
|
1380
|
+
assert.equal(b.working_goal_id, 1000090);
|
|
1381
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// tests/autonomy_fence_priority.mjs — the fence's priority goal, server side
|
|
2
|
+
// (task 1004453).
|
|
3
|
+
//
|
|
4
|
+
// Owner ruling, 2026-09-30: whoever starts the runner names the goal it works
|
|
5
|
+
// FIRST, from the hall's /builders/gate page or the API, and the server refuses a
|
|
6
|
+
// priority that is not on the allowlist. These drive REAL requests through the
|
|
7
|
+
// assembled autonomy router with only the auth gates stubbed (the
|
|
8
|
+
// routine_observability_api precedent), pin the SQL against a stubbed pool, and
|
|
9
|
+
// hold the migration to the shape the route relies on. The pure ordering rule is
|
|
10
|
+
// tests/autobongos_fence.mjs; the runner's use of it is tests/autobongos_loop.mjs.
|
|
11
|
+
//
|
|
12
|
+
// DB-free, no network. Run: node --test tests/autonomy_fence_priority.mjs
|
|
13
|
+
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const req = createRequire(import.meta.url);
|
|
23
|
+
const express = req('express');
|
|
24
|
+
const { attachFail } = req(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
25
|
+
|
|
26
|
+
const permStub = (key) => {
|
|
27
|
+
const mw = (_req, _res, next) => next();
|
|
28
|
+
mw.permKey = key;
|
|
29
|
+
return mw;
|
|
30
|
+
};
|
|
31
|
+
const passBuilder = (reqq, _res, next) => { reqq.builder = { id: 3, rank: 'archon' }; next(); };
|
|
32
|
+
|
|
33
|
+
function autonomyRouter() {
|
|
34
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
35
|
+
const saved = { b: api.requireBuilder, p: api.requirePermission };
|
|
36
|
+
api.requireBuilder = passBuilder;
|
|
37
|
+
api.requirePermission = permStub;
|
|
38
|
+
const routePath = req.resolve(path.join(ROOT, 'modules/autonomy/routes/autonomy.js'));
|
|
39
|
+
delete req.cache[routePath];
|
|
40
|
+
try {
|
|
41
|
+
return req(routePath)();
|
|
42
|
+
} finally {
|
|
43
|
+
api.requireBuilder = saved.b;
|
|
44
|
+
api.requirePermission = saved.p;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function serve(router) {
|
|
49
|
+
const app = express();
|
|
50
|
+
app.use(express.json());
|
|
51
|
+
app.use(attachFail);
|
|
52
|
+
app.use(router);
|
|
53
|
+
const server = app.listen(0);
|
|
54
|
+
await new Promise((r) => server.once('listening', r));
|
|
55
|
+
return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const post = (base, p, body) => fetch(`${base}${p}`, {
|
|
59
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('POST /autonomy/fence/priority sits behind the same owner-only atom as the allowlist', () => {
|
|
63
|
+
const router = autonomyRouter();
|
|
64
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/autonomy/fence/priority');
|
|
65
|
+
assert.ok(layer, 'the route is registered');
|
|
66
|
+
assert.equal(layer.route.methods.post, true);
|
|
67
|
+
assert.equal(layer.route.stack.length, 3, 'requireBuilder + requirePermission + handler');
|
|
68
|
+
assert.equal(layer.route.stack[1].handle.permKey, 'autonomy.fence.manage');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the route sets, clears, and refuses a goal that is not allowlisted', async () => {
|
|
72
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
73
|
+
const saved = db.setPriorityGoal;
|
|
74
|
+
const seen = [];
|
|
75
|
+
let answer = { row: { priority_goal_id: 1000119, updated_at: '2026-09-30T00:00:00.000Z' } };
|
|
76
|
+
db.setPriorityGoal = async (args) => { seen.push(args); return answer; };
|
|
77
|
+
const s = await serve(autonomyRouter());
|
|
78
|
+
try {
|
|
79
|
+
let r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
|
|
80
|
+
assert.equal(r.status, 200);
|
|
81
|
+
assert.equal((await r.json()).priority_goal_id, 1000119);
|
|
82
|
+
assert.deepEqual(seen.pop(), { goalId: 1000119, builderId: 3 }, 'the writer is the SESSION, never the body');
|
|
83
|
+
|
|
84
|
+
r = await post(s.base, '/autonomy/fence/priority', { goal_id: '1000119' });
|
|
85
|
+
assert.equal(r.status, 200, 'a stringified id is accepted, as the allowlist route accepts one');
|
|
86
|
+
assert.equal(seen.pop().goalId, 1000119);
|
|
87
|
+
|
|
88
|
+
answer = { row: { priority_goal_id: null, updated_at: '2026-09-30T00:00:00.000Z' } };
|
|
89
|
+
r = await post(s.base, '/autonomy/fence/priority', { goal_id: null });
|
|
90
|
+
assert.equal(r.status, 200);
|
|
91
|
+
assert.equal(seen.pop().goalId, null, 'null clears the priority');
|
|
92
|
+
|
|
93
|
+
answer = { notAllowed: true };
|
|
94
|
+
r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000090 });
|
|
95
|
+
assert.equal(r.status, 409, 'a priority must already be on the allowlist');
|
|
96
|
+
assert.equal((await r.json()).error.code, 'goal_not_allowlisted');
|
|
97
|
+
|
|
98
|
+
answer = { missing: true };
|
|
99
|
+
r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
|
|
100
|
+
assert.equal(r.status, 503);
|
|
101
|
+
|
|
102
|
+
const before = seen.length;
|
|
103
|
+
for (const bad of [{}, { goal_id: 0 }, { goal_id: -1 }, { goal_id: 'abc' }, { goal_id: 1.5 }]) {
|
|
104
|
+
r = await post(s.base, '/autonomy/fence/priority', bad);
|
|
105
|
+
assert.equal(r.status, 400, `${JSON.stringify(bad)} is refused`);
|
|
106
|
+
}
|
|
107
|
+
assert.equal(seen.length, before, 'a malformed request never reaches the database');
|
|
108
|
+
|
|
109
|
+
db.setPriorityGoal = async () => { throw new Error('connection reset'); };
|
|
110
|
+
r = await post(s.base, '/autonomy/fence/priority', { goal_id: 1000119 });
|
|
111
|
+
assert.equal(r.status, 500);
|
|
112
|
+
assert.ok(!JSON.stringify(await r.json()).includes('connection reset'), 'the database error stays in the log');
|
|
113
|
+
} finally {
|
|
114
|
+
s.close();
|
|
115
|
+
db.setPriorityGoal = saved;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('setPriorityGoal: the WHERE refuses a goal that is not allowlisted, and says which refusal it was', async () => {
|
|
120
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
121
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
122
|
+
const saved = api.pool.query;
|
|
123
|
+
const calls = [];
|
|
124
|
+
let replies = [];
|
|
125
|
+
api.pool.query = async (sql, params) => { calls.push({ sql, params }); return replies.shift(); };
|
|
126
|
+
try {
|
|
127
|
+
replies = [{ rows: [{ priority_goal_id: '1000119', updated_at: 't' }] }];
|
|
128
|
+
assert.deepEqual(await db.setPriorityGoal({ goalId: 1000119, builderId: 3 }), { row: { priority_goal_id: 1000119, updated_at: 't' } });
|
|
129
|
+
assert.match(calls[0].sql, /UPDATE autonomy_fence/);
|
|
130
|
+
assert.match(calls[0].sql, /EXISTS \(SELECT 1 FROM autonomy_allowed_goals WHERE goal_id = \$1::bigint\)/);
|
|
131
|
+
assert.deepEqual(calls[0].params, [1000119, 3]);
|
|
132
|
+
|
|
133
|
+
replies = [{ rows: [] }, { rows: [{ '?column?': 1 }] }];
|
|
134
|
+
assert.deepEqual(await db.setPriorityGoal({ goalId: 1000090 }), { notAllowed: true });
|
|
135
|
+
|
|
136
|
+
replies = [{ rows: [] }, { rows: [] }];
|
|
137
|
+
assert.deepEqual(await db.setPriorityGoal({ goalId: 1000090 }), { missing: true });
|
|
138
|
+
|
|
139
|
+
replies = [{ rows: [{ priority_goal_id: null, updated_at: 't' }] }];
|
|
140
|
+
assert.deepEqual(await db.setPriorityGoal({ goalId: null }), { row: { priority_goal_id: null, updated_at: 't' } });
|
|
141
|
+
} finally {
|
|
142
|
+
api.pool.query = saved;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('readFence carries the priority as a number, and null when unset', async () => {
|
|
147
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
148
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
149
|
+
const saved = api.pool.query;
|
|
150
|
+
let pri = '1000119';
|
|
151
|
+
api.pool.query = async (sql) => (/FROM autonomy_fence/.test(sql)
|
|
152
|
+
? { rows: [{ enabled: true, paused_reason: null, priority_goal_id: pri, updated_at: 't', updated_by: '3' }] }
|
|
153
|
+
: { rows: [{ goal_id: '1000119', note: null, added_at: 't', added_by: '3' }] });
|
|
154
|
+
try {
|
|
155
|
+
assert.equal((await db.readFence()).priority_goal_id, 1000119, 'a bigint string would never equal a numeric goal id');
|
|
156
|
+
pri = null;
|
|
157
|
+
assert.equal((await db.readFence()).priority_goal_id, null);
|
|
158
|
+
} finally {
|
|
159
|
+
api.pool.query = saved;
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('the heartbeat stores and returns the goal the current task came from', async () => {
|
|
164
|
+
const api = req(path.join(ROOT, 'src/module-api.js'));
|
|
165
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
166
|
+
const saved = api.pool.query;
|
|
167
|
+
const calls = [];
|
|
168
|
+
api.pool.query = async (sql, params) => { calls.push({ sql, params }); return { rows: [{ working_task_id: 4242, working_goal_id: 1000090 }] }; };
|
|
169
|
+
try {
|
|
170
|
+
await db.recordRunnerHeartbeat({ builderId: 3, host: 'winbox', workingTaskId: 4242, workingGoalId: 1000090 });
|
|
171
|
+
assert.match(calls[0].sql, /working_goal_id = EXCLUDED\.working_goal_id/);
|
|
172
|
+
assert.equal(calls[0].params[8], 1000090);
|
|
173
|
+
const rows = await db.readRunnerHeartbeats(3);
|
|
174
|
+
assert.equal(rows[0].working_goal_id, '1000090');
|
|
175
|
+
} finally {
|
|
176
|
+
api.pool.query = saved;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('the route accepts working_goal_id on a heartbeat and passes it through', async () => {
|
|
181
|
+
const db = req(path.join(ROOT, 'modules/autonomy/db.js'));
|
|
182
|
+
const saved = db.recordRunnerHeartbeat;
|
|
183
|
+
const seen = [];
|
|
184
|
+
db.recordRunnerHeartbeat = async (args) => { seen.push(args); return {}; };
|
|
185
|
+
const s = await serve(autonomyRouter());
|
|
186
|
+
try {
|
|
187
|
+
const r = await post(s.base, '/autonomy/heartbeat', { host: 'winbox', working_task_id: 4242, working_goal_id: 1000090 });
|
|
188
|
+
assert.equal(r.status, 200);
|
|
189
|
+
assert.equal(seen[0].workingGoalId, 1000090);
|
|
190
|
+
} finally {
|
|
191
|
+
s.close();
|
|
192
|
+
db.recordRunnerHeartbeat = saved;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('the migration ties the priority to the allowlist and clears it on revoke', () => {
|
|
197
|
+
const sql = fs.readFileSync(path.join(ROOT, 'modules/autonomy/migrations/autonomy_003_priority_goal.sql'), 'utf8')
|
|
198
|
+
.split('\n').filter((l) => !l.trim().startsWith('--')).join('\n');
|
|
199
|
+
assert.match(sql, /ADD COLUMN IF NOT EXISTS priority_goal_id bigint\s+REFERENCES autonomy_allowed_goals \(goal_id\) ON DELETE SET NULL/,
|
|
200
|
+
'revoking the priority goal must clear the priority, never leave a revoked goal "first"');
|
|
201
|
+
assert.match(sql, /ADD COLUMN IF NOT EXISTS working_goal_id bigint/);
|
|
202
|
+
assert.doesNotMatch(sql, /\bDROP\b/i, 'module migrations are additive');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('the gate page renders the priority, lists it first, and posts to the priority route', () => {
|
|
206
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/gate.js'), 'utf8');
|
|
207
|
+
assert.match(src, /renderFenceGoals\(fence\.goals, fence\.priority_goal_id\)/, 'the page must pass the priority it read');
|
|
208
|
+
assert.match(src, /sendJson\('POST', '\/autonomy\/fence\/priority', \{ goal_id: goalId \}\)/);
|
|
209
|
+
assert.match(src, /Make priority/);
|
|
210
|
+
assert.match(src, /Clear priority/);
|
|
211
|
+
});
|
package/tests/bongos_cli.mjs
CHANGED
|
@@ -87,7 +87,13 @@ test('VERBS: selfHelp is only claimed by scripts that really print their own usa
|
|
|
87
87
|
// `serve` is dev.js with --server pinned on, so it inherits dev's own usage()
|
|
88
88
|
// printer — verified: `bongos serve --help` prints the card and exits 0.
|
|
89
89
|
const selfHelp = Object.entries(cli.VERBS).filter(([, d]) => d.selfHelp).map(([v]) => v).sort();
|
|
90
|
-
assert.deepEqual(selfHelp, ['dev', 'module', 'serve', 'status', 'task', 'upgrade']);
|
|
90
|
+
assert.deepEqual(selfHelp, ['dev', 'feedback', 'module', 'serve', 'status', 'task', 'upgrade']);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('bongos feedback --help prints its own usage and sends nothing', () => {
|
|
94
|
+
const r = run('feedback', '--help');
|
|
95
|
+
assert.equal(r.status, 0, r.stderr);
|
|
96
|
+
assert.match(r.stdout, /bongos feedback "what went wrong"/);
|
|
91
97
|
});
|
|
92
98
|
|
|
93
99
|
// ---- spawned: the dispatcher answers --help without touching the script ----
|
|
@@ -405,10 +405,15 @@ await test('[F18] the vector fails CLOSED — forged, absent or unknown never op
|
|
|
405
405
|
`vector ${JSON.stringify(vector)} must not route`
|
|
406
406
|
);
|
|
407
407
|
}
|
|
408
|
-
// The allowlist is exactly
|
|
408
|
+
// The allowlist is exactly three members, and all are server-set at their call
|
|
409
409
|
// site — never read from a request body, which is what makes forging it a
|
|
410
410
|
// non-move rather than a merely-hard one.
|
|
411
|
-
assert.deepEqual([...routing.HOMELESS_ROUTING_VECTORS].sort(), ['api', 'discord-bugs']);
|
|
411
|
+
assert.deepEqual([...routing.HOMELESS_ROUTING_VECTORS].sort(), ['api', 'discord-bugs', 'instance-feedback']);
|
|
412
|
+
// The hub's feedback relay (task 1004462) names its own vector and never a rank,
|
|
413
|
+
// so a relayed bug can only ever land at 'backlog'.
|
|
414
|
+
const feedbackSrc = read('modules/platform-identity/instance-feedback.js');
|
|
415
|
+
assert.match(feedbackSrc, /vector: 'instance-feedback'/, 'the relay hardcodes its own vector');
|
|
416
|
+
assert.match(feedbackSrc, /filerRank: null/, 'and files with no rank');
|
|
412
417
|
const routeSrc = read('modules/ideas/routes/inbox.js');
|
|
413
418
|
assert.match(routeSrc, /vector: 'api'/, 'the route hardcodes its own vector');
|
|
414
419
|
assert.ok(!/vector:\s*body\.|vector:\s*req\.body/.test(routeSrc),
|
package/tests/idea_routing.mjs
CHANGED
|
@@ -172,7 +172,7 @@ await test('matrix: FAIL-CLOSED — an absent or unrecognised rank never hard-la
|
|
|
172
172
|
// -------------------------------------------------------------------------
|
|
173
173
|
|
|
174
174
|
await test('matrix: a homeless BUG from a trusted vector routes, and keeps the rank split', () => {
|
|
175
|
-
for (const vector of ['api', 'discord-bugs']) {
|
|
175
|
+
for (const vector of ['api', 'discord-bugs', 'instance-feedback']) {
|
|
176
176
|
assert.deepEqual(
|
|
177
177
|
decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector }),
|
|
178
178
|
{ route: true, status: 'ready', homeless: true, reason: 'bug_homeless_trusted_filer' },
|