@bongos/core 1.19.470 → 1.19.471

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.
@@ -22,8 +22,15 @@
22
22
  //
23
23
  // -------------------------------------------------------------------------
24
24
  // Release-stale-mode notes (V3.R49):
25
- // - Threshold is HOURS by default (env var GDS_STALE_CLAIM_HOURS, default
26
- // 6). The underlying sweepStaleClaims() helper takes minutes; we convert.
25
+ // - Threshold is HOURS, and it is THE PROJECT'S — resolved by src/stale-timer.js
26
+ // (--threshold-hours -> <PREFIX>_STALE_CLAIM_HOURS, legacy GDS_ honoured -> 6),
27
+ // the same reader the Archon roster uses so the surface that REPORTS staleness
28
+ // and this sweep that ACTS on it can no longer disagree (task 1003476; they were
29
+ // 24h and 6h apart). The underlying sweepStaleClaims() helper takes minutes;
30
+ // staleClaimMinutes() converts, in one place.
31
+ // - An unreadable timer STOPS the sweep rather than falling back. Releasing another
32
+ // builder's claim is not undone by reloading a page, so this mode refuses where a
33
+ // read-only surface degrades — see the posture note in src/stale-timer.js.
27
34
  // - We talk to Postgres directly via the existing src/db.js pool (peer auth
28
35
  // on the droplet). No HTTP session needed — this routine ships with the
29
36
  // server and runs in the same trust boundary.
@@ -45,6 +52,13 @@ const fs = require('node:fs');
45
52
  const path = require('node:path');
46
53
  const { execFileSync } = require('node:child_process');
47
54
  const { cliClient, requireSession, arg } = require('./cli-lib');
55
+ // The project's stale-claim timer — one number, one precedence, shared with the
56
+ // roster and the lib defaults (task 1003476). Scripts sit outside the kernel
57
+ // boundary, so a direct require is the idiom here.
58
+ const {
59
+ resolveStaleClaimHours, staleClaimMinutes, describeStaleTimer,
60
+ MIN_STALE_CLAIM_HOURS, MAX_STALE_CLAIM_HOURS,
61
+ } = require('../../src/stale-timer');
48
62
 
49
63
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
50
64
 
@@ -63,23 +77,31 @@ function loadDeps() {
63
77
  return { pool, sweepStaleClaims };
64
78
  }
65
79
 
80
+ // Resolve the timer THIS SWEEP will act on, and refuse rather than guess.
81
+ //
82
+ // Task 1003476 (goal 1000072) moved the precedence itself into src/stale-timer.js so
83
+ // the roster that REPORTS staleness and this sweeper that ENFORCES it read one number
84
+ // (they were 24h and 6h apart). What stays here is the posture: the resolver reports an
85
+ // unusable value instead of throwing, because a read-only surface should degrade to the
86
+ // default and SAY SO — but this sweeper releases other people's claims and stamps their
87
+ // session logs, and no reload undoes that. So an unreadable timer stops the sweep.
88
+ //
89
+ // `--threshold-hours` keeps behaving exactly as it did: an explicit per-run lens that
90
+ // overrides the project's timer for this invocation only, and a bad one is a usage error.
66
91
  function parseThresholdHours(args) {
67
92
  const cliVal = arg('--threshold-hours', args);
68
- if (cliVal != null) {
69
- const n = Number(cliVal);
70
- if (!Number.isFinite(n) || n <= 0) {
71
- throw new Error(`--threshold-hours must be a positive number (got ${cliVal})`);
72
- }
73
- return n;
74
- }
75
- if (process.env.GDS_STALE_CLAIM_HOURS) {
76
- const n = Number(process.env.GDS_STALE_CLAIM_HOURS);
77
- if (!Number.isFinite(n) || n <= 0) {
78
- throw new Error(`GDS_STALE_CLAIM_HOURS must be a positive number (got "${process.env.GDS_STALE_CLAIM_HOURS}")`);
79
- }
80
- return n;
93
+ const resolved = resolveStaleClaimHours({ override: cliVal });
94
+ if (resolved.invalid) {
95
+ const where = resolved.invalid.where === 'override'
96
+ ? '--threshold-hours'
97
+ : resolved.invalid.varName;
98
+ throw new Error(
99
+ `${where}="${resolved.invalid.raw}" is unusable (${resolved.invalid.reason}) — `
100
+ + `refusing to sweep on a timer this project cannot read. `
101
+ + `Fix it, or pass --threshold-hours ${MIN_STALE_CLAIM_HOURS}..${MAX_STALE_CLAIM_HOURS} for this run.`
102
+ );
81
103
  }
82
- return 6;
104
+ return resolved;
83
105
  }
84
106
 
85
107
  // Post a single broadcast about an auto-released claim. Best-effort —
@@ -98,9 +120,9 @@ function postDiscordPing(text) {
98
120
  }
99
121
  }
100
122
 
101
- async function releaseStale({ thresholdHours, dryRun }) {
123
+ async function releaseStale({ timer, dryRun }) {
102
124
  const { pool, sweepStaleClaims } = loadDeps();
103
- const thresholdMin = Math.round(thresholdHours * 60);
125
+ const thresholdMin = staleClaimMinutes(timer.hours);
104
126
 
105
127
  let stale;
106
128
  try {
@@ -111,8 +133,10 @@ async function releaseStale({ thresholdHours, dryRun }) {
111
133
  process.exit(1);
112
134
  }
113
135
 
136
+ // The header names the timer AND where it came from. A bare "6h" is what let the
137
+ // sweeper's 6 and the roster's 24 sit side by side unnoticed for a year of ops logs.
114
138
  const header =
115
- `stale-claim sweep (threshold=${thresholdHours}h / ${thresholdMin}m` +
139
+ `stale-claim sweep (threshold=${describeStaleTimer(timer)} / ${thresholdMin}m` +
116
140
  `${dryRun ? ', dry-run' : ''}): ${stale.length} claim(s) eligible`;
117
141
  console.log(header);
118
142
 
@@ -168,7 +192,7 @@ async function releaseStale({ thresholdHours, dryRun }) {
168
192
  const claimedAtSql = cur[0].claimed_at;
169
193
  const notesMd =
170
194
  `${NOTE_TAG} session_pulses heartbeat went silent for ` +
171
- `>= ${thresholdHours}h (threshold ${thresholdMin}m). ` +
195
+ `>= ${timer.hours}h (threshold ${thresholdMin}m, ${timer.source}). ` +
172
196
  (last_pulse_at
173
197
  ? `Last pulse: ${new Date(last_pulse_at).toISOString()}.`
174
198
  : `No pulse ever recorded; claimed_at ${new Date(claimedAtSql).toISOString()}.`) +
@@ -232,7 +256,7 @@ async function releaseStale({ thresholdHours, dryRun }) {
232
256
  const msg =
233
257
  `@${builderName} your claim on task #${outcome.task_id} ` +
234
258
  `("${outcome.taskTitle}") was auto-released because the session ` +
235
- `heartbeat went silent for >= ${thresholdHours}h. ` +
259
+ `heartbeat went silent for >= ${timer.hours}h. ` +
236
260
  `No credits awarded. Task is back to ready — reclaim with ` +
237
261
  `\`/builder-claim ${outcome.task_id}\` if you're back.`;
238
262
 
@@ -261,15 +285,15 @@ async function releaseStale({ thresholdHours, dryRun }) {
261
285
  const args = process.argv.slice(2);
262
286
 
263
287
  if (args.includes('--release-stale')) {
264
- let thresholdHours;
288
+ let timer;
265
289
  try {
266
- thresholdHours = parseThresholdHours(args);
290
+ timer = parseThresholdHours(args);
267
291
  } catch (e) {
268
292
  console.error(e.message);
269
293
  process.exit(1);
270
294
  }
271
295
  const dryRun = args.includes('--dry-run');
272
- await releaseStale({ thresholdHours, dryRun });
296
+ await releaseStale({ timer, dryRun });
273
297
  return;
274
298
  }
275
299
 
@@ -35,9 +35,47 @@ const { pool } = require('../pool');
35
35
  const { publicReadRateLimitMiddleware } = require('../middleware/rate-limit');
36
36
  const seams = require('../../module-seams');
37
37
  const { branding } = require('../../branding');
38
+ // The project's stale-claim timer (task 1003476, goal 1000072) — kernel config, so a
39
+ // direct require, the same as branding above. What the roster CALLS stale is now the
40
+ // same number the sweeper ACTS on.
41
+ const { resolveStaleClaimHours, MIN_STALE_CLAIM_HOURS, MAX_STALE_CLAIM_HOURS } = require('../../stale-timer');
42
+
38
43
  // task 1003208: structured logging — was console.*. A core file logs through
39
44
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
40
45
  const log = require('../logger').logger.child({ src: 'builders' });
46
+ // staleHoursFor(req) — the stale-claim threshold this roster read should use, and where
47
+ // it came from (task 1003476, goal 1000072). The two Archon staleness reads below (the
48
+ // roster and its per-builder expand view) had a byte-identical copy of this block each,
49
+ // both hardcoding 24 while the sweeper acted at 6 — so the screen an Archon uses to
50
+ // decide whether to intervene disagreed with the machine that had already intervened,
51
+ // twice, in two places. One helper, one number.
52
+ //
53
+ // The project timer answers by default; `?stale_hours=N` is a LENS ("what would 48h
54
+ // show me?") and is labelled as one. It NEVER throws on a misconfigured project timer:
55
+ // these are read-only surfaces, so a bad <PREFIX>_STALE_CLAIM_HOURS degrades to the
56
+ // default and is named in the response, putting the typo on the screen of the person
57
+ // who can fix it instead of 500-ing the page they opened to find it. (The sweeper takes
58
+ // the opposite posture and refuses to run — it releases claims; these only read.)
59
+ //
60
+ // Returns { hours, source } on success, or { fail: true } after answering 400 — a
61
+ // caller returns immediately on `fail`, the parseId convention already used here.
62
+ function staleHoursFor(req, res) {
63
+ if (req.query && req.query.stale_hours != null) {
64
+ const n = Number(req.query.stale_hours);
65
+ if (!Number.isFinite(n) || n < MIN_STALE_CLAIM_HOURS || n > MAX_STALE_CLAIM_HOURS) {
66
+ res.fail('bad_stale_hours', 400, { expected: `${MIN_STALE_CLAIM_HOURS}..${MAX_STALE_CLAIM_HOURS}` });
67
+ return { fail: true };
68
+ }
69
+ return { hours: n, source: 'query' };
70
+ }
71
+ const timer = resolveStaleClaimHours();
72
+ return {
73
+ hours: timer.hours,
74
+ source: timer.invalid
75
+ ? `default (invalid ${timer.invalid.varName}="${timer.invalid.raw}": ${timer.invalid.reason})`
76
+ : timer.source,
77
+ };
78
+ }
41
79
  // The lifecycle (tasks · claims · ship) carved to modules/lifecycle/ (BV1.R86 /
42
80
  // ADR 0093 §3): the Archon roster, the per-builder stale-claim view, the
43
81
  // per-builder grade breakdown, and the shipped-count read resolve the `lifecycle`
@@ -308,26 +346,24 @@ module.exports = function buildBuildersRouter() {
308
346
  // /builders hall so the Archon can promote / demote / release-stale-claim
309
347
  // without dropping into psql.
310
348
  //
311
- // Optional query param: ?stale_hours=N (default 24). Defines what counts
312
- // as "stale" for the per-row stale_claim flag — mirrors the threshold the
313
- // stale-claim-release sweeper uses but exposed as a roster knob so the
314
- // Archon can dial it in if needed.
349
+ // Optional query param: ?stale_hours=N. Defines what counts as "stale" for the
350
+ // per-row stale_claim flag. The DEFAULT is the project's configured stale-claim
351
+ // timer (task 1003476, goal 1000072) — literally the number the sweeper releases
352
+ // on, not a lookalike: this route defaulted to a hardcoded 24 while the sweeper
353
+ // acted at 6, so the Archon's screen called a claim healthy for eighteen hours
354
+ // after the machine had already taken it away. The param stays as a LENS ("what
355
+ // would 48h show me?"), and the response reports both the number and where it
356
+ // came from so the two can never quietly diverge again.
315
357
  //
316
358
  // rank: archon — surfaces every builder's onboarding state. Gate:
317
359
  // requirePermission('builder.roster.read') — BV1.R104; the same atom
318
360
  // /grades/by-builder and /builders/:id/active-claims already gate on (ADR 0151).
319
361
  router.get('/builders/roster', auth.requireBuilder, auth.requirePermission('builder.roster.read'), async (req, res) => {
320
- let staleHours = 24;
321
- if (req.query && req.query.stale_hours != null) {
322
- const n = Number(req.query.stale_hours);
323
- if (!Number.isFinite(n) || n <= 0 || n > 720) {
324
- return res.fail('bad_stale_hours', 400, { expected: '1..720' });
325
- }
326
- staleHours = n;
327
- }
362
+ const stale = staleHoursFor(req, res);
363
+ if (stale.fail) return;
328
364
  try {
329
- const builders = await lc().getBuildersRoster({ staleHours });
330
- res.json({ builders, stale_hours: staleHours });
365
+ const builders = await lc().getBuildersRoster({ staleHours: stale.hours });
366
+ res.json({ builders, stale_hours: stale.hours, stale_hours_source: stale.source });
331
367
  } catch (err) {
332
368
  log.error('[gds] GET /builders/roster', err);
333
369
  res.fail('roster_failed', { status: 500, message: 'internal error' });
@@ -393,17 +429,13 @@ module.exports = function buildBuildersRouter() {
393
429
  router.get('/builders/:id/active-claims', auth.requireBuilder, auth.requirePermission('builder.roster.read'), async (req, res) => {
394
430
  const targetId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
395
431
  if (targetId === null) return;
396
- let staleHours = 24;
397
- if (req.query && req.query.stale_hours != null) {
398
- const n = Number(req.query.stale_hours);
399
- if (!Number.isFinite(n) || n <= 0 || n > 720) {
400
- return res.fail('bad_stale_hours', 400, { expected: '1..720' });
401
- }
402
- staleHours = n;
403
- }
432
+ // Same timer as the roster it expands from — a claim the roster called stale must
433
+ // still be stale one click later (task 1003476).
434
+ const stale = staleHoursFor(req, res);
435
+ if (stale.fail) return;
404
436
  try {
405
- const claims = await lc().getActiveClaimsForBuilderWithStale(targetId, { staleHours });
406
- res.json({ builder_id: targetId, claims, stale_hours: staleHours });
437
+ const claims = await lc().getActiveClaimsForBuilderWithStale(targetId, { staleHours: stale.hours });
438
+ res.json({ builder_id: targetId, claims, stale_hours: stale.hours, stale_hours_source: stale.source });
407
439
  } catch (err) {
408
440
  log.error('[gds] GET /builders/:id/active-claims', err);
409
441
  res.fail('active_claims_failed', { status: 500, message: 'internal error' });
package/src/module-api.js CHANGED
@@ -16,6 +16,7 @@ const { logger: coreLogger } = require('./bongos/logger');
16
16
  const { pool, instanceDbName } = require('./bongos/pool');
17
17
  const branding = require('./branding');
18
18
  const instanceConfig = require('./instance-config');
19
+ const staleTimer = require('./stale-timer');
19
20
  const seams = require('./module-seams');
20
21
  const { buildInfo } = require('./build-info');
21
22
  const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePagination, pageMeta, PAGINATION } = require('./bongos/routes/_helpers');
@@ -33,7 +34,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
34
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
35
  // the entry to that file. Look for a version's history there, not here.
35
36
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.470'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
+ const CORE_VERSION = '1.19.471'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
38
 
38
39
  // A namespaced logger so a module's log lines are attributable + consistent.
39
40
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -235,6 +236,20 @@ module.exports = {
235
236
  // it instead of reaching into src/branding.js directly (BV1.R86).
236
237
  userAgent: branding.userAgent,
237
238
  resolveEnv: instanceConfig.resolveEnv,
239
+ // --- the project's STALE-CLAIM TIMER (task 1003476, goal 1000072) — how long a
240
+ // claim may go silent before this project calls it abandoned. ONE number for
241
+ // every surface that reports or enforces it: before this, the sweeper that
242
+ // actually releases claims used 6h while the Archon roster that reports them
243
+ // used 24h, so the screen said "healthy" for eighteen hours after the machine
244
+ // had already taken the claim away. Read here, never re-derived from the env
245
+ // by a module: the precedence, the 1..720 bound and the invalid-value
246
+ // reporting live in that one reader. See src/stale-timer.js for the
247
+ // reporting-degrades / enforcement-refuses split a caller must choose between.
248
+ // Only the two a module actually needs: the doorway is a stability contract,
249
+ // so the CLI-facing describeStaleTimer / DEFAULT_STALE_CLAIM_HOURS stay behind
250
+ // a direct require in scripts/ rather than joining the published surface.
251
+ resolveStaleClaimHours: staleTimer.resolveStaleClaimHours,
252
+ staleClaimMinutes: staleTimer.staleClaimMinutes,
238
253
  readConfigFileSync: instanceConfig.readConfigFileSync,
239
254
  configHome: instanceConfig.configHome,
240
255
  configPath: instanceConfig.configPath,
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ // src/stale-timer.js — the project's stale-claim timer: ONE number for "how long
4
+ // may a claim go silent before this project calls it abandoned", resolved once and
5
+ // read by everything that reports or enforces staleness.
6
+ //
7
+ // WHY THIS FILE EXISTS (task 1003476, goal 1000072 "Human legibility", criterion
8
+ // legibility-sort-and-staleness). "Stale" had three answers and no owner:
9
+ //
10
+ // scripts/gds/pulse.js --release-stale 6h GDS_STALE_CLAIM_HOURS, else 6
11
+ // getBuildersRoster / GET /builders/roster 24h hardcoded
12
+ // sweepStaleClaims / getStaleClaimsForBuilder 30min hardcoded lib default
13
+ //
14
+ // The sweep is the one that ACTS — it auto-releases the claim, tags the session log
15
+ // `[auto-released:stale]` and hands the task back to the queue. The roster is what a
16
+ // person LOOKS AT to decide whether to intervene. They disagreed by eighteen hours,
17
+ // so the Archon's roster reported a claim healthy for the better part of a day after
18
+ // the sweeper had already taken it away. A steering surface that reports a different
19
+ // rule from the one the machine enforces is precisely the failure this goal is named
20
+ // for, and no amount of reading either file alone reveals it.
21
+ //
22
+ // So: one resolver, one number, one documented precedence, and every surface says
23
+ // WHERE its number came from.
24
+ //
25
+ // THE FAIL POSTURE IS ASYMMETRIC, AND DELIBERATELY SO. A misconfigured timer must not
26
+ // silently become a different policy, but the two kinds of caller cannot want the same
27
+ // thing from it:
28
+ // • REPORTING degrades. A read-only surface takes the default and labels the source
29
+ // `default (invalid …)`, so a typo shows up on the screen of the person who can fix
30
+ // it instead of 500-ing the roster they were using to find the problem.
31
+ // • ENFORCEMENT refuses. The sweep releases other people's claims and a reload does
32
+ // not undo that, so it declines to run at all on a timer it cannot read.
33
+ // `resolve()` never throws; it reports. Which of the two postures to take is the
34
+ // caller's call, and each caller states it (see scripts/gds/pulse.js).
35
+ //
36
+ // This is a KERNEL file (fitness.js KERNEL_FILES): host configuration a module reads
37
+ // rather than hardcodes, in the same category as instance-config.js and branding.js.
38
+ // It imports only the kernel + node built-ins.
39
+
40
+ const instanceConfig = require('./instance-config');
41
+
42
+ // The env suffix, resolved through the branded prefix — so this reads
43
+ // <PREFIX>_STALE_CLAIM_HOURS (CLOUDBONGOS_ on vanilla) while instance-config's legacy
44
+ // prefix chain still honours the GDS_STALE_CLAIM_HOURS the live sweeper has always
45
+ // used. Nothing in a running deployment has to change for this to resolve to what it
46
+ // resolves to today.
47
+ const ENV_SUFFIX = 'STALE_CLAIM_HOURS';
48
+
49
+ // The last-resort timer, in hours. 6 because that is what the SWEEP has always used,
50
+ // and the sweep is the surface that acts — adopting the roster's cosmetic 24 would
51
+ // have quietly made every project three times more patient with a dead claim.
52
+ const DEFAULT_STALE_CLAIM_HOURS = 6;
53
+
54
+ // The accepted range, in hours. Not taste: `GET /builders/roster` has published
55
+ // `1..720` as its `stale_hours` contract since V3.R14, and one timer means one bound.
56
+ // 720h is 30 days — past that a "timer" is an off switch, and an off switch should be
57
+ // asked for out loud rather than typed as a big number.
58
+ const MIN_STALE_CLAIM_HOURS = 1;
59
+ const MAX_STALE_CLAIM_HOURS = 720;
60
+
61
+ // Parse one candidate into hours, or say why it is unusable. Pure.
62
+ // Returns { hours } or { reason } — never throws, because both callers below want to
63
+ // decide for themselves what an unusable value means.
64
+ function parseHours(raw) {
65
+ if (raw == null || raw === '') return { reason: 'empty' };
66
+ const n = Number(raw);
67
+ if (!Number.isFinite(n)) return { reason: 'not a number' };
68
+ if (n <= 0) return { reason: 'must be positive' };
69
+ if (n < MIN_STALE_CLAIM_HOURS || n > MAX_STALE_CLAIM_HOURS) {
70
+ return { reason: `out of range (${MIN_STALE_CLAIM_HOURS}..${MAX_STALE_CLAIM_HOURS})` };
71
+ }
72
+ return { hours: n };
73
+ }
74
+
75
+ // Which env var actually supplied the value — for the receipt only.
76
+ // instanceConfig.resolveEnv() returns the VALUE and not the name it came from, so the
77
+ // name is re-derived by walking the SAME exported prefix order it walks and taking the
78
+ // first non-empty hit, which is by construction the one it returned. The value is never
79
+ // read from here: this decides what to PRINT, so a drift in the chain costs a label and
80
+ // never the timer.
81
+ function envVarNameFor(suffix, env) {
82
+ const order = [instanceConfig.envPrefix(), ...instanceConfig.LEGACY_ENV_PREFIXES];
83
+ const seen = new Set();
84
+ for (const pfx of order) {
85
+ if (!pfx || seen.has(pfx)) continue;
86
+ seen.add(pfx);
87
+ const name = `${pfx}_${suffix}`;
88
+ const v = env[name];
89
+ if (v !== undefined && v !== '') return name;
90
+ }
91
+ return `${instanceConfig.envPrefix()}_${suffix}`;
92
+ }
93
+
94
+ // resolveStaleClaimHours({ override, env }) — the project's stale-claim timer, and the
95
+ // provenance of the number, for any surface that reports or enforces staleness.
96
+ //
97
+ // Precedence, widest-scope last:
98
+ // 1. `override` — a per-invocation lens (`--threshold-hours`, `?stale_hours=`). A
99
+ // person asking "what would 48h show me?" is not reconfiguring the
100
+ // project, so this never becomes the project's answer.
101
+ // 2. env — <PREFIX>_STALE_CLAIM_HOURS (legacy GDS_/OTB_/PMS_ honoured). This
102
+ // IS the project's configured timer: the provisioning runner writes
103
+ // it into the instance's web.env (ADR 0190), so an owner changing it
104
+ // reaches a running project without a checkout.
105
+ // 3. the default — DEFAULT_STALE_CLAIM_HOURS.
106
+ //
107
+ // Returns { hours, source, invalid }:
108
+ // hours — always a usable positive number, whatever went wrong
109
+ // source — 'override' | 'env:<VARNAME>' | 'default', for a surface to show a reader
110
+ // invalid — null, or { where, varName, raw, reason }: something was SET and could not
111
+ // be used. `hours` has already fallen through to the next tier; a reporting
112
+ // caller should say so, an enforcing caller should refuse (see the header).
113
+ function resolveStaleClaimHours({ override, env = process.env } = {}) {
114
+ if (override != null && override !== '') {
115
+ const got = parseHours(override);
116
+ if (got.hours != null) return { hours: got.hours, source: 'override', invalid: null };
117
+ return {
118
+ hours: DEFAULT_STALE_CLAIM_HOURS,
119
+ source: 'default',
120
+ invalid: { where: 'override', varName: null, raw: String(override), reason: got.reason },
121
+ };
122
+ }
123
+
124
+ const raw = instanceConfig.resolveEnv(ENV_SUFFIX, { env });
125
+ if (raw != null && raw !== '') {
126
+ const varName = envVarNameFor(ENV_SUFFIX, env);
127
+ const got = parseHours(raw);
128
+ if (got.hours != null) return { hours: got.hours, source: `env:${varName}`, invalid: null };
129
+ return {
130
+ hours: DEFAULT_STALE_CLAIM_HOURS,
131
+ source: 'default',
132
+ invalid: { where: 'env', varName, raw: String(raw), reason: got.reason },
133
+ };
134
+ }
135
+
136
+ return { hours: DEFAULT_STALE_CLAIM_HOURS, source: 'default', invalid: null };
137
+ }
138
+
139
+ // staleClaimMinutes(hours) — the same timer in the unit the SQL predicates take.
140
+ // Exists so the hours→minutes rounding happens in one place: the two pulse.js sweeper
141
+ // queries count in minutes while every human-facing surface counts in hours, and a
142
+ // second copy of `* 60` is how the two drift apart again.
143
+ function staleClaimMinutes(hours) {
144
+ const n = Number(hours);
145
+ if (!Number.isFinite(n) || n <= 0) {
146
+ throw new Error('staleClaimMinutes: hours must be a positive number');
147
+ }
148
+ return Math.max(1, Math.round(n * 60));
149
+ }
150
+
151
+ // describeStaleTimer(resolved) — one sentence naming the timer and where it came from,
152
+ // for a CLI header or an API receipt. A number with no provenance is what let the 6h
153
+ // and the 24h coexist unnoticed, so every surface that prints the timer prints this.
154
+ function describeStaleTimer(resolved) {
155
+ const r = resolved || {};
156
+ const base = `${r.hours}h (${r.source})`;
157
+ if (!r.invalid) return base;
158
+ const who = r.invalid.varName || r.invalid.where;
159
+ return `${base} — ignoring ${who}="${r.invalid.raw}" (${r.invalid.reason})`;
160
+ }
161
+
162
+ // ENV_SUFFIX and parseHours are deliberately NOT exported: every caller reads the
163
+ // timer through resolveStaleClaimHours (that is the whole point of this file — one
164
+ // resolver, no second reader), so exporting the pieces it resolves FROM would
165
+ // re-open the door ADR 0226 closed. The default and the bound ARE exported, and
166
+ // for the same reason: the tests assert "no env falls back to the project default"
167
+ // and "the bound is the 1..720 the roster already publishes", and a test that
168
+ // re-typed 6 or 720 would be the second copy of the number this ADR exists to
169
+ // delete.
170
+ module.exports = {
171
+ DEFAULT_STALE_CLAIM_HOURS,
172
+ MIN_STALE_CLAIM_HOURS,
173
+ MAX_STALE_CLAIM_HOURS,
174
+ resolveStaleClaimHours,
175
+ staleClaimMinutes,
176
+ describeStaleTimer,
177
+ };
@@ -33,6 +33,7 @@ const PUBLISHED_SURFACE = [
33
33
  'llmCache', // BV1.R73: shared LLM-cost cache (1.6.0; tracked domain leak)
34
34
  'llmPricing', // BV1.R77: shared token→USD pricing (1.7.0; tracked domain leak — economy carve)
35
35
  'branding', 'clientBranding', 'userAgent', 'resolveEnv', 'readConfigFileSync', 'configHome', 'configPath', // userAgent added 1.12.0 (BV1.R86; github-push UA)
36
+ 'resolveStaleClaimHours', 'staleClaimMinutes', // added by task 1003476 (ADR 0226; goal 1000072): the PROJECT's stale-claim timer — the sweep that releases a silent claim and the Archon roster that reports one read the same number through here, after eighteen hours of disagreeing (6 vs 24)
36
37
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
37
38
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
38
39
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
@@ -248,7 +248,46 @@ await t('GET /builders/roster as archon → 200', async () => {
248
248
  });
249
249
  assert.equal(r.status, 200);
250
250
  assert.ok(Array.isArray(r.json?.builders));
251
- assert.equal(r.json?.stale_hours, 24);
251
+ // The default is the PROJECT's stale-claim timer, not a number this route owns
252
+ // (task 1003476, goal 1000072). It used to assert a hardcoded 24 while the sweeper
253
+ // released at 6 — this assertion WAS the eighteen-hour lie, written down.
254
+ const timer = require('../src/stale-timer.js');
255
+ assert.equal(r.json?.stale_hours, timer.resolveStaleClaimHours().hours);
256
+ assert.equal(r.json?.stale_hours_source, 'default');
257
+ });
258
+
259
+ await t('GET /builders/roster → the timer follows the project env', async () => {
260
+ const prev = process.env.GDS_STALE_CLAIM_HOURS;
261
+ process.env.GDS_STALE_CLAIM_HOURS = '9';
262
+ try {
263
+ const r = await send({
264
+ port, method: 'GET', path: '/api/gds/builders/roster',
265
+ headers: { 'x-stub-builder-id': '1', 'x-stub-builder-rank': 'archon' },
266
+ });
267
+ assert.equal(r.json?.stale_hours, 9, 'resolved per request, not frozen at boot');
268
+ assert.equal(r.json?.stale_hours_source, 'env:GDS_STALE_CLAIM_HOURS');
269
+ } finally {
270
+ if (prev === undefined) delete process.env.GDS_STALE_CLAIM_HOURS;
271
+ else process.env.GDS_STALE_CLAIM_HOURS = prev;
272
+ }
273
+ });
274
+
275
+ await t('GET /builders/roster → a bad project timer degrades, it does not 500', async () => {
276
+ // The Archon opened this page to find the problem; 500-ing it hides the problem.
277
+ const prev = process.env.GDS_STALE_CLAIM_HOURS;
278
+ process.env.GDS_STALE_CLAIM_HOURS = 'six-ish';
279
+ try {
280
+ const r = await send({
281
+ port, method: 'GET', path: '/api/gds/builders/roster',
282
+ headers: { 'x-stub-builder-id': '1', 'x-stub-builder-rank': 'archon' },
283
+ });
284
+ assert.equal(r.status, 200);
285
+ assert.equal(r.json?.stale_hours, 6);
286
+ assert.match(String(r.json?.stale_hours_source), /^default \(invalid GDS_STALE_CLAIM_HOURS="six-ish"/);
287
+ } finally {
288
+ if (prev === undefined) delete process.env.GDS_STALE_CLAIM_HOURS;
289
+ else process.env.GDS_STALE_CLAIM_HOURS = prev;
290
+ }
252
291
  });
253
292
 
254
293
  await t('GET /builders/roster?stale_hours=48 → echoes 48', async () => {
@@ -258,6 +297,7 @@ await t('GET /builders/roster?stale_hours=48 → echoes 48', async () => {
258
297
  });
259
298
  assert.equal(r.status, 200);
260
299
  assert.equal(r.json?.stale_hours, 48);
300
+ assert.equal(r.json?.stale_hours_source, 'query', 'a lens is labelled as a lens');
261
301
  });
262
302
 
263
303
  await t('GET /builders/roster?stale_hours=0 → 400', async () => {