@bongos/core 1.19.78 → 1.19.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.78",
6
- "core_contract": "1.19.78",
7
- "source_commit": "e325ccc071131d7c01dbf333d8f7cd57ae312039",
5
+ "core_version": "1.19.80",
6
+ "core_contract": "1.19.80",
7
+ "source_commit": "e9830526a46cd238c6ea856406a1bcde6fcb6e4f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-12T22:44:08.133Z",
9
+ "built_at": "2026-08-12T23:02:51.567Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 295,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1225,
14
+ "functional_verbatim": 1226,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1541,
20
- "tree_sha256": "96bcb5ac17be8a28801e81faab41c95d0b6b2bfee4cd4919231a9b2e0ac6a8a9",
19
+ "file_count": 1542,
20
+ "tree_sha256": "8efe860e490a9f9bca1f3739c652e205e29d50643e86361a1eb631f7474a1ba9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -37,7 +37,7 @@
37
37
  {
38
38
  "path": ".claude/skills/builder-box/SKILL.md",
39
39
  "mode": "0000644",
40
- "sha256": "a24e22a6e7102654d71a255210b260d2c2c0dd9047a5bab128fa3e44c6b511c9"
40
+ "sha256": "d8bfc9298bbb136742147e83d2500b9cb0961fec35ca15dc3cb773c9ecdf8b4b"
41
41
  },
42
42
  {
43
43
  "path": ".claude/skills/builder-claim/SKILL.md",
@@ -2597,7 +2597,7 @@
2597
2597
  {
2598
2598
  "path": "modules/dev-box/boxes.js",
2599
2599
  "mode": "0000644",
2600
- "sha256": "fbf9bb7bd4c70ac81627d7d473a94e98e6976581bc29f60c5342d428a39238cf"
2600
+ "sha256": "a56b75218b3ac3fed0bb495b9c7f41b2794dafa5f3e6ec28cbb21a97e9b25b7b"
2601
2601
  },
2602
2602
  {
2603
2603
  "path": "modules/dev-box/db.js",
@@ -2982,7 +2982,7 @@
2982
2982
  {
2983
2983
  "path": "modules/hall-ui/public/box-panel.js",
2984
2984
  "mode": "0000644",
2985
- "sha256": "d4b32aa4382100a7e60cc83e83ef23ba34dac0ff759143286b2deaebb8f56cb1"
2985
+ "sha256": "3834d4aac8cb7ebc604973e145f396d0893b4cf62abc530f2027c491b31c625d"
2986
2986
  },
2987
2987
  {
2988
2988
  "path": "modules/hall-ui/public/brand-apply.js",
@@ -4057,12 +4057,12 @@
4057
4057
  {
4058
4058
  "path": "package-lock.json",
4059
4059
  "mode": "0000644",
4060
- "sha256": "7829011d4ec272839d524b9e2f9449e16c18ff635378e52490091cb025cefe3f"
4060
+ "sha256": "b7e3e581321a968a2978affc8766e5bc9ec8f412f0c3e7a94c47916138724de9"
4061
4061
  },
4062
4062
  {
4063
4063
  "path": "package.json",
4064
4064
  "mode": "0000644",
4065
- "sha256": "b179d0a0a83c292a353548cceac274dba29e8e296c62565244cc4d2fc81e6951"
4065
+ "sha256": "283e5a12a702be6937db0eb28a69775e3cb6b760ae217b3824d65ba7feb6743b"
4066
4066
  },
4067
4067
  {
4068
4068
  "path": "public-docs/index.html",
@@ -4467,7 +4467,7 @@
4467
4467
  {
4468
4468
  "path": "scripts/gds/api.js",
4469
4469
  "mode": "0000644",
4470
- "sha256": "e2df5d6f4436595aee60d338198c0c577c82490856ba4f5cd051ca48dc6a710d"
4470
+ "sha256": "8f7332da44883801fb4674747c9e9a60e485353b36df6bebe1db8fc1ebd3558f"
4471
4471
  },
4472
4472
  {
4473
4473
  "path": "scripts/gds/api.ps1",
@@ -4557,7 +4557,7 @@
4557
4557
  {
4558
4558
  "path": "scripts/gds/box.js",
4559
4559
  "mode": "0000644",
4560
- "sha256": "41e1a0c0ef56d5cf10cc73124964c114854a12d8dd3de249d4bafcda04dd2106"
4560
+ "sha256": "117b0ed6730e35559356403b50240d0ef5b6ab46752a8cf76de4e0a8ad244d42"
4561
4561
  },
4562
4562
  {
4563
4563
  "path": "scripts/gds/build-bongos.js",
@@ -4617,7 +4617,7 @@
4617
4617
  {
4618
4618
  "path": "scripts/gds/cli-lib.js",
4619
4619
  "mode": "0000644",
4620
- "sha256": "6c42a8ee151ba5e12475e50b0d113d306fd01c5c95100f131713fb3abeaa4e62"
4620
+ "sha256": "e86444334085b00ff4256fcb6bf15e5f443dc75bdcbf8ca6eb87f0b41fcd3230"
4621
4621
  },
4622
4622
  {
4623
4623
  "path": "scripts/gds/code.js",
@@ -5687,7 +5687,7 @@
5687
5687
  {
5688
5688
  "path": "src/module-api.js",
5689
5689
  "mode": "0000644",
5690
- "sha256": "65dcc9e68f955842239c2c9d4f26e77c920ede6e79608347758ca7d6d71fb5e4"
5690
+ "sha256": "e85465ed2f13494396cf2d74bad0c8a2b6e52d42d48c493a3d0dcf4694f6072e"
5691
5691
  },
5692
5692
  {
5693
5693
  "path": "src/module-loader/loader.js",
@@ -6042,7 +6042,7 @@
6042
6042
  {
6043
6043
  "path": "tests/boxes.mjs",
6044
6044
  "mode": "0000644",
6045
- "sha256": "8702916e37e8cc08ae0e055b1bc7ea70da71720c51bd2a05df2a4dc598a17f97"
6045
+ "sha256": "5a8d2ca5709f2c4ae5089b53883a5a892beeb6920bacbddbd352b106c4ec0e34"
6046
6046
  },
6047
6047
  {
6048
6048
  "path": "tests/branding.mjs",
@@ -6129,6 +6129,11 @@
6129
6129
  "mode": "0000644",
6130
6130
  "sha256": "b9258f690d5dc7c87bcbb12d510b3071a38669777ae9a92a99e683e76ea15e83"
6131
6131
  },
6132
+ {
6133
+ "path": "tests/cli_exit_no_abort.mjs",
6134
+ "mode": "0000644",
6135
+ "sha256": "7bc4d54d29458c70cd545aecac1ca1f7b8cc13d7b1dacaa90a8c2184ef553304"
6136
+ },
6132
6137
  {
6133
6138
  "path": "tests/cli_lib_formatters.mjs",
6134
6139
  "mode": "0000644",
@@ -7,7 +7,7 @@ You are showing a builder the status of **their own** dev box and helping them g
7
7
 
8
8
  ## Background
9
9
 
10
- The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-and-park) and recreates on demand, so a builder's box is often `parked` between sessions — that's normal and cheap, not a problem. The read API:
10
+ The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-and-park) and recreates on demand, so a builder's box is often `parked` between sessions — that's normal and cheap, not a problem. **The idle sweep parks; it does not destroy** (task 1002726 — it *did* destroy droplet and snapshot for a long stretch, so a builder who remembers losing work that way is remembering something real that is now fixed). Full reclaim happens only after `BOX_DORMANT_DAYS` (14) parked, and **only after a warning was actually delivered** — an unwarned box is deferred, not reclaimed (task 1002727). The read API:
11
11
  - `GET /api/gds/box/me` → the caller's box: `state`, `scope`, `ip`, `hostname`, cost, activity timestamps.
12
12
  - `GET /api/gds/box/ssh-key` → the caller's registered SSH keys (fingerprints + labels).
13
13
  - `GET /api/gds/box/terminal` → the caller's active box's browser-terminal `url` + basic-auth `credential` (the Chromebook/browser on-ramp, ADR 0038), once the box has published them.
@@ -26,7 +26,8 @@ The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-a
26
26
  - **state = `none`** → they have no box yet. On a Mac/Windows: run `/builder-connect` (registers their SSH key + installs the Desktop file). To actually create the box hands-off, `POST /api/gds/box/ensure` queues the auto-provision (no operator CLI needed). *(The old path — ask an operator to `box.js onboard <login>` — still works but is no longer required.)*
27
27
  - **state = `active`** → box is up at `hostname` (`ip`). They can connect from Claude Desktop → Code → Environment → "Example Dev Box". For a browser/Chromebook (ADR 0038), the terminal comes from the web: `GET /api/gds/box/terminal` returns a `url` + a basic-auth `credential` (username `otb`, password = <redacted>; open the url, enter them, then type `claude` in the terminal. No SSH needed.
28
28
  - **Live game preview — the sandbox (ADR 0044).** If `/box/terminal` returns a `url` (i.e. the box is on the named tunnel), the builder also has a private live preview of the game running on their own box, at the **same hostname with `term-` swapped for `sandbox-`** — e.g. terminal `https://term-<login>.example.com` → preview `https://sandbox-<login>.example.com`. It serves their working copy of the game (degraded — fully playable, no saved state), so they can review changes in a browser **without shipping to prod**. The loop: edit in the terminal → run **`/builder-stage`** (or `box-preview restart`) → refresh the preview tab. Staging via `/builder-stage` also records the review marker that `/builder-ship`'s sandbox-first gate checks (#927, ADR 0046) — on a dev box, game changes are staged + reviewed on the sandbox before they ship to the main game. `box-preview status` shows whether it's up + the URL; `box-preview logs` tails the server. (No preview on a quick-tunnel box — that path can't carry the game port; it needs the named tunnel.)
29
- - **state = `parked`** → idle-suspended (cheap, expected). It wakes on demand: `POST /api/gds/box/ensure` queues the wake and it's back in ~1 min (no operator needed). Reassure them their data is safe in the snapshot.
29
+ - **state = `parked`** → idle-suspended (cheap, expected). It wakes on demand: `POST /api/gds/box/ensure` queues the wake and it's back in ~1 min (no operator needed). Their data IS safe in the snapshot — say so plainly, and mean it: the idle path preserves the filesystem, and the only thing that deletes the snapshot warns them first with days of lead time.
30
+ - **state = `destroyed`** → the box is genuinely gone, and so is anything uncommitted on it. This is NOT the idle path — it means they closed it themselves, an operator deprovisioned it, or it sat parked past the dormancy window after being warned. `POST /box/ensure` gives them a **fresh** box, not their old one; don't imply otherwise.
30
31
  - **state = `provisioning` / `waking`** → in flight; check back in a minute.
31
32
  - **state = `error`** → flag it to an operator (don't try to fix infra from here).
32
33
  - **SSH keys** → list the fingerprints + labels. If empty, they haven't connected yet → `/builder-connect`. They can remove a stale key with `DELETE /api/gds/box/ssh-key/:id` (own-scoped).
@@ -182,6 +182,10 @@ function selectIdleBoxes(rows, { idleMinutes, nowMs } = {}) {
182
182
 
183
183
  // Dormant = parked boxes that have been parked longer than dormantDays. These
184
184
  // get fully de-provisioned (snapshot deleted too) to reclaim the last cents.
185
+ // Since task 1002726 this is the ONLY automated path that destroys a snapshot —
186
+ // the idle sweep parks and preserves one — so it is the last thing standing
187
+ // between a builder and losing uncommitted work. Task 1002727 gates it on a
188
+ // delivered warning.
185
189
  function selectDormantBoxes(rows, { dormantDays, nowMs } = {}) {
186
190
  if (!Number.isFinite(dormantDays) || dormantDays <= 0) return [];
187
191
  if (!Number.isFinite(nowMs)) return [];
@@ -193,6 +197,40 @@ function selectDormantBoxes(rows, { dormantDays, nowMs } = {}) {
193
197
  });
194
198
  }
195
199
 
200
+ // Task 1002727 — the WARNING window: parked long enough to be heading for
201
+ // reclaim, but not yet reclaimable. A box here gets one heads-up so its builder
202
+ // can wake it (or accept the loss) before the snapshot goes. Boxes already past
203
+ // dormantDays are excluded — they belong to selectDormantBoxes, which refuses to
204
+ // reclaim an unwarned box. Pure; the caller injects nowMs.
205
+ function selectDormantWarnBoxes(rows, { dormantDays, warnLeadDays, nowMs } = {}) {
206
+ if (!Number.isFinite(dormantDays) || dormantDays <= 0) return [];
207
+ if (!Number.isFinite(warnLeadDays) || warnLeadDays <= 0) return [];
208
+ if (!Number.isFinite(nowMs)) return [];
209
+ const warnAfter = nowMs - Math.max(dormantDays - warnLeadDays, 0) * 86_400_000;
210
+ const reclaimAfter = nowMs - dormantDays * 86_400_000;
211
+ return (rows || []).filter((r) => {
212
+ if (r.state !== 'parked') return false;
213
+ const p = toMs(r.parked_at);
214
+ return p != null && p <= warnAfter && p > reclaimAfter;
215
+ });
216
+ }
217
+
218
+ // Task 1002727 — was this box already warned during its CURRENT parked interval?
219
+ // box_events is the state store, so this needs no schema change: a
220
+ // 'dormant_warning' row written at or after parked_at is the receipt. Keying on
221
+ // parked_at (rather than just the newest event) means a wake→park cycle re-arms
222
+ // the warning instead of reusing a stale one from a previous park.
223
+ async function hasDormantWarning(db, { boxId, parkedAt } = {}) {
224
+ if (!boxId || !parkedAt) return false;
225
+ const { rows } = await db.query(
226
+ `SELECT 1 FROM box_events
227
+ WHERE box_id = $1 AND event = 'dormant_warning' AND created_at >= $2
228
+ LIMIT 1`,
229
+ [boxId, parkedAt]
230
+ );
231
+ return rows.length > 0;
232
+ }
233
+
196
234
  // ---------------------------------------------------------------------------
197
235
  // PURE: state-transition legality
198
236
  // ---------------------------------------------------------------------------
@@ -834,6 +872,8 @@ module.exports = {
834
872
  effectiveActivityMs,
835
873
  selectIdleBoxes,
836
874
  selectDormantBoxes,
875
+ selectDormantWarnBoxes,
876
+ hasDormantWarning,
837
877
  // pure — transitions
838
878
  TRANSITIONS,
839
879
  canTransition,
@@ -299,23 +299,43 @@
299
299
  schedulePoll(5000);
300
300
  return;
301
301
  }
302
- // No open intent and not active/provisioning/waking. Two cases:
303
- // • destroyed → the builder HAD a box that is now gone — they closed it, or
304
- // it was auto-stopped after ~10 min idle, which tears down the tunnel + its
305
- // DNS. Opening the old terminal/sandbox URL then fails with a raw browser
306
- // "can't find the server" error and no explanation (idea #295). Don't leave
307
- // them staring at a bare button: say what happened + offer to turn it back on.
302
+ // No open intent and not active/provisioning/waking. Three cases:
303
+ // • parked → asleep after the idle sweep, filesystem preserved (task 1002726).
304
+ // Reachable again as of that task; offer a wake, and say the files are safe.
305
+ // • destroyed → the builder HAD a box that is now gone — they closed it, an
306
+ // operator deprovisioned it, or it passed the dormancy window after being
307
+ // warned. Either way the tunnel + DNS are torn down, so opening the old
308
+ // terminal/sandbox URL fails with a raw browser "can't find the server" and
309
+ // no explanation (idea #295). Don't leave them staring at a bare button.
308
310
  // • none → never had a box (first run). Keep the clean first-run button.
309
311
  closeTermWindow();
310
312
  stopPoll();
311
313
  enableOpenBtn(null);
314
+ // task 1002728 — PARKED is a real, reachable state again since task 1002726
315
+ // made the idle sweep park instead of destroy. Before that it could never
316
+ // happen, so there was no branch for it and a parked box fell through to
317
+ // the first-run "Request dev box" screen — which reads as "your box is
318
+ // gone" at the exact moment the box and its files are safe.
319
+ if (state === 'parked') {
320
+ setRequestBtn(true, 'Wake my box');
321
+ setOut('<p class="setup__box-msg">Your dev box is asleep — wake it below.</p>'
322
+ + '<p class="setup__box-close-hint">It was put to sleep after a couple of hours idle so it stops '
323
+ + 'costing money. <strong>Your files are kept</strong>, including anything you had not committed — '
324
+ + 'waking it brings the box back as you left it, in about a minute. A sleeping box has no terminal '
325
+ + 'or live-preview URL, so an old link will fail to load until it is awake.</p>'
326
+ + '<p class="setup__box-close-hint">If it stays asleep for a couple of weeks we clean it up to save '
327
+ + 'cost, but you will get a warning first with several days to wake it.</p>');
328
+ return;
329
+ }
312
330
  if (state === 'destroyed') {
313
331
  setRequestBtn(true, 'Turn box back on');
314
332
  setOut('<p class="setup__box-msg">Your dev box is stopped — turn it back on below.</p>'
315
- + '<p class="setup__box-close-hint">Boxes auto-stop after about 10 minutes idle to save cost '
316
- + '(or when you close one). A stopped box has no terminal or live-preview URL, so an old '
317
- + 'link will fail to load. Turning it back on takes about a minute. '
318
- + '<strong>Anything not committed or shipped on the old box is gone</strong> — commit early next time.</p>'
333
+ + '<p class="setup__box-close-hint">This happens when you close a box yourself, or after it has been '
334
+ + 'asleep long enough to be cleaned up (you are warned first). A stopped box has no terminal or '
335
+ + 'live-preview URL, so an old link will fail to load. Turning it back on takes about a minute, and '
336
+ + 'gives you a <strong>fresh</strong> box — <strong>anything not committed or shipped on the old one '
337
+ + 'is gone</strong>. (Going idle no longer does this: an idle box is put to sleep with your files '
338
+ + 'intact.)</p>'
319
339
  + '<p class="setup__box-close-hint">The box you turn on has a <strong>new identity</strong>. If Claude '
320
340
  + 'Desktop says <strong>&ldquo;Host denied&rdquo;</strong> or asks for a password when reconnecting, fully quit '
321
341
  + 'and reopen Desktop (it re-pins your box automatically) or press <strong>Repair</strong> — do not keep turning '
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.78",
3
+ "version": "1.19.80",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.78",
9
+ "version": "1.19.80",
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.19.78",
3
+ "version": "1.19.80",
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",
@@ -18,7 +18,7 @@
18
18
  // on a 2xx, 1 on any non-2xx or transport error, 2 on bad invocation.
19
19
 
20
20
  const fs = require('node:fs');
21
- const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit } = require('./cli-lib');
21
+ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanceAndExit, cliExit } = require('./cli-lib');
22
22
 
23
23
 
24
24
  (async () => {
@@ -62,7 +62,7 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
62
62
  res = await api.request(method, relPath, body != null ? { body } : {});
63
63
  } catch (e) {
64
64
  console.error(`api: request failed: ${e.message}`);
65
- process.exit(1);
65
+ return cliExit(1); // task 1002734: never process.exit() after a request
66
66
  }
67
67
  if (!quiet) console.error(`${method} ${urlPath} → ${res.status}`);
68
68
  // task 1501: on a dev box the baked session is box-scoped (ADR 0053) and every
@@ -79,5 +79,9 @@ const { cliClient, arg, stripBom, resolveQuiet, isBoxScope, printBoxScopeGuidanc
79
79
  if (res.data !== undefined && res.data !== null) {
80
80
  console.log(JSON.stringify(res.data, null, 2));
81
81
  }
82
- process.exit(res.ok ? 0 : 1);
82
+ // task 1002734: process.exit() here aborted node 5/5 on Windows —
83
+ // "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c"
84
+ // — AFTER the JSON had already printed, so every caller that checks the exit code
85
+ // read a successful command as a failure. Set the code and let the loop drain.
86
+ cliExit(res.ok ? 0 : 1);
83
87
  })();
@@ -15,7 +15,8 @@
15
15
  // wake <builder> recreate the droplet from its snapshot
16
16
  // deprovision <builder> destroy droplet AND snapshot (full reclaim)
17
17
  // sweep-idle PARK every active box idle > BOX_IDLE_MINUTES (snapshot kept)
18
- // sweep-dormant deprovision every box parked > BOX_DORMANT_DAYS (pings builder)
18
+ // sweep-dormant warn, then deprovision every box parked > BOX_DORMANT_DAYS
19
+ // (the ONLY automated path that destroys a snapshot)
19
20
  // status print the box roster + estimated cost
20
21
  //
21
22
  // <builder> is a GitHub login or a numeric builder id.
@@ -102,6 +103,8 @@ const CONFIG = {
102
103
  baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
103
104
  idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
104
105
  dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
106
+ // task 1002727 — how many days BEFORE the reclaim a builder gets warned.
107
+ dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
105
108
  sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
106
109
  dnsHook: process.env.BOX_DNS_HOOK || null,
107
110
  tag: process.env.BOX_TAG || 'otb-builder-box',
@@ -1051,27 +1054,71 @@ async function cmdSweepIdle(pool, boxes, { apply }) {
1051
1054
  }
1052
1055
 
1053
1056
  // ---- sweep-dormant ---------------------------------------------------------
1057
+ // Task 1002727 — since the idle sweep parks rather than destroys (task 1002726),
1058
+ // this is the ONLY automated path that deletes a snapshot, i.e. the only one that
1059
+ // can take a builder's uncommitted work. So it warns before it takes: a box
1060
+ // entering the warning window gets one heads-up, and a box past the threshold is
1061
+ // reclaimed ONLY if that warning was actually delivered during its current parked
1062
+ // interval. If it never was — runner downtime, a chat outage, a box that sailed
1063
+ // past the window while the sweep was off — the reclaim is DEFERRED and the
1064
+ // warning is sent instead. That way "no automated path silently discards work"
1065
+ // holds even when the machinery itself misbehaves.
1054
1066
 
1055
1067
  async function cmdSweepDormant(pool, boxes, { apply }) {
1056
1068
  const rows = await boxes.listBoxes(pool, { state: 'parked' });
1057
- const dormant = boxes.selectDormantBoxes(rows, { dormantDays: CONFIG.dormantDays, nowMs: Date.now() });
1058
- console.log(`dormant sweep (threshold=${CONFIG.dormantDays}d${apply ? '' : ', dry-run'}): ${dormant.length} of ${rows.length} parked box(es) dormant`);
1059
- if (dormant.length === 0) { console.log(' nothing dormant — exit clean'); return { reclaimed: 0 }; }
1060
- const doApi = apply ? makeDo(apply) : null;
1061
- let reclaimed = 0, errors = 0, pingFailed = 0;
1069
+ const now = Date.now();
1070
+ const dormant = boxes.selectDormantBoxes(rows, { dormantDays: CONFIG.dormantDays, nowMs: now });
1071
+ const warnAt = Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0);
1072
+ const warning = boxes.selectDormantWarnBoxes(rows, {
1073
+ dormantDays: CONFIG.dormantDays, warnLeadDays: CONFIG.dormantWarnLeadDays, nowMs: now,
1074
+ });
1075
+ console.log(`dormant sweep (reclaim >${CONFIG.dormantDays}d, warn >${warnAt}d${apply ? '' : ', dry-run'}): ${dormant.length} dormant, ${warning.length} entering the warning window, of ${rows.length} parked`);
1076
+ if (dormant.length === 0 && warning.length === 0) { console.log(' nothing dormant — exit clean'); return { reclaimed: 0, warned: 0, deferred: 0 }; }
1077
+ // Built lazily: a sweep that only WARNS must not require a DO token.
1078
+ let doApi = null;
1079
+ const needDo = () => (doApi || (doApi = makeDo(apply)));
1080
+ let reclaimed = 0, errors = 0, pingFailed = 0, warned = 0, deferred = 0;
1081
+
1082
+ const sendWarning = async (box, days) => {
1083
+ const left = Math.max(CONFIG.dormantDays - days, 0);
1084
+ const ping = postReclaimPing(`@${box.github_login} — your dev box has been parked ${days} days. Its saved disk image will be reclaimed in ${left} day(s) to save cost, and anything on it you haven't pushed will be gone with it. Wake it any time with POST /api/gds/box/ensure to keep it.`);
1085
+ if (!ping.ok) { pingFailed++; return false; }
1086
+ await boxes.recordEvent(pool, {
1087
+ boxId: box.id, builderId: box.builder_id, event: 'dormant_warning',
1088
+ detail: `parked ${days}d; reclaim in ${left}d`, actor: 'sweep:dormant',
1089
+ });
1090
+ warned++;
1091
+ return true;
1092
+ };
1093
+
1094
+ for (const box of warning) {
1095
+ const days = Math.round((now - Date.parse(box.parked_at)) / 86400000);
1096
+ if (!apply) { console.log(` would warn @${box.github_login} (parked ${days}d)`); continue; }
1097
+ if (await boxes.hasDormantWarning(pool, { boxId: box.id, parkedAt: box.parked_at })) continue;
1098
+ const ok = await sendWarning(box, days);
1099
+ console.log(` ${ok ? '✓ warned' : '✖ warning FAILED for'} @${box.github_login} (parked ${days}d)`);
1100
+ }
1101
+
1062
1102
  for (const box of dormant) {
1063
- const days = Math.round((Date.now() - Date.parse(box.parked_at)) / 86400000);
1103
+ const days = Math.round((now - Date.parse(box.parked_at)) / 86400000);
1064
1104
  if (!apply) { console.log(` would reclaim @${box.github_login}'s box (parked ${days}d, snapshot ${box.snapshot_id})`); continue; }
1105
+ // The gate: no receipt, no reclaim. Warn now, let a later sweep take it.
1106
+ if (!(await boxes.hasDormantWarning(pool, { boxId: box.id, parkedAt: box.parked_at }))) {
1107
+ deferred++;
1108
+ const ok = await sendWarning(box, days);
1109
+ console.log(` ⏸ deferred @${box.github_login} (parked ${days}d) — never warned; ${ok ? 'warning sent, reclaim next sweep' : 'warning FAILED, will retry'}`);
1110
+ continue;
1111
+ }
1065
1112
  try {
1066
- await deprovisionOne(pool, boxes, doApi, box, { actor: 'sweep:dormant' });
1113
+ await deprovisionOne(pool, boxes, needDo(), box, { actor: 'sweep:dormant' });
1067
1114
  reclaimed++;
1068
- const ping = postReclaimPing(`@${box.github_login} — your dev box was idle for ${days} days and has been reclaimed to save cost. Queue a fresh one anytime with POST /api/gds/box/ensure (no operator needed); your memory is server-side and unaffected.`);
1115
+ const ping = postReclaimPing(`@${box.github_login} — your dev box was parked for ${days} days and has now been reclaimed to save cost, as warned. Queue a fresh one anytime with POST /api/gds/box/ensure (no operator needed); your memory is server-side and unaffected.`);
1069
1116
  if (!ping.ok) pingFailed++;
1070
1117
  console.log(` ✓ reclaimed @${box.github_login} (parked ${days}d)${ping.ok ? '' : ' [chat ping failed]'}`);
1071
1118
  } catch (e) { errors++; await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message); console.log(` ✖ @${box.github_login}: ${e.message}`); }
1072
1119
  }
1073
- console.log(`done — reclaimed=${reclaimed} ping-failed=${pingFailed} errors=${errors} dry-run=${!apply}`);
1074
- return { reclaimed, errors, pingFailed };
1120
+ console.log(`done — reclaimed=${reclaimed} warned=${warned} deferred=${deferred} ping-failed=${pingFailed} errors=${errors} dry-run=${!apply}`);
1121
+ return { reclaimed, warned, deferred, errors, pingFailed };
1075
1122
  }
1076
1123
 
1077
1124
  // ---- reconcile (source-access clawback) ------------------------------------
@@ -1369,7 +1416,8 @@ Commands:
1369
1416
  run-intents drain the auto-provision/wake-on-connect queue (#701):
1370
1417
  execute each pending box_intents row (provision/wake)
1371
1418
  sweep-idle park active boxes idle > ${CONFIG.idleMinutes}m
1372
- sweep-dormant deprovision boxes parked > ${CONFIG.dormantDays}d (pings builder)
1419
+ sweep-dormant warn at ${Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0)}d, then deprovision boxes parked > ${CONFIG.dormantDays}d
1420
+ (never reclaims a box that was not warned first)
1373
1421
  reconcile source-access clawback: deprovision below-floor/inactive
1374
1422
  boxes, rescope graduated ones (ADR 0031 §6, #600)
1375
1423
  reconcile-drift repair DO⇄DB drift BOTH ways (task 1289): force-delete
@@ -1441,4 +1489,4 @@ async function main() {
1441
1489
 
1442
1490
  if (require.main === module) main();
1443
1491
 
1444
- module.exports = { cmdProvision, deprovisionOne, parkOne, cmdSweepIdle, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
1492
+ module.exports = { cmdProvision, deprovisionOne, parkOne, cmdSweepIdle, cmdSweepDormant, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, BOX_INFRA_TEMPLATE };
@@ -115,6 +115,20 @@ function hallBase(base = API_BASE) {
115
115
  // buys these one-shot processes nothing, so route requests through a dispatcher
116
116
  // with keep-alive effectively disabled (mirrors cf-tunnel.js's dispatcher
117
117
  // pattern). Degrades to the default dispatcher if undici can't be required.
118
+ //
119
+ // task 1002734 — THIS ALONE IS NOT ENOUGH, and the measurements say so: with the
120
+ // dispatcher wired in and undici resolving fine, `node scripts/gds/api.js GET
121
+ // /tasks/<id>` still aborted 5/5 on Windows 11 / Node 24. The dispatcher removes
122
+ // one contributing handle; the actual trigger is calling process.exit() at all
123
+ // while the request machinery is still tearing down. A minimal synthetic
124
+ // fetch-then-exit does NOT reproduce, so it takes the real stack (the dynamic ESM
125
+ // client import + the Agent + any child spawn / consumed stdin) to line up — which
126
+ // is exactly why hunting the single guilty handle is a dead end.
127
+ //
128
+ // The rule for every CLI here: after a request, DO NOT call process.exit().
129
+ // Set process.exitCode and return, and let the loop drain — see cliExit below.
130
+ // Verified: the pre-push gate went 8/8 aborting to 0/8 on that change alone
131
+ // (task 1002725), and api.js 5/5 to 0/5 (this task).
118
132
  let _noKeepAliveDispatcher;
119
133
  let _noKeepAliveTried = false;
120
134
  function noKeepAliveDispatcher() {
@@ -127,6 +141,19 @@ function noKeepAliveDispatcher() {
127
141
  return _noKeepAliveDispatcher;
128
142
  }
129
143
 
144
+ // cliExit(code) — the post-request replacement for process.exit(code). Records the
145
+ // exit status and lets node exit naturally once the loop drains, which is what
146
+ // keeps the UV_HANDLE_CLOSING abort above from firing. Callers must `return` after
147
+ // calling it: unlike process.exit() it does NOT stop execution.
148
+ //
149
+ // Gotcha (learned in task 1002725): a hard exit MASKS leaked timers/handles. Once a
150
+ // CLI exits naturally, anything still armed — an uncleared setTimeout, an open
151
+ // interval — keeps the process alive. Clear them, or the CLI hangs instead of
152
+ // crashing.
153
+ function cliExit(code = 0) {
154
+ process.exitCode = code;
155
+ }
156
+
130
157
  async function readSessionFile(p) {
131
158
  try {
132
159
  const raw = await fsp.readFile(p, 'utf8');
@@ -760,6 +787,7 @@ module.exports = {
760
787
  resolveQuiet,
761
788
  readSessionToken,
762
789
  apiCall,
790
+ cliExit,
763
791
  cliClient,
764
792
  requireSession,
765
793
  printUnauthGuidanceAndExit,
package/src/module-api.js CHANGED
@@ -583,8 +583,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
583
583
  // landed since 1.19.76 with no explicit bump. run 31647860631. (task 1002620)
584
584
  // 1.19.78 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
585
585
  // landed since 1.19.77 with no explicit bump. run 31648127800. (task 1002620)
586
+ // 1.19.79 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
587
+ // landed since 1.19.78 with no explicit bump. run 31648954641. (task 1002620)
588
+ // 1.19.80 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
589
+ // landed since 1.19.79 with no explicit bump. run 31649366675. (task 1002620)
586
590
  // ---------------------------------------------------------------------------
587
- const CORE_VERSION = '1.19.78'; // CI auto-patch carrier (ADR 0161); see changelog above
591
+ const CORE_VERSION = '1.19.80'; // CI auto-patch carrier (ADR 0161); see changelog above
588
592
 
589
593
  // A namespaced logger so a module's log lines are attributable + consistent.
590
594
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/boxes.mjs CHANGED
@@ -1332,5 +1332,97 @@ await ta('cmdSweepIdle: a box running Claude is never parked mid-session', async
1332
1332
  assert.equal(h.states.length, 0);
1333
1333
  });
1334
1334
 
1335
+ console.log('\ntask 1002727 — the dormant sweep warns before it takes:');
1336
+
1337
+ const DORMANT_DAY = 86_400_000;
1338
+ const parked = (d, extra = {}) => ({
1339
+ id: 9, builder_id: 9, state: 'parked', github_login: 'sleeper', snapshot_id: 's1',
1340
+ parked_at: new Date(NOW - d * DORMANT_DAY).toISOString(), ...extra,
1341
+ });
1342
+
1343
+ t('selectDormantWarnBoxes: picks boxes in the lead window, not before, not after', () => {
1344
+ const rows = [parked(10), parked(12), parked(15), parked(3)];
1345
+ const warn = boxes.selectDormantWarnBoxes(rows, { dormantDays: 14, warnLeadDays: 3, nowMs: NOW });
1346
+ const days = warn.map((r) => Math.round((NOW - Date.parse(r.parked_at)) / DORMANT_DAY)).sort((a, b) => a - b);
1347
+ assert.deepEqual(days, [12], 'only the box inside [11d, 14d) is warned');
1348
+ });
1349
+
1350
+ t('selectDormantWarnBoxes: a box already past the threshold is NOT in the warn set', () => {
1351
+ // It belongs to selectDormantBoxes, which refuses to reclaim it unwarned.
1352
+ const warn = boxes.selectDormantWarnBoxes([parked(20)], { dormantDays: 14, warnLeadDays: 3, nowMs: NOW });
1353
+ assert.equal(warn.length, 0);
1354
+ });
1355
+
1356
+ t('selectDormantWarnBoxes: guards bad inputs', () => {
1357
+ assert.deepEqual(boxes.selectDormantWarnBoxes(null, { dormantDays: 14, warnLeadDays: 3, nowMs: NOW }), []);
1358
+ assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 0, warnLeadDays: 3, nowMs: NOW }), []);
1359
+ assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 14, warnLeadDays: 0, nowMs: NOW }), []);
1360
+ assert.deepEqual(boxes.selectDormantWarnBoxes([], { dormantDays: 14, warnLeadDays: 3, nowMs: NaN }), []);
1361
+ });
1362
+
1363
+ await ta('hasDormantWarning: keyed on the CURRENT parked interval, so a re-park re-arms', async () => {
1364
+ const seen = [];
1365
+ const db = { async query(sql, params) { seen.push({ sql, params }); return { rows: [] }; } };
1366
+ const parkedAt = new Date(NOW - 5 * DORMANT_DAY).toISOString();
1367
+ const got = await boxes.hasDormantWarning(db, { boxId: 9, parkedAt });
1368
+ assert.equal(got, false, 'no receipt → not warned');
1369
+ assert.match(seen[0].sql, /dormant_warning/);
1370
+ assert.match(seen[0].sql, /created_at >= \$2/, 'scoped to events AFTER this park, not any historical one');
1371
+ assert.deepEqual(seen[0].params, [9, parkedAt]);
1372
+ });
1373
+
1374
+ await ta('hasDormantWarning: a missing box id or parked_at is false, never a query', async () => {
1375
+ const db = { async query() { throw new Error('should not query'); } };
1376
+ assert.equal(await boxes.hasDormantWarning(db, { boxId: null, parkedAt: 'x' }), false);
1377
+ assert.equal(await boxes.hasDormantWarning(db, { boxId: 9, parkedAt: null }), false);
1378
+ });
1379
+
1380
+ await ta('cmdSweepDormant: a box past the threshold that was NEVER warned is DEFERRED, not reclaimed', async () => {
1381
+ // The safety property: even if the warning window was missed entirely (runner
1382
+ // downtime, chat outage), the snapshot is not destroyed on this pass.
1383
+ const box = parked(30);
1384
+ const destroyed = [];
1385
+ const events = [];
1386
+ const fakeBoxes = {
1387
+ async listBoxes() { return [box]; },
1388
+ selectDormantBoxes: boxes.selectDormantBoxes,
1389
+ selectDormantWarnBoxes: boxes.selectDormantWarnBoxes,
1390
+ async hasDormantWarning() { return false; }, // never warned
1391
+ async recordEvent(_db, e) { events.push(e); },
1392
+ async setBoxState() { return {}; },
1393
+ async recordComputeCost() { return 0; },
1394
+ async recordStorageCost() { return 0; },
1395
+ async getBoxByBuilderId() { return { state: 'parked' }; },
1396
+ };
1397
+ const lines = [];
1398
+ const origLog = console.log;
1399
+ console.log = (...a) => lines.push(a.join(' '));
1400
+ let res;
1401
+ try { res = await boxCli.cmdSweepDormant({}, fakeBoxes, { apply: true }); }
1402
+ finally { console.log = origLog; }
1403
+ assert.equal(res.reclaimed, 0, 'NOTHING reclaimed without a delivered warning');
1404
+ assert.equal(res.deferred, 1, 'the reclaim is deferred to a later sweep');
1405
+ assert.equal(destroyed.length, 0);
1406
+ assert.ok(lines.some((l) => /deferred/.test(l)), 'the deferral is logged, not silent');
1407
+ });
1408
+
1409
+ await ta('cmdSweepDormant: dry-run reclaims and warns nothing', async () => {
1410
+ const fakeBoxes = {
1411
+ async listBoxes() { return [parked(30), parked(12)]; },
1412
+ selectDormantBoxes: boxes.selectDormantBoxes,
1413
+ selectDormantWarnBoxes: boxes.selectDormantWarnBoxes,
1414
+ async hasDormantWarning() { return true; },
1415
+ async recordEvent() { throw new Error('dry-run must not write'); },
1416
+ async setBoxState() { throw new Error('dry-run must not write'); },
1417
+ };
1418
+ const origLog = console.log;
1419
+ console.log = () => {};
1420
+ let res;
1421
+ try { res = await boxCli.cmdSweepDormant({}, fakeBoxes, { apply: false }); }
1422
+ finally { console.log = origLog; }
1423
+ assert.equal(res.reclaimed, 0);
1424
+ assert.equal(res.warned, 0);
1425
+ });
1426
+
1335
1427
  console.log(`\nboxes.mjs: ${passed} passed, ${failed} failed`);
1336
1428
  process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,135 @@
1
+ // tests/cli_exit_no_abort.mjs — the GDS CLIs must not abort natively on exit
2
+ // (task 1002734; the pre-push twin of this is tests/pre_push_hook.mjs, task 1002725).
3
+ //
4
+ // THE BUG. On Windows + Node 24, calling process.exit() after the CLI has made a
5
+ // request aborts the process before it can exit cleanly:
6
+ //
7
+ // Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76
8
+ //
9
+ // `node scripts/gds/api.js GET /tasks/<id>` reproduced this 5/5: the JSON printed
10
+ // correctly and THEN the process died with exit 127, so every caller that checks an
11
+ // exit code — CI, shell scripts, the ship pipeline — read a successful command as a
12
+ // failure. Setting process.exitCode and letting the loop drain took it to 0/6.
13
+ //
14
+ // A no-keep-alive undici dispatcher was already wired into cli-lib for this exact
15
+ // assertion and was NOT sufficient on its own; only dropping the hard exit fixed it.
16
+ //
17
+ // NETWORK-FREE, but not server-free. The failure cases point the CLI at a dead
18
+ // localhost port; the success case runs a throwaway stub server in its own process.
19
+ // Both matter: the crash is in the TEARDOWN, and a 200 tears down more machinery than
20
+ // a refused connection — the wild repro was a successful call.
21
+ //
22
+ // Run: node --test tests/cli_exit_no_abort.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import { spawnSync, spawn } from 'node:child_process';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const API = path.join(ROOT, 'scripts', 'gds', 'api.js');
33
+ const CLI_LIB = path.join(ROOT, 'scripts', 'gds', 'cli-lib.js');
34
+ const DEAD_BASE = 'http://127.0.0.1:9'; // discard port — connection refused, instantly
35
+
36
+ // The native abort prints this and nothing else can be relied on; it is NOT a
37
+ // JS-level error, so it never appears as a caught exception.
38
+ const ABORT_RE = /Assertion failed|UV_HANDLE_CLOSING/i;
39
+
40
+ function runApi(args, env = {}) {
41
+ return spawnSync(process.execPath, [API, ...args], {
42
+ cwd: ROOT,
43
+ encoding: 'utf8',
44
+ env: { ...process.env, GDS_API_BASE: DEAD_BASE, ...env },
45
+ });
46
+ }
47
+
48
+ test('api.js does not abort natively when a request fails', () => {
49
+ const r = runApi(['GET', '/api/gds/me', '--quiet']);
50
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `native abort on the request-failure path:\n${r.stderr}`);
51
+ assert.equal(r.status, 1, `a failed request must exit 1, not ${r.status} (127 = the abort)`);
52
+ assert.match(r.stderr, /request failed/i);
53
+ });
54
+
55
+ test('api.js reports a usage error without aborting (pre-request path)', () => {
56
+ const r = runApi(['GET']); // missing path
57
+ assert.doesNotMatch(r.stderr || '', ABORT_RE);
58
+ assert.equal(r.status, 2, 'bad invocation is exit 2');
59
+ });
60
+
61
+ test('api.js exit code survives repeated runs (the abort was intermittent-looking)', () => {
62
+ // The synthetic minimal case (bare fetch + process.exit) does NOT reproduce, so a
63
+ // single green run proves little — the real stack has to line up. Run it enough
64
+ // times that a reintroduced hard exit shows up rather than slipping through.
65
+ for (let i = 0; i < 5; i++) {
66
+ const r = runApi(['GET', '/api/gds/me', '--quiet']);
67
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively`);
68
+ assert.equal(r.status, 1, `run ${i + 1} exited ${r.status}`);
69
+ }
70
+ });
71
+
72
+ // The SUCCESS teardown is the path that actually aborted in the wild: api.js printed
73
+ // its JSON and then died at exit 127. A failed request tears down less machinery, so
74
+ // the failure cases above cannot cover this. Serve a real 200 — still no external
75
+ // network — and assert the clean exit 0.
76
+ //
77
+ // The stub MUST run in its own process. An in-process http.createServer here would
78
+ // deadlock: spawnSync blocks this process's event loop, so the server could never
79
+ // accept the child's connection and the test would hang forever.
80
+ const STUB_SRC = `
81
+ const http = require('node:http');
82
+ const s = http.createServer((q, r) => {
83
+ r.writeHead(200, { 'Content-Type': 'application/json' });
84
+ r.end(JSON.stringify({ ok: true, builder: { id: 1, rank: 'archon' } }));
85
+ });
86
+ s.listen(0, '127.0.0.1', () => console.log('PORT=' + s.address().port));
87
+ `;
88
+
89
+ function startStub() {
90
+ const proc = spawn(process.execPath, ['-e', STUB_SRC], { stdio: ['ignore', 'pipe', 'ignore'] });
91
+ return new Promise((resolve, reject) => {
92
+ let buf = '';
93
+ const timer = setTimeout(() => { proc.kill(); reject(new Error('stub server did not report a port')); }, 10_000);
94
+ proc.stdout.on('data', (d) => {
95
+ buf += d.toString();
96
+ const m = buf.match(/PORT=(\d+)/);
97
+ if (m) { clearTimeout(timer); resolve({ proc, port: Number(m[1]) }); }
98
+ });
99
+ proc.on('error', (e) => { clearTimeout(timer); reject(e); });
100
+ });
101
+ }
102
+
103
+ test('api.js exits 0 after a SUCCESSFUL request — the path that aborted in the wild', async () => {
104
+ const { proc, port } = await startStub();
105
+ try {
106
+ for (let i = 0; i < 3; i++) {
107
+ const r = runApi(['GET', '/api/gds/me', '--quiet'], { GDS_API_BASE: `http://127.0.0.1:${port}` });
108
+ assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively after a 200:\n${r.stderr}`);
109
+ assert.equal(r.status, 0, `a successful request must exit 0, got ${r.status} (127 = the abort); stderr: ${r.stderr}`);
110
+ assert.match(r.stdout, /"ok"/, 'the JSON body must still reach stdout');
111
+ }
112
+ } finally {
113
+ proc.kill();
114
+ }
115
+ });
116
+
117
+ test('cli-lib exports cliExit — the documented replacement for a post-request exit', async () => {
118
+ const mod = await import(`file://${CLI_LIB.split(path.sep).join('/')}`);
119
+ const cliExit = mod.default?.cliExit ?? mod.cliExit;
120
+ assert.equal(typeof cliExit, 'function', 'cliExit must be exported for the CLI sweep to adopt');
121
+ });
122
+
123
+ test('api.js routes its post-request exits through cliExit, not process.exit', () => {
124
+ const src = fs.readFileSync(API, 'utf8');
125
+ // The two surviving process.exit() calls are the pre-request guards (usage + body
126
+ // parse), which run before any network work and are safe. Everything after the
127
+ // request must use cliExit.
128
+ const afterRequest = src.slice(src.indexOf('let res;'));
129
+ const stripped = afterRequest.replace(/\/\/[^\n]*/g, '');
130
+ assert.ok(
131
+ !/process\.exit\s*\(/.test(stripped),
132
+ 'no process.exit() may remain after the request is issued — that is what aborts node on Windows'
133
+ );
134
+ assert.match(afterRequest, /cliExit\(/, 'the post-request paths must set the exit code via cliExit');
135
+ });