@bongos/core 1.19.77 → 1.19.79

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.77",
6
- "core_contract": "1.19.77",
7
- "source_commit": "a20aad3a45d55f5a39b9923d17ec460bfd504630",
5
+ "core_version": "1.19.79",
6
+ "core_contract": "1.19.79",
7
+ "source_commit": "ba59ac00319af3e83900b468e2feac5038806c60",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-12T22:40:06.341Z",
9
+ "built_at": "2026-08-12T22:56:44.247Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 295,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1541,
20
- "tree_sha256": "3125b6aac967f4e3e3e323f3572eee2b53307f8f0acfb7699a40202bc966e354",
20
+ "tree_sha256": "351a95bc990504fb425ec46898380ecd98b6518301c81afc6046244459dd15e0",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2582,7 +2582,7 @@
2582
2582
  {
2583
2583
  "path": "modules/dev-box/box-access.js",
2584
2584
  "mode": "0000644",
2585
- "sha256": "2afd598f687392b313dc872e73d4e6b61af27ce96d140685268ea5697955d636"
2585
+ "sha256": "8d2dc69a139a5a658866a18527efeb5d9ae989e05883fa6cbd0ea63db0ba9476"
2586
2586
  },
2587
2587
  {
2588
2588
  "path": "modules/dev-box/box-credential.js",
@@ -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",
@@ -2612,7 +2612,7 @@
2612
2612
  {
2613
2613
  "path": "modules/dev-box/routes/box.js",
2614
2614
  "mode": "0000644",
2615
- "sha256": "a9f51c0ddc4fc8c558d0c6dde77ffcd3e1b1262a6f14f93c88e1bb3e9093538d"
2615
+ "sha256": "ba75c28d8927ebbda1482f394a0062febc6e32a52863814d00d6ad2a6667a591"
2616
2616
  },
2617
2617
  {
2618
2618
  "path": "modules/discord/CLAUDE.md",
@@ -3402,7 +3402,7 @@
3402
3402
  {
3403
3403
  "path": "modules/lifecycle/db.js",
3404
3404
  "mode": "0000644",
3405
- "sha256": "bc1526d068698e489fc2a698c04418e60097f18b56fd5c0dcdb6a6f62bbd1c81"
3405
+ "sha256": "5e7dc6c267384eb3b691f49ec82631425151b97d2208eaf342922bf46146965f"
3406
3406
  },
3407
3407
  {
3408
3408
  "path": "modules/lifecycle/done-when.js",
@@ -3437,7 +3437,7 @@
3437
3437
  {
3438
3438
  "path": "modules/lifecycle/lifecycle.js",
3439
3439
  "mode": "0000644",
3440
- "sha256": "5c0d0da5d6a8bcfb920bc432558066840e649cbc624e064dc4e55a926a3d8bb4"
3440
+ "sha256": "8f62f50c5a81d649df6b85ef7c89e57a0bf0e8877482693861a4f9cd5cfafe5a"
3441
3441
  },
3442
3442
  {
3443
3443
  "path": "modules/lifecycle/merge-lock.js",
@@ -4057,12 +4057,12 @@
4057
4057
  {
4058
4058
  "path": "package-lock.json",
4059
4059
  "mode": "0000644",
4060
- "sha256": "ffd10996e222dd7fe49aa05ad7177979e18cb7871ee3666f2cc96c9822e8075e"
4060
+ "sha256": "c96137763abb2061735f0a815401ead7047d116c5eb6dd538178e9e23c9ca0bd"
4061
4061
  },
4062
4062
  {
4063
4063
  "path": "package.json",
4064
4064
  "mode": "0000644",
4065
- "sha256": "3cdd1266637cfb67eed301d96a9f6414d32d98f1d4e9033e20aa6687806526c3"
4065
+ "sha256": "97227cfa1c95b171af85bd2f248f894ebe6f8795cd9364b6dc87180fc00e445e"
4066
4066
  },
4067
4067
  {
4068
4068
  "path": "public-docs/index.html",
@@ -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",
@@ -5687,7 +5687,7 @@
5687
5687
  {
5688
5688
  "path": "src/module-api.js",
5689
5689
  "mode": "0000644",
5690
- "sha256": "fee8a499a0208f8f825a317e5ab631684bcb2c718dde601e6eab23eda5f2728c"
5690
+ "sha256": "ff17cf82d49bb2e21188381d9621bbf4f5cf61ac3e49b10ea5ea32685d170c14"
5691
5691
  },
5692
5692
  {
5693
5693
  "path": "src/module-loader/loader.js",
@@ -6032,7 +6032,7 @@
6032
6032
  {
6033
6033
  "path": "tests/box_task_scope.mjs",
6034
6034
  "mode": "0000644",
6035
- "sha256": "e6cb9f94ae75fc9dd9134c85d8074bfe008eadd64eaddb0df2f1d6d851683c36"
6035
+ "sha256": "22a2dc8bdccb9900b642371b85e8cc1785ecfacaf8f7579b042951425d95d794"
6036
6036
  },
6037
6037
  {
6038
6038
  "path": "tests/box_terminal_ssrf.mjs",
@@ -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",
@@ -203,18 +203,33 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
203
203
  // + passed by the route in R94) selects the surface, three ways:
204
204
  // - scopeKeys is a NON-EMPTY array → TASK scope: BASE ∪ the claims' module dirs
205
205
  // (via sparsePathsForScopeKeys + the passed-in `moduleScopeMap`).
206
- // - scopeKeys is an EMPTY array → BASE-only (a live box with no active claim /
207
- // no scoped goal — the pre-existing Bongos instance, nothing extra).
206
+ // - scopeKeys is an EMPTY array → depends on whether the builder is actually
207
+ // working (see `hasActiveClaims` below).
208
208
  // - scopeKeys is ABSENT (undefined) → the pre-ADR-0148 RANK fallback (full for
209
209
  // Metic+, else the starter sparse surface). This is the path a vanilla instance
210
210
  // with no lifecycle port takes, and it preserves the old behavior EXACTLY — so
211
211
  // shipping R93 before R94 wires the route changes nothing in production.
212
212
  //
213
+ // TASK 1002729 — the empty-array case is NOT one case, it is two, and collapsing
214
+ // them was a live defect. moduleScopeForActiveClaims returns [] both when the
215
+ // builder holds no claim AND when they hold claims whose goals declare no
216
+ // scope_modules (the goal wall is optional; only 13 of 46 goals on the live
217
+ // instance declared one). Treating the second as "no scope" handed a working
218
+ // builder a box with NO modules/ directory at all — less code than the rank
219
+ // fallback would have given a newcomer. On the live fleet that was 247 of 248
220
+ // source grants, including an Archon holding 7 active claims.
221
+ // - [] + hasActiveClaims true → RANK fallback. The builder is working; an
222
+ // undeclared goal wall is a planning gap, and it must not cripple their box.
223
+ // - [] + hasActiveClaims false → BASE-only, as before (a genuinely unscoped box
224
+ // — the pre-existing Bongos instance, nothing extra). Least privilege holds.
225
+ // `hasActiveClaims` is optional: omitted (undefined) preserves the old BASE-only
226
+ // reading, so a caller that hasn't been taught the signal is unchanged.
227
+ //
213
228
  // Returns one of:
214
229
  // { allowed:false, reason:'BUILDER_INACTIVE' }
215
230
  // { allowed:false, reason:'INSUFFICIENT_RANK', required, actual }
216
231
  // { allowed:true, scope, spec }
217
- function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, moduleScopeMap } = {}) {
232
+ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap } = {}) {
218
233
  if (status && status !== 'active') {
219
234
  return { allowed: false, reason: 'BUILDER_INACTIVE' };
220
235
  }
@@ -228,11 +243,19 @@ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, sco
228
243
  }
229
244
  // Claim-driven task scope (present → the box asks for exactly its work's code).
230
245
  if (Array.isArray(scopeKeys)) {
231
- const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
232
- const scope = scopeKeys.length > 0 ? 'task' : 'base';
233
- return { allowed: true, scope, spec: { scope, mode: 'sparse', sparsePaths } };
246
+ if (scopeKeys.length > 0) {
247
+ const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
248
+ return { allowed: true, scope: 'task', spec: { scope: 'task', mode: 'sparse', sparsePaths } };
249
+ }
250
+ // Empty. A builder mid-claim whose goals declare no wall gets the rank scope,
251
+ // NOT a stripped box — the planning gap is not theirs to pay for.
252
+ if (!hasActiveClaims) {
253
+ const sparsePaths = sparsePathsForScopeKeys([], moduleScopeMap);
254
+ return { allowed: true, scope: 'base', spec: { scope: 'base', mode: 'sparse', sparsePaths } };
255
+ }
234
256
  }
235
- // No claim signal (no lifecycle port on this instance): the pre-ADR-0148 rank fallback.
257
+ // No claim signal (no lifecycle port on this instance), or claims with an
258
+ // undeclared goal wall: the pre-ADR-0148 rank fallback.
236
259
  const scope = boxScopeForRank(rank);
237
260
  return { allowed: true, scope, spec: cloneSpecForScope(scope) };
238
261
  }
@@ -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,
@@ -444,6 +444,7 @@ module.exports = function buildBoxRouter() {
444
444
  // NEVER fail the source fetch on a scope-read hiccup — degrade to rank scope.
445
445
  const lifecycle = api.resolveOptional('lifecycle');
446
446
  let scopeKeys;
447
+ let hasActiveClaims;
447
448
  if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
448
449
  try {
449
450
  scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
@@ -452,7 +453,19 @@ module.exports = function buildBoxRouter() {
452
453
  scopeKeys = undefined;
453
454
  }
454
455
  }
455
- const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, moduleScopeMap: api.moduleScopeMap });
456
+ // task 1002729 — only needed to disambiguate an EMPTY scope: no claim (BASE)
457
+ // vs claims whose goals declare no wall (rank scope). Skipped otherwise so
458
+ // the common path keeps its single query.
459
+ if (Array.isArray(scopeKeys) && scopeKeys.length === 0
460
+ && lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
461
+ try {
462
+ hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
463
+ } catch (claimErr) {
464
+ console.error('[gds] source-access claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
465
+ hasActiveClaims = undefined;
466
+ }
467
+ }
468
+ const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
456
469
  const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
457
470
  const auditDeny = (reason, extra) =>
458
471
  box && boxAccess.recordSourceEvent(pool, {
@@ -548,6 +561,7 @@ module.exports = function buildBoxRouter() {
548
561
  // (optional lifecycle port; degrade to rank scope on any hiccup).
549
562
  const lifecycle = api.resolveOptional('lifecycle');
550
563
  let scopeKeys;
564
+ let hasActiveClaims;
551
565
  if (lifecycle && typeof lifecycle.moduleScopeForActiveClaims === 'function') {
552
566
  try {
553
567
  scopeKeys = await lifecycle.moduleScopeForActiveClaims(req.builder.id);
@@ -556,7 +570,18 @@ module.exports = function buildBoxRouter() {
556
570
  scopeKeys = undefined;
557
571
  }
558
572
  }
559
- const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, moduleScopeMap: api.moduleScopeMap });
573
+ // task 1002729 — same disambiguation as source-access, so this read-only
574
+ // companion can never report a scope the real fetch wouldn't grant.
575
+ if (Array.isArray(scopeKeys) && scopeKeys.length === 0
576
+ && lifecycle && typeof lifecycle.activeClaimCountForBuilder === 'function') {
577
+ try {
578
+ hasActiveClaims = (await lifecycle.activeClaimCountForBuilder(req.builder.id)) > 0;
579
+ } catch (claimErr) {
580
+ console.error('[gds] /box/scope claim-count read (non-fatal, → BASE)', claimErr && claimErr.message ? claimErr.message : claimErr);
581
+ hasActiveClaims = undefined;
582
+ }
583
+ }
584
+ const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
560
585
  if (!decision.allowed) {
561
586
  return res.fail('SOURCE_ACCESS_REVOKED', 403, {
562
587
  reason: decision.reason,
@@ -4756,6 +4756,29 @@ async function moduleScopeForActiveClaims(builderId, deps = {}) {
4756
4756
  return rows.map((r) => r.module_key);
4757
4757
  }
4758
4758
 
4759
+ // Task 1002729 — the companion signal moduleScopeForActiveClaims cannot carry.
4760
+ // That query INNER JOINs goals and unnests scope_modules, so it returns [] for
4761
+ // BOTH "this builder holds no claim" and "this builder holds claims whose goals
4762
+ // declare no scope_modules". Those must not be treated alike: the first is a
4763
+ // genuinely unscoped box (BASE-only is right), the second is a builder actively
4764
+ // working who would be handed a box with no modules/ at all. Live evidence for
4765
+ // why this matters: 247 of 248 source grants on the cloudbongos fleet resolved to
4766
+ // BASE, including an Archon holding 7 active claims — every one of those claims
4767
+ // sat on a goal with scope_modules = {}. Counts CLAIMS, deliberately not joining
4768
+ // goals, so it stays true regardless of how the goal wall is (or isn't) declared.
4769
+ async function activeClaimCountForBuilder(builderId, deps = {}) {
4770
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
4771
+ const id = Number(builderId);
4772
+ if (!Number.isInteger(id)) {
4773
+ throw new Error('activeClaimCountForBuilder: builderId must be numeric');
4774
+ }
4775
+ const { rows } = await activePool.query(
4776
+ `SELECT count(*)::int AS n FROM claims WHERE builder_id = $1 AND released_at IS NULL`,
4777
+ [id]
4778
+ );
4779
+ return rows.length ? Number(rows[0].n) : 0;
4780
+ }
4781
+
4759
4782
  // task 1642 — every active claim bound to a given CLI session. "Active" is the
4760
4783
  // claims-table convention (released_at IS NULL); there is NO `status` column on
4761
4784
  // claims (a common misread — the task's status lives on `tasks`). Keyed on
@@ -5625,6 +5648,7 @@ module.exports = {
5625
5648
  getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
5626
5649
  getActiveClaimsForBuilder,
5627
5650
  moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
5651
+ activeClaimCountForBuilder, // task 1002729 — distinguishes "no claim" from "claim with an empty goal wall"
5628
5652
  getActiveClaimsForSession,
5629
5653
  getLastClaimHolder, // --regrade ownership guard (#678)
5630
5654
  getTaskGrade, // V4.R08 — diff-hash reuse check before grading
@@ -65,4 +65,7 @@ module.exports = {
65
65
  // resolver (goal 1000051 / R92, ADR 0148 §2). Keys only; box-access maps them to
66
66
  // git sparse-checkout globs through the doorway `moduleScopeMap`.
67
67
  moduleScopeForActiveClaims: (...a) => db.moduleScopeForActiveClaims(...a),
68
+ // task 1002729 — the "is this builder actually working right now" signal that
69
+ // lets box-access tell an unscoped box from a crippled one (see db.js).
70
+ activeClaimCountForBuilder: (...a) => db.activeClaimCountForBuilder(...a),
68
71
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.77",
3
+ "version": "1.19.79",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.77",
9
+ "version": "1.19.79",
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.77",
3
+ "version": "1.19.79",
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",
@@ -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 };
package/src/module-api.js CHANGED
@@ -581,8 +581,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
581
581
  // landed since 1.19.75 with no explicit bump. run 31637719876. (task 1002620)
582
582
  // 1.19.77 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
583
583
  // landed since 1.19.76 with no explicit bump. run 31647860631. (task 1002620)
584
+ // 1.19.78 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
584
588
  // ---------------------------------------------------------------------------
585
- const CORE_VERSION = '1.19.77'; // CI auto-patch carrier (ADR 0161); see changelog above
589
+ const CORE_VERSION = '1.19.79'; // CI auto-patch carrier (ADR 0161); see changelog above
586
590
 
587
591
  // A namespaced logger so a module's log lines are attributable + consistent.
588
592
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -96,4 +96,74 @@ await test('an inactive builder is denied even with claims in scope', async () =
96
96
  assert.equal(inactive.decision.reason, 'BUILDER_INACTIVE');
97
97
  });
98
98
 
99
+ // =========================================================================
100
+ // Task 1002729 — an EMPTY scope is two different situations, not one
101
+ // =========================================================================
102
+ // The live defect: moduleScopeForActiveClaims returns [] both for "holds no
103
+ // claim" and for "holds claims whose goals declare no scope_modules". Reading
104
+ // both as BASE handed working builders a box with no modules/ at all — 247 of
105
+ // 248 grants on the live fleet, including an Archon with 7 active claims.
106
+
107
+ // A pool that answers BOTH queries the resolver pair now issues: the
108
+ // claims→goals→scope_modules unnest, and the plain active-claim count.
109
+ function poolReturningWithClaims(moduleKeyRows, claimCount) {
110
+ const c = makeSqlAwareClient((s) => {
111
+ if (/FROM claims c/.test(s) && /unnest\(g\.scope_modules\)/.test(s)) return { rows: moduleKeyRows };
112
+ if (/count\(\*\)/.test(s) && /FROM claims/.test(s)) return { rows: [{ n: claimCount }] };
113
+ return null;
114
+ });
115
+ return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
116
+ }
117
+
118
+ async function specForClaimsWithCount({ builderId = 3, rank = 'metic', status = 'active', moduleKeyRows, claimCount }) {
119
+ const pool = poolReturningWithClaims(moduleKeyRows, claimCount);
120
+ const scopeKeys = await db.moduleScopeForActiveClaims(builderId, { pool });
121
+ const hasActiveClaims = (await db.activeClaimCountForBuilder(builderId, { pool })) > 0;
122
+ return { scopeKeys, hasActiveClaims, decision: access.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: scopeMap }) };
123
+ }
124
+
125
+ await test('claims held but every goal wall empty → RANK scope, never a stripped box', async () => {
126
+ // Exactly Lars's live shape: 7 active claims, all on goals with scope_modules={}.
127
+ const { scopeKeys, hasActiveClaims, decision } = await specForClaimsWithCount({
128
+ rank: 'archon', moduleKeyRows: [], claimCount: 7,
129
+ });
130
+ assert.deepEqual(scopeKeys, [], 'the union query still yields nothing — the goals declare no wall');
131
+ assert.equal(hasActiveClaims, true, 'but the builder IS working');
132
+ assert.equal(decision.scope, 'full', 'an Archon mid-claim gets the full surface, not base');
133
+ assert.equal(decision.spec.mode, 'full');
134
+ assert.notEqual(decision.scope, 'base', 'the regression this test exists to prevent');
135
+ });
136
+
137
+ await test('no claims at all → BASE-only is preserved (least privilege holds)', async () => {
138
+ const { hasActiveClaims, decision } = await specForClaimsWithCount({
139
+ rank: 'archon', moduleKeyRows: [], claimCount: 0,
140
+ });
141
+ assert.equal(hasActiveClaims, false);
142
+ assert.equal(decision.scope, 'base', 'an idle box is still not handed the repo');
143
+ assert.deepEqual(decision.spec.sparsePaths, [...BASE]);
144
+ });
145
+
146
+ await test('a declared goal wall still wins over the rank fallback', async () => {
147
+ // Claims AND a wall → task scope, exactly as before; the fix must not widen this.
148
+ const { decision } = await specForClaimsWithCount({
149
+ rank: 'archon', moduleKeyRows: [{ module_key: 'status-ui' }], claimCount: 2,
150
+ });
151
+ assert.equal(decision.scope, 'task', 'a real wall is still honoured, not overridden by rank');
152
+ assert.deepEqual(decision.spec.sparsePaths, [...BASE, 'modules/status-ui']);
153
+ });
154
+
155
+ await test('a Thetes mid-claim with no wall gets STARTER, not full — rank still bounds it', async () => {
156
+ const { decision } = await specForClaimsWithCount({
157
+ rank: 'thetes', moduleKeyRows: [], claimCount: 1,
158
+ });
159
+ assert.equal(decision.scope, 'starter', 'the fallback is rank-bounded; it is not a blanket widen');
160
+ });
161
+
162
+ await test('activeClaimCountForBuilder rejects a non-numeric builder id', async () => {
163
+ await assert.rejects(
164
+ () => db.activeClaimCountForBuilder('not-a-number', { pool: poolReturningWithClaims([], 0) }),
165
+ /must be numeric/,
166
+ );
167
+ });
168
+
99
169
  summary();
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);