@bongos/core 1.20.32 → 1.20.34
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 +39 -29
- package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +19 -5
- package/docs/adr/README.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/autobongos-windows-host.md +1 -1
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/autonomy/cadence.js +17 -0
- package/modules/autonomy/digest.js +5 -2
- package/modules/autonomy/gauge.js +5 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/gds/autobongos-loop.js +37 -1
- package/scripts/gds/autobongos-run.js +189 -120
- package/scripts/gds/claim.js +10 -2
- package/scripts/gds/move-escalation.js +141 -0
- package/scripts/gds/provision.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/autobongos_cadence.mjs +36 -0
- package/tests/autobongos_loop.mjs +249 -95
- package/tests/autonomy_digest.mjs +10 -0
- package/tests/claim_error_surface.mjs +7 -0
- package/tests/cli_exit_no_abort.mjs +7 -2
- package/tests/move_escalation.mjs +191 -0
- package/tests/plain_cards.mjs +2 -2
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// tests/move_escalation.mjs — a core move nobody can heal files ONE blocker and ONE notice,
|
|
2
|
+
// and a later move that lands closes it (task 1004449, goal 1000090 deploy self-heal).
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS AT RISK. Two opposite failures: a person never hears about a move that left a
|
|
5
|
+
// project down, or hears about it on every runner tick until they mute the channel. The
|
|
6
|
+
// blockers table here is a fake that enforces the real (source, source_ref) uniqueness, and
|
|
7
|
+
// the drain loop is the real one, so "one per failed move" is measured across retries and
|
|
8
|
+
// re-runs rather than assumed.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/move_escalation.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
process.env.PROVISION_APP_USER = process.env.PROVISION_APP_USER || 'lars'; // appUser() refuses a Windows account name
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const E = require('../scripts/gds/move-escalation.js');
|
|
18
|
+
const P = require('../scripts/gds/provision.js');
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
const t = async (name, fn) => {
|
|
23
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
24
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const inst = (over = {}) => ({ id: 7, slug: 'cloudbongos', db_name: 'cloudbongos', port: 3002, status: 'active', hosting_shape: 'control-plane', owner_builder_id: 1, ...over });
|
|
28
|
+
const unknownFail = { ok: false, error: 'core upgrade to 1.20.31 failed and was rolled back: health check failed after restart', failure: { class: 'unknown', reason: 'health' } };
|
|
29
|
+
|
|
30
|
+
// A blockers table with the real partial unique index on (source, source_ref).
|
|
31
|
+
function blockersTable() {
|
|
32
|
+
const rows = []; let next = 500;
|
|
33
|
+
const query = async (sql, params) => {
|
|
34
|
+
if (/INSERT INTO blockers/.test(sql)) {
|
|
35
|
+
const [title, body_md, source, source_ref] = params;
|
|
36
|
+
if (rows.some((r) => r.source === source && r.source_ref === source_ref)) return { rows: [] };
|
|
37
|
+
const r = { id: next++, title, body_md, source, source_ref, status: 'open' }; rows.push(r); return { rows: [{ id: r.id }] };
|
|
38
|
+
}
|
|
39
|
+
if (/UPDATE blockers/.test(sql)) {
|
|
40
|
+
const [source, like, note] = params; const prefix = like.replace(/%$/, '');
|
|
41
|
+
const hit = rows.filter((r) => r.status === 'open' && r.source === source && r.source_ref.startsWith(prefix));
|
|
42
|
+
for (const r of hit) { r.status = 'resolved'; r.resolution_note = note; }
|
|
43
|
+
return { rows: hit.map((r) => ({ id: r.id })) };
|
|
44
|
+
}
|
|
45
|
+
return { rows: [] };
|
|
46
|
+
};
|
|
47
|
+
return { rows, query };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log('\nwho needs a person (needsPerson):');
|
|
51
|
+
|
|
52
|
+
await t('unknown and rollback_failed escalate; every other class does not', () => {
|
|
53
|
+
const i = { action: 'core-upgrade' };
|
|
54
|
+
assert.equal(E.needsPerson({ intent: i, failure: { class: 'unknown', reason: 'health' } }), true);
|
|
55
|
+
assert.equal(E.needsPerson({ intent: i, failure: { class: 'unknown', reason: 'rollback_failed' }, terminal: true }), true);
|
|
56
|
+
assert.equal(E.needsPerson({ intent: i, failure: { class: 'unknown', reason: 'remedy_did_not_hold' } }), true, 'a fix that did not hold (task 1004448)');
|
|
57
|
+
for (const c of ['transient', 'known_wedge', 'needs_decision']) assert.equal(E.needsPerson({ intent: i, failure: { class: c, reason: 'x' } }), false, c);
|
|
58
|
+
assert.equal(E.needsPerson({ intent: { action: 'restart' }, failure: { class: 'unknown', reason: 'x' } }), false, 'core moves only');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
console.log('\nwhat is said (blockerFor, noticeText):');
|
|
62
|
+
|
|
63
|
+
await t('the blocker names project, target, reason and what the move reported', () => {
|
|
64
|
+
const b = E.blockerFor({ inst: inst(), intent: { id: 11, target_version: '1.20.31' }, failure: unknownFail.failure, error: unknownFail.error });
|
|
65
|
+
assert.equal(b.source, 'provisioning');
|
|
66
|
+
assert.equal(b.sourceRef, 'core-move:7:11');
|
|
67
|
+
assert.match(b.title, /cloudbongos: the core move to 1\.20\.31 failed/);
|
|
68
|
+
assert.match(b.bodyMd, /`health`/);
|
|
69
|
+
assert.match(b.bodyMd, /health check failed after restart/);
|
|
70
|
+
assert.match(b.bodyMd, /closes by itself/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await t('a failed rollback says the project may be down', () => {
|
|
74
|
+
const b = E.blockerFor({ inst: inst(), intent: { id: 11, target_version: '1.20.31' }, failure: { class: 'unknown', reason: 'rollback_failed' }, error: 'x' });
|
|
75
|
+
assert.match(b.title, /may be down/);
|
|
76
|
+
assert.match(E.noticeText({ kind: 'failed', name: 'x', down: true }), /may be down/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await t('reported output cannot break out of its code block', () => {
|
|
80
|
+
const b = E.blockerFor({ inst: inst(), intent: { id: 1, target_version: '1' }, failure: unknownFail.failure, error: 'a```\n# injected' });
|
|
81
|
+
assert.equal((b.bodyMd.match(/```/g) || []).length, 2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await t('Discord names only a PUBLIC project, and never carries the upgrade output', () => {
|
|
85
|
+
assert.match(E.noticeText({ kind: 'failed', name: null, to: '1.20.31' }), /^⚠️ A private project/);
|
|
86
|
+
assert.match(E.noticeText({ kind: 'failed', name: 'mercury', to: '1.20.31' }), /\*\*mercury\*\*/);
|
|
87
|
+
assert.doesNotMatch(E.noticeText({ kind: 'failed', name: 'm', to: '1' }), /health/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
console.log('\nonce per failed move, closed by success (afterRetire / afterSuccess):');
|
|
91
|
+
|
|
92
|
+
function deps(table, { visibility = 'private' } = {}) {
|
|
93
|
+
const posts = [];
|
|
94
|
+
return { posts, d: { apply: true, log: () => {}, db: { query: table.query }, postNotice: (text) => posts.push(text), provisioning: { effectiveSettings: () => ({ visibility }) } } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await t('a retired unknown move files one blocker and one notice', async () => {
|
|
98
|
+
const table = blockersTable(); const { posts, d } = deps(table);
|
|
99
|
+
const r = await E.afterRetire({ intent: { id: 11, action: 'core-upgrade', target_version: '1.20.31' }, inst: inst(), result: unknownFail, deps: d });
|
|
100
|
+
assert.equal(r.filed, true);
|
|
101
|
+
assert.equal(table.rows.length, 1);
|
|
102
|
+
assert.equal(posts.length, 1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await t('the same move retiring again files nothing and posts nothing', async () => {
|
|
106
|
+
const table = blockersTable(); const { posts, d } = deps(table);
|
|
107
|
+
const i = { id: 11, action: 'core-upgrade', target_version: '1.20.31' };
|
|
108
|
+
await E.afterRetire({ intent: i, inst: inst(), result: unknownFail, deps: d });
|
|
109
|
+
const again = await E.afterRetire({ intent: i, inst: inst(), result: unknownFail, deps: d });
|
|
110
|
+
assert.equal(again.filed, false);
|
|
111
|
+
assert.equal(table.rows.length, 1);
|
|
112
|
+
assert.equal(posts.length, 1, 'edge-triggered: the notice follows the insert');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('a later move that lands closes this project\'s blockers — and only this project\'s', async () => {
|
|
116
|
+
const table = blockersTable(); const { posts, d } = deps(table, { visibility: 'public' });
|
|
117
|
+
await E.afterRetire({ intent: { id: 11, action: 'core-upgrade', target_version: '1.20.31' }, inst: inst(), result: unknownFail, deps: d });
|
|
118
|
+
await E.afterRetire({ intent: { id: 12, action: 'core-upgrade', target_version: '1.20.31' }, inst: inst({ id: 70, slug: 'other' }), result: unknownFail, deps: d });
|
|
119
|
+
const r = await E.afterSuccess({ intent: { id: 13, action: 'core-upgrade', target_version: '1.20.32' }, inst: inst(), result: { ok: true, served: '1.20.32' }, deps: d });
|
|
120
|
+
assert.equal(r.closed, 1);
|
|
121
|
+
assert.deepEqual(table.rows.map((x) => x.status), ['resolved', 'open'], 'instance 70 is not instance 7 — a LIKE prefix must not over-match');
|
|
122
|
+
assert.match(table.rows[0].resolution_note, /later core move to 1\.20\.32 succeeded \(intent 13\)/);
|
|
123
|
+
assert.match(posts[posts.length - 1], /^✅ \*\*cloudbongos\*\* took its core update to 1\.20\.32/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await t('a no-op or a failed result closes nothing; nothing open posts no all-clear', async () => {
|
|
127
|
+
const table = blockersTable(); const { posts, d } = deps(table);
|
|
128
|
+
await E.afterRetire({ intent: { id: 11, action: 'core-upgrade', target_version: '1' }, inst: inst(), result: unknownFail, deps: d });
|
|
129
|
+
assert.equal((await E.afterSuccess({ intent: { id: 13, action: 'core-upgrade' }, inst: inst(), result: { ok: true, noop: true }, deps: d })).closed, 0);
|
|
130
|
+
assert.equal((await E.afterSuccess({ intent: { id: 13, action: 'core-upgrade' }, inst: inst(), result: { ok: false }, deps: d })).closed, 0);
|
|
131
|
+
const empty = blockersTable(); const e2 = deps(empty);
|
|
132
|
+
await E.afterSuccess({ intent: { id: 13, action: 'core-upgrade' }, inst: inst(), result: { ok: true, served: '1' }, deps: e2.d });
|
|
133
|
+
assert.equal(e2.posts.length, 0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await t('a database error never throws into the drain', async () => {
|
|
137
|
+
const { d } = deps({ query: async () => { throw new Error('boom'); } });
|
|
138
|
+
assert.deepEqual(await E.afterRetire({ intent: { id: 1, action: 'core-upgrade' }, inst: inst(), result: unknownFail, deps: d }), { filed: false });
|
|
139
|
+
assert.deepEqual(await E.afterSuccess({ intent: { id: 1, action: 'core-upgrade' }, inst: inst(), result: { ok: true, served: '1' }, deps: d }), { closed: 0 });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.log('\nthe drain loop, end to end:');
|
|
143
|
+
|
|
144
|
+
// One move that fails `health` (rolled back, unknown), then — a later, separate move — lands.
|
|
145
|
+
async function drain({ table, row, upgradeFails }) {
|
|
146
|
+
const posts = []; const resolved = [];
|
|
147
|
+
const deps = {
|
|
148
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
149
|
+
postNotice: (text) => posts.push(text),
|
|
150
|
+
db: { query: async (sql, params) => {
|
|
151
|
+
if (/blockers/.test(sql)) return table.query(sql, params);
|
|
152
|
+
if (/SET state='pending'/.test(sql)) { row.state = 'pending'; row.not_before = new Date(Date.now() + params[params.length - 1]); }
|
|
153
|
+
return { rows: [] };
|
|
154
|
+
} },
|
|
155
|
+
provisioning: {
|
|
156
|
+
claimNextIntent: async () => { if (row.state !== 'pending' || (row.not_before && row.not_before > new Date())) return null; row.state = 'running'; row.attempts += 1; return { ...row }; },
|
|
157
|
+
getInstanceById: async () => inst(),
|
|
158
|
+
resolveIntent: async (_db, id, state) => { resolved.push(state); row.state = state; },
|
|
159
|
+
setInstanceStatus: async () => {}, recordEvent: async () => {}, clearErrorNote: async () => {},
|
|
160
|
+
effectiveSettings: () => ({ visibility: 'private' }),
|
|
161
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
162
|
+
},
|
|
163
|
+
exec: (cmd) => {
|
|
164
|
+
if (/upgrade\.js/.test(cmd) && upgradeFails) { const e = new Error('Command failed'); e.stderr = '✖ health check failed after restart\nBONGOS_UPGRADE_OUTCOME {"stage":"rolled_back","reason":"health"}\n'; throw e; }
|
|
165
|
+
return { ok: true, stdout: /\/version/.test(cmd) ? JSON.stringify({ coreVersion: upgradeFails ? '1.20.30' : row.target_version, schemaPending: 0 }) : '' };
|
|
166
|
+
},
|
|
167
|
+
writeFile: () => ({ ok: true }),
|
|
168
|
+
readInstalledCoreVersion: () => '1.20.30', selfInstanceRoot: () => '/srv/cb',
|
|
169
|
+
updateChannel: { DEFAULT_CHANNEL: 'patch', normalizeChannel: (c) => c || 'patch', channelAllows: () => true, listAvailableVersions: () => ({ ok: true, versions: ['1.20.30', '1.20.31', '1.20.32'] }) },
|
|
170
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }), checkCaddyWiring: () => null,
|
|
171
|
+
};
|
|
172
|
+
await P.cmdRunIntents(deps);
|
|
173
|
+
return { posts, resolved };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
await t('a move that fails unknown files ONE blocker; a later move that lands closes it', async () => {
|
|
177
|
+
const table = blockersTable();
|
|
178
|
+
const first = await drain({ table, upgradeFails: true, row: { id: 11, instance_id: 7, action: 'core-upgrade', target_version: '1.20.31', attempts: 0, state: 'pending' } });
|
|
179
|
+
assert.deepEqual(first.resolved, ['error'], 'retired at once — unknown is not retried');
|
|
180
|
+
assert.equal(table.rows.length, 1);
|
|
181
|
+
assert.equal(table.rows[0].source_ref, 'core-move:7:11');
|
|
182
|
+
assert.equal(first.posts.length, 1);
|
|
183
|
+
const later = await drain({ table, upgradeFails: false, row: { id: 12, instance_id: 7, action: 'core-upgrade', target_version: '1.20.32', attempts: 0, state: 'pending' } });
|
|
184
|
+
assert.deepEqual(later.resolved, ['done']);
|
|
185
|
+
assert.equal(table.rows[0].status, 'resolved');
|
|
186
|
+
assert.equal(later.posts.length, 1);
|
|
187
|
+
assert.match(later.posts[0], /^✅ A private project took its core update to 1\.20\.32/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
console.log(`\nmove_escalation: ${passed} passed, ${failed} failed`);
|
|
191
|
+
if (failed) process.exit(1);
|
package/tests/plain_cards.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import * as shipLand from '../scripts/gds/ship-land.js';
|
|
|
18
18
|
|
|
19
19
|
const require = createRequire(import.meta.url);
|
|
20
20
|
const { JARGON_TERMS } = require('../modules/sessions/accessibility.js');
|
|
21
|
-
const { formatClaimFailure } = require('../scripts/gds/claim.js');
|
|
21
|
+
const { formatClaimFailure, QUEUE_GATED_EXIT } = require('../scripts/gds/claim.js');
|
|
22
22
|
const { formatGradeOutcome } = require('../scripts/gds/ship-grade-steps.js');
|
|
23
23
|
|
|
24
24
|
let passed = 0;
|
|
@@ -130,7 +130,7 @@ test('a card interpolates nothing — it is static text, so it adds no injection
|
|
|
130
130
|
test('a refused claim prints the engineer text first and the card after it', () => {
|
|
131
131
|
const r = { status: 409, data: { error: { code: 'REBASE_REQUIRED', message: 'x', details: { rebaseTasks: [{ id: 7, reason: 'strand:pr_not_landed' }] } } } };
|
|
132
132
|
const { lines, exit } = formatClaimFailure(r, 42);
|
|
133
|
-
assert.equal(exit,
|
|
133
|
+
assert.equal(exit, QUEUE_GATED_EXIT, 'the queue-wide gate keeps its own exit code (task 1004396)');
|
|
134
134
|
assert.match(lines[0], /queue is gated/);
|
|
135
135
|
const at = lines.findIndex((l) => l.includes(PLAIN_MARKER));
|
|
136
136
|
assert.ok(at > 0, 'card follows the existing guidance');
|