switchroom 0.14.83 → 0.14.85
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/dist/cli/autoaccept-poll.js +179 -6
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/credits-watch.ts +50 -10
- package/telegram-plugin/dist/gateway/gateway.js +44 -13
- package/telegram-plugin/gateway/gateway.ts +37 -4
- package/telegram-plugin/gateway/ipc-protocol.ts +22 -1
- package/telegram-plugin/gateway/ipc-server.ts +22 -0
- package/telegram-plugin/tests/credits-watch.test.ts +65 -1
- package/telegram-plugin/tests/ipc-server-validate-quota-wall.test.ts +37 -0
|
@@ -176,6 +176,71 @@ function sendKeys2(agentName, keys) {
|
|
|
176
176
|
|
|
177
177
|
// src/agents/wedge-watchdog.ts
|
|
178
178
|
var WEDGE_FOOTER_SIGNATURE = /(?=[\s\S]*[Ee]sc(?:ape)?[^\n]*cancel)(?=[\s\S]*(?:to select|to navigate|\u2191\/\u2193))/;
|
|
179
|
+
var RATE_LIMIT_MENU_SIGNATURE = /(?=[\s\S]*\/rate-limit-options)(?=[\s\S]*(?:Switch to usage credits|Upgrade your plan))/;
|
|
180
|
+
var MONTHS = {
|
|
181
|
+
jan: 0,
|
|
182
|
+
feb: 1,
|
|
183
|
+
mar: 2,
|
|
184
|
+
apr: 3,
|
|
185
|
+
may: 4,
|
|
186
|
+
jun: 5,
|
|
187
|
+
jul: 6,
|
|
188
|
+
aug: 7,
|
|
189
|
+
sep: 8,
|
|
190
|
+
oct: 9,
|
|
191
|
+
nov: 10,
|
|
192
|
+
dec: 11
|
|
193
|
+
};
|
|
194
|
+
function parseWeeklyReset(text, nowMs = Date.now()) {
|
|
195
|
+
const m = text.match(/resets\s+([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{1,2})(?::(\d{2}))?\s*([ap]m)?\s*(?:\(([^)]+)\))?/i);
|
|
196
|
+
if (!m)
|
|
197
|
+
return null;
|
|
198
|
+
const mon = MONTHS[m[1].slice(0, 3).toLowerCase()];
|
|
199
|
+
if (mon === undefined)
|
|
200
|
+
return null;
|
|
201
|
+
const day = Number(m[2]);
|
|
202
|
+
let hour = Number(m[3]);
|
|
203
|
+
const minute = m[4] ? Number(m[4]) : 0;
|
|
204
|
+
const ampm = m[5]?.toLowerCase();
|
|
205
|
+
if (ampm === "pm" && hour < 12)
|
|
206
|
+
hour += 12;
|
|
207
|
+
if (ampm === "am" && hour === 12)
|
|
208
|
+
hour = 0;
|
|
209
|
+
if (!Number.isFinite(day) || !Number.isFinite(hour) || day < 1 || day > 31 || hour > 23) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const tz = m[6]?.trim();
|
|
213
|
+
const probeYear = new Date(nowMs).getUTCFullYear();
|
|
214
|
+
for (const year of [probeYear, probeYear + 1]) {
|
|
215
|
+
const epoch = wallClockToEpoch(year, mon, day, hour, minute, tz);
|
|
216
|
+
if (epoch != null && epoch > nowMs - 60000)
|
|
217
|
+
return epoch;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
function wallClockToEpoch(year, month, day, hour, minute, tz) {
|
|
222
|
+
const asUtc = Date.UTC(year, month, day, hour, minute, 0);
|
|
223
|
+
if (!tz)
|
|
224
|
+
return asUtc;
|
|
225
|
+
try {
|
|
226
|
+
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
227
|
+
timeZone: tz,
|
|
228
|
+
year: "numeric",
|
|
229
|
+
month: "2-digit",
|
|
230
|
+
day: "2-digit",
|
|
231
|
+
hour: "2-digit",
|
|
232
|
+
minute: "2-digit",
|
|
233
|
+
second: "2-digit",
|
|
234
|
+
hour12: false
|
|
235
|
+
});
|
|
236
|
+
const parts = Object.fromEntries(fmt.formatToParts(new Date(asUtc)).filter((p) => p.type !== "literal").map((p) => [p.type, p.value]));
|
|
237
|
+
const shown = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second));
|
|
238
|
+
const offset = shown - asUtc;
|
|
239
|
+
return asUtc - offset;
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
179
244
|
var DEFAULT_POLL_MS2 = 5000;
|
|
180
245
|
var DEFAULT_STABILITY_THRESHOLD = 3;
|
|
181
246
|
var DEFAULT_COOLDOWN_MS = 60000;
|
|
@@ -193,6 +258,9 @@ async function runWedgeWatchdog(opts) {
|
|
|
193
258
|
const cooldownMs = opts.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
194
259
|
const deferToPrompts = opts.deferToPrompts ?? PROMPTS2;
|
|
195
260
|
const signature = opts.wedgeSignature ?? WEDGE_FOOTER_SIGNATURE;
|
|
261
|
+
const rateLimitSignature = opts.rateLimitSignature === null ? null : opts.rateLimitSignature ?? RATE_LIMIT_MENU_SIGNATURE;
|
|
262
|
+
const onRateLimitMenu = opts.onRateLimitMenu;
|
|
263
|
+
const parseReset = opts.parseReset ?? parseWeeklyReset;
|
|
196
264
|
const maxPolls = opts.maxPolls ?? Number.POSITIVE_INFINITY;
|
|
197
265
|
const now = opts.now ?? Date.now;
|
|
198
266
|
const sleep = opts.sleep ?? defaultSleep2;
|
|
@@ -202,6 +270,7 @@ async function runWedgeWatchdog(opts) {
|
|
|
202
270
|
let lastKey = null;
|
|
203
271
|
let cooldownUntil = 0;
|
|
204
272
|
let fires = 0;
|
|
273
|
+
let rateLimitFires = 0;
|
|
205
274
|
let polls = 0;
|
|
206
275
|
while (polls < maxPolls) {
|
|
207
276
|
polls++;
|
|
@@ -212,8 +281,38 @@ async function runWedgeWatchdog(opts) {
|
|
|
212
281
|
console.error(`[wedge-watchdog] ${opts.agentName}: capture threw: ${err.message}`);
|
|
213
282
|
text = "";
|
|
214
283
|
}
|
|
215
|
-
const
|
|
216
|
-
|
|
284
|
+
const isRateLimitMenu = !!text && rateLimitSignature !== null && rateLimitSignature.test(text);
|
|
285
|
+
const isBlockingModal = !isRateLimitMenu && !!text && signature.test(text) && !deferToPrompts.some((p) => p.match.test(text));
|
|
286
|
+
if (isRateLimitMenu) {
|
|
287
|
+
const key = stabilityKey(text);
|
|
288
|
+
if (key === lastKey) {
|
|
289
|
+
stableCount++;
|
|
290
|
+
} else {
|
|
291
|
+
stableCount = 1;
|
|
292
|
+
lastKey = key;
|
|
293
|
+
}
|
|
294
|
+
if (stableCount >= stabilityThreshold && now() >= cooldownUntil) {
|
|
295
|
+
const resetAt = parseReset(text, now());
|
|
296
|
+
console.error(`[wedge-watchdog] ${opts.agentName}: rate-limit (weekly-quota) menu detected ` + `after ${stableCount} stable polls \u2014 signalling failover` + (resetAt != null ? ` (resets ${new Date(resetAt).toISOString()})` : " (reset unparsed)") + ` then parking with Esc`);
|
|
297
|
+
if (onRateLimitMenu) {
|
|
298
|
+
try {
|
|
299
|
+
onRateLimitMenu(opts.agentName, resetAt);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error(`[wedge-watchdog] ${opts.agentName}: onRateLimitMenu threw: ${err.message}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
send(opts.agentName, ["Escape"]);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.error(`[wedge-watchdog] ${opts.agentName}: send threw: ${err.message}`);
|
|
308
|
+
}
|
|
309
|
+
fires++;
|
|
310
|
+
rateLimitFires++;
|
|
311
|
+
cooldownUntil = now() + cooldownMs;
|
|
312
|
+
stableCount = 0;
|
|
313
|
+
lastKey = null;
|
|
314
|
+
}
|
|
315
|
+
} else if (isBlockingModal) {
|
|
217
316
|
const key = stabilityKey(text);
|
|
218
317
|
if (key === lastKey) {
|
|
219
318
|
stableCount++;
|
|
@@ -239,7 +338,74 @@ async function runWedgeWatchdog(opts) {
|
|
|
239
338
|
}
|
|
240
339
|
await sleep(pollIntervalMs);
|
|
241
340
|
}
|
|
242
|
-
return { fires, polls, reason: "max-polls" };
|
|
341
|
+
return { fires, rateLimitFires, polls, reason: "max-polls" };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/agents/rate-limit-signal.ts
|
|
345
|
+
import { createConnection } from "node:net";
|
|
346
|
+
import { join } from "node:path";
|
|
347
|
+
function resolveGatewaySocketPath() {
|
|
348
|
+
const stateDir = process.env.TELEGRAM_STATE_DIR ?? "/state/agent/telegram";
|
|
349
|
+
return process.env.SWITCHROOM_GATEWAY_SOCKET ?? join(stateDir, "gateway.sock");
|
|
350
|
+
}
|
|
351
|
+
function signalQuotaWall(agentName, resetAt, opts = {}) {
|
|
352
|
+
const socketPath = opts.socketPath ?? resolveGatewaySocketPath();
|
|
353
|
+
const connect = opts._connect ?? ((p) => createConnection(p));
|
|
354
|
+
const log = opts._log ?? ((m) => console.error(m));
|
|
355
|
+
const connectTimeoutMs = opts.connectTimeoutMs ?? 5000;
|
|
356
|
+
const msg = { type: "quota_wall_detected", agentName };
|
|
357
|
+
if (resetAt != null)
|
|
358
|
+
msg.resetAt = resetAt;
|
|
359
|
+
const line = JSON.stringify(msg) + `
|
|
360
|
+
`;
|
|
361
|
+
return new Promise((resolve) => {
|
|
362
|
+
let settled = false;
|
|
363
|
+
const done = (ok) => {
|
|
364
|
+
if (settled)
|
|
365
|
+
return;
|
|
366
|
+
settled = true;
|
|
367
|
+
resolve(ok);
|
|
368
|
+
};
|
|
369
|
+
let sock;
|
|
370
|
+
try {
|
|
371
|
+
sock = connect(socketPath);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
log(`[rate-limit-signal] ${agentName}: connect threw: ${err.message}`);
|
|
374
|
+
done(false);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const timer = setTimeout(() => {
|
|
378
|
+
log(`[rate-limit-signal] ${agentName}: connect timed out`);
|
|
379
|
+
try {
|
|
380
|
+
sock.destroy();
|
|
381
|
+
} catch {}
|
|
382
|
+
done(false);
|
|
383
|
+
}, connectTimeoutMs);
|
|
384
|
+
sock.on("connect", () => {
|
|
385
|
+
try {
|
|
386
|
+
sock.write(line, () => {
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
try {
|
|
389
|
+
sock.end();
|
|
390
|
+
} catch {}
|
|
391
|
+
log(`[rate-limit-signal] ${agentName}: quota_wall_detected sent`);
|
|
392
|
+
done(true);
|
|
393
|
+
});
|
|
394
|
+
} catch (err) {
|
|
395
|
+
clearTimeout(timer);
|
|
396
|
+
log(`[rate-limit-signal] ${agentName}: write threw: ${err.message}`);
|
|
397
|
+
try {
|
|
398
|
+
sock.destroy();
|
|
399
|
+
} catch {}
|
|
400
|
+
done(false);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
sock.on("error", (err) => {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
log(`[rate-limit-signal] ${agentName}: socket error: ${err.message}`);
|
|
406
|
+
done(false);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
243
409
|
}
|
|
244
410
|
|
|
245
411
|
// src/cli/autoaccept-poll.ts
|
|
@@ -259,10 +425,17 @@ async function main() {
|
|
|
259
425
|
console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog disabled (SWITCHROOM_WEDGE_WATCHDOG=0) \u2014 exiting after boot phase`);
|
|
260
426
|
process.exit(0);
|
|
261
427
|
}
|
|
428
|
+
const rateLimitDetect = process.env.SWITCHROOM_RATE_LIMIT_DETECT !== "0";
|
|
262
429
|
try {
|
|
263
|
-
console.error(`[autoaccept-poll] ${agentName}: entering wedge-watchdog (continuous)`);
|
|
264
|
-
const res = await runWedgeWatchdog({
|
|
265
|
-
|
|
430
|
+
console.error(`[autoaccept-poll] ${agentName}: entering wedge-watchdog (continuous)` + (rateLimitDetect ? " +rate-limit-detect" : " (rate-limit-detect OFF)"));
|
|
431
|
+
const res = await runWedgeWatchdog({
|
|
432
|
+
agentName,
|
|
433
|
+
rateLimitSignature: rateLimitDetect ? undefined : null,
|
|
434
|
+
onRateLimitMenu: rateLimitDetect ? (name, resetAt) => {
|
|
435
|
+
signalQuotaWall(name, resetAt);
|
|
436
|
+
} : undefined
|
|
437
|
+
});
|
|
438
|
+
console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog returned reason=${res.reason} fires=${res.fires} rateLimitFires=${res.rateLimitFires}`);
|
|
266
439
|
} catch (err) {
|
|
267
440
|
console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog unexpected throw: ${err.message}`);
|
|
268
441
|
}
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -49815,8 +49815,8 @@ var {
|
|
|
49815
49815
|
} = import__.default;
|
|
49816
49816
|
|
|
49817
49817
|
// src/build-info.ts
|
|
49818
|
-
var VERSION = "0.14.
|
|
49819
|
-
var COMMIT_SHA = "
|
|
49818
|
+
var VERSION = "0.14.85";
|
|
49819
|
+
var COMMIT_SHA = "292c683f";
|
|
49820
49820
|
|
|
49821
49821
|
// src/cli/agent.ts
|
|
49822
49822
|
init_source();
|
package/package.json
CHANGED
|
@@ -27,20 +27,53 @@ import { join } from "path";
|
|
|
27
27
|
const STATE_FILE = "credits-watch.json";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
* that warrant a user-facing notification. Other values (null,
|
|
32
|
-
* undefined, transient unknowns) are treated as "no notification
|
|
33
|
-
* needed".
|
|
30
|
+
* Which `cachedExtraUsageDisabledReason` values warrant a user-facing alarm.
|
|
34
31
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
32
|
+
* IMPORTANT — empty by default (subscription-only model). The flag name says it
|
|
33
|
+
* all: `cachedExtraUsageDisabledReason` is *always* "why **extra usage** (the
|
|
34
|
+
* optional pay-as-you-go layer beyond the Pro/Max plan) is disabled." switchroom
|
|
35
|
+
* is subscription-only by design (compliance pillar 3 — "the plan is the
|
|
36
|
+
* ceiling, no API/pay-as-you-go"), so extra usage being OFF is the **expected,
|
|
37
|
+
* desired** state. Every value (`out_of_credits`, `extra_usage_disabled`,
|
|
38
|
+
* `credits_exhausted`, even `org_level_disabled` = "org admin disabled extra
|
|
39
|
+
* usage") describes that benign state — none indicates a real switchroom
|
|
40
|
+
* failure. Firing a "cron + replies will fail, buy credits" card on it is a
|
|
41
|
+
* false alarm AND advises something that contradicts the subscription-honest
|
|
42
|
+
* model.
|
|
43
|
+
*
|
|
44
|
+
* The genuine failure modes are covered elsewhere:
|
|
45
|
+
* - an account's subscription window exhausts → stderr → fleet auto-fallback
|
|
46
|
+
* (model-unavailable.ts → fireFleetAutoFallback) rolls to a healthy account;
|
|
47
|
+
* - the WHOLE fleet exhausts → the quota-watch all-exhausted operator alert.
|
|
48
|
+
*
|
|
49
|
+
* So credits-watch defaults to silent. An operator who actually runs on
|
|
50
|
+
* pay-as-you-go and wants the alarm can opt specific reasons back in via
|
|
51
|
+
* `SWITCHROOM_CREDITS_WATCH_FATAL_REASONS` (comma-separated). See
|
|
52
|
+
* `resolveCreditWatchFatalReasons`.
|
|
37
53
|
*/
|
|
38
|
-
const
|
|
54
|
+
export const DEFAULT_CREDIT_FATAL_REASONS = new Set<string>();
|
|
55
|
+
|
|
56
|
+
/** All reason strings recognized for opt-in via the env var. */
|
|
57
|
+
const KNOWN_CREDIT_REASONS = [
|
|
39
58
|
"out_of_credits",
|
|
40
59
|
"org_level_disabled",
|
|
41
60
|
"credits_exhausted",
|
|
42
61
|
"extra_usage_disabled",
|
|
43
|
-
]
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the active fatal-reason set. Empty by default (subscription-only);
|
|
66
|
+
* if `SWITCHROOM_CREDITS_WATCH_FATAL_REASONS` is set, parse it as a
|
|
67
|
+
* comma-separated list (tokens kept verbatim — an unknown token is harmless,
|
|
68
|
+
* it simply never matches a real reason value). `*` opts in all known reasons.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveCreditWatchFatalReasons(env: NodeJS.ProcessEnv): Set<string> {
|
|
71
|
+
const raw = env.SWITCHROOM_CREDITS_WATCH_FATAL_REASONS;
|
|
72
|
+
if (!raw || raw.trim().length === 0) return new Set(DEFAULT_CREDIT_FATAL_REASONS);
|
|
73
|
+
if (raw.trim() === "*") return new Set(KNOWN_CREDIT_REASONS);
|
|
74
|
+
const wanted = new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
|
|
75
|
+
return wanted;
|
|
76
|
+
}
|
|
44
77
|
|
|
45
78
|
export interface CreditState {
|
|
46
79
|
/** Last reason we notified the user about. null when healthy / never notified. */
|
|
@@ -103,15 +136,22 @@ export function evaluateCreditState(args: {
|
|
|
103
136
|
currentReason: string | null;
|
|
104
137
|
prev: CreditState;
|
|
105
138
|
now: number;
|
|
139
|
+
/**
|
|
140
|
+
* Which reasons are treated as fatal (worth alarming). Defaults to
|
|
141
|
+
* `DEFAULT_CREDIT_FATAL_REASONS` (empty — subscription-only: extra-usage-off
|
|
142
|
+
* is never a real failure). The gateway passes the env-resolved set.
|
|
143
|
+
*/
|
|
144
|
+
fatalReasons?: Set<string>;
|
|
106
145
|
}): CreditDecision {
|
|
107
146
|
const { agentName, currentReason, prev, now } = args;
|
|
147
|
+
const fatalReasons = args.fatalReasons ?? DEFAULT_CREDIT_FATAL_REASONS;
|
|
108
148
|
|
|
109
149
|
// Non-fatal current state (null, or some unknown reason) — no
|
|
110
150
|
// notification regardless of prev (we already notified on entry to
|
|
111
151
|
// fatal; recovery from a known-fatal state below is the only path
|
|
112
152
|
// that fires when current is null).
|
|
113
|
-
const currentIsFatal = currentReason != null &&
|
|
114
|
-
const prevIsFatal = prev.lastNotifiedReason != null &&
|
|
153
|
+
const currentIsFatal = currentReason != null && fatalReasons.has(currentReason);
|
|
154
|
+
const prevIsFatal = prev.lastNotifiedReason != null && fatalReasons.has(prev.lastNotifiedReason);
|
|
115
155
|
|
|
116
156
|
// Recovery path: last-notified was fatal, current is null/non-fatal.
|
|
117
157
|
if (!currentIsFatal && prevIsFatal) {
|
|
@@ -46487,6 +46487,13 @@ function validateClientMessage(msg) {
|
|
|
46487
46487
|
const inb = m.inbound;
|
|
46488
46488
|
return inb.type === "inbound" && typeof inb.chatId === "string" && inb.chatId.length > 0 && typeof inb.text === "string" && typeof inb.messageId === "number" && typeof inb.user === "string" && typeof inb.userId === "number" && typeof inb.ts === "number" && typeof inb.meta === "object" && inb.meta !== null;
|
|
46489
46489
|
}
|
|
46490
|
+
case "quota_wall_detected": {
|
|
46491
|
+
if (typeof m.agentName !== "string" || !AGENT_NAME_RE3.test(m.agentName))
|
|
46492
|
+
return false;
|
|
46493
|
+
if (m.resetAt !== undefined && (typeof m.resetAt !== "number" || !Number.isFinite(m.resetAt)))
|
|
46494
|
+
return false;
|
|
46495
|
+
return true;
|
|
46496
|
+
}
|
|
46490
46497
|
case "request_config_approval": {
|
|
46491
46498
|
if (typeof m.requestId !== "string" || m.requestId.length === 0 || m.requestId.length > 64)
|
|
46492
46499
|
return false;
|
|
@@ -46548,6 +46555,7 @@ function createIpcServer(options) {
|
|
|
46548
46555
|
onOperatorEvent,
|
|
46549
46556
|
onPtyPartial,
|
|
46550
46557
|
onInjectInbound,
|
|
46558
|
+
onQuotaWallDetected,
|
|
46551
46559
|
onRequestDriveApproval,
|
|
46552
46560
|
onRequestMs365Approval,
|
|
46553
46561
|
onRequestConfigApproval,
|
|
@@ -46632,6 +46640,10 @@ function createIpcServer(options) {
|
|
|
46632
46640
|
if (onInjectInbound)
|
|
46633
46641
|
onInjectInbound(client3, msg);
|
|
46634
46642
|
break;
|
|
46643
|
+
case "quota_wall_detected":
|
|
46644
|
+
if (onQuotaWallDetected)
|
|
46645
|
+
onQuotaWallDetected(client3, msg);
|
|
46646
|
+
break;
|
|
46635
46647
|
case "request_drive_approval":
|
|
46636
46648
|
if (onRequestDriveApproval) {
|
|
46637
46649
|
onRequestDriveApproval(client3, msg).catch((err) => {
|
|
@@ -52495,12 +52507,22 @@ function extractFlowItems(line) {
|
|
|
52495
52507
|
import { readFileSync as readFileSync32, writeFileSync as writeFileSync21, existsSync as existsSync33, mkdirSync as mkdirSync21 } from "fs";
|
|
52496
52508
|
import { join as join30 } from "path";
|
|
52497
52509
|
var STATE_FILE = "credits-watch.json";
|
|
52498
|
-
var
|
|
52510
|
+
var DEFAULT_CREDIT_FATAL_REASONS = new Set;
|
|
52511
|
+
var KNOWN_CREDIT_REASONS = [
|
|
52499
52512
|
"out_of_credits",
|
|
52500
52513
|
"org_level_disabled",
|
|
52501
52514
|
"credits_exhausted",
|
|
52502
52515
|
"extra_usage_disabled"
|
|
52503
|
-
]
|
|
52516
|
+
];
|
|
52517
|
+
function resolveCreditWatchFatalReasons(env) {
|
|
52518
|
+
const raw = env.SWITCHROOM_CREDITS_WATCH_FATAL_REASONS;
|
|
52519
|
+
if (!raw || raw.trim().length === 0)
|
|
52520
|
+
return new Set(DEFAULT_CREDIT_FATAL_REASONS);
|
|
52521
|
+
if (raw.trim() === "*")
|
|
52522
|
+
return new Set(KNOWN_CREDIT_REASONS);
|
|
52523
|
+
const wanted = new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
|
|
52524
|
+
return wanted;
|
|
52525
|
+
}
|
|
52504
52526
|
function emptyCreditState() {
|
|
52505
52527
|
return { lastNotifiedReason: null, lastNotifiedAt: 0 };
|
|
52506
52528
|
}
|
|
@@ -52529,8 +52551,9 @@ function readClaudeJsonOverage(claudeConfigDir) {
|
|
|
52529
52551
|
}
|
|
52530
52552
|
function evaluateCreditState(args) {
|
|
52531
52553
|
const { agentName: agentName3, currentReason, prev, now } = args;
|
|
52532
|
-
const
|
|
52533
|
-
const
|
|
52554
|
+
const fatalReasons = args.fatalReasons ?? DEFAULT_CREDIT_FATAL_REASONS;
|
|
52555
|
+
const currentIsFatal = currentReason != null && fatalReasons.has(currentReason);
|
|
52556
|
+
const prevIsFatal = prev.lastNotifiedReason != null && fatalReasons.has(prev.lastNotifiedReason);
|
|
52534
52557
|
if (!currentIsFatal && prevIsFatal) {
|
|
52535
52558
|
return {
|
|
52536
52559
|
kind: "notify",
|
|
@@ -52857,11 +52880,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52857
52880
|
}
|
|
52858
52881
|
|
|
52859
52882
|
// ../src/build-info.ts
|
|
52860
|
-
var VERSION = "0.14.
|
|
52861
|
-
var COMMIT_SHA = "
|
|
52862
|
-
var COMMIT_DATE = "2026-06-
|
|
52883
|
+
var VERSION = "0.14.85";
|
|
52884
|
+
var COMMIT_SHA = "292c683f";
|
|
52885
|
+
var COMMIT_DATE = "2026-06-07T14:50:17+10:00";
|
|
52863
52886
|
var LATEST_PR = null;
|
|
52864
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
52887
|
+
var COMMITS_AHEAD_OF_TAG = 2;
|
|
52865
52888
|
|
|
52866
52889
|
// gateway/boot-version.ts
|
|
52867
52890
|
function formatRelativeAgo(iso) {
|
|
@@ -56158,6 +56181,13 @@ var ipcServer = createIpcServer({
|
|
|
56158
56181
|
pendingInboundBuffer.push(msg.agentName, msg.inbound);
|
|
56159
56182
|
}
|
|
56160
56183
|
},
|
|
56184
|
+
onQuotaWallDetected(_client, msg) {
|
|
56185
|
+
const WEEKLY_MS = 604800000;
|
|
56186
|
+
const untilMs = typeof msg.resetAt === "number" && Number.isFinite(msg.resetAt) && msg.resetAt > Date.now() ? msg.resetAt : Date.now() + WEEKLY_MS;
|
|
56187
|
+
process.stderr.write(`telegram gateway: quota_wall_detected agent=${msg.agentName} until=${new Date(untilMs).toISOString()}` + (msg.resetAt == null ? " (reset unparsed \u2192 +7d default)" : "") + ` \u2014 triggering fleet auto-fallback
|
|
56188
|
+
`);
|
|
56189
|
+
fireFleetAutoFallback(msg.agentName, untilMs);
|
|
56190
|
+
},
|
|
56161
56191
|
log: (msg) => process.stderr.write(`telegram gateway: ipc \u2014 ${msg}
|
|
56162
56192
|
`)
|
|
56163
56193
|
});
|
|
@@ -60961,13 +60991,13 @@ var fleetFallbackGate = createFleetFallbackGate({
|
|
|
60961
60991
|
function wouldFireFleetAutoFallback() {
|
|
60962
60992
|
return fleetFallbackGate.wouldFire();
|
|
60963
60993
|
}
|
|
60964
|
-
async function fireFleetAutoFallback(triggerAgent) {
|
|
60965
|
-
return fleetFallbackGate.fire(() => doFireFleetAutoFallback(triggerAgent), (err) => {
|
|
60994
|
+
async function fireFleetAutoFallback(triggerAgent, untilMs) {
|
|
60995
|
+
return fleetFallbackGate.fire(() => doFireFleetAutoFallback(triggerAgent, untilMs), (err) => {
|
|
60966
60996
|
process.stderr.write(`telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${err?.message ?? err}
|
|
60967
60997
|
`);
|
|
60968
60998
|
});
|
|
60969
60999
|
}
|
|
60970
|
-
async function doFireFleetAutoFallback(triggerAgent) {
|
|
61000
|
+
async function doFireFleetAutoFallback(triggerAgent, untilMs) {
|
|
60971
61001
|
try {
|
|
60972
61002
|
const client3 = await getAuthBrokerClient(triggerAgent);
|
|
60973
61003
|
if (!client3) {
|
|
@@ -60986,7 +61016,7 @@ async function doFireFleetAutoFallback(triggerAgent) {
|
|
|
60986
61016
|
state: state4,
|
|
60987
61017
|
quotas,
|
|
60988
61018
|
failover: async () => {
|
|
60989
|
-
const r = await client3.markExhausted();
|
|
61019
|
+
const r = await client3.markExhausted(untilMs);
|
|
60990
61020
|
return { rolledTo: r.rolledTo ?? null, rolled: r.rolled };
|
|
60991
61021
|
},
|
|
60992
61022
|
triggerAgent,
|
|
@@ -61021,7 +61051,8 @@ async function runCreditWatch() {
|
|
|
61021
61051
|
agentName: agentName3,
|
|
61022
61052
|
currentReason: reason,
|
|
61023
61053
|
prev,
|
|
61024
|
-
now: Date.now()
|
|
61054
|
+
now: Date.now(),
|
|
61055
|
+
fatalReasons: resolveCreditWatchFatalReasons(process.env)
|
|
61025
61056
|
});
|
|
61026
61057
|
if (decision.kind === "skip") {
|
|
61027
61058
|
return;
|
|
@@ -348,6 +348,7 @@ import type {
|
|
|
348
348
|
PtyPartialForward,
|
|
349
349
|
InboundMessage,
|
|
350
350
|
InjectInboundMessage,
|
|
351
|
+
QuotaWallDetectedMessage,
|
|
351
352
|
PermissionEvent,
|
|
352
353
|
} from './ipc-protocol.js'
|
|
353
354
|
import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
|
|
@@ -407,6 +408,7 @@ import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-di
|
|
|
407
408
|
import {
|
|
408
409
|
readClaudeJsonOverage,
|
|
409
410
|
evaluateCreditState,
|
|
411
|
+
resolveCreditWatchFatalReasons,
|
|
410
412
|
loadCreditState,
|
|
411
413
|
saveCreditState,
|
|
412
414
|
} from '../credits-watch.js'
|
|
@@ -6259,6 +6261,30 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6259
6261
|
}
|
|
6260
6262
|
},
|
|
6261
6263
|
|
|
6264
|
+
// The wedge-watchdog detected claude's /rate-limit-options weekly-quota menu
|
|
6265
|
+
// (a TUI wall that never produced a 429, so the inference-path auto-fallback
|
|
6266
|
+
// never fired). Trigger the SAME fleet auto-fallback the 429 path uses,
|
|
6267
|
+
// threading the parsed weekly reset as markExhausted's `until` — with a
|
|
6268
|
+
// weekly-scale FALLBACK when the sidecar couldn't parse it (resetAt absent):
|
|
6269
|
+
// passing undefined would let markExhausted use its ~5h default, which would
|
|
6270
|
+
// un-exhaust a weekly-walled account and re-wedge it within hours. The
|
|
6271
|
+
// existing chain handles the rest (roll to a fallback subscription account,
|
|
6272
|
+
// or the all-exhausted operator alert when none has quota). Fire-and-forget.
|
|
6273
|
+
onQuotaWallDetected(_client: IpcClient, msg: QuotaWallDetectedMessage) {
|
|
6274
|
+
const WEEKLY_MS = 7 * 24 * 60 * 60 * 1000
|
|
6275
|
+
const untilMs =
|
|
6276
|
+
typeof msg.resetAt === 'number' && Number.isFinite(msg.resetAt) && msg.resetAt > Date.now()
|
|
6277
|
+
? msg.resetAt
|
|
6278
|
+
: Date.now() + WEEKLY_MS
|
|
6279
|
+
process.stderr.write(
|
|
6280
|
+
`telegram gateway: quota_wall_detected agent=${msg.agentName} ` +
|
|
6281
|
+
`until=${new Date(untilMs).toISOString()}` +
|
|
6282
|
+
(msg.resetAt == null ? ' (reset unparsed → +7d default)' : '') +
|
|
6283
|
+
' — triggering fleet auto-fallback\n',
|
|
6284
|
+
)
|
|
6285
|
+
void fireFleetAutoFallback(msg.agentName, untilMs)
|
|
6286
|
+
},
|
|
6287
|
+
|
|
6262
6288
|
log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
|
|
6263
6289
|
})
|
|
6264
6290
|
|
|
@@ -14605,9 +14631,9 @@ function wouldFireFleetAutoFallback(): boolean {
|
|
|
14605
14631
|
* so the user sees the outcome inline with the original "Model
|
|
14606
14632
|
* unavailable" card.
|
|
14607
14633
|
*/
|
|
14608
|
-
async function fireFleetAutoFallback(triggerAgent: string): Promise<void> {
|
|
14634
|
+
async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Promise<void> {
|
|
14609
14635
|
return fleetFallbackGate.fire(
|
|
14610
|
-
() => doFireFleetAutoFallback(triggerAgent),
|
|
14636
|
+
() => doFireFleetAutoFallback(triggerAgent, untilMs),
|
|
14611
14637
|
(err) => {
|
|
14612
14638
|
process.stderr.write(
|
|
14613
14639
|
`telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
|
|
@@ -14620,7 +14646,7 @@ async function fireFleetAutoFallback(triggerAgent: string): Promise<void> {
|
|
|
14620
14646
|
* user-visible announcement was broadcast). False on no-op /
|
|
14621
14647
|
* error / idempotent-skip — caller uses this to decide whether to
|
|
14622
14648
|
* arm the post-fire suppression window. */
|
|
14623
|
-
async function doFireFleetAutoFallback(triggerAgent: string): Promise<boolean> {
|
|
14649
|
+
async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number): Promise<boolean> {
|
|
14624
14650
|
try {
|
|
14625
14651
|
const client = await getAuthBrokerClient(triggerAgent)
|
|
14626
14652
|
if (!client) {
|
|
@@ -14655,7 +14681,11 @@ async function doFireFleetAutoFallback(triggerAgent: string): Promise<boolean> {
|
|
|
14655
14681
|
// operator is explicitly choosing, and is admin); only this automatic
|
|
14656
14682
|
// path moves to the non-admin verb.
|
|
14657
14683
|
failover: async () => {
|
|
14658
|
-
|
|
14684
|
+
// The 429 inference path passes no `until` (broker ~5h default). The
|
|
14685
|
+
// rate-limit-MENU path (quota_wall_detected) passes the parsed WEEKLY
|
|
14686
|
+
// reset, so the walled account isn't re-probed (and re-wedged) within
|
|
14687
|
+
// the 5h default while it's weekly-capped.
|
|
14688
|
+
const r = await client.markExhausted(untilMs)
|
|
14659
14689
|
return { rolledTo: r.rolledTo ?? null, rolled: r.rolled }
|
|
14660
14690
|
},
|
|
14661
14691
|
triggerAgent,
|
|
@@ -14713,6 +14743,9 @@ async function runCreditWatch(): Promise<void> {
|
|
|
14713
14743
|
currentReason: reason,
|
|
14714
14744
|
prev,
|
|
14715
14745
|
now: Date.now(),
|
|
14746
|
+
// Subscription-only: empty by default → extra-usage-off never alarms.
|
|
14747
|
+
// Opt back in via SWITCHROOM_CREDITS_WATCH_FATAL_REASONS. See credits-watch.ts.
|
|
14748
|
+
fatalReasons: resolveCreditWatchFatalReasons(process.env),
|
|
14716
14749
|
})
|
|
14717
14750
|
if (decision.kind === 'skip') {
|
|
14718
14751
|
return
|
|
@@ -393,6 +393,26 @@ export interface RequestConfigFinalizeMessage {
|
|
|
393
393
|
detail?: string;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
/**
|
|
397
|
+
* The autoaccept-poll wedge-watchdog detected claude's `/rate-limit-options`
|
|
398
|
+
* weekly-quota menu (a TUI wall that never produced a 429 the gateway could
|
|
399
|
+
* see). Asks the gateway to trigger the EXISTING account-failover chain
|
|
400
|
+
* (markExhausted → roll to a fallback subscription account, or the
|
|
401
|
+
* all-exhausted operator alert). Fire-and-forget; no reply.
|
|
402
|
+
*
|
|
403
|
+
* Trust model (same as inject_inbound): the socket is per-agent inside the
|
|
404
|
+
* container, but `agentName` is still validated server-side and never trusted
|
|
405
|
+
* to authorize anything beyond triggering the agent's own failover.
|
|
406
|
+
*/
|
|
407
|
+
export interface QuotaWallDetectedMessage {
|
|
408
|
+
type: "quota_wall_detected";
|
|
409
|
+
agentName: string;
|
|
410
|
+
/** Parsed weekly-reset epoch-ms. Omitted when the sidecar couldn't parse it;
|
|
411
|
+
* the gateway then uses a weekly-scale default for markExhausted's `until`
|
|
412
|
+
* (NOT the ~5h default, which would un-exhaust a weekly wall and re-wedge). */
|
|
413
|
+
resetAt?: number;
|
|
414
|
+
}
|
|
415
|
+
|
|
396
416
|
export type ClientToGateway =
|
|
397
417
|
| RegisterMessage
|
|
398
418
|
| ToolCallMessage
|
|
@@ -407,4 +427,5 @@ export type ClientToGateway =
|
|
|
407
427
|
| RequestDriveApprovalMessage
|
|
408
428
|
| RequestMs365ApprovalMessage
|
|
409
429
|
| RequestConfigApprovalMessage
|
|
410
|
-
| RequestConfigFinalizeMessage
|
|
430
|
+
| RequestConfigFinalizeMessage
|
|
431
|
+
| QuotaWallDetectedMessage;
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
GatewayToClient,
|
|
5
5
|
HeartbeatMessage,
|
|
6
6
|
InjectInboundMessage,
|
|
7
|
+
QuotaWallDetectedMessage,
|
|
7
8
|
OperatorEventForward,
|
|
8
9
|
PermissionRequestForward,
|
|
9
10
|
PtyPartialForward,
|
|
@@ -44,6 +45,14 @@ export interface IpcServerOptions {
|
|
|
44
45
|
* inline scheduler simply ignore inject_inbound messages.
|
|
45
46
|
*/
|
|
46
47
|
onInjectInbound?: (client: IpcClient, msg: InjectInboundMessage) => void;
|
|
48
|
+
/**
|
|
49
|
+
* The autoaccept-poll wedge-watchdog detected claude's `/rate-limit-options`
|
|
50
|
+
* weekly-quota menu (no 429 ever reached the gateway). Handler is expected to
|
|
51
|
+
* trigger the existing fleet auto-fallback for `msg.agentName`, threading
|
|
52
|
+
* `msg.resetAt` as the markExhausted `until`. Fire-and-forget; gateways that
|
|
53
|
+
* don't run failover simply ignore it.
|
|
54
|
+
*/
|
|
55
|
+
onQuotaWallDetected?: (client: IpcClient, msg: QuotaWallDetectedMessage) => void;
|
|
47
56
|
/**
|
|
48
57
|
* RFC E §4.2 Cut 2 — Drive-write PreToolUse hook asks the gateway
|
|
49
58
|
* to register a kernel approval request + post a diff-preview
|
|
@@ -237,6 +246,15 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
237
246
|
&& typeof inb.meta === "object"
|
|
238
247
|
&& inb.meta !== null;
|
|
239
248
|
}
|
|
249
|
+
case "quota_wall_detected": {
|
|
250
|
+
// wedge-watchdog detected the /rate-limit-options weekly-quota menu.
|
|
251
|
+
if (typeof m.agentName !== "string"
|
|
252
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
253
|
+
// resetAt optional; when present it must be a finite epoch-ms.
|
|
254
|
+
if (m.resetAt !== undefined
|
|
255
|
+
&& (typeof m.resetAt !== "number" || !Number.isFinite(m.resetAt as number))) return false;
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
240
258
|
case "request_config_approval": {
|
|
241
259
|
// #1623 — hostd-initiated config-edit approval card. Wire shape
|
|
242
260
|
// only; the handler module validates the diff content.
|
|
@@ -317,6 +335,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
317
335
|
onOperatorEvent,
|
|
318
336
|
onPtyPartial,
|
|
319
337
|
onInjectInbound,
|
|
338
|
+
onQuotaWallDetected,
|
|
320
339
|
onRequestDriveApproval,
|
|
321
340
|
onRequestMs365Approval,
|
|
322
341
|
onRequestConfigApproval,
|
|
@@ -425,6 +444,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
425
444
|
case "inject_inbound":
|
|
426
445
|
if (onInjectInbound) onInjectInbound(client, msg as InjectInboundMessage);
|
|
427
446
|
break;
|
|
447
|
+
case "quota_wall_detected":
|
|
448
|
+
if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
|
|
449
|
+
break;
|
|
428
450
|
case "request_drive_approval":
|
|
429
451
|
if (onRequestDriveApproval) {
|
|
430
452
|
// Handler is async — fire-and-forget here; the handler
|
|
@@ -13,6 +13,7 @@ import { join } from "path";
|
|
|
13
13
|
import {
|
|
14
14
|
readClaudeJsonOverage,
|
|
15
15
|
evaluateCreditState,
|
|
16
|
+
resolveCreditWatchFatalReasons,
|
|
16
17
|
loadCreditState,
|
|
17
18
|
saveCreditState,
|
|
18
19
|
emptyCreditState,
|
|
@@ -81,10 +82,14 @@ describe("readClaudeJsonOverage", () => {
|
|
|
81
82
|
});
|
|
82
83
|
});
|
|
83
84
|
|
|
84
|
-
describe("evaluateCreditState — transition decisions", () => {
|
|
85
|
+
describe("evaluateCreditState — transition decisions (machinery, explicit fatal set)", () => {
|
|
85
86
|
const NOW = 1_780_000_000_000;
|
|
86
87
|
const HEALTHY = emptyCreditState();
|
|
87
88
|
const FATAL_OUT = { lastNotifiedReason: "out_of_credits", lastNotifiedAt: NOW - 1000 };
|
|
89
|
+
// The transition machinery is policy-agnostic — pass an explicit fatal set so
|
|
90
|
+
// these tests pin entered/changed/exited behaviour independent of the (now
|
|
91
|
+
// empty) subscription-only default.
|
|
92
|
+
const FATAL = new Set(["out_of_credits", "org_level_disabled", "credits_exhausted", "extra_usage_disabled"]);
|
|
88
93
|
|
|
89
94
|
it("entry: healthy → fatal triggers a notify", () => {
|
|
90
95
|
const d = evaluateCreditState({
|
|
@@ -92,6 +97,7 @@ describe("evaluateCreditState — transition decisions", () => {
|
|
|
92
97
|
currentReason: "out_of_credits",
|
|
93
98
|
prev: HEALTHY,
|
|
94
99
|
now: NOW,
|
|
100
|
+
fatalReasons: FATAL,
|
|
95
101
|
});
|
|
96
102
|
expect(d.kind).toBe("notify");
|
|
97
103
|
if (d.kind !== "notify") return;
|
|
@@ -108,6 +114,7 @@ describe("evaluateCreditState — transition decisions", () => {
|
|
|
108
114
|
currentReason: "out_of_credits",
|
|
109
115
|
prev: FATAL_OUT,
|
|
110
116
|
now: NOW,
|
|
117
|
+
fatalReasons: FATAL,
|
|
111
118
|
});
|
|
112
119
|
expect(d.kind).toBe("skip");
|
|
113
120
|
if (d.kind !== "skip") return;
|
|
@@ -120,6 +127,7 @@ describe("evaluateCreditState — transition decisions", () => {
|
|
|
120
127
|
currentReason: "org_level_disabled",
|
|
121
128
|
prev: FATAL_OUT,
|
|
122
129
|
now: NOW,
|
|
130
|
+
fatalReasons: FATAL,
|
|
123
131
|
});
|
|
124
132
|
expect(d.kind).toBe("notify");
|
|
125
133
|
if (d.kind !== "notify") return;
|
|
@@ -134,6 +142,7 @@ describe("evaluateCreditState — transition decisions", () => {
|
|
|
134
142
|
currentReason: null,
|
|
135
143
|
prev: FATAL_OUT,
|
|
136
144
|
now: NOW,
|
|
145
|
+
fatalReasons: FATAL,
|
|
137
146
|
});
|
|
138
147
|
expect(d.kind).toBe("notify");
|
|
139
148
|
if (d.kind !== "notify") return;
|
|
@@ -148,6 +157,7 @@ describe("evaluateCreditState — transition decisions", () => {
|
|
|
148
157
|
currentReason: "some_unknown_transient_reason",
|
|
149
158
|
prev: HEALTHY,
|
|
150
159
|
now: NOW,
|
|
160
|
+
fatalReasons: FATAL,
|
|
151
161
|
});
|
|
152
162
|
expect(d.kind).toBe("skip");
|
|
153
163
|
if (d.kind !== "skip") return;
|
|
@@ -160,6 +170,7 @@ describe("evaluateCreditState — transition decisions", () => {
|
|
|
160
170
|
currentReason: null,
|
|
161
171
|
prev: HEALTHY,
|
|
162
172
|
now: NOW,
|
|
173
|
+
fatalReasons: FATAL,
|
|
163
174
|
});
|
|
164
175
|
expect(d.kind).toBe("skip");
|
|
165
176
|
});
|
|
@@ -170,6 +181,7 @@ describe("evaluateCreditState — transition decisions", () => {
|
|
|
170
181
|
currentReason: "out_of_credits",
|
|
171
182
|
prev: HEALTHY,
|
|
172
183
|
now: NOW,
|
|
184
|
+
fatalReasons: FATAL,
|
|
173
185
|
});
|
|
174
186
|
expect(d.kind).toBe("notify");
|
|
175
187
|
if (d.kind !== "notify") return;
|
|
@@ -178,6 +190,58 @@ describe("evaluateCreditState — transition decisions", () => {
|
|
|
178
190
|
});
|
|
179
191
|
});
|
|
180
192
|
|
|
193
|
+
describe("evaluateCreditState — subscription-only default (the fix)", () => {
|
|
194
|
+
const NOW = 1_780_000_000_000;
|
|
195
|
+
const HEALTHY = emptyCreditState();
|
|
196
|
+
|
|
197
|
+
// With the default (empty) fatal set, NONE of the extra-usage reasons alarm —
|
|
198
|
+
// because for subscription-only switchroom, extra-usage-off is the expected
|
|
199
|
+
// state and real exhaustion is handled by failover. This is the bug fix.
|
|
200
|
+
for (const reason of ["out_of_credits", "extra_usage_disabled", "credits_exhausted", "org_level_disabled"]) {
|
|
201
|
+
it(`default: '${reason}' does NOT alarm (no false 'out of credits' card)`, () => {
|
|
202
|
+
const d = evaluateCreditState({
|
|
203
|
+
agentName: "clerk",
|
|
204
|
+
currentReason: reason,
|
|
205
|
+
prev: HEALTHY,
|
|
206
|
+
now: NOW,
|
|
207
|
+
// fatalReasons omitted → DEFAULT_CREDIT_FATAL_REASONS (empty)
|
|
208
|
+
});
|
|
209
|
+
expect(d.kind).toBe("skip");
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
it("opt-in via explicit set restores the alarm (operator on pay-as-you-go)", () => {
|
|
214
|
+
const d = evaluateCreditState({
|
|
215
|
+
agentName: "clerk",
|
|
216
|
+
currentReason: "out_of_credits",
|
|
217
|
+
prev: HEALTHY,
|
|
218
|
+
now: NOW,
|
|
219
|
+
fatalReasons: new Set(["out_of_credits"]),
|
|
220
|
+
});
|
|
221
|
+
expect(d.kind).toBe("notify");
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("resolveCreditWatchFatalReasons", () => {
|
|
226
|
+
it("defaults to EMPTY (subscription-only)", () => {
|
|
227
|
+
expect(resolveCreditWatchFatalReasons({}).size).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
it("parses a comma-separated opt-in list", () => {
|
|
230
|
+
const s = resolveCreditWatchFatalReasons({ SWITCHROOM_CREDITS_WATCH_FATAL_REASONS: "out_of_credits, org_level_disabled" });
|
|
231
|
+
expect(s.has("out_of_credits")).toBe(true);
|
|
232
|
+
expect(s.has("org_level_disabled")).toBe(true);
|
|
233
|
+
expect(s.size).toBe(2);
|
|
234
|
+
});
|
|
235
|
+
it("'*' opts in all known reasons", () => {
|
|
236
|
+
const s = resolveCreditWatchFatalReasons({ SWITCHROOM_CREDITS_WATCH_FATAL_REASONS: "*" });
|
|
237
|
+
expect(s.has("out_of_credits")).toBe(true);
|
|
238
|
+
expect(s.size).toBeGreaterThanOrEqual(4);
|
|
239
|
+
});
|
|
240
|
+
it("blank/whitespace → empty", () => {
|
|
241
|
+
expect(resolveCreditWatchFatalReasons({ SWITCHROOM_CREDITS_WATCH_FATAL_REASONS: " " }).size).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
181
245
|
describe("loadCreditState / saveCreditState — round-trip", () => {
|
|
182
246
|
let tmp: string;
|
|
183
247
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation contract for the `quota_wall_detected` IPC verb — the signal the
|
|
3
|
+
* autoaccept-poll wedge-watchdog sends when it sees claude's /rate-limit-options
|
|
4
|
+
* weekly-quota menu, asking the gateway to trigger account failover.
|
|
5
|
+
*
|
|
6
|
+
* A rogue process on the same UDS must not be able to inject a malformed
|
|
7
|
+
* payload: agentName is required + name-shaped, resetAt (optional) must be a
|
|
8
|
+
* finite number.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import { validateClientMessage } from "../gateway/ipc-server.js";
|
|
12
|
+
|
|
13
|
+
describe("validateClientMessage — quota_wall_detected", () => {
|
|
14
|
+
it("accepts a well-formed signal (with resetAt)", () => {
|
|
15
|
+
expect(
|
|
16
|
+
validateClientMessage({ type: "quota_wall_detected", agentName: "finn", resetAt: 1_780_000_000_000 }),
|
|
17
|
+
).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("accepts a well-formed signal WITHOUT resetAt (optional)", () => {
|
|
21
|
+
expect(validateClientMessage({ type: "quota_wall_detected", agentName: "finn" })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("rejects a missing / non-string / malformed agentName", () => {
|
|
25
|
+
expect(validateClientMessage({ type: "quota_wall_detected" })).toBe(false);
|
|
26
|
+
expect(validateClientMessage({ type: "quota_wall_detected", agentName: 123 })).toBe(false);
|
|
27
|
+
expect(validateClientMessage({ type: "quota_wall_detected", agentName: "" })).toBe(false);
|
|
28
|
+
expect(validateClientMessage({ type: "quota_wall_detected", agentName: "../etc" })).toBe(false);
|
|
29
|
+
expect(validateClientMessage({ type: "quota_wall_detected", agentName: "Finn UPPER" })).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("rejects a non-finite / non-number resetAt", () => {
|
|
33
|
+
expect(validateClientMessage({ type: "quota_wall_detected", agentName: "finn", resetAt: "soon" })).toBe(false);
|
|
34
|
+
expect(validateClientMessage({ type: "quota_wall_detected", agentName: "finn", resetAt: NaN })).toBe(false);
|
|
35
|
+
expect(validateClientMessage({ type: "quota_wall_detected", agentName: "finn", resetAt: Infinity })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|