@bongos/core 1.20.35 → 1.20.37

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.
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/module-assess-tests.js — the Tests part of a module's assessment:
3
+ // run a published module version's OWN test suite and record the pass rate as a
4
+ // module_assessment_signals row (task 1003791, ADR 0343; table from core_265).
5
+ //
6
+ // Why this cannot reuse the unit gate. run-unit-tests.js discovers
7
+ // modules/<key>/tests/*.mjs but runs them only when isModuleEnabled(key) — and a
8
+ // catalog module ships default:false (ADR 0107 §6), so every store module's tests
9
+ // are skipped there by construction (the trap task 1003630 documented). This runs
10
+ // the tests straight from the published TARBALL, with no enabled/disabled check at
11
+ // all, so what is measured is exactly the build a buyer would install.
12
+ //
13
+ // Outcomes (core_265's vocabulary — a missing part is never a zero):
14
+ // scored the module has tests and they ran: score = round(100 × files that
15
+ // passed / files run), sample_size = files run. The unit is a test
16
+ // FILE (one process, its exit code), the same unit the core gate uses.
17
+ // no_data the module declares no tests — NOT a fail (the task says the two
18
+ // must not score the same).
19
+ // not_scored the tarball could not be read or verified, so nothing could run.
20
+ //
21
+ // Where the tests run. A module's tests reach into the core by relative path
22
+ // (`../../../docs/…`) and resolve packages from the core's node_modules, so the
23
+ // module is unpacked at the same depth modules/<key>/ sits at:
24
+ // <core root>/.module-assess-<run>/<key>/ — node's walk-up resolution then finds
25
+ // the core with no junctions to create (and so none a cleanup could follow; see
26
+ // worktree.js). Each file runs in its own node process, serially, with a timeout
27
+ // and a stripped environment: no DATABASE_URL, tokens or keys are handed to
28
+ // third-party code. That is a floor, not a sandbox: a test process can still read
29
+ // any file the OS user can (the core's own .env sits two directories up) and reach
30
+ // the network.
31
+ //
32
+ // SO A PUBLISHED MODULE'S TESTS RUN ONLY IN A SEPARATE TESTING ENVIRONMENT
33
+ // (owner, 2026-09-30): never on the control plane, where the secrets live. The
34
+ // store path (recordTestsSignal) refuses unless the process declares it is that
35
+ // environment with MODULE_TEST_SANDBOX=1 — set only on the isolated test box, no
36
+ // secrets on disk, which is its own task. `--dir` stays open: it runs a module
37
+ // from your own checkout, i.e. code you already chose to run.
38
+ //
39
+ // node scripts/gds/module-assess-tests.js <store_module_versions.id> run + record
40
+ // node scripts/gds/module-assess-tests.js --dir modules/<key> dry run on a
41
+ // local module
42
+ // (prints; no DB)
43
+
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ const crypto = require('node:crypto');
47
+ const { execFile } = require('node:child_process');
48
+ const { isCleanTeardownAbort } = require('./run-unit-tests');
49
+
50
+ const CORE_ROOT = path.resolve(__dirname, '..', '..');
51
+ const PER_FILE_TIMEOUT_MS = 90_000; // the unit gate's own per-file cap
52
+ const SANDBOX_PREFIX = '.module-assess-';
53
+ // The only variables a module's test process inherits. PATH to find tools; the
54
+ // Windows ones node needs to start at all; nothing that names a credential.
55
+ const ENV_KEEP = ['PATH', 'Path', 'SystemRoot', 'SYSTEMROOT', 'windir', 'TEMP', 'TMP', 'TMPDIR', 'COMSPEC', 'PATHEXT'];
56
+
57
+ // The module's test files: tests/*.mjs at the top of tests/, exactly what
58
+ // run-unit-tests.js discovers for a module on disk. `files` is module-relative paths.
59
+ function discoverTestFiles(paths) {
60
+ return paths.filter((p) => /^tests\/[^/]+\.mjs$/.test(p)).sort();
61
+ }
62
+
63
+ function testEnv(sandbox) {
64
+ const env = {};
65
+ for (const k of ENV_KEEP) if (process.env[k] !== undefined) env[k] = process.env[k];
66
+ env.NODE_ENV = 'test';
67
+ env.HOME = sandbox;
68
+ env.USERPROFILE = sandbox;
69
+ return env;
70
+ }
71
+
72
+ // Run one file; resolve { file, passed, timedOut, code, tail }. Never rejects.
73
+ function runFile(file, { cwd, env, timeoutMs, exec = execFile }) {
74
+ return new Promise((resolve) => {
75
+ exec(process.execPath, [file], { cwd, env, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, windowsHide: true },
76
+ (err, stdout, stderr) => {
77
+ const out = String(stdout || '');
78
+ const errText = String(stderr || '');
79
+ // A suite that passed but aborted in libuv teardown on Windows passed (task 1003845).
80
+ const passed = !err || isCleanTeardownAbort(out, errText);
81
+ const timedOut = !!(err && err.killed);
82
+ // Why it failed: the suite's last stdout lines (its verdict) and the HEAD of
83
+ // stderr, where node puts the thrown error — its tail is only the stack and version.
84
+ const lines = (t, n, fromEnd) => { const ls = t.split(/\r?\n/).filter((l) => l.trim()); return (fromEnd ? ls.slice(-n) : ls.slice(0, n)).join('\n'); };
85
+ const tail = passed ? '' : [lines(out, 3, true), lines(errText, 4, false)].filter(Boolean).join('\n').slice(0, 600);
86
+ resolve({ passed, timedOut, code: err ? (err.code ?? null) : 0, tail });
87
+ });
88
+ });
89
+ }
90
+
91
+ // Remove a sandbox this file made. It holds only real files written by placeFiles
92
+ // (never a link), and the name check means a bad argument cannot aim this anywhere else.
93
+ function removeSandbox(dir, root) {
94
+ if (!path.basename(dir).startsWith(SANDBOX_PREFIX) || path.dirname(dir) !== root) {
95
+ throw new Error(`refusing to remove ${dir}: not a module-assess sandbox`);
96
+ }
97
+ fs.rmSync(dir, { recursive: true, force: true });
98
+ }
99
+
100
+ function placeFiles(files, target) {
101
+ for (const f of files) {
102
+ const dest = path.join(target, ...f.path.split('/'));
103
+ if (!dest.startsWith(target + path.sep)) throw new Error(`"${f.path}" would land outside the module`);
104
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
105
+ fs.writeFileSync(dest, f.buf);
106
+ }
107
+ }
108
+
109
+ // The gate in front of running module code. `trusted` is only for code you
110
+ // already chose to run (a module in your own checkout, `--dir`). Anything else — a
111
+ // published, third-party module — runs only where the process declares itself the
112
+ // separate testing environment (MODULE_TEST_SANDBOX=1) AND nothing secret is
113
+ // visible: no credential-looking variable in the environment, no .env at the core
114
+ // root. The second half is what stops a mis-set flag on a real server from
115
+ // quietly reopening the hole. Returns null (go) or { code, message }.
116
+ const SECRET_ENV_RE = /(^DATABASE_URL$|^PG(PASSWORD|PASSFILE)$|TOKEN|SECRET|PASSWORD|API_KEY|PRIVATE_KEY|_KEY$)/i;
117
+ function inTestSandbox(env = process.env) {
118
+ return env.MODULE_TEST_SANDBOX === '1';
119
+ }
120
+ function runRefusal({ trusted = false, env = process.env, root = CORE_ROOT } = {}) {
121
+ if (trusted) return null;
122
+ if (!inTestSandbox(env)) {
123
+ return { code: 'not_in_test_sandbox', message: "refusing to run a published module's tests here: they run only in the separate testing environment (MODULE_TEST_SANDBOX=1), never on a server holding secrets" };
124
+ }
125
+ const leaked = Object.keys(env).filter((k) => SECRET_ENV_RE.test(k) && env[k]);
126
+ if (leaked.length || fs.existsSync(path.join(root, '.env'))) {
127
+ return { code: 'secrets_present', message: `refusing: MODULE_TEST_SANDBOX=1 is set but secrets are visible here (${leaked.length ? leaked.sort().join(', ') : '.env at the core root'}) — the testing environment must hold none` };
128
+ }
129
+ return null;
130
+ }
131
+
132
+ // Run a module's own tests from its files ([{ path, buf }], module-relative) and
133
+ // return the signal fields: { outcome, score, sample_size, reason, detail }.
134
+ // Pure apart from the sandbox it writes and removes; `exec` is injectable for tests.
135
+ async function assessTests(key, files, { root = CORE_ROOT, timeoutMs = PER_FILE_TIMEOUT_MS, exec, trusted = false, env = process.env } = {}) {
136
+ // Checked here too, not only in recordTestsSignal, so no caller can reach the
137
+ // code that runs a module without passing the gate.
138
+ const refused = runRefusal({ trusted, env, root });
139
+ if (refused) throw Object.assign(new Error(refused.message), { code: refused.code });
140
+ const tests = discoverTestFiles(files.map((f) => f.path));
141
+ if (tests.length === 0) {
142
+ return { outcome: 'no_data', score: null, sample_size: 0, reason: 'the module declares no tests (no tests/*.mjs)', detail: { files: [] } };
143
+ }
144
+ const sandbox = path.join(root, `${SANDBOX_PREFIX}${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
145
+ const moduleDir = path.join(sandbox, key);
146
+ try {
147
+ placeFiles(files, moduleDir);
148
+ const env = testEnv(sandbox);
149
+ const results = [];
150
+ for (const t of tests) {
151
+ // Serial on purpose, like the unit gate: tests share the core tree around them.
152
+ const r = await runFile(path.join(moduleDir, ...t.split('/')), { cwd: moduleDir, env, timeoutMs, exec });
153
+ results.push({ file: t, ...r });
154
+ }
155
+ const passed = results.filter((r) => r.passed).length;
156
+ const failed = results.filter((r) => !r.passed);
157
+ return {
158
+ outcome: 'scored',
159
+ score: Math.round((100 * passed) / results.length),
160
+ sample_size: results.length,
161
+ reason: failed.length ? `${failed.length} of ${results.length} test file(s) failed` : `all ${results.length} test file(s) passed`,
162
+ detail: { files: results.map(({ file, passed: ok, timedOut, tail }) => ({ file, passed: ok, ...(timedOut ? { timed_out: true } : {}), ...(tail ? { tail } : {}) })) },
163
+ };
164
+ } finally {
165
+ removeSandbox(sandbox, root);
166
+ }
167
+ }
168
+
169
+ // Append one Tests signal row for a store version. Never UPDATEs (core_265 is
170
+ // append-only: the current result is the newest row per (version, part)).
171
+ async function insertSignal(versionId, s, { db }) {
172
+ const { rows: [row] } = await db.query(
173
+ `INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
174
+ VALUES ($1, 'tests', $2, $3, $4, $5, $6)
175
+ RETURNING id, version_id, part, outcome, score, sample_size, reason, measured_at`,
176
+ [versionId, s.outcome, s.score, s.sample_size, s.reason, JSON.stringify(s.detail || {})]);
177
+ return row;
178
+ }
179
+
180
+ // Read, verify and test one published version, then record the result. A tarball
181
+ // that is missing or fails verification is recorded as not_scored — the part could
182
+ // not be measured, which is not the same as the module failing its tests.
183
+ async function recordTestsSignal(versionId, { db, storeDir, root, timeoutMs, exec, env = process.env } = {}) {
184
+ const refused = runRefusal({ env, root });
185
+ if (refused) return { ok: false, ...refused };
186
+ const pool = db || require('../../src/bongos/pool').pool;
187
+ const { versionArtifactFile } = require('../../src/bongos/module-store');
188
+ const { verifyModuleArtifact } = require('./module-artifact');
189
+ const { rows: [ver] } = await pool.query(
190
+ 'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
191
+ if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
192
+
193
+ let signal;
194
+ try {
195
+ const file = versionArtifactFile(ver, storeDir ? { dir: storeDir } : {});
196
+ const tgz = await fs.promises.readFile(file);
197
+ const v = await verifyModuleArtifact(tgz, { key: ver.module_key, includeFiles: true });
198
+ signal = v.ok
199
+ ? await assessTests(ver.module_key, v.files, { root, timeoutMs, exec, env })
200
+ : { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code}): ${v.message}`, detail: {} };
201
+ } catch (e) {
202
+ signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the tests could not run (${e.code || 'error'})`, detail: {} };
203
+ }
204
+ const row = await insertSignal(ver.id, signal, { db: pool });
205
+ return { ok: true, version: ver, signal: row };
206
+ }
207
+
208
+ // Local dry run: read modules/<key>/ from disk and print what the signal would be.
209
+ function readModuleDir(dir) {
210
+ const out = [];
211
+ const walk = (rel) => {
212
+ for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
213
+ if (e.name === 'node_modules' || e.name === '.git') continue;
214
+ const r = rel ? `${rel}/${e.name}` : e.name;
215
+ if (e.isDirectory()) walk(r);
216
+ else if (e.isFile()) out.push({ path: r, buf: fs.readFileSync(path.join(dir, r)) });
217
+ }
218
+ };
219
+ walk('');
220
+ return out;
221
+ }
222
+
223
+ async function main(argv) {
224
+ const i = argv.indexOf('--dir');
225
+ if (i !== -1) {
226
+ const dir = path.resolve(argv[i + 1] || '');
227
+ const key = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8')).key;
228
+ const s = await assessTests(key, readModuleDir(dir), { trusted: true });
229
+ console.log(JSON.stringify(s, null, 2));
230
+ return 0;
231
+ }
232
+ const id = argv[0];
233
+ if (!/^\d+$/.test(String(id || ''))) {
234
+ console.error('usage: node scripts/gds/module-assess-tests.js <store_module_versions.id> | --dir modules/<key>');
235
+ return 2;
236
+ }
237
+ const res = await recordTestsSignal(id);
238
+ if (!res.ok) { console.error(res.message); return 1; }
239
+ const s = res.signal;
240
+ console.log(`${res.version.module_key} ${res.version.version}: tests ${s.outcome}${s.score !== null ? ` ${s.score}/100` : ''} — ${s.reason} (signal ${s.id})`);
241
+ return 0;
242
+ }
243
+
244
+ if (require.main === module) {
245
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e.stack || e.message); process.exit(1); });
246
+ }
247
+
248
+ module.exports = { discoverTestFiles, assessTests, recordTestsSignal, insertSignal, runRefusal, testEnv, SANDBOX_PREFIX };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.35'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.37'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -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' }),
@@ -431,7 +431,7 @@ await t('schema_pending passes through as a count, and UNKNOWN stays null rather
431
431
  await t('an open intent is surfaced with its target, so the page can say what is already running', () => {
432
432
  const p = CU.previewFromSnapshot(previewInst, { served_version: '1.19.758', available: [], recommended: null },
433
433
  { action: 'core-upgrade', state: 'running', target_version: '1.19.762' });
434
- assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762', not_before: null, last_error: null });
434
+ assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762', not_before: null, last_error: null, failure: null });
435
435
  });
436
436
 
437
437
  console.log('\nthe deploy page is gated on the same atom as its routes:');
@@ -1007,5 +1007,12 @@ await t('the preview says when a waiting move will try again, and why it is wait
1007
1007
  'an ordinary pending intent shows no error');
1008
1008
  });
1009
1009
 
1010
+ await t('a waiting move says what KIND of failure it is waiting out; a plain pending one says none (task 1004450)', () => {
1011
+ const p = CU.previewFromSnapshot(ACTIVE, null, waiting({ failure_class: 'known_wedge', failure_reason: 'dirty_tree' }), null);
1012
+ assert.deepEqual(p.open_intent.failure, { class: 'known_wedge', reason: 'dirty_tree' });
1013
+ assert.equal(CU.previewFromSnapshot(ACTIVE, null, waiting({ not_before: null, failure_class: 'transient' }), null).open_intent.failure, null);
1014
+ assert.equal(CU.previewFromSnapshot(ACTIVE, null, waiting(), null).open_intent.failure, null, 'a row from before the columns');
1015
+ });
1016
+
1010
1017
  console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
1011
1018
  if (failed) process.exit(1);