@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.
@@ -130,6 +130,178 @@
130
130
  return v ? `<span class="ov-mono">${escapeHtml(String(v))}</span>` : '<span class="ps-pill ps-pill--absent">no reading</span>';
131
131
  }
132
132
 
133
+ // ---- why a move stopped, in words (task 1004450) ---------------------------
134
+ //
135
+ // The runner classifies every failed move (scripts/gds/upgrade-outcome.js) into one of
136
+ // four kinds, each with a fixed reason code. The page used to show only the runner's raw
137
+ // output, addressed to someone holding a shell. These are the same reasons in the
138
+ // owner's words. A code with no entry falls back to a plain "no words for this" rather
139
+ // than a guess — the raw output is still one click away.
140
+ const REASON_WORDS = {
141
+ registry_unreadable: 'the list of published versions could not be read',
142
+ not_published: 'that version is not published yet',
143
+ npm_install: 'installing the new core stopped part-way',
144
+ dirty_tree: 'the project had unsaved file changes on its machine',
145
+ disk_full: 'the machine ran out of disk space',
146
+ schema_pending: 'the project’s database was behind its code',
147
+ outside_channel: 'that version is outside this project’s update rule',
148
+ shape_not_automated: 'this kind of project is not moved from this page',
149
+ downgrade: 'that version is older than the one running',
150
+ artist_gate: 'an artist review has to pass first',
151
+ module_incompatible: 'a module this project uses is not ready for that core',
152
+ remedy_did_not_hold: 'the automatic fix was tried once and the same problem came back',
153
+ remedy_failed: 'the automatic fix itself did not work',
154
+ health: 'the project did not answer after restarting on the new core',
155
+ migrate: 'updating the project’s database failed',
156
+ restart: 'the project would not restart',
157
+ rollback_failed: 'the new core failed, and putting the old one back failed too',
158
+ no_target: 'the move did not say which version to go to',
159
+ bad_slug: 'the project’s name on the machine could not be used',
160
+ installed_unreadable: 'the version installed on the machine could not be read',
161
+ served_mismatch: 'the project was not serving the version its machine had installed',
162
+ already_on: 'the project is already on that version',
163
+ db_unresolvable: 'the machine could not tell which database belongs to this project',
164
+ db_identity: 'the project’s database did not match the one the move expected',
165
+ health_url_missing: 'there was no address to check the project was answering after the move',
166
+ core_source: 'the new core could not be found to install',
167
+ integrity_pin: 'the new core did not match the checksum it was published with',
168
+ };
169
+ const reasonWords = (f) => (f && REASON_WORDS[f.reason]) || 'the upgrade stopped for a reason this page has no words for yet';
170
+ // What the runner's one automatic fix (scripts/gds/wedge-remedy.js) did for a known snag.
171
+ const REMEDY_WORDS = {
172
+ dirty_tree: 'saved those changes',
173
+ disk_full: 'cleared out old backups',
174
+ schema_pending: 'brought the database up to date',
175
+ };
176
+
177
+ // A pending move whose next try is in the FUTURE is waiting out a retry, not moving. The
178
+ // runner re-queues a failed move as pending with a not_before (task 1004447), so "pending"
179
+ // alone cannot tell the two apart — and treating a six-hour wait as a move in flight is
180
+ // how the page used to poll for five minutes and then say "still working".
181
+ function waitingOf(i) {
182
+ return !!(i && i.state === 'pending' && i.not_before && new Date(i.not_before).getTime() > Date.now());
183
+ }
184
+
185
+ // "14:05, in about 40 min" — when a waiting move tries again, as a clock time the owner
186
+ // can hold the page to, and the distance to it.
187
+ function whenNext(iso) {
188
+ const t = new Date(iso);
189
+ const mins = Math.max(1, Math.round((t.getTime() - Date.now()) / 60000));
190
+ const dist = mins < 90 ? `${mins} min` : `${Math.round(mins / 60)}h`;
191
+ let clock = '';
192
+ try { clock = t.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch (e) { clock = ''; }
193
+ return clock ? `${clock}, in about ${dist}` : `in about ${dist}`;
194
+ }
195
+
196
+ // A move waiting to try again. The press it offers is the SAME move, sooner: the route
197
+ // brings a waiting move's retry forward rather than refusing it (enqueueCoreUpgrade).
198
+ function waitingHtml(p) {
199
+ const i = p.open_intent;
200
+ const f = i.failure;
201
+ const at = escapeHtml(whenNext(i.not_before));
202
+ const to = escapeHtml(String(i.target_version || ''));
203
+ let body;
204
+ if (f && f.class === 'known_wedge') {
205
+ const did = REMEDY_WORDS[f.reason];
206
+ body = `<strong>A known snag stopped the move to core ${to}:</strong> ${escapeHtml(reasonWords(f))}.
207
+ ${did ? `It ${escapeHtml(did)} on its own, and` : 'It'} tries the move once more at ${at}.`;
208
+ } else if (f && f.class === 'transient') {
209
+ body = `<strong>Waiting to try core ${to} again at ${at}.</strong> The last try stopped because
210
+ ${escapeHtml(reasonWords(f))} — that is usually temporary, so it tries again on its own.`;
211
+ } else {
212
+ body = `<strong>Waiting to try core ${to} again at ${at}.</strong>`;
213
+ }
214
+ return `
215
+ <div class="dp-settled dp-settled--waiting">
216
+ <p class="ov-row__sub">${body} Nothing needs doing — this page shows the result when it lands.</p>
217
+ <div class="ps-controls">
218
+ <button type="button" class="btn" id="dp-retry-${escapeHtml(String(p.instance_id))}">Try now</button>
219
+ </div>
220
+ </div>`;
221
+ }
222
+
223
+ // A move that stopped for good, worded by what KIND of stop it was. Each kind says what
224
+ // happens next, because that is the question the owner is left with: is anything still
225
+ // being done about this, and is any of it mine to do?
226
+ const BLOCKERS_LINK = '<a href="/blockers">the owner’s blockers</a>';
227
+ function failureHtml(p, m, when, detail) {
228
+ const f = m.failure;
229
+ const cls = f && f.class;
230
+ const id = escapeHtml(String(p.instance_id));
231
+ const to = escapeHtml(String(m.target_version));
232
+ const still = `This project is still running core ${escapeHtml(String(p.served_version))}.`;
233
+ const card = (mod, head, rest, action = '') => `
234
+ <div class="dp-settled dp-settled--${mod}">
235
+ <p class="ov-row__sub"><strong>${head}</strong>${rest}</p>
236
+ ${action}
237
+ ${detail}
238
+ </div>`;
239
+ // A row from before the runner classified failures: the old wording, unchanged.
240
+ if (!cls) return card('failed', `The move to core ${to} did not land`, ` — attempted${when}. ${still}`);
241
+ const why = escapeHtml(reasonWords(f));
242
+ if (f.reason === 'rollback_failed') {
243
+ return card('failed', `The move to core ${to} failed, and the old core could not be put back`,
244
+ ` — attempted${when}. This project may be down. A person needs to look; it has been filed in ${BLOCKERS_LINK}.`);
245
+ }
246
+ if (cls === 'needs_decision') {
247
+ let action = '';
248
+ if (f.reason === 'outside_channel' && p.update_channel !== 'minor') {
249
+ action = `<div class="ps-controls">
250
+ <button type="button" class="btn btn--primary" id="dp-allow-${id}">Allow new releases and move to ${to}</button>
251
+ </div>`;
252
+ }
253
+ const next = {
254
+ outside_channel: action ? ' Allowing new releases changes this project’s update rule, then makes the move.' : ' Change the update rule below, then move again.',
255
+ artist_gate: ' Approve the artist review, or change the artist gate in this project’s settings, then move again.',
256
+ downgrade: ' Nothing to do unless you meant to go back to an older core.',
257
+ module_incompatible: ' Turn that module off in this project’s settings and move again, or wait for a core it works with — the technical detail below names the module.',
258
+ shape_not_automated: ' Whoever runs this project’s machine moves its core there.',
259
+ }[f.reason] || '';
260
+ return card('decision', `Your call: the move to core ${to} is waiting on a decision`,
261
+ ` — ${why}. Attempted${when}. ${still}${next}`, action);
262
+ }
263
+ if (cls === 'transient') {
264
+ const retry = String(m.target_version) === String(p.recommended_version)
265
+ ? `<div class="ps-controls"><button type="button" class="btn" id="dp-again-${id}">Try again</button></div>` : '';
266
+ return card('failed', `The move to core ${to} kept stopping`,
267
+ ` — ${why}. That is usually temporary, so it tried again on its own over several hours, then gave up${when}. ${still}`, retry);
268
+ }
269
+ // NO blockers link here, deliberately: move-escalation.js files one only for an unknown
270
+ // failure or a failed rollback, so pointing at the list would send the owner to look for
271
+ // a row that does not exist. The step is named instead.
272
+ if (cls === 'known_wedge') {
273
+ return card('failed', `The move to core ${to} hit a known snag it could not fix here`,
274
+ ` — ${why}. Attempted${when}. ${still} The automatic fix could not be applied on this project’s
275
+ machine, so whoever runs that machine clears the snag; the technical detail below says what it is.
276
+ After that, the move can be pressed again.`);
277
+ }
278
+ return card('failed', `The move to core ${to} did not land, and nothing automatic can fix it`,
279
+ ` — ${why}. Attempted${when}. ${still} It has been filed in ${BLOCKERS_LINK} for a person to look at.`);
280
+ }
281
+
282
+ // The project's update rule (task 1004445): how far the runner may move its core. A
283
+ // RULE, never a move — saving one queues nothing and restarts nothing.
284
+ const RULES = [
285
+ ['pinned', 'Stay put', 'no new versions are offered; this project stays on the core it runs'],
286
+ ['patch', 'Fixes only', 'takes fixes within the release line it is on'],
287
+ ['minor', 'Fixes and new releases', 'also takes the next release line when one comes out'],
288
+ ];
289
+ function ruleHtml(p) {
290
+ const cur = RULES.find((r) => r[0] === p.update_channel);
291
+ if (!cur) return '';
292
+ const id = escapeHtml(String(p.instance_id));
293
+ const opts = RULES.map(([k, label]) => `<option value="${k}"${k === cur[0] ? ' selected' : ''}>${escapeHtml(label)}</option>`).join('');
294
+ return `
295
+ <div class="dp-rule">
296
+ <p class="ov-row__sub"><strong>Update rule: ${escapeHtml(cur[1])}</strong> — ${escapeHtml(cur[2])}.</p>
297
+ <div class="ps-controls">
298
+ <label class="dp-rule__label" for="dp-rule-sel-${id}">Change to</label>
299
+ <select id="dp-rule-sel-${id}" class="dp-rule__select">${opts}</select>
300
+ <button type="button" class="btn" id="dp-rule-save-${id}">Save rule</button>
301
+ </div>
302
+ </div>`;
303
+ }
304
+
133
305
  // ---- the states ONE box can be in ----------------------------------------
134
306
 
135
307
  // Something is already running. The press is not offered at all — offering a button
@@ -141,6 +313,7 @@
141
313
  // reload, and neither is being watched — so it says what is true instead.
142
314
  function busyHtml(p) {
143
315
  const i = p.open_intent;
316
+ if (waitingOf(i)) return waitingHtml(p);
144
317
  return `
145
318
  <p class="ov-row__sub">A <strong>${escapeHtml(i.action)}</strong> is ${escapeHtml(i.state)} for this box${
146
319
  i.target_version ? ` (to core ${escapeHtml(i.target_version)})` : ''}.
@@ -186,7 +359,7 @@
186
359
  // verbatim and kept CLOSED: the owner cannot run `sudo -u`, so 30 lines of it (the
187
360
  // dirty-tree refusal prints a whole `git status`) is not what a deploy page leads on.
188
361
  const detail = m.error
189
- ? `<details class="dp-settled__detail"><summary>What the upgrade said</summary>
362
+ ? `<details class="dp-settled__detail"><summary>Technical detail — what the upgrade said</summary>
190
363
  <pre class="dp-preflight__error">${escapeHtml(String(m.error))}</pre></details>`
191
364
  : '';
192
365
  if (m.state === 'error' && m.superseded) {
@@ -199,14 +372,7 @@
199
372
  ${detail}
200
373
  </div>`;
201
374
  }
202
- if (m.state === 'error') {
203
- return `
204
- <div class="dp-settled dp-settled--failed">
205
- <p class="ov-row__sub"><strong>The move to core ${escapeHtml(String(m.target_version))} did not land</strong>
206
- — attempted${when}. This project is still running core ${escapeHtml(String(p.served_version))}.</p>
207
- ${detail}
208
- </div>`;
209
- }
375
+ if (m.state === 'error') return failureHtml(p, m, when, detail);
210
376
  // LANDED, BUT GITHUB DID NOT GET IT (task 1004065). The runner pushes a hosted
211
377
  // project's new pin to its repo after the move; when it could not, the move is still
212
378
  // done — the site runs the new core — and `warning` says why the repo was not updated.
@@ -439,6 +605,7 @@
439
605
  : ''}
440
606
  ${schemaBehindHtml(p)}
441
607
  ${action}
608
+ ${ruleHtml(p)}
442
609
  </div></li>`;
443
610
  }
444
611
 
@@ -463,6 +630,54 @@
463
630
  if (cancel) cancel.addEventListener('click', () => { steps.set(p.instance_id, 'preview'); repaint(); });
464
631
  const confirm = $(`dp-confirm-${id}`);
465
632
  if (confirm) confirm.addEventListener('click', () => move(p));
633
+ // Task 1004450: the waiting move, sooner; a stopped temporary one, again; the one-click
634
+ // answer to an update-rule refusal; and the rule itself.
635
+ const retry = $(`dp-retry-${id}`);
636
+ if (retry && p.open_intent) retry.addEventListener('click', () => move(p, p.open_intent.target_version));
637
+ const again = $(`dp-again-${id}`);
638
+ if (again && p.last_move) again.addEventListener('click', () => move(p, p.last_move.target_version));
639
+ const allow = $(`dp-allow-${id}`);
640
+ if (allow && p.last_move) allow.addEventListener('click', () => allowAndMove(p, p.last_move.target_version, allow));
641
+ const save = $(`dp-rule-save-${id}`);
642
+ if (save) save.addEventListener('click', () => saveRule(p, save));
643
+ }
644
+
645
+ // Change the rule, then report the server's own sentence and re-read the row.
646
+ async function setRule(p, channel) {
647
+ const r = await sendJson('POST', `/provisioning/instances/${p.instance_id}/update-channel`, { channel });
648
+ toast(r.message || 'Update rule saved.');
649
+ return r;
650
+ }
651
+
652
+ async function saveRule(p, btn) {
653
+ const sel = $(`dp-rule-sel-${p.instance_id}`);
654
+ const channel = sel && sel.value;
655
+ if (!channel) return;
656
+ btn.disabled = true;
657
+ try {
658
+ await setRule(p, channel);
659
+ } catch (e) {
660
+ if (e && e.kind === '401') return;
661
+ toast(e.message || 'Could not change the update rule.');
662
+ btn.disabled = false;
663
+ return;
664
+ }
665
+ await refreshOne(p);
666
+ }
667
+
668
+ // "Allow new releases and move": the rule first, and only if it took, the move — a move
669
+ // queued under the old rule would be refused the same way again.
670
+ async function allowAndMove(p, to, btn) {
671
+ if (btn) { btn.disabled = true; btn.textContent = 'Changing the rule…'; }
672
+ try {
673
+ await setRule(p, 'minor');
674
+ } catch (e) {
675
+ if (e && e.kind === '401') return;
676
+ toast(e.message || 'Could not change the update rule.');
677
+ if (btn) { btn.disabled = false; btn.textContent = `Allow new releases and move to ${to}`; }
678
+ return;
679
+ }
680
+ await move(p, to);
466
681
  }
467
682
 
468
683
  // What the page is currently showing: the ONE box this hall is about, whether that box
@@ -491,11 +706,12 @@
491
706
  if (view.box) wire(view.box);
492
707
  }
493
708
 
494
- async function move(p) {
709
+ // `to` defaults to the version the owner was SHOWN; the retry buttons pass the move they
710
+ // are retrying, which is also a version on screen.
711
+ async function move(p, to = p.recommended_version) {
495
712
  const btn = $(`dp-confirm-${p.instance_id}`);
496
713
  if (btn) { btn.disabled = true; btn.textContent = 'Queueing…'; }
497
714
  const id = String(p.instance_id);
498
- const to = p.recommended_version;
499
715
  try {
500
716
  // `p.recommended_version` — the version this owner was SHOWN for THIS box, not
501
717
  // whatever is newest at the moment of the click.
@@ -532,6 +748,10 @@
532
748
  // that "done" would drop the row out of its in-flight state early.
533
749
  if (!now.open_intent && now.served_version === to) { settled = true; break; }
534
750
  if (!now.open_intent && now.last_move && now.last_move.state === 'error') { settled = true; break; }
751
+ // WAITING IS SETTLED TOO (task 1004450). A pending move whose next try is minutes or
752
+ // hours away is not in flight; the row says when it tries again, and watching it for
753
+ // five minutes would end in a "still working" toast about a move that is not running.
754
+ if (waitingOf(now.open_intent)) { settled = true; break; }
535
755
  }
536
756
  moving.delete(p.instance_id);
537
757
  repaint();
@@ -186,3 +186,31 @@
186
186
  padding: 0.35rem 0;
187
187
  min-height: 24px;
188
188
  }
189
+
190
+ /* Why a move stopped, by kind (task 1004450). WAITING is the accent, like a move in
191
+ flight — something is still being done, on its own clock. A DECISION is the warn mark:
192
+ nothing is broken, and the next step is the owner's. */
193
+ .dp-settled--waiting {
194
+ border-left: 3px solid var(--accent);
195
+ padding-left: 0.7rem;
196
+ }
197
+ .dp-settled--decision {
198
+ border-left: 3px solid var(--warn);
199
+ padding-left: 0.7rem;
200
+ }
201
+ .dp-settled .ps-controls { margin: 0.4rem 0 0.2rem; }
202
+
203
+ /* The project's update rule (task 1004445) — a quiet setting under the move, set apart by
204
+ a hairline so it does not read as part of whichever state sits above it. */
205
+ .dp-rule {
206
+ margin: 0.9rem 0 0;
207
+ padding-top: 0.7rem;
208
+ border-top: 1px solid var(--rule-soft);
209
+ }
210
+ .dp-rule .ps-controls { align-items: center; gap: 0.5rem; }
211
+ .dp-rule__label { font-size: 0.86rem; color: var(--ink-soft); }
212
+ .dp-rule__select { min-height: 32px; }
213
+ /* A link inside a stopped move's sentence ("the owner's blockers") is a control and owes
214
+ the 24px floor: the prose-link recipe buys the height with padding and hands it back
215
+ with an equal negative margin, so the line does not re-space. */
216
+ .dp-settled .ov-row__sub a { display: inline-block; min-height: 24px; padding: 3px 0; margin: -3px 0; }
@@ -348,7 +348,10 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
348
348
  preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
349
349
  // not_before: a failed move waiting out its retry backoff (task 1004447) — the page can
350
350
  // say "trying again at …" instead of an unexplained "pending" for up to six hours.
351
- open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null, not_before: openIntent.not_before || null, last_error: openIntent.not_before && openIntent.last_error ? String(openIntent.last_error).slice(0, 600) : null } : null,
351
+ open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null, not_before: openIntent.not_before || null, last_error: openIntent.not_before && openIntent.last_error ? String(openIntent.last_error).slice(0, 600) : null,
352
+ // WHY it is waiting (task 1004450): a transient retry and a known snag the runner just
353
+ // fixed wait the same way, and the page words them differently. Only on a waiting row.
354
+ failure: openIntent.not_before && openIntent.failure_class ? { class: openIntent.failure_class, reason: openIntent.failure_reason || null } : null } : null,
352
355
  // THE LAST MOVE, WHATEVER BECAME OF IT (task 1004145).
353
356
  //
354
357
  // `open_intent` is `state IN ('pending','running')` — by the index's own definition.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.35",
3
+ "version": "1.20.37",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.35",
9
+ "version": "1.20.37",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.35",
3
+ "version": "1.20.37",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8297,5 +8297,21 @@
8297
8297
  "id": "1004457",
8298
8298
  "text": "The project setup wizard now offers Render's Free plan for your app, with a plain note that a free app sleeps when idle and takes about a minute to wake. Starter stays the default. The build and start command boxes no longer l"
8299
8299
  }
8300
+ ],
8301
+ "1.20.36": [
8302
+ {
8303
+ "id": "1004450",
8304
+ "text": "The deploy page now explains why an update stopped in plain words, says when it will try again, and offers a one-click fix when the only thing in the way is your own update setting. You can also see and change how far a projec"
8305
+ }
8306
+ ],
8307
+ "1.20.37": [
8308
+ {
8309
+ "id": "1003791",
8310
+ "text": "A module's own tests can now be run and scored: the store records what share of them pass, and a module with no tests shows as 'no data yet' instead of failing."
8311
+ },
8312
+ {
8313
+ "id": "1004406",
8314
+ "text": "The unattended runner stops restarting every time anyone merges: it now updates its code in place and only restarts when its own code has changed."
8315
+ }
8300
8316
  ]
8301
8317
  }
@@ -359,7 +359,7 @@ const MAX_CLAIM_ATTEMPTS = 5;
359
359
  const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
360
360
 
361
361
  function newRunnerState() {
362
- return { setAside: new Map(), queueGate: null, limitUntil: null };
362
+ return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null };
363
363
  }
364
364
  const RUNNER_STATE = newRunnerState();
365
365
 
@@ -666,13 +666,16 @@ function heartbeatPath() {
666
666
  // output, reddening `unit` on main and blocking every PR. Use a regular FILE as the
667
667
  // parent directory instead: mkdir under it is ENOTDIR immediately, everywhere, and it
668
668
  // does not depend on the caller's permissions.
669
- function writeHeartbeat(now = Date.now()) {
669
+ function writeHeartbeat(now = Date.now(), runnerState = RUNNER_STATE) {
670
670
  try {
671
671
  const p = heartbeatPath();
672
672
  fs.mkdirSync(path.dirname(p), { recursive: true });
673
673
  // `head` is what this process is RUNNING, not what is on disk (task 1004374).
674
674
  // Alive and alive-on-stale-code look identical from a check-in age alone.
675
- fs.writeFileSync(p, JSON.stringify({ pid: process.pid, at: now, head: LOADED_HEAD }));
675
+ // `disk_head` appears only when the checkout was refreshed in place past what
676
+ // is running (task 1004406): "up to date on disk, old supervisor" is visible.
677
+ const disk = runnerState && runnerState.diskHead && runnerState.diskHead !== LOADED_HEAD ? runnerState.diskHead : undefined;
678
+ fs.writeFileSync(p, JSON.stringify({ pid: process.pid, at: now, head: LOADED_HEAD, ...(disk ? { disk_head: disk } : {}) }));
676
679
  return true;
677
680
  } catch (_) { return false; }
678
681
  }
@@ -851,10 +854,79 @@ async function codeDrifted(deps = {}) {
851
854
  // the comparison untestable in isolation.
852
855
  const mine = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
853
856
  if (!mine) return null;
857
+ // Measured from what is on DISK once the checkout has been refreshed in place
858
+ // (task 1004406), or every loop after a refresh would see the same drift again.
859
+ const base = ((deps.state || RUNNER_STATE).diskHead) || mine;
854
860
  let tip = null;
855
861
  try { tip = await runFetchHead(deps); } catch (_) { return null; }
856
862
  if (!tip) return null;
857
- return tip !== mine ? { from: mine.slice(0, 8), to: String(tip).slice(0, 8) } : false;
863
+ return tip !== base ? { from: base.slice(0, 8), to: String(tip).slice(0, 8) } : false;
864
+ }
865
+
866
+ // ── pull always, restart rarely (task 1004406) ──────────────────────────────
867
+ //
868
+ // Owner-approved 2026-09-30. Main moves every time ANY session lands, and a
869
+ // relaunch per merge meant a busy day of restarts (four in fourteen minutes on
870
+ // 2026-09-29). But a restart only refreshes code THIS process holds in memory:
871
+ // every worker, and every claim.js / worktree.js / release.js the loop shells
872
+ // out to, is a fresh process that runs whatever is on disk. So on drift the
873
+ // checkout is updated in place, and the process exits for the wrapper only when
874
+ // a change touches code it has actually loaded.
875
+ //
876
+ // SUPERVISOR_PATH_RES catch the supervisor's own files even before they are
877
+ // required (autobongos-verify.js is loaded lazily on the first ship), so a change
878
+ // to a file the loop is ABOUT to load cannot mix old callers with a new callee.
879
+ const SUPERVISOR_PATH_RES = [/^scripts\/gds\/autobongos-[^/]+$/, /^modules\/autonomy\//];
880
+
881
+ // The repo files this process has loaded, repo-relative with forward slashes.
882
+ function loadedFiles() {
883
+ const root = REPO_ROOT + path.sep;
884
+ const nm = path.sep + 'node_modules' + path.sep;
885
+ return Object.keys(require.cache)
886
+ .filter((p) => p.startsWith(root) && !p.includes(nm))
887
+ .map((p) => path.relative(REPO_ROOT, p).split(path.sep).join('/'));
888
+ }
889
+
890
+ // The changed files that force a restart: ones this process holds, or ones under
891
+ // the supervisor's own paths. Everything else is picked up by the next child.
892
+ function supervisorTouched(changed = [], loaded = loadedFiles()) {
893
+ const held = new Set(loaded);
894
+ return changed.filter((f) => held.has(f) || SUPERVISOR_PATH_RES.some((re) => re.test(f)));
895
+ }
896
+
897
+ // refreshCheckout — the wrapper's fetch + reset, done from inside the process.
898
+ //
899
+ // THE MAIN CHECKOUT ONLY. It runs `git reset --hard`, and a linked worktree is a
900
+ // claim somebody is building in; resetting one would destroy their work. The
901
+ // check compares git-dir to git-common-dir, which are equal only in the main
902
+ // checkout. Armed only by main() (`opts.refreshInPlace`), so a test driving
903
+ // forever() can never reach it — in CI that reset would wipe the code under test.
904
+ //
905
+ // The diff is from the LOADED commit, not the last refresh, so a supervisor
906
+ // change that landed several refreshes ago still forces the restart it needs.
907
+ async function refreshCheckout(deps = {}) {
908
+ const runCmd = deps.run || run;
909
+ const git = (args) => runCmd('git', args, { cwd: REPO_ROOT });
910
+ try {
911
+ const [gd, cd] = await Promise.all([git(['rev-parse', '--git-dir']), git(['rev-parse', '--git-common-dir'])]);
912
+ if (!gd.ok || !cd.ok) return { ok: false, reason: 'could not tell which checkout this is' };
913
+ if (path.resolve(REPO_ROOT, String(gd.stdout).trim()) !== path.resolve(REPO_ROOT, String(cd.stdout).trim())) {
914
+ return { ok: false, reason: 'not the main checkout — refusing to reset a linked worktree' };
915
+ }
916
+ const loaded = deps.loadedHead ? await deps.loadedHead(deps) : await loadedHead(deps);
917
+ if (!loaded) return { ok: false, reason: 'the running commit is unknown' };
918
+ if (!(await git(['fetch', '--quiet', 'origin', 'main'])).ok) return { ok: false, reason: 'fetch failed' };
919
+ const fh = await git(['rev-parse', 'FETCH_HEAD']);
920
+ const to = fh.ok ? String(fh.stdout).trim() : '';
921
+ if (!/^[0-9a-f]{40}$/.test(to)) return { ok: false, reason: 'could not read the fetched commit' };
922
+ const diff = await git(['diff', '--name-only', loaded, to]);
923
+ if (!diff.ok) return { ok: false, reason: 'could not diff the running commit against main' };
924
+ if (!(await git(['reset', '--quiet', '--hard', to])).ok) return { ok: false, reason: 'reset failed' };
925
+ const changed = String(diff.stdout).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
926
+ return { ok: true, to, changed };
927
+ } catch (e) {
928
+ return { ok: false, reason: e && e.message ? e.message : String(e) };
929
+ }
858
930
  }
859
931
 
860
932
  // The head of origin/main, or null. `git ls-remote` rather than `fetch`: it moves
@@ -932,7 +1004,9 @@ async function forever(opts, deps = {}) {
932
1004
  // reports itself anyway: the hall reads the AGE of the last check-in, so a
933
1005
  // failed beat shows up as silence, which is the signal.
934
1006
  const beat = async (fields) => {
935
- try { beatFile(); } catch (_) { /* a runner that cannot write its file still runs */ }
1007
+ // The runner state is passed, not defaulted: the file names `disk_head` from
1008
+ // it (task 1004406), and an injected state must reach the beat that reports it.
1009
+ try { beatFile(Date.now(), deps.state || RUNNER_STATE); } catch (_) { /* a runner that cannot write its file still runs */ }
936
1010
  try { await beatServer(fields, deps); } catch (_) { /* and one that cannot reach the instance still runs */ }
937
1011
  };
938
1012
  // The running commit, read ONCE, BEFORE the first beat (task 1004397). It used
@@ -993,14 +1067,35 @@ async function forever(opts, deps = {}) {
993
1067
  // spends nearly all of its time and an upgrade should not sit behind it.
994
1068
  const upMs = (deps.now ? deps.now() : Date.now()) - Date.parse(RUNNER_STARTED_AT);
995
1069
  const mayUpgrade = upMs >= MIN_UPTIME_BEFORE_UPGRADE_MS;
996
- const drift = mayUpgrade
1070
+ let drift = mayUpgrade
997
1071
  ? (deps.codeDrifted ? await deps.codeDrifted(deps) : await codeDrifted(deps))
998
1072
  : false;
999
1073
  if (drift) {
1000
- log({ event: 'upgrade_exit', from: drift.from, to: drift.to,
1001
- reason: `main moved from ${drift.from} to ${drift.to} — exiting so the wrapper can relaunch on the new code` });
1002
- await beat({ mode: state.mode, last_event: 'upgrade_exit' });
1003
- return UPGRADE_EXIT_CODE;
1074
+ // Pull always, restart rarely (task 1004406). Only main() arms the refresh.
1075
+ let why = 'exiting so the wrapper can relaunch on the new code';
1076
+ if (opts.refreshInPlace) {
1077
+ const refreshed = await (deps.refreshCheckout || refreshCheckout)(deps);
1078
+ if (refreshed && refreshed.ok) {
1079
+ const touched = supervisorTouched(refreshed.changed, deps.loadedFiles ? deps.loadedFiles() : loadedFiles());
1080
+ if (!touched.length) {
1081
+ (deps.state || RUNNER_STATE).diskHead = refreshed.to;
1082
+ log({ event: 'code_refreshed', from: drift.from, to: refreshed.to.slice(0, 8), changed: refreshed.changed.length,
1083
+ reason: `main moved from ${drift.from} to ${refreshed.to.slice(0, 8)} — checkout updated in place; none of the ${refreshed.changed.length} changed file(s) is code this process runs, so no restart` });
1084
+ await beat({ mode: state.mode, last_event: 'code_refreshed' });
1085
+ drift = null;
1086
+ } else {
1087
+ const more = touched.length > 5 ? `, +${touched.length - 5} more` : '';
1088
+ why = `it changed code this process runs (${touched.slice(0, 5).join(', ')}${more}) — exiting so the wrapper can relaunch on it`;
1089
+ }
1090
+ } else {
1091
+ why = `the in-place refresh did not run (${(refreshed && refreshed.reason) || 'no result'}) — exiting so the wrapper's own refresh can`;
1092
+ }
1093
+ }
1094
+ if (drift) {
1095
+ log({ event: 'upgrade_exit', from: drift.from, to: drift.to, reason: `main moved from ${drift.from} to ${drift.to} — ${why}` });
1096
+ await beat({ mode: state.mode, last_event: 'upgrade_exit' });
1097
+ return UPGRADE_EXIT_CODE;
1098
+ }
1004
1099
  }
1005
1100
 
1006
1101
  const { waited, jumped } = await wait(state.waitS, { ...deps, beat }, opts.cadence);
@@ -1015,7 +1110,7 @@ async function main(argv) {
1015
1110
  // it lived on the machine being fenced; the database owns that now, and an empty
1016
1111
  // `--goal` means "every allowlisted goal" rather than "anything claimable". The
1017
1112
  // refusal did not disappear — it moved somewhere the runner host cannot edit.
1018
- if (opts.forever) return forever(opts);
1113
+ if (opts.forever) return forever({ ...opts, refreshInPlace: true });
1019
1114
 
1020
1115
  // The one-shot path, kept for an operator running a single task by hand. It
1021
1116
  // still reconciles first: a leftover claim is a leftover claim whoever is
@@ -1043,5 +1138,5 @@ module.exports = {
1043
1138
  worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
1044
1139
  heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
1045
1140
  isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
1046
- newRunnerState, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1141
+ newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1047
1142
  };