@ran-sh/dsh-crew 1.4.0 → 1.5.1

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.
@@ -0,0 +1,207 @@
1
+ // Per-model peak/off-peak scheduling.
2
+ //
3
+ // Some providers price by time of day: DeepSeek charges double during its peak
4
+ // hours (UTC 01:00-04:00 and 06:00-10:00, Monday to Friday) and half that
5
+ // off-peak. An operator who wants to avoid that spend needs to say which models
6
+ // may run during peak, which must not, and which should merely be flagged.
7
+ //
8
+ // One schedule is shared; the per-model list decides who it applies to. A model
9
+ // absent from the list is unrestricted — the feature is opt-in per model, so
10
+ // turning it on never silently changes routing for everything at once.
11
+ //
12
+ // Wall-clock windows are stored in the configured offset, not UTC, so the value
13
+ // an operator types is the value they see. The defaults are DeepSeek's published
14
+ // peak hours (UTC 01:00-04:00 and 06:00-10:00, Mon-Fri) expressed in the default
15
+ // UTC+8: 09:00-12:00 and 14:00-18:00.
16
+ //
17
+ // This module is pure: no I/O, no ambient clock. Callers pass `at`.
18
+
19
+ /** Per-model restriction strength. Absent from the list means `off`. */
20
+ export const MODEL_SCHEDULE_MODES = Object.freeze(['off', 'warn', 'block']);
21
+
22
+ /** Peak means "expensive". Off-peak is every hour outside the windows. */
23
+ export const DEFAULT_PEAK_WINDOWS = Object.freeze([
24
+ Object.freeze({ start: '09:00', end: '12:00' }),
25
+ Object.freeze({ start: '14:00', end: '18:00' }),
26
+ ]);
27
+
28
+ export const DEFAULT_TIMEZONE_OFFSET_MINUTES = 8 * 60;
29
+ export const DEFAULT_PEAK_WEEKDAYS = Object.freeze([1, 2, 3, 4, 5]);
30
+ export const MINUTES_PER_DAY = 24 * 60;
31
+ // A fixed offset is bounded by the real-world range of UTC offsets; anything
32
+ // wider is a typo, not a timezone.
33
+ const MAX_OFFSET_MINUTES = 14 * 60;
34
+
35
+ /** "HH:MM" in 24-hour form to minutes past local midnight, or null. */
36
+ function clockMinutes(value) {
37
+ if (typeof value !== 'string') return null;
38
+ const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
39
+ if (!match) return null;
40
+ const hours = Number(match[1]);
41
+ const minutes = Number(match[2]);
42
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null;
43
+ if (hours > 23 || minutes > 59) return null;
44
+ return hours * 60 + minutes;
45
+ }
46
+
47
+ function offsetMinutes(value, fallback) {
48
+ if (value === undefined || value === null) return fallback;
49
+ // Strict: a partially numeric string ("480garbage") or a float is a broken
50
+ // value, not a request for 480, and must not silently move the clock.
51
+ if (typeof value === 'number' ? !Number.isInteger(value) : !/^-?\d+$/.test(String(value).trim())) return null;
52
+ const parsed = typeof value === 'number' ? value : Number.parseInt(String(value).trim(), 10);
53
+ if (Math.abs(parsed) > MAX_OFFSET_MINUTES) return null;
54
+ return parsed;
55
+ }
56
+
57
+ function weekdayList(value, fallback) {
58
+ if (value === undefined || value === null) return [...fallback];
59
+ // A present-but-unusable container is not a request for Mon-Fri: treating it
60
+ // as one could switch on restrictions the operator never specified.
61
+ if (!Array.isArray(value)) return null;
62
+ return [...new Set(value.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6))].sort((a, b) => a - b);
63
+ }
64
+
65
+ function peakWindows(value, fallback) {
66
+ if (value === undefined || value === null) return fallback.map((window) => ({ ...window }));
67
+ if (!Array.isArray(value)) return null;
68
+ const windows = [];
69
+ for (const entry of value) {
70
+ const start = clockMinutes(entry?.start);
71
+ const end = clockMinutes(entry?.end);
72
+ // A window that does not parse is dropped rather than widened to all day.
73
+ if (start === null || end === null || start === end) continue;
74
+ windows.push({ start: entry.start.trim(), end: entry.end.trim() });
75
+ }
76
+ return windows;
77
+ }
78
+
79
+ function modelModes(value) {
80
+ if (!Array.isArray(value)) return [];
81
+ const seen = new Set();
82
+ const modes = [];
83
+ for (const entry of value) {
84
+ const provider = typeof entry?.provider === 'string' ? entry.provider.trim() : '';
85
+ const model = typeof entry?.model === 'string' ? entry.model.trim() : '';
86
+ const mode = typeof entry?.mode === 'string' ? entry.mode.trim() : '';
87
+ if (!provider || !model || !MODEL_SCHEDULE_MODES.includes(mode)) continue;
88
+ // `off` is the absence of a rule, so it is not stored.
89
+ if (mode === 'off') continue;
90
+ const key = `${provider}\0${model}`;
91
+ if (seen.has(key)) continue;
92
+ seen.add(key);
93
+ modes.push({ provider, model, mode });
94
+ }
95
+ return modes;
96
+ }
97
+
98
+ /**
99
+ * Coerce a stored or user-supplied schedule into the canonical shape.
100
+ *
101
+ * Lenient toward *missing* fields, strict toward *broken* ones. A missing field
102
+ * gets its documented default; a field that is present but unusable (a string
103
+ * where a list belongs, a non-integer offset) makes the whole schedule
104
+ * non-restricting instead of substituting defaults. Substituting would let
105
+ * accidental corruption switch on restrictions the operator never asked for.
106
+ */
107
+ export function normalizeModelSchedule(raw) {
108
+ const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
109
+ // Keep the per-model rules even when the schedule turns inert: with no windows
110
+ // nothing is ever peak, so the rules cannot fire, and preserving them means a
111
+ // corrupted window does not also destroy the operator's model choices once the
112
+ // coerced config is written back.
113
+ const models = modelModes(source.models);
114
+ const offset = offsetMinutes(source.timezone_offset_minutes, DEFAULT_TIMEZONE_OFFSET_MINUTES);
115
+ const weekdays = weekdayList(source.weekdays, DEFAULT_PEAK_WEEKDAYS);
116
+ const windows = peakWindows(source.peak_windows, DEFAULT_PEAK_WINDOWS);
117
+ if (offset === null || weekdays === null || windows === null) {
118
+ // Fail open by emptying the axis that activates restrictions — weekdays
119
+ // schedule the windows — while keeping every field that still parses. A
120
+ // whole-schedule reset would discard a valid custom offset and windows, and
121
+ // the panel writes the schedule as a whole, so that loss could stick.
122
+ return {
123
+ timezone_offset_minutes: offset === null ? DEFAULT_TIMEZONE_OFFSET_MINUTES : offset,
124
+ weekdays: [],
125
+ peak_windows: windows === null ? [] : windows,
126
+ models,
127
+ };
128
+ }
129
+ return {
130
+ timezone_offset_minutes: offset,
131
+ weekdays,
132
+ peak_windows: windows,
133
+ models,
134
+ };
135
+ }
136
+
137
+ export function defaultModelSchedule() {
138
+ return normalizeModelSchedule({
139
+ timezone_offset_minutes: DEFAULT_TIMEZONE_OFFSET_MINUTES,
140
+ weekdays: [...DEFAULT_PEAK_WEEKDAYS],
141
+ peak_windows: DEFAULT_PEAK_WINDOWS.map((window) => ({ ...window })),
142
+ models: [],
143
+ });
144
+ }
145
+
146
+ /** Local wall clock for an instant under a fixed offset. */
147
+ function localParts(at, offset) {
148
+ const shifted = new Date(at.getTime() + offset * 60_000);
149
+ return {
150
+ weekday: shifted.getUTCDay(),
151
+ minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
152
+ };
153
+ }
154
+
155
+ function inWindow(minutes, start, end) {
156
+ return start < end
157
+ ? minutes >= start && minutes < end
158
+ // A window crossing midnight runs from `start` on one day to `end` the next.
159
+ : minutes >= start || minutes < end;
160
+ }
161
+
162
+ /**
163
+ * Whether one instant falls in a peak window.
164
+ *
165
+ * A window crossing midnight also belongs to the weekday it starts on, so the
166
+ * early-morning half is checked against the previous day.
167
+ */
168
+ export function isPeakAt(schedule, at = new Date()) {
169
+ const normalized = normalizeModelSchedule(schedule);
170
+ if (normalized.weekdays.length === 0) return false;
171
+ const { weekday, minutes } = localParts(at, normalized.timezone_offset_minutes);
172
+ for (const window of normalized.peak_windows) {
173
+ const start = clockMinutes(window.start);
174
+ const end = clockMinutes(window.end);
175
+ if (start === null || end === null) continue;
176
+ if (!inWindow(minutes, start, end)) continue;
177
+ const owner = start < end || minutes >= start ? weekday : (weekday + 6) % 7;
178
+ if (normalized.weekdays.includes(owner)) return true;
179
+ }
180
+ return false;
181
+ }
182
+
183
+ /** The configured mode for one model ref; an unlisted model is unrestricted. */
184
+ export function modelScheduleMode(schedule, ref) {
185
+ const provider = typeof ref?.provider === 'string' ? ref.provider.trim() : '';
186
+ const model = typeof ref?.model === 'string' ? ref.model.trim() : '';
187
+ if (!provider || !model) return 'off';
188
+ const entry = normalizeModelSchedule(schedule).models.find(
189
+ (candidate) => candidate.provider === provider && candidate.model === model,
190
+ );
191
+ return entry?.mode ?? 'off';
192
+ }
193
+
194
+ /**
195
+ * Decide whether one model ref is currently peak-restricted.
196
+ *
197
+ * Returns `null` when the model is unrestricted, off-peak, or the schedule is
198
+ * empty; otherwise `{ mode, peak: true }` where `mode` is `warn` or `block`.
199
+ * The caller decides: `block` skips the candidate, `warn` selects it and records
200
+ * the advisory.
201
+ */
202
+ export function scheduleAdmission(schedule, ref, at = new Date()) {
203
+ const mode = modelScheduleMode(schedule, ref);
204
+ if (mode === 'off') return null;
205
+ if (!isPeakAt(schedule, at)) return null;
206
+ return { mode, peak: true };
207
+ }
Binary file
package/src/policy.mjs CHANGED
@@ -9,23 +9,24 @@
9
9
  export * from './policy-legacy.mjs';
10
10
  import * as legacy from './policy-legacy.mjs';
11
11
  import { normalizeAdaptiveRouting } from './adaptive-routing.mjs';
12
+ import { normalizeModelSchedule } from './model-schedule.mjs';
12
13
 
13
- export { normalizeAdaptiveRouting };
14
- export const CONFIG_SCHEMA_VERSION = 4;
15
- const MIN_CANONICAL_SCHEMA_VERSION = 3;
16
-
17
- function validObject(value) {
18
- return !!value && typeof value === 'object' && !Array.isArray(value);
19
- }
20
-
21
- const EXECUTION_TRANSPORTS = new Set(['hub-3210', 'standalone-legacy']);
22
-
23
- function normalizeExecutionTransport(value) {
24
- return EXECUTION_TRANSPORTS.has(value) ? value : 'hub-3210';
25
- }
26
-
27
- function hasCanonicalAuthority(raw) {
28
- return Number(raw?.config_schema_version) >= MIN_CANONICAL_SCHEMA_VERSION
14
+ export { normalizeAdaptiveRouting };
15
+ export const CONFIG_SCHEMA_VERSION = 4;
16
+ const MIN_CANONICAL_SCHEMA_VERSION = 3;
17
+
18
+ function validObject(value) {
19
+ return !!value && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+
22
+ const EXECUTION_TRANSPORTS = new Set(['hub-3210', 'standalone-legacy']);
23
+
24
+ function normalizeExecutionTransport(value) {
25
+ return EXECUTION_TRANSPORTS.has(value) ? value : 'hub-3210';
26
+ }
27
+
28
+ function hasCanonicalAuthority(raw) {
29
+ return Number(raw?.config_schema_version) >= MIN_CANONICAL_SCHEMA_VERSION
29
30
  && validObject(raw?.worker)
30
31
  && validObject(raw?.review)
31
32
  && validObject(raw?.execution);
@@ -71,44 +72,49 @@ function normalizeLegacySnapshot(raw, canonical) {
71
72
  };
72
73
  }
73
74
 
74
- function modelPolicyWithAdaptive(basePolicy, rawPolicy) {
75
- const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive);
76
- const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
77
- ? rawPolicy.ordering
78
- : adaptive.enabled ? 'health-aware' : 'manual';
79
- return {
80
- ...basePolicy,
81
- adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
82
- ordering,
83
- health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
84
- };
85
- }
75
+ function modelPolicyWithAdaptive(basePolicy, rawPolicy) {
76
+ const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive);
77
+ const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
78
+ ? rawPolicy.ordering
79
+ : adaptive.enabled ? 'health-aware' : 'manual';
80
+ return {
81
+ ...basePolicy,
82
+ adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
83
+ ordering,
84
+ health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
85
+ };
86
+ }
86
87
 
87
88
  function normalizeCanonical(raw) {
88
89
  const base = legacy.getCanonical(raw);
89
90
  const providerMode = legacy.normalizeWorkerProviderMode(base.worker?.provider_mode);
90
91
  const canonical = {
91
92
  ...base,
92
- execution: {
93
- ...base.execution,
93
+ execution: {
94
+ ...base.execution,
94
95
  // There is one global dispatch permission. `execution.enabled` is a
95
96
  // canonical mirror of the top-level switch, never a second authority.
96
- enabled: base.subagents_enabled,
97
- transport: normalizeExecutionTransport(raw?.execution?.transport ?? base.execution?.transport),
98
- },
97
+ enabled: base.subagents_enabled,
98
+ transport: normalizeExecutionTransport(raw?.execution?.transport ?? base.execution?.transport),
99
+ },
99
100
  worker: {
100
101
  ...base.worker,
101
102
  provider_mode: providerMode,
102
103
  model_policy: modelPolicyWithAdaptive(base.worker.model_policy, raw?.worker?.model_policy),
103
104
  },
104
- review: {
105
- ...base.review,
106
- gate: ['required', 'optional', 'off'].includes(raw?.review?.gate) ? raw.review.gate : 'required',
105
+ review: {
106
+ ...base.review,
107
+ gate: ['required', 'optional', 'off'].includes(raw?.review?.gate) ? raw.review.gate : 'required',
107
108
  // v0.3 still exposes one Worker Provider selector. Until per-role
108
109
  // provider modes become first-class, keep both roles aligned.
109
110
  provider_mode: providerMode,
110
111
  model_policy: modelPolicyWithAdaptive(base.review.model_policy, raw?.review?.model_policy),
111
112
  },
113
+ // One schedule regulates every restricted model; the per-model list decides
114
+ // who it applies to. Top-level rather than per-role because the same model
115
+ // can sit in either role's priority list, so the restriction is a property
116
+ // of the model, not of the role that happens to route to it.
117
+ model_schedule: normalizeModelSchedule(raw?.model_schedule),
112
118
  };
113
119
  canonical.legacy = normalizeLegacySnapshot(raw, canonical);
114
120
  return canonical;
@@ -170,6 +176,7 @@ export function normalizeGlobalConfig(raw = {}) {
170
176
  review_state: canonical.review.state,
171
177
  auto_review: canonical.review.auto_review === true,
172
178
  worker_provider_mode: canonical.worker.provider_mode,
179
+ model_schedule: canonical.model_schedule,
173
180
  flash_model_priority: [...canonical.worker.model_policy.priority],
174
181
  flash_model_priority_configured: canonical.worker.model_policy.priorityConfigured,
175
182
  flash_model_fallback: canonical.worker.model_policy.fallback,
@@ -191,32 +198,32 @@ export function normalizeGlobalConfig(raw = {}) {
191
198
  * adaptive sub-domain, so direct canonical-ish v0.2 callers retain the exact
192
199
  * priority/escalation semantics they had before schema-v3 existed.
193
200
  */
194
- export function resolveModelPolicy(config = {}, role = 'worker', context = {}) {
195
- const base = legacy.resolveModelPolicy(config, role, context);
196
- const rawPolicy = role === 'reviewer'
197
- ? config?.review?.model_policy
198
- : config?.worker?.model_policy;
199
- const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive ?? base.adaptive);
200
- const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
201
- ? rawPolicy.ordering
202
- : adaptive.enabled ? 'health-aware' : 'manual';
203
- return {
204
- ...base,
205
- adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
206
- ordering,
207
- health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
208
- };
209
- }
210
-
211
- /** Automatic review respects the v4 reviewer gate; session opt-out remains authoritative. */
212
- export function shouldAutoReview(config = {}, session = {}) {
213
- const normalized = normalizeGlobalConfig(config);
214
- const gate = ['required', 'optional', 'off'].includes(config?.review?.gate)
215
- ? config.review.gate
216
- : normalized.review?.gate;
217
- if (gate === 'off') return false;
218
- return legacy.shouldAutoReview(normalized, session);
219
- }
201
+ export function resolveModelPolicy(config = {}, role = 'worker', context = {}) {
202
+ const base = legacy.resolveModelPolicy(config, role, context);
203
+ const rawPolicy = role === 'reviewer'
204
+ ? config?.review?.model_policy
205
+ : config?.worker?.model_policy;
206
+ const adaptive = normalizeAdaptiveRouting(rawPolicy?.adaptive ?? base.adaptive);
207
+ const ordering = rawPolicy?.ordering === 'health-aware' || rawPolicy?.ordering === 'manual'
208
+ ? rawPolicy.ordering
209
+ : adaptive.enabled ? 'health-aware' : 'manual';
210
+ return {
211
+ ...base,
212
+ adaptive: { ...adaptive, enabled: ordering === 'health-aware' },
213
+ ordering,
214
+ health_gate: rawPolicy?.health_gate === 'off' ? 'off' : 'hard-failures',
215
+ };
216
+ }
217
+
218
+ /** Automatic review respects the v4 reviewer gate; session opt-out remains authoritative. */
219
+ export function shouldAutoReview(config = {}, session = {}) {
220
+ const normalized = normalizeGlobalConfig(config);
221
+ const gate = ['required', 'optional', 'off'].includes(config?.review?.gate)
222
+ ? config.review.gate
223
+ : normalized.review?.gate;
224
+ if (gate === 'off') return false;
225
+ return legacy.shouldAutoReview(normalized, session);
226
+ }
220
227
 
221
228
  /** Explicit legacy-import normalizer for the persistence layer. */
222
229
  export function normalizeLegacyGlobalConfig(raw = {}) {
@@ -0,0 +1,110 @@
1
+ // Release liveness claims.
2
+ //
3
+ // A running Hub keeps executing the release it was started from, but it loads
4
+ // several modules lazily with a cache-busting query (`import('...?t=' + Date.now())`)
5
+ // so that a config edit is visible without a restart. Those lazy reads go to
6
+ // disk every time, which means deleting the release under a live process breaks
7
+ // it permanently: the routes that were already loaded keep working while
8
+ // everything behind a lazy import answers 500.
9
+ //
10
+ // Release retention keeps only the newest few, and it protected the *current*
11
+ // pointer — not the one a running process is actually executing. So a machine
12
+ // could be left with a Hub whose config routes were dead, and an update would
13
+ // not repair it because the damage is to the process, not the files.
14
+ //
15
+ // A process therefore records that it is using a release. Retention skips any
16
+ // release with a live claim. Liveness is decided by the pid, not by the file, so
17
+ // a process that dies without cleaning up cannot pin a release forever.
18
+
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ export const IN_USE_DIRNAME = 'in-use';
25
+
26
+ export function releaseInUseDir({ home = homedir() } = {}) {
27
+ return join(home, '.config', 'dsh-crew', 'app', IN_USE_DIRNAME);
28
+ }
29
+
30
+ /** The release root that owns `moduleUrl`, or null when it is outside one. */
31
+ export function releaseRootFor(moduleUrl) {
32
+ let path;
33
+ try { path = fileURLToPath(moduleUrl); } catch { return null; }
34
+ // <release>/src/<...> or <release>/src/hub/entry.mjs → walk up to the release root.
35
+ let dir = dirname(path);
36
+ for (let depth = 0; depth < 6; depth++) {
37
+ if (existsSync(join(dir, 'package.json'))) {
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
40
+ if (pkg?.name === '@ran-sh/dsh-crew') return resolve(dir);
41
+ } catch { /* not the payload manifest; keep walking */ }
42
+ }
43
+ const parent = dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function isAlive(pid) {
51
+ if (!Number.isInteger(pid) || pid <= 0) return false;
52
+ try { process.kill(pid, 0); return true; }
53
+ catch (error) { return error?.code === 'EPERM'; }
54
+ }
55
+
56
+ /**
57
+ * Record that this process is running `releasePath`. Best effort: failing to
58
+ * claim must never stop the Hub from starting, because the cost of a missed
59
+ * claim is a possible retention of one extra release.
60
+ */
61
+ export function claimReleaseInUse({ moduleUrl = import.meta.url, releasePath, home = homedir(), pid = process.pid, now = Date.now() } = {}) {
62
+ try {
63
+ const target = releasePath ?? releaseRootFor(moduleUrl);
64
+ if (!target) return null;
65
+ const dir = releaseInUseDir({ home });
66
+ mkdirSync(dir, { recursive: true });
67
+ const file = join(dir, `${pid}.json`);
68
+ writeFileSync(file, JSON.stringify({ pid, release: resolve(target), claimed_at: now }) + '\n');
69
+ return file;
70
+ } catch { return null; }
71
+ }
72
+
73
+ export function releaseClaimFile({ home = homedir(), pid = process.pid } = {}) {
74
+ return join(releaseInUseDir({ home }), `${pid}.json`);
75
+ }
76
+
77
+ export function releaseClaimInUse({ home = homedir(), pid = process.pid } = {}) {
78
+ const file = releaseClaimFile({ home, pid });
79
+ return existsSync(file) || false;
80
+ }
81
+
82
+ export function clearReleaseClaim({ home = homedir(), pid = process.pid } = {}) {
83
+ try { rmSync(releaseClaimFile({ home, pid }), { force: true }); return true; } catch { return false; }
84
+ }
85
+
86
+ /**
87
+ * Releases currently held by a live process. Dead claims are removed as they are
88
+ * encountered, so the directory cannot accumulate stale files.
89
+ */
90
+ export function liveReleaseClaims({ home = homedir(), alive = isAlive } = {}) {
91
+ const dir = releaseInUseDir({ home });
92
+ let names;
93
+ // A state directory that is unreadable — or replaced by a file — must read as
94
+ // "no claims" rather than throwing: this runs inside install, and failing it
95
+ // would block an update over a problem that only affects pruning.
96
+ try { names = readdirSync(dir); } catch { return []; }
97
+ const live = [];
98
+ for (const name of names) {
99
+ if (!name.endsWith('.json')) continue;
100
+ const file = join(dir, name);
101
+ let record;
102
+ try { record = JSON.parse(readFileSync(file, 'utf8')); } catch { record = null; }
103
+ if (record && Number.isInteger(record.pid) && typeof record.release === 'string' && alive(record.pid)) {
104
+ live.push(resolve(record.release));
105
+ continue;
106
+ }
107
+ try { rmSync(file, { force: true }); } catch { /* best effort */ }
108
+ }
109
+ return [...new Set(live)];
110
+ }
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '1.4.0';
39
+ export const RUNTIME_VERSION = '1.5.1';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
package/src/server.mjs CHANGED
@@ -421,7 +421,12 @@ async function buildConfigReport() {
421
421
  reason_code: workspaceReadiness.reason_code ?? workspaceReadiness.code ?? 'WORKSPACE_NOT_CHECKED',
422
422
  },
423
423
  };
424
- const modelCallability = reprojectRuntimeModelCallability(snapshot, { enabled_roles });
424
+ // `reprojectRuntimeModelCallability` takes `enabled_roles`; the local
425
+ // binding is camelCase. Naming the property matters: a bare
426
+ // `{ enabled_roles }` shorthand is a ReferenceError, and it fires on
427
+ // every report whenever the Hub returns a readiness snapshot, which is
428
+ // the normal case.
429
+ const modelCallability = reprojectRuntimeModelCallability(snapshot, { enabled_roles: enabledRoles });
425
430
  return modelCallability ? { ...snapshot, model_callability: modelCallability } : snapshot;
426
431
  })()
427
432
  : buildRuntimeReadinessSnapshot({