@genee/omp-opsx-addon 0.8.0 → 0.10.0
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/README.md +440 -71
- package/index.ts +1106 -235
- package/lib/agent-defs.ts +80 -89
- package/lib/complexity-router.ts +336 -0
- package/lib/concurrency-control.ts +1246 -0
- package/lib/concurrency-multiproc.worker.ts +376 -0
- package/lib/concurrency-state.ts +397 -0
- package/lib/concurrency-tuner.ts +773 -0
- package/lib/concurrency-writer.ts +894 -0
- package/lib/continuity-guard.ts +117 -0
- package/lib/family-filter.ts +115 -2
- package/lib/model-roles.ts +43 -41
- package/lib/model-selector.ts +300 -36
- package/lib/model-speed.ts +189 -0
- package/lib/pick-model-render.ts +414 -0
- package/lib/pipe-core.ts +303 -93
- package/lib/pipe-push.ts +212 -26
- package/lib/provider-variants.ts +154 -0
- package/lib/reachability-refresh.ts +10 -2
- package/lib/selection-filters.ts +42 -1
- package/lib/stall-detector.ts +185 -0
- package/lib/system-prompt.ts +28 -11
- package/lib/tiers-data.ts +47 -5
- package/lib/tiers-updater.ts +51 -8
- package/lib/unified-config.ts +735 -68
- package/lib/usage-render.ts +57 -4
- package/lib/usage-widget.ts +18 -4
- package/package.json +1 -1
- package/skills/opsx-orchestration-protocol/SKILL.md +87 -0
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency-state persistence and decay — tasks 5.1–5.4 of
|
|
3
|
+
* `adaptive-provider-concurrency` (design D7, spec Requirement
|
|
4
|
+
* 「可观测与持久化衰减」).
|
|
5
|
+
*
|
|
6
|
+
* This is the durable half of the control loop: it remembers the *learned*
|
|
7
|
+
* limit across restarts, decays it while a provider looks abandoned, and
|
|
8
|
+
* guarantees the stop path keeps the last finite value.
|
|
9
|
+
*
|
|
10
|
+
* The state file is a cache of learned values, never the shared gate — the
|
|
11
|
+
* authoritative cross-process limit lives in `providers.maxInFlightRequests`
|
|
12
|
+
* (group 4 / `lib/concurrency-writer.ts`). This module consequently has no
|
|
13
|
+
* config write path, no `remove`, and no timer: it cannot delete a managed key
|
|
14
|
+
* or turn a managed provider unlimited. Decay write-backs go through the same
|
|
15
|
+
* gate/lease/writer as every other write (task 5.3).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { dirname } from 'path';
|
|
19
|
+
import { clampLimit, type ProviderConcurrencyState } from './concurrency-tuner.js';
|
|
20
|
+
import { nodeWriterFileSystem, type Warn, type WriterFileSystemPort } from './concurrency-writer.js';
|
|
21
|
+
import { canonicalizeProvider } from './usage-resolver.js';
|
|
22
|
+
|
|
23
|
+
/** State-file name directly under the config root (design D7). */
|
|
24
|
+
export const STATE_FILE_NAME = 'opsx-concurrency-state.json';
|
|
25
|
+
|
|
26
|
+
/** On-disk schema version; bump when the entry shape changes. */
|
|
27
|
+
export const STATE_FILE_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
const NO_WARN: Warn = () => {};
|
|
30
|
+
const PREFIX = '[omp-opsx-addon] concurrency-state:';
|
|
31
|
+
|
|
32
|
+
let stateSeq = 0;
|
|
33
|
+
|
|
34
|
+
function message(err: unknown): string {
|
|
35
|
+
return err instanceof Error ? err.message : String(err);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Truncate to an integer, or `fallback` when the value is not a finite number. */
|
|
39
|
+
function toInt(value: unknown, fallback: number): number {
|
|
40
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Runtime narrowing for untrusted JSON nodes (schema parse is overkill for a flat record). */
|
|
44
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One provider's durable state. The streak counters and the latency baseline
|
|
50
|
+
* ride along so the AIMD trajectory resumes rather than restarts after a
|
|
51
|
+
* process swap.
|
|
52
|
+
*/
|
|
53
|
+
export interface PersistedProviderState {
|
|
54
|
+
/** Learned limit `L` at the last save. */
|
|
55
|
+
limit: number;
|
|
56
|
+
/** Decay target — `L`'s seed value at the last save. */
|
|
57
|
+
initial: number;
|
|
58
|
+
/** Epoch ms of the save that last carried this entry. */
|
|
59
|
+
updatedAt: number;
|
|
60
|
+
/** Epoch ms of the last window that carried an observation (`0` = never). */
|
|
61
|
+
lastObservationAt: number;
|
|
62
|
+
/** Consecutive bad windows at the last save. */
|
|
63
|
+
badStreak: number;
|
|
64
|
+
/** Consecutive clean windows at the last save. */
|
|
65
|
+
cleanStreak: number;
|
|
66
|
+
/** Latency EWMA baseline in ms, or `null` until observed. */
|
|
67
|
+
ewmaGenMs: number | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Provider id → durable state, i.e. the body of the state file. */
|
|
71
|
+
export type ConcurrencyStateFile = Record<string, PersistedProviderState>;
|
|
72
|
+
|
|
73
|
+
// ── reading (5.1 restart recovery / 5.2 fallback) ────────────────────
|
|
74
|
+
|
|
75
|
+
export interface LoadStateArgs {
|
|
76
|
+
/** Absolute path of the state file (config root + {@link STATE_FILE_NAME}). */
|
|
77
|
+
statePath: string;
|
|
78
|
+
fileSystem?: WriterFileSystemPort;
|
|
79
|
+
warn?: Warn;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validate one on-disk entry. Returns `null` when the entry carries no usable
|
|
84
|
+
* learned limit — the caller then falls back to the shared config value, and
|
|
85
|
+
* the provider is never treated as unlimited.
|
|
86
|
+
*/
|
|
87
|
+
function sanitizeEntry(provider: string, raw: unknown, warn: Warn): PersistedProviderState | null {
|
|
88
|
+
if (!isPlainObject(raw)) {
|
|
89
|
+
warn(`${PREFIX} state entry for ${provider} is not an object; ignored (the shared config value is used)`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const limit = raw.limit;
|
|
93
|
+
if (typeof limit !== 'number' || !Number.isFinite(limit) || Math.trunc(limit) < 1) {
|
|
94
|
+
warn(`${PREFIX} state entry for ${provider} has no usable limit; ignored (the shared config value is used)`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const learned = Math.trunc(limit);
|
|
98
|
+
const updatedAt = Math.max(0, toInt(raw.updatedAt, 0));
|
|
99
|
+
const ewma = raw.ewmaGenMs;
|
|
100
|
+
return {
|
|
101
|
+
limit: learned,
|
|
102
|
+
initial: Math.max(1, toInt(raw.initial, learned)),
|
|
103
|
+
updatedAt,
|
|
104
|
+
lastObservationAt: Math.max(0, toInt(raw.lastObservationAt, updatedAt)),
|
|
105
|
+
badStreak: Math.max(0, toInt(raw.badStreak, 0)),
|
|
106
|
+
cleanStreak: Math.max(0, toInt(raw.cleanStreak, 0)),
|
|
107
|
+
ewmaGenMs: typeof ewma === 'number' && Number.isFinite(ewma) && ewma > 0 ? ewma : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Canonicalize the provider keys of an entry list, dropping unusable entries. */
|
|
112
|
+
function canonicalEntries(
|
|
113
|
+
entries: readonly (readonly [string, unknown])[],
|
|
114
|
+
warn: Warn,
|
|
115
|
+
): ConcurrencyStateFile {
|
|
116
|
+
const out: ConcurrencyStateFile = {};
|
|
117
|
+
for (const [rawId, rawEntry] of entries) {
|
|
118
|
+
const id = canonicalizeProvider(String(rawId).trim());
|
|
119
|
+
if (!id) {
|
|
120
|
+
warn(`${PREFIX} state entry with an empty provider id is ignored`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (Object.hasOwn(out, id)) {
|
|
124
|
+
warn(`${PREFIX} duplicate state entry for ${id} is ignored`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const entry = sanitizeEntry(id, rawEntry, warn);
|
|
128
|
+
if (entry) out[id] = entry;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse state-file text; every failure degrades to an empty map plus a warn. */
|
|
134
|
+
function parseStateFile(text: string, statePath: string, warn: Warn): ConcurrencyStateFile {
|
|
135
|
+
let parsed: unknown;
|
|
136
|
+
try {
|
|
137
|
+
parsed = JSON.parse(text);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
warn(`${PREFIX} state file ${statePath} is corrupt (${message(err)}); falling back to the shared config value`);
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
if (!isPlainObject(parsed)) {
|
|
143
|
+
warn(`${PREFIX} state file ${statePath} is not an object; falling back to the shared config value`);
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
const providers = parsed.providers;
|
|
147
|
+
if (!isPlainObject(providers)) {
|
|
148
|
+
warn(`${PREFIX} state file ${statePath} has no providers map; falling back to the shared config value`);
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
return canonicalEntries(Object.entries(providers), warn);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read the persisted per-provider state (task 5.1/5.2).
|
|
156
|
+
*
|
|
157
|
+
* A missing file is the normal first-run path — nothing has been learned yet —
|
|
158
|
+
* so it is silent and yields `{}`. Corrupt JSON, a non-object document, a
|
|
159
|
+
* missing `providers` map and entries without a usable `limit` all degrade the
|
|
160
|
+
* same way: the entry is dropped, a warn is recorded, and {@link restoreLimit}
|
|
161
|
+
* falls back to the shared config value (then `initial`). Never to unlimited.
|
|
162
|
+
*/
|
|
163
|
+
export function loadConcurrencyState(args: LoadStateArgs): ConcurrencyStateFile {
|
|
164
|
+
const fileSystem = args.fileSystem ?? nodeWriterFileSystem;
|
|
165
|
+
const text = fileSystem.readFile(args.statePath);
|
|
166
|
+
if (text === null) return {};
|
|
167
|
+
return parseStateFile(text, args.statePath, args.warn ?? NO_WARN);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface RestoreLimitArgs {
|
|
171
|
+
/** `concurrency_floor`. */
|
|
172
|
+
floor: number;
|
|
173
|
+
/** `concurrency_ceiling` (per-provider override already applied). */
|
|
174
|
+
ceiling: number;
|
|
175
|
+
/** Current `providers.maxInFlightRequests[provider]`, or `null` when absent. */
|
|
176
|
+
sharedValue: number | null;
|
|
177
|
+
/** The provider's `initial` (seed) value. */
|
|
178
|
+
initial: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* `L` for a restarting control loop: the persisted learned limit first, then
|
|
183
|
+
* the shared config value, then `initial` — always clamped into
|
|
184
|
+
* `[floor, ceiling]` (and therefore always a finite value `>= 1`).
|
|
185
|
+
*
|
|
186
|
+
* Priority order matters for task 5.1: a restart MUST resume the learned value
|
|
187
|
+
* rather than the seed. It is the *takeover* path (a peer handing over the
|
|
188
|
+
* lease) that re-reads the shared config value, via
|
|
189
|
+
* `resolveTakeoverStart` in `lib/concurrency-writer.ts`.
|
|
190
|
+
*/
|
|
191
|
+
export function restoreLimit(
|
|
192
|
+
persisted: PersistedProviderState | null | undefined,
|
|
193
|
+
args: RestoreLimitArgs,
|
|
194
|
+
): number {
|
|
195
|
+
const learned = persisted?.limit;
|
|
196
|
+
if (typeof learned === 'number' && Number.isFinite(learned) && learned >= 1) {
|
|
197
|
+
return clampLimit(learned, args.floor, args.ceiling);
|
|
198
|
+
}
|
|
199
|
+
const shared = args.sharedValue;
|
|
200
|
+
if (typeof shared === 'number' && Number.isFinite(shared) && shared >= 1) {
|
|
201
|
+
return clampLimit(shared, args.floor, args.ceiling);
|
|
202
|
+
}
|
|
203
|
+
return clampLimit(args.initial, args.floor, args.ceiling);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── decay (5.3) ──────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
export interface DecayStepArgs {
|
|
209
|
+
/** Injected clock (epoch ms). */
|
|
210
|
+
now: number;
|
|
211
|
+
/** `concurrency_decay_after_ms`. */
|
|
212
|
+
decayAfterMs: number;
|
|
213
|
+
/** Step target — the provider's `initial`. */
|
|
214
|
+
initial: number;
|
|
215
|
+
/** Lower bound; the step MUST NOT cross it. */
|
|
216
|
+
floor: number;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface DecayStepResult {
|
|
220
|
+
/** The advanced state; identical to the input (same reference) when nothing moved. */
|
|
221
|
+
state: ProviderConcurrencyState;
|
|
222
|
+
/** `true` when this call consumed one decay period and moved `L` by one. */
|
|
223
|
+
moved: boolean;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* One decay step for a provider that has gone `decayAfterMs` without an
|
|
228
|
+
* observation: move `L` one notch toward `initial`, never below `floor`, and
|
|
229
|
+
* consume one period so the cadence does not drift (the anchor advances by
|
|
230
|
+
* exactly `decayAfterMs`, keeping a step per elapsed period).
|
|
231
|
+
*
|
|
232
|
+
* `L <= initial` (already at or below the target) and `L === floor` do not
|
|
233
|
+
* move. A non-finite/non-positive `decayAfterMs` disables decay entirely rather
|
|
234
|
+
* than decaying on every render tick — the config layer already falls back to
|
|
235
|
+
* the `concurrency_decay_after_ms` default, so a garbage value here must not be
|
|
236
|
+
* able to drive the loop.
|
|
237
|
+
*
|
|
238
|
+
* Pure: the input state is never mutated, and an unmoved result is returned
|
|
239
|
+
* by reference (no allocation on the common path).
|
|
240
|
+
*/
|
|
241
|
+
export function applyDecayStep(state: ProviderConcurrencyState, args: DecayStepArgs): DecayStepResult {
|
|
242
|
+
const decayAfter = args.decayAfterMs;
|
|
243
|
+
if (!Number.isFinite(decayAfter) || decayAfter <= 0) return { state, moved: false };
|
|
244
|
+
const floor = Math.max(1, toInt(args.floor, 1));
|
|
245
|
+
const anchor = Number.isFinite(state.lastObservationAt) ? state.lastObservationAt : 0;
|
|
246
|
+
if (args.now - anchor < decayAfter) return { state, moved: false };
|
|
247
|
+
|
|
248
|
+
const target = Math.max(floor, toInt(args.initial, 1));
|
|
249
|
+
if (!Number.isFinite(state.limit) || state.limit <= target) return { state, moved: false };
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
state: { ...state, limit: Math.max(floor, state.limit - 1), lastObservationAt: anchor + decayAfter },
|
|
253
|
+
moved: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── writing (5.1) ────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
export interface SaveStateArgs {
|
|
260
|
+
/** Absolute path of the state file (config root + {@link STATE_FILE_NAME}). */
|
|
261
|
+
statePath: string;
|
|
262
|
+
/** Full per-provider state to persist. */
|
|
263
|
+
state: ConcurrencyStateFile;
|
|
264
|
+
fileSystem?: WriterFileSystemPort;
|
|
265
|
+
warn?: Warn;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Entry content minus `updatedAt` — the basis for 「这一轮没有变化」. */
|
|
269
|
+
function samePersistedContent(a: PersistedProviderState, b: PersistedProviderState): boolean {
|
|
270
|
+
return a.limit === b.limit
|
|
271
|
+
&& a.initial === b.initial
|
|
272
|
+
&& a.lastObservationAt === b.lastObservationAt
|
|
273
|
+
&& a.badStreak === b.badStreak
|
|
274
|
+
&& a.cleanStreak === b.cleanStreak
|
|
275
|
+
&& a.ewmaGenMs === b.ewmaGenMs;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Snapshot the live control-loop states into the durable file body.
|
|
280
|
+
*
|
|
281
|
+
* This is the bridge the controller's persist step uses: the field names differ
|
|
282
|
+
* (`cleanStreak` on disk vs `cleanWindows` in the loop) and the live state
|
|
283
|
+
* carries runtime-only members (`cooldownUntil`, the partial clean-window
|
|
284
|
+
* accumulator) that are deliberately not persisted — a restart restarts the
|
|
285
|
+
* current clean window (conservative) while the counted streak and the latency
|
|
286
|
+
* baseline resume.
|
|
287
|
+
*
|
|
288
|
+
* `previous` is the body handed to the last save. An entry whose content
|
|
289
|
+
* (everything but the timestamp) is unchanged keeps `previous[id].updatedAt`,
|
|
290
|
+
* so `updatedAt` advances only when the entry really changed — without that,
|
|
291
|
+
* every evaluation window would re-stamp all entries and defeat the no-op
|
|
292
|
+
* write guard in {@link saveConcurrencyState}.
|
|
293
|
+
*/
|
|
294
|
+
export function toStateFile(
|
|
295
|
+
states: Iterable<readonly [string, ProviderConcurrencyState]>,
|
|
296
|
+
updatedAt: number,
|
|
297
|
+
previous?: ConcurrencyStateFile | null,
|
|
298
|
+
): ConcurrencyStateFile {
|
|
299
|
+
const out: ConcurrencyStateFile = {};
|
|
300
|
+
for (const [provider, state] of states) {
|
|
301
|
+
const id = canonicalizeProvider(provider);
|
|
302
|
+
if (!id) continue;
|
|
303
|
+
const entry: PersistedProviderState = {
|
|
304
|
+
limit: state.limit,
|
|
305
|
+
initial: state.initial,
|
|
306
|
+
updatedAt,
|
|
307
|
+
lastObservationAt: state.lastObservationAt,
|
|
308
|
+
badStreak: state.badStreak,
|
|
309
|
+
cleanStreak: state.cleanWindows,
|
|
310
|
+
ewmaGenMs: state.ewmaGenMs,
|
|
311
|
+
};
|
|
312
|
+
const prior = previous?.[id];
|
|
313
|
+
if (prior && samePersistedContent(prior, entry)) entry.updatedAt = prior.updatedAt;
|
|
314
|
+
out[id] = entry;
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Serialize the state file body. The file-level `updatedAt` tracks the newest entry. */
|
|
320
|
+
function serializeStateFile(state: ConcurrencyStateFile, warn: Warn): string {
|
|
321
|
+
const providers = canonicalEntries(Object.entries(state ?? {}), warn);
|
|
322
|
+
let updatedAt = 0;
|
|
323
|
+
for (const entry of Object.values(providers)) updatedAt = Math.max(updatedAt, entry.updatedAt);
|
|
324
|
+
return `${JSON.stringify({ version: STATE_FILE_VERSION, updatedAt, providers }, null, 2)}\n`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Atomically persist the learned state (`tmp` + `rename`) — task 5.1.
|
|
329
|
+
*
|
|
330
|
+
* Fire-and-forget: the returned promise exists so a shutdown path can flush,
|
|
331
|
+
* but the render tick MUST NOT await it. Failures only warn — the previous file
|
|
332
|
+
* stays in place, so a restart keeps the last finite learned values.
|
|
333
|
+
*
|
|
334
|
+
* A window that changed nothing writes nothing: the file is only rewritten when
|
|
335
|
+
* its bytes would differ. The comparison is against the file itself (not a
|
|
336
|
+
* cached「上次写完的内容」), so a failed write is retried on the next window and
|
|
337
|
+
* a peer writer that replaced the file is still corrected. `toStateFile` keeps
|
|
338
|
+
* `updatedAt` stable for unchanged entries, which is what makes two no-op
|
|
339
|
+
* windows serialize identically.
|
|
340
|
+
*/
|
|
341
|
+
export async function saveConcurrencyState(args: SaveStateArgs): Promise<void> {
|
|
342
|
+
const fileSystem = args.fileSystem ?? nodeWriterFileSystem;
|
|
343
|
+
const warn = args.warn ?? NO_WARN;
|
|
344
|
+
const tmp = `${args.statePath}.tmp-${process.pid}-${stateSeq++}`;
|
|
345
|
+
try {
|
|
346
|
+
const text = serializeStateFile(args.state, warn);
|
|
347
|
+
if (fileSystem.readFile(args.statePath) === text) return;
|
|
348
|
+
fileSystem.ensureDir(dirname(args.statePath));
|
|
349
|
+
fileSystem.writeFile(tmp, text);
|
|
350
|
+
fileSystem.rename(tmp, args.statePath);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
warn(`${PREFIX} state file ${args.statePath} update failed (${message(err)}); keeping the previous state`);
|
|
353
|
+
try {
|
|
354
|
+
fileSystem.remove(tmp);
|
|
355
|
+
} catch { /* the failed write left nothing to clean up */ }
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── shutdown fail-safe (5.4) ─────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
export interface StopFailSafeArgs {
|
|
362
|
+
/** Absolute path of the state file (config root + {@link STATE_FILE_NAME}). */
|
|
363
|
+
statePath: string;
|
|
364
|
+
fileSystem?: WriterFileSystemPort;
|
|
365
|
+
warn?: Warn;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Shutdown guard for tuner stop / plugin unload / process exit — task 5.4.
|
|
370
|
+
*
|
|
371
|
+
* Deliberately inert. The fail-safe is the *absence* of a cleanup write: this
|
|
372
|
+
* module never touches `providers.maxInFlightRequests`, so the last successful
|
|
373
|
+
* finite value survives a stop, and no managed key is ever deleted. The state
|
|
374
|
+
* file is likewise never removed nor overwritten with an empty body. All this
|
|
375
|
+
* does is surface a degradation warn when the file a restart would read is
|
|
376
|
+
* missing or unusable, so a silent loss of the learned values is observable.
|
|
377
|
+
*/
|
|
378
|
+
export async function stopFailSafe(args: StopFailSafeArgs): Promise<void> {
|
|
379
|
+
const fileSystem = args.fileSystem ?? nodeWriterFileSystem;
|
|
380
|
+
const warn = args.warn ?? NO_WARN;
|
|
381
|
+
try {
|
|
382
|
+
const text = fileSystem.readFile(args.statePath);
|
|
383
|
+
if (text === null) {
|
|
384
|
+
warn(
|
|
385
|
+
`${PREFIX} state file ${args.statePath} is missing at shutdown; the last shared config values are kept and no keys are removed`,
|
|
386
|
+
);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (Object.keys(parseStateFile(text, args.statePath, warn)).length === 0) {
|
|
390
|
+
warn(
|
|
391
|
+
`${PREFIX} state file ${args.statePath} holds no usable learned limits at shutdown; the last shared config values are kept`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
} catch (err) {
|
|
395
|
+
warn(`${PREFIX} shutdown state check failed (${message(err)}); the last shared config values are kept`);
|
|
396
|
+
}
|
|
397
|
+
}
|