@bongos/core 1.25.31 → 1.25.33

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.
@@ -1,15 +1,21 @@
1
1
  # The welcome — Home's first visit of the day (tasks 1004785, 1004818)
2
2
 
3
3
  The owner's Builders' Hall idea 1001262, second half (the first is [`home-reshuffle.md`](home-reshuffle.md)).
4
- Files: `public/welcome-boot.js` + `public/welcome-boot.css`, and the owner's two paintings
5
- `public/welcome-day.webp` / `public/welcome-night.webp`, on Home only.
4
+ Files: `public/welcome-boot.js` + `public/welcome-boot.css`, on Home only. The scene is drawn by
5
+ Home's own city painters (`sky-time.js`, `city-draw.js`, `home-city-draw.js`).
6
6
 
7
7
  **How the look got here (2026-10-23, one day of owner rounds).** Task 1004785 built v5 "the tower"
8
8
  ([`docs/design/mocks/hall/welcome/v5.src.html`](../../../docs/design/mocks/hall/welcome/v5.src.html)),
9
9
  drawn live from the Repo Atlas. The owner then asked for day and night and a slow way out, then
10
10
  swapped the tower for a glowing module city with a lunch crew on a beam, dropped the crew, and
11
11
  finally had Gemini paint the city by day and by night and asked for those to be animated (task
12
- 1004818). The atlas-drawn scene is gone; the painting is the scene.
12
+ 1004818). The atlas-drawn scene is gone.
13
+
14
+ **The painting is retired too (task 1005308, owner 2026-11-14: "retire it everywhere").** The scene
15
+ is now the city in the clouds from task 1005196, the same city Home and the City page draw, in the
16
+ hour's sky. The two WebP paintings and every light that was pinned to their pixels (the circuit
17
+ towers' pulses, the hover cars, the moon and stars) are deleted. The game's start and load screens
18
+ stand on the same scene (`records/start-screen.md`).
13
19
 
14
20
  - **When.** A signed-in builder's first Home load of the LOCAL day, per builder id
15
21
  (`localStorage['hall-welcome-day:<id>']`). Storage that throws is never shown it, because it could not
@@ -20,15 +26,16 @@ finally had Gemini paint the city by day and by night and asked for those to be
20
26
  `welcome-boot.js` loads before the `shell-trail.js` / `craft-mode.js` / `shell.js` trio, which
21
27
  `tests/craft_mode.mjs` keeps adjacent.
22
28
  - **Day and night.** `timeOfDay` makes 06:00 to 17:59 local `day`, the rest `night`; the root wears
23
- `.wb--day` or `.wb--night` and shows the matching painting. It opens from a veil (`.wb__veil`),
24
- white by day and black by night, that lifts once the painting has loaded (or after 1.5s).
25
- - **The motion is laid on the painting, in its own pixels.** `.wb__art` covers the screen at the
26
- paintings' 1024x572 shape (both share one layout), so the SVG over it (`viewBox 0 0 1024 572`) keeps
27
- every light on its mark however the screen crops: `TOWERS` (the circuit towers, where `pulseRuns`
28
- climbs), `BEACONS` (mast lights, night), `LANES` (hover cars), the moon and stars (night), plus a
29
- slow drift of the whole picture and two clouds. **A new painting must keep that layout, or the
30
- coordinates in welcome-boot.js move with it** — `tests/welcome_boot.mjs` checks both files are
31
- 1024x572 WebP.
29
+ `.wb--day` or `.wb--night`, which sets the ink. It opens from a veil (`.wb__veil`), white by day
30
+ and black by night, that lifts two frames after the scene is painted (or after 1.5s). The scene's
31
+ own colours follow the hour more finely (`sky-time.js`: night, dawn, midday, dusk, blended).
32
+ - **The scene is the city, as a backdrop.** `paintScene(host)` asks `OTBHomeCityDraw.citySvg` for an
33
+ empty week (the Cloud Bongos core and the museum, standing in cloud) and rewrites it with
34
+ `sceneShape`: every link becomes a plain group, tooltips and goal hooks are dropped, the faded
35
+ `hc-quiet` look is lifted so the core leads, and every id is prefixed `cs-` so it never meets the
36
+ ids of Home's own city on the same page. It covers the screen, standing on the bottom edge
37
+ (`xMidYMax slice`), over `.cloud-scene`'s sky gradient, with a slow drift and two clouds. The same
38
+ `paintScene` is handed to the game's start screen as `scene`.
32
39
  - **Banner.** "Working on the core of <project>" from `__BRANDING__.identity` (never a hardcoded word),
33
40
  with `core <version>` under it when the public `GET /version` reports one.
34
41
  - **Sequence.** Veil lifts, the city powers up (pulses brighten), the greeting comes up at ~3.4s.
@@ -40,11 +47,13 @@ finally had Gemini paint the city by day and by night and asked for those to be
40
47
  Artisan → stays on Home (the Ideator door, once `/thinking`, folded into Artist). A pick also calls
41
48
  `OTBCraftMode.choose()` with patron / artist / artisan. **Patron shows only where
42
49
  `OTBCraftMode.offered('patron')`** — the patron craft is a seat — and the list is built when "Start with your role" is pressed, after the seats have settled.
43
- - **No painting** (a 404) hides the image and leaves the sky gradient; everything else works.
44
- - **Colours are the paintings'**, in both hall themes: `welcome-boot.css` and `welcome-boot.js` are on
45
- `tests/hall_audit.mjs`'s literal exemptions with that reason.
46
- - **Tests.** `tests/welcome_boot.mjs` (the day rule, the paintings' files and shape, every light inside
47
- the painting and every pulse inside its tower, the banner, the role map, the load order);
48
- `tests/welcome_boot_browser.mjs` runs Home's markup with the real shell, craft mode, welcome and
49
- paintings in a browser: once a day, Skip, Esc, no double picker, the seat rule, no painting, reduced
50
- motion, the veil by day and night, the slow exit, and that the old drawn scene is gone.
50
+ - **No city painters** on the page: the root wears `.wb--bare`, the hour's sky gradient stands alone,
51
+ and everything else works.
52
+ - **The scene's colours are the hour's sky tokens only** (`--sky-now-*`); the welcome's ink, veil and
53
+ panels keep their fixed day/night literals, on `tests/hall_audit.mjs`'s literal exemptions.
54
+ - **Tests.** `tests/welcome_boot.mjs` (the day rule, that the painting and its lights are gone, the
55
+ scene's shapes with no door, no unprefixed id and no typed colour, the banner, the role map, the
56
+ load order); `tests/welcome_boot_browser.mjs` runs Home's markup with the real shell, craft mode,
57
+ welcome and city painters in a browser: once a day, Skip, Esc, no double picker, the seat rule, no
58
+ painters, reduced motion, the city in the hour's sky by day and night, the slow exit, and that the
59
+ painting's lights are gone.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.31",
3
+ "version": "1.25.33",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.31",
9
+ "version": "1.25.33",
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.25.31",
3
+ "version": "1.25.33",
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",
@@ -11961,5 +11961,17 @@
11961
11961
  "id": "1005310",
11962
11962
  "text": "The governor's street asks the server for less and gets its answers sooner."
11963
11963
  }
11964
+ ],
11965
+ "1.25.32": [
11966
+ {
11967
+ "id": "1005308",
11968
+ "text": "The old circuit-city painting is gone. The welcome screen and the game's start screens now open on the city in the clouds, coloured for the time of day: deep night with lit windows, a warm dawn, a bright midday."
11969
+ }
11970
+ ],
11971
+ "1.25.33": [
11972
+ {
11973
+ "id": "1005202",
11974
+ "text": "A runner that cannot download new code from GitHub now shows \"Stuck on old code\" on its card, with the fix, instead of looking healthy."
11975
+ }
11964
11976
  ]
11965
11977
  }
@@ -308,18 +308,28 @@ function git(args, opts = {}) {
308
308
  }
309
309
 
310
310
  // refreshCheckout — the .cmd's fetch + reset, on the runner's own copy only.
311
- function refreshCheckout(log = () => {}) {
312
- assertRunnerCopy();
313
- const f = git(['fetch', '--quiet', 'origin', 'main']);
314
- if (f.status !== 0) {
315
- // The log is the ONLY place this is said (the card reads check-ins, not fetches), so it
316
- // carries the remedy too — a fetch that fails for a credential reason fails every loop,
317
- // and the runner then works forever on the code it happens to have (task 1005096).
311
+ //
312
+ // Straight fetch failures are counted in `counter` and handed to the supervisor
313
+ // through `env` (AUTOBONGOS_FETCH_FAILURES), which seeds its own count, so its
314
+ // check-in can say `cannot_fetch` and the device's card stops reading healthy
315
+ // (task 1005202). The collaborators are injectable for the test only.
316
+ function refreshCheckout(log = () => {}, {
317
+ git: gitFn = git, assert = assertRunnerCopy, remedy = githubRemedy, counter = null, env = null,
318
+ } = {}) {
319
+ assert();
320
+ const f = gitFn(['fetch', '--quiet', 'origin', 'main']);
321
+ const fetched = f.status === 0;
322
+ if (counter) counter.fetchFailures = fetched ? 0 : (counter.fetchFailures || 0) + 1;
323
+ if (counter && env) env.AUTOBONGOS_FETCH_FAILURES = String(counter.fetchFailures);
324
+ if (!fetched) {
325
+ // A fetch that fails for a credential reason fails every loop, and the runner then
326
+ // works forever on the code it happens to have (task 1005096), so the log carries
327
+ // the remedy, and the count above carries the fact to the card.
318
328
  log(`git fetch failed — running the code already here (${String(f.stderr || '').trim().slice(0, 200)})`);
319
- log(` if that keeps happening, it is not transient: ${githubRemedy()}`);
329
+ log(` if that keeps happening, it is not transient: ${remedy()}`);
320
330
  return false;
321
331
  }
322
- const r = git(['reset', '--quiet', '--hard', 'origin/main']);
332
+ const r = gitFn(['reset', '--quiet', '--hard', 'origin/main']);
323
333
  if (r.status !== 0) { log(`git reset failed (${String(r.stderr || '').trim().slice(0, 200)})`); return false; }
324
334
  return true;
325
335
  }
@@ -821,6 +831,7 @@ async function runCommand({ paths = hostPaths() } = {}) {
821
831
  const log = makeLogger(paths, { echo: !!process.stdout.isTTY });
822
832
  const env = supervisorEnv(process.env, paths, dirName);
823
833
  const state = { child: null, stopping: false };
834
+ const fetchCounter = { fetchFailures: 0 }; // straight failed fetches, handed to the supervisor (task 1005202)
824
835
  const keepAwake = makeKeepAwake();
825
836
  const selfSha = fileSha(__filename);
826
837
  writeLock(paths);
@@ -836,7 +847,7 @@ async function runCommand({ paths = hostPaths() } = {}) {
836
847
  rotateLog(paths);
837
848
  const out = await runLoop({}, {
838
849
  log, shouldStop: () => state.stopping,
839
- refresh: () => refreshCheckout(log),
850
+ refresh: () => refreshCheckout(log, { counter: fetchCounter, env }),
840
851
  ensureDeps: () => ensureDeps(paths, log),
841
852
  spawnSupervisor: () => spawnSupervisorReal({ paths, env, log, keepAwake, state }),
842
853
  selfChanged: () => fileSha(__filename) !== selfSha,
@@ -924,5 +935,5 @@ module.exports = {
924
935
  windowsRunArgv, windowsRunCommand, windowsStartSpawn, legacyBootTaskInstalled, launchdPlist,
925
936
  stopRunner, stopReport, exchangeCode, claudeState, waitForCheckIn,
926
937
  signIn, cleanBase, install, removeStartEntry, installedState, status, digestArgv,
927
- githubRemedy, GITHUB_REMEDY_GENERIC,
938
+ githubRemedy, GITHUB_REMEDY_GENERIC, refreshCheckout,
928
939
  };
@@ -511,8 +511,19 @@ const MAX_CLAIM_ATTEMPTS = 5;
511
511
  // guess that is wrong costs one wasted spawn per half hour, not one per wake.
512
512
  const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
513
513
 
514
+ // How many straight failures to reach origin before the check-in says
515
+ // `cannot_fetch` (task 1005202). A blip fails once or twice and clears; a
516
+ // credential fault (the active GitHub account cannot see the project) fails
517
+ // every time, forever, and the runner then works on whatever code it has. The
518
+ // polls inside a wait are a minute apart, so this is a few minutes of failing.
519
+ const CANNOT_FETCH_AFTER = 3;
520
+
521
+ // fetchFailures starts from the host wrapper's own count (AUTOBONGOS_FETCH_FAILURES,
522
+ // set by autobongos-host.js), so a restart does not reset the card to healthy.
514
523
  function newRunnerState() {
515
- return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null, mainHead: null, landSweepAt: null };
524
+ const seeded = Number.parseInt(process.env.AUTOBONGOS_FETCH_FAILURES, 10);
525
+ return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null, mainHead: null, landSweepAt: null,
526
+ fetchFailures: Number.isInteger(seeded) && seeded > 0 ? seeded : 0 };
516
527
  }
517
528
  const RUNNER_STATE = newRunnerState();
518
529
 
@@ -920,9 +931,23 @@ function heartbeatIdentity(deps = {}) {
920
931
  return out;
921
932
  }
922
933
 
934
+ // cannotFetchEvent — what a beat says once the runner has failed to reach the
935
+ // project CANNOT_FETCH_AFTER times in a row (task 1005202). It replaces the idle
936
+ // words ("waiting", "loop", nothing), which would otherwise read as healthy. It
937
+ // never hides a restart, a setting holding the runner, or the task in its hands:
938
+ // each of those is its own truth about this minute.
939
+ function cannotFetchEvent(fields = {}, state = RUNNER_STATE) {
940
+ if (!state || !((state.fetchFailures || 0) >= CANNOT_FETCH_AFTER)) return fields.last_event;
941
+ if (fields.working_task_id) return fields.last_event;
942
+ const ev = fields.last_event;
943
+ if (ev === 'upgrade_exit' || (typeof ev === 'string' && ev.startsWith('config_idle:'))) return ev;
944
+ return 'cannot_fetch';
945
+ }
946
+
923
947
  async function postHeartbeat(fields = {}, deps = {}) {
924
948
  try {
925
949
  const api = deps.api || await (require('./cli-lib').cliClient)();
950
+ const lastEvent = cannotFetchEvent(fields, deps.state || RUNNER_STATE);
926
951
  await api.autonomy.postAutonomyHeartbeat({
927
952
  body: {
928
953
  host: os.hostname(),
@@ -930,6 +955,7 @@ async function postHeartbeat(fields = {}, deps = {}) {
930
955
  started_at: RUNNER_STARTED_AT,
931
956
  ...heartbeatIdentity(deps),
932
957
  ...fields,
958
+ ...(lastEvent ? { last_event: lastEvent } : {}),
933
959
  },
934
960
  });
935
961
  return true;
@@ -1165,11 +1191,17 @@ async function refreshCheckout(deps = {}) {
1165
1191
  // The head of origin/main, or null. `git ls-remote` rather than `fetch`: it moves
1166
1192
  // no refs and writes nothing, so polling it every few minutes cannot interfere
1167
1193
  // with a worker building in a tree beside it.
1194
+ //
1195
+ // Every call also counts toward `fetchFailures` (task 1005202): a success clears
1196
+ // it, anything else adds one, so the heartbeat can say the runner cannot reach
1197
+ // the project instead of reading healthy on code it can never update.
1168
1198
  async function runFetchHead(deps = {}) {
1169
1199
  const runCmd = deps.run || run;
1170
- const r = await runCmd('git', ['ls-remote', 'origin', 'refs/heads/main']);
1171
- if (!r.ok) return null;
1172
- const m = /^([0-9a-f]{40})/.exec(String(r.stdout).trim());
1200
+ const st = deps.state || RUNNER_STATE;
1201
+ let r = null;
1202
+ try { r = await runCmd('git', ['ls-remote', 'origin', 'refs/heads/main']); } catch (_) { r = null; }
1203
+ const m = r && r.ok ? /^([0-9a-f]{40})/.exec(String(r.stdout).trim()) : null;
1204
+ st.fetchFailures = m ? 0 : (st.fetchFailures || 0) + 1;
1173
1205
  return m ? m[1] : null;
1174
1206
  }
1175
1207
 
@@ -1395,6 +1427,6 @@ module.exports = {
1395
1427
  worktreeName, readFence, gradeLedgerPath, gradeCapReader, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
1396
1428
  heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
1397
1429
  isRunnerTree, RUNNER_TREE_RE, postHeartbeat, claudeAccountLabel, RUNNER_STARTED_AT, failureReason,
1398
- newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1430
+ newRunnerState, runFetchHead, cannotFetchEvent, CANNOT_FETCH_AFTER, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1399
1431
  readRunLogRows, recheckPendingLands, LAND_SWEEP_MS,
1400
1432
  };
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.31'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.33'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,123 @@
1
+ // tests/autobongos_cannot_fetch.mjs — a runner that can never fetch says so on
2
+ // its card (task 1005202).
3
+ //
4
+ // Found on a real Mac: two GitHub accounts were signed in, the active one could
5
+ // not see the private project, and every `git fetch` 404'd. The runner checked in
6
+ // as healthy and worked on the code it happened to clone, forever; only its own
7
+ // log said otherwise. These pin the signal end to end: the host and the
8
+ // supervisor count straight fetch failures, past CANNOT_FETCH_AFTER the check-in
9
+ // says `cannot_fetch`, the verdict is not ok and carries the remedy, and the
10
+ // device's button and checklist stop reading healthy. One good fetch clears it.
11
+ //
12
+ // DB-free, no network. Run: node --test tests/autobongos_cannot_fetch.mjs
13
+
14
+ import { test } from 'node:test';
15
+ import assert from 'node:assert/strict';
16
+ import path from 'node:path';
17
+ import { createRequire } from 'node:module';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const req = createRequire(import.meta.url);
22
+ const runner = req(path.join(ROOT, 'scripts/bongos/autobongos-run.js'));
23
+ const host = req(path.join(ROOT, 'scripts/bongos/autobongos-host.js'));
24
+ const health = req(path.join(ROOT, 'modules/autonomy/runner-health.js'));
25
+ const RB = req(path.join(ROOT, 'modules/hall-ui/public/runner-button.js'));
26
+
27
+ const A = 'a'.repeat(40);
28
+ const NOW = Date.parse('2026-11-14T12:00:00.000Z');
29
+ const agoS = (s) => new Date(NOW - s * 1000).toISOString();
30
+
31
+ const failing = async () => ({ ok: false, stdout: '', stderr: 'remote: Repository not found.' });
32
+ const working = async () => ({ ok: true, stdout: `${A}\trefs/heads/main\n` });
33
+
34
+ function recorder() {
35
+ const bodies = [];
36
+ const api = { autonomy: { postAutonomyHeartbeat: async ({ body }) => { bodies.push(body); return { ok: true }; } } };
37
+ return { bodies, api };
38
+ }
39
+
40
+ test('the supervisor counts straight failures to reach main, and one success clears the count', async () => {
41
+ const state = runner.newRunnerState();
42
+ assert.equal(state.fetchFailures, 0);
43
+ for (let i = 1; i <= 3; i++) {
44
+ assert.equal(await runner.runFetchHead({ run: failing, state }), null);
45
+ assert.equal(state.fetchFailures, i);
46
+ }
47
+ await runner.runFetchHead({ run: async () => { throw new Error('spawn git ENOENT'); }, state });
48
+ assert.equal(state.fetchFailures, 4, 'a git that cannot even start is a failure too');
49
+ assert.equal(await runner.runFetchHead({ run: working, state }), A);
50
+ assert.equal(state.fetchFailures, 0);
51
+ });
52
+
53
+ test('past the threshold the check-in says cannot_fetch instead of a healthy idle', async () => {
54
+ const { bodies, api } = recorder();
55
+ const state = runner.newRunnerState();
56
+ const deps = { api, state, claudeAccountLabel: () => null };
57
+ for (let i = 0; i < runner.CANNOT_FETCH_AFTER - 1; i++) await runner.runFetchHead({ run: failing, state });
58
+ await runner.postHeartbeat({ last_event: 'waiting' }, deps);
59
+ assert.equal(bodies.at(-1).last_event, 'waiting', 'a blip below the threshold is not an alarm');
60
+
61
+ await runner.runFetchHead({ run: failing, state });
62
+ for (const ev of ['waiting', 'loop', 'nothing_claimable', undefined]) {
63
+ await runner.postHeartbeat(ev ? { last_event: ev } : {}, deps);
64
+ assert.equal(bodies.at(-1).last_event, 'cannot_fetch', String(ev));
65
+ }
66
+ // What it must never hide: a restart, a setting holding it, or the task in its hands.
67
+ await runner.postHeartbeat({ last_event: 'upgrade_exit' }, deps);
68
+ assert.equal(bodies.at(-1).last_event, 'upgrade_exit');
69
+ await runner.postHeartbeat({ last_event: 'config_idle:no_goals' }, deps);
70
+ assert.equal(bodies.at(-1).last_event, 'config_idle:no_goals');
71
+ await runner.postHeartbeat({ last_event: 'claimed', working_task_id: 7 }, deps);
72
+ assert.equal(bodies.at(-1).last_event, 'claimed');
73
+
74
+ await runner.runFetchHead({ run: working, state });
75
+ await runner.postHeartbeat({ last_event: 'waiting' }, deps);
76
+ assert.equal(bodies.at(-1).last_event, 'waiting', 'one good fetch and the card reads healthy again');
77
+ });
78
+
79
+ test('the host counts its own failed fetches and hands the count to the supervisor it starts', () => {
80
+ const env = {};
81
+ const counter = { fetchFailures: 0 };
82
+ const bad = () => ({ status: 128, stdout: '', stderr: 'fatal: repository not found' });
83
+ const good = () => ({ status: 0, stdout: '', stderr: '' });
84
+ const opts = { git: bad, assert: () => {}, remedy: () => 'switch account', counter, env };
85
+ assert.equal(host.refreshCheckout(() => {}, opts), false);
86
+ assert.equal(host.refreshCheckout(() => {}, opts), false);
87
+ assert.equal(counter.fetchFailures, 2);
88
+ assert.equal(env.AUTOBONGOS_FETCH_FAILURES, '2');
89
+ // The supervisor it starts begins from that count, so the card does not wait
90
+ // for a fresh run of failures after every restart.
91
+ const saved = process.env.AUTOBONGOS_FETCH_FAILURES;
92
+ process.env.AUTOBONGOS_FETCH_FAILURES = env.AUTOBONGOS_FETCH_FAILURES;
93
+ try { assert.equal(runner.newRunnerState().fetchFailures, 2); } finally {
94
+ if (saved === undefined) delete process.env.AUTOBONGOS_FETCH_FAILURES; else process.env.AUTOBONGOS_FETCH_FAILURES = saved;
95
+ }
96
+ assert.equal(host.refreshCheckout(() => {}, { ...opts, git: good }), true);
97
+ assert.equal(counter.fetchFailures, 0);
98
+ assert.equal(env.AUTOBONGOS_FETCH_FAILURES, '0');
99
+ });
100
+
101
+ test('the verdict is cannot_fetch: not ok, and it carries the remedy', () => {
102
+ const d = health.describeRunner({ host: 'mac-mini', mode: 'full', last_event: 'cannot_fetch', last_seen_at: agoS(30) }, { nowMs: NOW });
103
+ assert.equal(d.state, 'cannot_fetch');
104
+ assert.equal(d.ok, false, 'a runner pinned to old code is not healthy');
105
+ assert.match(d.detail, /mac-mini/);
106
+ assert.match(d.remedy, /gh auth switch/);
107
+ // Silence still wins: a runner nobody has heard from cannot vouch for anything.
108
+ assert.equal(health.describeRunner({ host: 'mac-mini', last_event: 'cannot_fetch', last_seen_at: agoS(3 * 3600) }, { nowMs: NOW }).state, 'dead');
109
+ });
110
+
111
+ test("the device's button says it is stuck on old code, and names the fix", () => {
112
+ const fence = { enabled: true, scope: { goals: [1] }, project: { enabled: true, goals: [{ goal_id: 1 }] } };
113
+ const runnerRow = {
114
+ host: 'mac-mini', run_requested: true, run_changed_at: agoS(3600), last_seen_at: agoS(30),
115
+ health: { state: 'cannot_fetch', host: 'mac-mini', age: '30s', remedy: 'Run gh auth switch on it.' },
116
+ };
117
+ const b = RB.buttonState({ runner: runnerRow, fence, nowMs: NOW });
118
+ assert.equal(b.status, 'Stuck on old code');
119
+ assert.equal(b.tone, 'warn');
120
+ assert.equal(b.act, 'stop');
121
+ assert.match(b.sub, /cannot fetch new code/);
122
+ assert.match(b.sub, /gh auth switch/);
123
+ });
@@ -112,8 +112,9 @@ test('the sky tokens are art: no control, no text and no chrome spends one', ()
112
112
  const sel = m[1].trim();
113
113
  if (f === 'style.css' && /(^|\n):root$/.test(sel)) continue; // the token block itself
114
114
  // only the two scenes spend them: the City page's frame, stage and cloud; Home's scene, its
115
- // stage, and (in game mode) the ground of the Home view
116
- if (!/^(\.city-frame|\.city-stage|\.city-cloud|\.city-stars|\.hc-svg|\.hc-cloud|\.hc-stage|html\[data-game="on"\] body\[data-page="home"\]$)/.test(sel)) { hits.push(`${f}: ${sel}`); continue; }
115
+ // stage, and (in game mode) the ground of the Home view; and the cloud scene behind the welcome
116
+ // and the game's start screens (task 1005308)
117
+ if (!/^(\.city-frame|\.city-stage|\.city-cloud|\.city-stars|\.hc-svg|\.hc-cloud|\.hc-stage|\.cloud-scene|html\[data-game="on"\] body\[data-page="home"\]$)/.test(sel)) { hits.push(`${f}: ${sel}`); continue; }
117
118
  for (const d of m[2].split(';')) {
118
119
  const prop = d.split(':')[0].trim();
119
120
  if (/--sky-/.test(d) && (prop === 'color' || /^border|^outline|^caret|^text-/.test(prop))) hits.push(`${f}: ${sel} { ${prop} }`);
@@ -95,13 +95,14 @@ test('the load screen fills one segment per week, up to the week it is showing (
95
95
  assert.equal(G.segsDone(0, 0), 0);
96
96
  });
97
97
 
98
- test('a fresh sign-in is handed to the start screen with the still, and the craft picker waits for the world', () => {
98
+ test('a fresh sign-in is handed to the start screen with the city in the clouds, and the craft picker waits for the world', () => {
99
99
  const s = sandbox({ store: { 'hall-welcome-signin': '1' } });
100
100
  const access = { authed: true }, builder = { id: 7, display_name: 'Ada' };
101
101
  assert.equal(s.window.OTBWelcome.onAccess(access, builder), true, 'the shell is told a screen is up, so it asks no picker');
102
102
  assert.equal(s.started.length, 1);
103
103
  assert.equal(s.started[0].name, 'Harbor');
104
- assert.match(s.started[0].art, /^\/builders\/welcome-(day|night)\.webp$/);
104
+ assert.equal(typeof s.started[0].scene, 'function', 'the scene is painted, not a picture (task 1005308)');
105
+ assert.equal(s.started[0].art, undefined, 'the circuit-city painting is retired');
105
106
  assert.equal(s.store['hall-welcome-signin'], undefined, 'the flag is spent');
106
107
  assert.deepEqual(s.asked, [], 'nothing is asked while the start screen is up');
107
108
  s.started[0].done();
@@ -1,8 +1,8 @@
1
1
  // tests/welcome_boot.mjs — the welcome's pure parts (tasks 1004785, 1004818).
2
2
  //
3
3
  // WHY. The welcome decides things a reader would only notice going wrong over days: whether today
4
- // is the first visit, whether it is day or night (and so which painting), what the banner names,
5
- // where each role goes, and whether the motion still sits on the painting. Those are pure, so this
4
+ // is the first visit, whether it is day or night, what the banner names, where each role goes,
5
+ // and that the scene is the city in the clouds with its doors taken out (task 1005308). Those are pure, so this
6
6
  // runs the REAL welcome-boot.js (it is Node-require-safe). The overlay itself is
7
7
  // tests/welcome_boot_browser.mjs.
8
8
  import assert from 'node:assert/strict';
@@ -39,40 +39,48 @@ test('the greeting follows the local hour, and the name falls back sensibly', ()
39
39
  assert.equal(W.nameOf({}), 'builder');
40
40
  });
41
41
 
42
- test('day is 06:00 to 17:59 local, night the rest, and each has its painting (task 1004818)', () => {
42
+ test('day is 06:00 to 17:59 local, night the rest (the ink and the veil follow it)', () => {
43
43
  const at = (h, m = 0) => W.timeOfDay(new Date(2026, 9, 23, h, m));
44
44
  assert.deepEqual([at(5, 59), at(6), at(12), at(17, 59), at(18), at(23, 30), at(0)],
45
45
  ['night', 'day', 'day', 'day', 'night', 'night', 'night']);
46
- assert.equal(W.artFor('day'), '/builders/welcome-day.webp');
47
- assert.equal(W.artFor('night'), '/builders/welcome-night.webp');
48
46
  });
49
47
 
50
- test('both paintings ship, as WebP, in the one shape the motion is laid out for', () => {
51
- for (const url of Object.values(W.ART)) {
52
- const file = path.join(HALL, url.replace(/^\/builders\//, ''));
53
- const buf = fs.readFileSync(file);
54
- assert.equal(buf.toString('latin1', 0, 4) + buf.toString('latin1', 8, 12), 'RIFFWEBP', url + ' is a WebP');
55
- assert.ok(buf.length < 400 * 1024, url + ' stays small enough for a first paint');
56
- // The VP8X header carries the canvas size (24-bit, minus one) at bytes 24..29.
57
- assert.equal(buf.toString('latin1', 12, 16), 'VP8X', url + ' carries its size');
58
- const w = 1 + buf.readUIntLE(24, 3), h = 1 + buf.readUIntLE(27, 3);
59
- assert.deepEqual([w, h], [W.AW, W.AH], url + ' is the painting the lights were placed on');
48
+ // Task 1005308 (owner, 2026-11-14): the circuit-city painting is retired everywhere; the scene is the
49
+ // city in the clouds Home's own painters draw (task 1005196), as a backdrop.
50
+ test('the circuit-city painting is retired: no picture file, no lights pinned to its pixels', () => {
51
+ for (const name of ['welcome-day.webp', 'welcome-night.webp']) assert.equal(fs.existsSync(path.join(HALL, name)), false, name + ' is gone');
52
+ for (const gone of ['ART', 'artFor', 'TOWERS', 'LANES', 'AW', 'AH', 'pulseRuns', 'BEACONS']) assert.equal(W[gone], undefined, gone + ' went with the painting');
53
+ for (const file of ['welcome-boot.js', 'welcome-boot.css', 'shell-game.js', 'shell-game.css', 'index.html']) {
54
+ assert.doesNotMatch(fs.readFileSync(path.join(HALL, file), 'utf8'), /welcome-(day|night)\.webp|wb__img|gs__img|wb-motion/, file + ' names no part of the painting');
60
55
  }
61
56
  });
62
57
 
63
- test('every light sits inside the painting, and every pulse climbs inside its tower', () => {
64
- const inside = ([x, y]) => x >= 0 && x <= W.AW && y >= 0 && y <= W.AH;
65
- assert.equal(W.BEACONS, undefined, 'the blinking mast lights are gone (task 1005087)');
66
- for (const t of W.TOWERS) assert.ok(inside([t.x, t.y]) && inside([t.x + t.w, t.y + t.h]), 'tower inside the painting');
67
- const runs = W.pulseRuns(W.TOWERS, 3, 7);
68
- assert.equal(runs.length, W.TOWERS.length * 3);
69
- for (const r of runs) {
70
- const t = W.TOWERS[r.tower];
71
- assert.ok(r.x >= t.x && r.x <= t.x + t.w, 'a pulse runs up its own tower');
72
- assert.ok(r.y1 > r.y0 && r.y0 >= t.y && r.y1 <= t.y + t.h, 'bottom to top, within the tower');
73
- assert.ok(r.dur > 0 && r.delay >= 0);
58
+ test("the scene is the city in the clouds, drawn by Home's own painters, with every door taken out", async () => {
59
+ const vm = await import('node:vm');
60
+ const ctx = { window: {}, console };
61
+ vm.createContext(ctx);
62
+ vm.runInContext(fs.readFileSync(path.join(HALL, 'city-draw.js'), 'utf8'), ctx);
63
+ const Gov = ctx.window.GovCity;
64
+ const Draw = require(path.join(HALL, 'home-city-draw.js'));
65
+ assert.ok(Gov && Draw, 'the painters load');
66
+ const s = W.sceneShapes(Draw, Gov);
67
+ assert.match(s.viewBox, /^0 0 1000 \d+$/);
68
+ const all = [];
69
+ (function walk(n) { all.push(n); n.kids.forEach(walk); })({ tag: 'root', attrs: {}, kids: s.nodes });
70
+ assert.ok(all.some((n) => /hc-cloud/.test(n.attrs.class || '')), 'it stands in cloud');
71
+ assert.ok(all.filter((n) => n.tag === 'path').length > 50, 'the core and the museum are drawn');
72
+ assert.equal(all.filter((n) => n.tag === 'a').length, 0, 'no link: it is a backdrop');
73
+ for (const n of all) {
74
+ for (const k of ['href', 'tabindex', 'aria-label', 'data-tip', 'data-tip-body', 'data-goal']) assert.equal(n.attrs[k], undefined, k + ' is taken out');
75
+ if (n.attrs.id) assert.ok(n.attrs.id.startsWith(W.SCENE_ID), "its ids never meet Home's own city's");
76
+ for (const v of Object.values(n.attrs)) assert.doesNotMatch(String(v), /url\(#(?!cs-)/, 'every reference points at its own prefixed id');
77
+ assert.doesNotMatch(n.attrs.class || '', /hc-quiet/, 'nothing is faded: the core leads');
74
78
  }
75
- assert.deepEqual(W.pulseRuns(W.TOWERS, 3, 7), runs, 'seeded: every visit draws the same picture');
79
+ const json = JSON.stringify(s);
80
+ assert.doesNotMatch(json, /#[0-9a-f]{6}\b/i, "no typed colour: the hour's sky tokens only");
81
+ assert.match(json, /--sky-now-/, "it paints in the hour's sky");
82
+ assert.equal(W.sceneShapes(null, Gov), null, 'no painters: no city, and the sky stands alone');
83
+ assert.equal(W.paintScene(null), false);
76
84
  });
77
85
 
78
86
  test('the banner is the logo, named from the branding pack, never a hardcoded word (task 1004953)', () => {