@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.
- package/.bongos-core.json +38 -23
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0226-one-stale-timer-reporting-degrades-enforcement-refuses.md +121 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +9 -5
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/task-lib.js +7 -3
- package/modules/lifecycle/db-ship.js +22 -11
- package/modules/sessions/pulse.js +15 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +6 -0
- package/scripts/gds/pulse.js +48 -24
- package/src/bongos/routes/builders.js +56 -24
- package/src/module-api.js +16 -1
- package/src/stale-timer.js +177 -0
- package/tests/module_api.mjs +1 -0
- package/tests/onboarding_observability.mjs +41 -1
- package/tests/stale_timer.mjs +230 -0
package/scripts/gds/pulse.js
CHANGED
|
@@ -22,8 +22,15 @@
|
|
|
22
22
|
//
|
|
23
23
|
// -------------------------------------------------------------------------
|
|
24
24
|
// Release-stale-mode notes (V3.R49):
|
|
25
|
-
// - Threshold is HOURS
|
|
26
|
-
//
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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({
|
|
123
|
+
async function releaseStale({ timer, dryRun }) {
|
|
102
124
|
const { pool, sweepStaleClaims } = loadDeps();
|
|
103
|
-
const thresholdMin =
|
|
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=${
|
|
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
|
-
`>= ${
|
|
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 >= ${
|
|
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
|
|
288
|
+
let timer;
|
|
265
289
|
try {
|
|
266
|
-
|
|
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({
|
|
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
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
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
|
-
|
|
321
|
-
if (
|
|
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:
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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:
|
|
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.
|
|
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
|
+
};
|
package/tests/module_api.mjs
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|