switchroom 0.14.85 → 0.14.86
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/auth-broker/index.js +9 -9
- package/dist/cli/autoaccept-poll.js +1 -1
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +17 -8
- package/telegram-plugin/gateway/exhaust-until.ts +45 -0
- package/telegram-plugin/gateway/gateway.ts +18 -10
- package/telegram-plugin/tests/exhaust-until.test.ts +86 -0
|
@@ -13941,24 +13941,24 @@ class AuthBroker {
|
|
|
13941
13941
|
}
|
|
13942
13942
|
servingAccount(identity2) {
|
|
13943
13943
|
const account = this.callerAccount(identity2);
|
|
13944
|
-
if (identity2.kind
|
|
13944
|
+
if (identity2.kind === "operator")
|
|
13945
13945
|
return account;
|
|
13946
|
-
return this.
|
|
13946
|
+
return this.accountWithFailover(account);
|
|
13947
13947
|
}
|
|
13948
13948
|
isAccountExhausted(account) {
|
|
13949
13949
|
const q = this.quota[account];
|
|
13950
13950
|
return q !== undefined && q.exhausted_until > this.now();
|
|
13951
13951
|
}
|
|
13952
|
-
|
|
13953
|
-
if (!
|
|
13954
|
-
return
|
|
13952
|
+
accountWithFailover(account) {
|
|
13953
|
+
if (!account || !this.isAccountExhausted(account))
|
|
13954
|
+
return account ?? null;
|
|
13955
13955
|
for (const cand of this.config.auth?.fallback_order ?? []) {
|
|
13956
|
-
if (cand ===
|
|
13956
|
+
if (cand === account || this.isAccountExhausted(cand))
|
|
13957
13957
|
continue;
|
|
13958
13958
|
if (readAccountCredentials(cand, this.home))
|
|
13959
13959
|
return cand;
|
|
13960
13960
|
}
|
|
13961
|
-
return
|
|
13961
|
+
return account;
|
|
13962
13962
|
}
|
|
13963
13963
|
async opGetCredentials(socket, id, identity2) {
|
|
13964
13964
|
const account = this.servingAccount(identity2);
|
|
@@ -14600,7 +14600,7 @@ class AuthBroker {
|
|
|
14600
14600
|
const auth = this.config.auth ?? {};
|
|
14601
14601
|
const fanned = [];
|
|
14602
14602
|
for (const [name, agent] of Object.entries(this.config.agents ?? {})) {
|
|
14603
|
-
const effective = agent.auth?.override ?? auth.active;
|
|
14603
|
+
const effective = this.accountWithFailover(agent.auth?.override ?? auth.active);
|
|
14604
14604
|
if (effective === label) {
|
|
14605
14605
|
if (this.fanoutForAgent(name))
|
|
14606
14606
|
fanned.push(name);
|
|
@@ -14645,7 +14645,7 @@ class AuthBroker {
|
|
|
14645
14645
|
const agent = (this.config.agents ?? {})[name];
|
|
14646
14646
|
if (!agent)
|
|
14647
14647
|
return false;
|
|
14648
|
-
const effective = agent.auth?.override ?? auth.active;
|
|
14648
|
+
const effective = this.accountWithFailover(agent.auth?.override ?? auth.active);
|
|
14649
14649
|
if (!effective)
|
|
14650
14650
|
return false;
|
|
14651
14651
|
return this.mirrorAccountToAgent(effective, name);
|
|
@@ -176,7 +176,7 @@ 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]
|
|
179
|
+
var RATE_LIMIT_MENU_SIGNATURE = /(?=[\s\S]*Stop and wait for)(?=[\s\S]*(?:usage credits|Upgrade your plan|\/rate-limit-options))/;
|
|
180
180
|
var MONTHS = {
|
|
181
181
|
jan: 0,
|
|
182
182
|
feb: 1,
|
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.86";
|
|
49819
|
+
var COMMIT_SHA = "8c518120";
|
|
49820
49820
|
|
|
49821
49821
|
// src/cli/agent.ts
|
|
49822
49822
|
init_source();
|
package/package.json
CHANGED
|
@@ -41381,6 +41381,15 @@ function createFleetFallbackGate(opts) {
|
|
|
41381
41381
|
};
|
|
41382
41382
|
}
|
|
41383
41383
|
|
|
41384
|
+
// gateway/exhaust-until.ts
|
|
41385
|
+
var EXHAUST_WEEKLY_FLOOR_MS = 7 * 24 * 60 * 60 * 1000;
|
|
41386
|
+
function resolveExhaustUntil(resetAtMs, now = Date.now()) {
|
|
41387
|
+
if (typeof resetAtMs === "number" && Number.isFinite(resetAtMs) && resetAtMs > now) {
|
|
41388
|
+
return resetAtMs;
|
|
41389
|
+
}
|
|
41390
|
+
return now + EXHAUST_WEEKLY_FLOOR_MS;
|
|
41391
|
+
}
|
|
41392
|
+
|
|
41384
41393
|
// gateway/auth-add-flow.ts
|
|
41385
41394
|
import { spawn } from "node:child_process";
|
|
41386
41395
|
import { existsSync as existsSync13, mkdirSync as mkdirSync10, readFileSync as readFileSync9, rmSync as rmSync3 } from "node:fs";
|
|
@@ -52880,11 +52889,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52880
52889
|
}
|
|
52881
52890
|
|
|
52882
52891
|
// ../src/build-info.ts
|
|
52883
|
-
var VERSION = "0.14.
|
|
52884
|
-
var COMMIT_SHA = "
|
|
52885
|
-
var COMMIT_DATE = "2026-06-
|
|
52886
|
-
var LATEST_PR =
|
|
52887
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
52892
|
+
var VERSION = "0.14.86";
|
|
52893
|
+
var COMMIT_SHA = "8c518120";
|
|
52894
|
+
var COMMIT_DATE = "2026-06-07T05:43:21Z";
|
|
52895
|
+
var LATEST_PR = 2223;
|
|
52896
|
+
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52888
52897
|
|
|
52889
52898
|
// gateway/boot-version.ts
|
|
52890
52899
|
function formatRelativeAgo(iso) {
|
|
@@ -55245,7 +55254,8 @@ function emitGatewayOperatorEvent(event) {
|
|
|
55245
55254
|
});
|
|
55246
55255
|
renderedKeyboard = undefined;
|
|
55247
55256
|
if (willActuallyFire) {
|
|
55248
|
-
|
|
55257
|
+
const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime());
|
|
55258
|
+
fireFleetAutoFallback(agent, untilMs);
|
|
55249
55259
|
}
|
|
55250
55260
|
} else {
|
|
55251
55261
|
try {
|
|
@@ -56182,8 +56192,7 @@ var ipcServer = createIpcServer({
|
|
|
56182
56192
|
}
|
|
56183
56193
|
},
|
|
56184
56194
|
onQuotaWallDetected(_client, msg) {
|
|
56185
|
-
const
|
|
56186
|
-
const untilMs = typeof msg.resetAt === "number" && Number.isFinite(msg.resetAt) && msg.resetAt > Date.now() ? msg.resetAt : Date.now() + WEEKLY_MS;
|
|
56195
|
+
const untilMs = resolveExhaustUntil(msg.resetAt);
|
|
56187
56196
|
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
56197
|
`);
|
|
56189
56198
|
fireFleetAutoFallback(msg.agentName, untilMs);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exhaust-until.ts — the single decision for the `until` (epoch-ms) handed to
|
|
3
|
+
* the broker's mark-exhausted, shared by BOTH quota-wall paths in the gateway:
|
|
4
|
+
* - the /rate-limit-options menu signal (onQuotaWallDetected), and
|
|
5
|
+
* - the inference-path 429 (the model-unavailable operator-event path).
|
|
6
|
+
*
|
|
7
|
+
* Factored out of gateway.ts so the decision is unit-testable without importing
|
|
8
|
+
* the gateway module (heavy import-time side effects). Pure: no IPC, no clock
|
|
9
|
+
* except the injectable `now`.
|
|
10
|
+
*
|
|
11
|
+
* The ONE invariant both paths depend on: NEVER return undefined. A weekly wall
|
|
12
|
+
* reaches the gateway as a reset hint the parser can't always read:
|
|
13
|
+
* - Menu path: the sidecar's parseWeeklyReset handles "resets Jun 9, 5am (tz)"
|
|
14
|
+
* → a finite future epoch flows straight through.
|
|
15
|
+
* - 429 path: model-unavailable.ts's parseResetTime handles the ROLLING
|
|
16
|
+
* wordings ("resets in 2h", "retry after 60s") → a finite ~hours epoch. But
|
|
17
|
+
* the WEEKLY wording reaches it as "resets <Mon> <D>, <H>am", which
|
|
18
|
+
* parseResetTime CANNOT parse (`new Date('Jun 9, 5am 2026')` → Invalid Date)
|
|
19
|
+
* → resetAtMs is undefined here EXACTLY when the wall is weekly.
|
|
20
|
+
*
|
|
21
|
+
* undefined (or a past / NaN reset) is the dangerous case: markExhausted's ~5h
|
|
22
|
+
* default would un-exhaust a weekly-walled account after 5h and let the broker
|
|
23
|
+
* re-mirror it onto the fleet (the #2218 rollback). So undefined → the +7d
|
|
24
|
+
* floor. A too-long `until` only delays the broker's re-probe of the account;
|
|
25
|
+
* it never serves an exhausted account, so erring long is the compliance-safe
|
|
26
|
+
* direction (vision.md pillar 3).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Weekly-quota window — the safety floor for an exhaustion `until`. */
|
|
30
|
+
export const EXHAUST_WEEKLY_FLOOR_MS = 7 * 24 * 60 * 60 * 1000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the exhaustion `until` (epoch-ms) from a best-effort reset timestamp.
|
|
34
|
+
* Returns `resetAtMs` when it is a finite epoch in the future; otherwise the
|
|
35
|
+
* +7d floor anchored at `now`. NEVER returns undefined.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveExhaustUntil(
|
|
38
|
+
resetAtMs: number | undefined,
|
|
39
|
+
now: number = Date.now(),
|
|
40
|
+
): number {
|
|
41
|
+
if (typeof resetAtMs === 'number' && Number.isFinite(resetAtMs) && resetAtMs > now) {
|
|
42
|
+
return resetAtMs
|
|
43
|
+
}
|
|
44
|
+
return now + EXHAUST_WEEKLY_FLOOR_MS
|
|
45
|
+
}
|
|
@@ -125,6 +125,7 @@ import {
|
|
|
125
125
|
} from './microsoft-connect-flow.js'
|
|
126
126
|
import { resolveAuthBrokerSocketPath } from '../../src/auth/broker/client.js'
|
|
127
127
|
import { createFleetFallbackGate } from '../fleet-fallback-gate.js'
|
|
128
|
+
import { resolveExhaustUntil } from './exhaust-until.js'
|
|
128
129
|
import {
|
|
129
130
|
pendingAuthAddFlows,
|
|
130
131
|
startAccountAuthSession,
|
|
@@ -4400,8 +4401,17 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
4400
4401
|
// separately with the causal-shape headline ("5-hour limit on
|
|
4401
4402
|
// ken" instead of generic "quota exhausted") — see
|
|
4402
4403
|
// auth-snapshot-format.ts → renderFallbackAnnouncement.
|
|
4404
|
+
//
|
|
4405
|
+
// Thread the parsed reset as markExhausted's `until` via the shared
|
|
4406
|
+
// resolveExhaustUntil floor. parseResetTime gives a finite epoch for
|
|
4407
|
+
// ROLLING wordings ("resets in 2h", "retry after 60s"); a WEEKLY wall
|
|
4408
|
+
// ("resets Jun 9, 5am") is UNPARSEABLE here → undefined → +7d floor.
|
|
4409
|
+
// Pre-fix this called fireFleetAutoFallback(agent) with no until, so a
|
|
4410
|
+
// weekly wall that surfaced as a 429 got markExhausted's ~5h default and
|
|
4411
|
+
// the broker re-mirrored the still-walled account onto the fleet after 5h.
|
|
4403
4412
|
if (willActuallyFire) {
|
|
4404
|
-
|
|
4413
|
+
const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
|
|
4414
|
+
void fireFleetAutoFallback(agent, untilMs)
|
|
4405
4415
|
}
|
|
4406
4416
|
} else {
|
|
4407
4417
|
try {
|
|
@@ -6271,11 +6281,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
6271
6281
|
// existing chain handles the rest (roll to a fallback subscription account,
|
|
6272
6282
|
// or the all-exhausted operator alert when none has quota). Fire-and-forget.
|
|
6273
6283
|
onQuotaWallDetected(_client: IpcClient, msg: QuotaWallDetectedMessage) {
|
|
6274
|
-
const
|
|
6275
|
-
const untilMs =
|
|
6276
|
-
typeof msg.resetAt === 'number' && Number.isFinite(msg.resetAt) && msg.resetAt > Date.now()
|
|
6277
|
-
? msg.resetAt
|
|
6278
|
-
: Date.now() + WEEKLY_MS
|
|
6284
|
+
const untilMs = resolveExhaustUntil(msg.resetAt)
|
|
6279
6285
|
process.stderr.write(
|
|
6280
6286
|
`telegram gateway: quota_wall_detected agent=${msg.agentName} ` +
|
|
6281
6287
|
`until=${new Date(untilMs).toISOString()}` +
|
|
@@ -14681,10 +14687,12 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
|
|
|
14681
14687
|
// operator is explicitly choosing, and is admin); only this automatic
|
|
14682
14688
|
// path moves to the non-admin verb.
|
|
14683
14689
|
failover: async () => {
|
|
14684
|
-
//
|
|
14685
|
-
// rate-limit-MENU path
|
|
14686
|
-
// reset,
|
|
14687
|
-
// the
|
|
14690
|
+
// BOTH paths now pass a resolved `untilMs` via resolveExhaustUntil
|
|
14691
|
+
// (never undefined): the rate-limit-MENU path threads the parsed weekly
|
|
14692
|
+
// reset, and the 429 inference path threads parseResetTime's value or
|
|
14693
|
+
// the +7d floor when it's a weekly wall it can't parse. So a
|
|
14694
|
+
// weekly-capped account is never re-probed (and re-wedged) within the
|
|
14695
|
+
// broker's ~5h default.
|
|
14688
14696
|
const r = await client.markExhausted(untilMs)
|
|
14689
14697
|
return { rolledTo: r.rolledTo ?? null, rolled: r.rolled }
|
|
14690
14698
|
},
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the shared exhaustion-`until` decision (exhaust-until.ts) and
|
|
3
|
+
* the 429-path composition it sits in.
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing invariant (vision.md pillar 3 + the #2218 weekly-wall
|
|
6
|
+
* rollback): the value handed to markExhausted is NEVER undefined, so a weekly
|
|
7
|
+
* wall can never fall back to the broker's ~5h default and un-exhaust early.
|
|
8
|
+
*
|
|
9
|
+
* The 429-path composition test runs the REAL detectModelUnavailable
|
|
10
|
+
* (model-unavailable.ts) into the REAL resolveExhaustUntil — proving the gateway
|
|
11
|
+
* 429 branch (`resolveExhaustUntil(modelUnavailable.resetAt?.getTime())`) threads
|
|
12
|
+
* a finite `until` for both the rolling and the unparseable-weekly wordings.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { resolveExhaustUntil, EXHAUST_WEEKLY_FLOOR_MS } from '../gateway/exhaust-until.js'
|
|
17
|
+
import { detectModelUnavailable } from '../model-unavailable.js'
|
|
18
|
+
|
|
19
|
+
const NOW = Date.UTC(2026, 5, 7, 0, 0, 0) // 2026-06-07
|
|
20
|
+
|
|
21
|
+
describe('resolveExhaustUntil', () => {
|
|
22
|
+
it('passes through a finite reset in the future', () => {
|
|
23
|
+
const reset = NOW + 2 * 60 * 60 * 1000 // +2h (a rolling window)
|
|
24
|
+
expect(resolveExhaustUntil(reset, NOW)).toBe(reset)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('undefined → +7d floor (the WEEKLY-wall case), never undefined', () => {
|
|
28
|
+
const until = resolveExhaustUntil(undefined, NOW)
|
|
29
|
+
expect(until).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
|
|
30
|
+
expect(until).toBeGreaterThan(NOW + 5 * 60 * 60 * 1000) // past the ~5h default
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('a past / now / NaN / non-finite reset → +7d floor (treated as unparsed)', () => {
|
|
34
|
+
expect(resolveExhaustUntil(NOW - 1, NOW)).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
|
|
35
|
+
expect(resolveExhaustUntil(NOW, NOW)).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
|
|
36
|
+
expect(resolveExhaustUntil(Number.NaN, NOW)).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
|
|
37
|
+
expect(resolveExhaustUntil(Number.POSITIVE_INFINITY, NOW)).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('the floor is comfortably longer than the broker ~5h mark-exhausted default', () => {
|
|
41
|
+
expect(EXHAUST_WEEKLY_FLOOR_MS).toBeGreaterThan(5 * 60 * 60 * 1000)
|
|
42
|
+
expect(EXHAUST_WEEKLY_FLOOR_MS).toBe(7 * 24 * 60 * 60 * 1000)
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('429-path composition: detectModelUnavailable → resolveExhaustUntil', () => {
|
|
47
|
+
// This mirrors gateway.ts's 429 branch:
|
|
48
|
+
// const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
|
|
49
|
+
// NB: parseResetTime anchors RELATIVE wordings ("in 2h", "retry after Ns") at
|
|
50
|
+
// the real Date.now() — not injectable through detectModelUnavailable — so the
|
|
51
|
+
// rolling-window assertions check the SHAPE (finite, future, well below the
|
|
52
|
+
// +7d floor, no longer than the stated window), never an exact epoch.
|
|
53
|
+
function untilFor(stderr: string): { u: number; before: number } {
|
|
54
|
+
const before = Date.now()
|
|
55
|
+
const d = detectModelUnavailable(stderr)
|
|
56
|
+
expect(d).not.toBeNull()
|
|
57
|
+
return { u: resolveExhaustUntil(d?.resetAt?.getTime(), before), before }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('ROLLING wording ("resets in 2h 15m") → a finite ~2h until, NOT the +7d floor', () => {
|
|
61
|
+
const { u, before } = untilFor('usage limit reached · resets in 2h 15m')
|
|
62
|
+
expect(Number.isFinite(u)).toBe(true)
|
|
63
|
+
expect(u).toBeGreaterThan(before)
|
|
64
|
+
const window = (2 * 60 + 15) * 60 * 1000
|
|
65
|
+
expect(u).toBeLessThanOrEqual(before + window + 2000) // ~2h15m, allow detect/resolve skew
|
|
66
|
+
expect(u).toBeLessThan(before + EXHAUST_WEEKLY_FLOOR_MS / 2) // unmistakably NOT the floor
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('ROLLING wording ("retry after 60 seconds") → a finite ~60s until, NOT the +7d floor', () => {
|
|
70
|
+
const { u, before } = untilFor('rate_limit_error: retry after 60 seconds — usage limit')
|
|
71
|
+
expect(u).toBeGreaterThan(before)
|
|
72
|
+
expect(u).toBeLessThanOrEqual(before + 60 * 1000 + 2000)
|
|
73
|
+
expect(u).toBeLessThan(before + EXHAUST_WEEKLY_FLOOR_MS / 2)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("WEEKLY wording (\"resets Jun 9, 5am (tz)\") is UNPARSEABLE on the 429 path → +7d floor, NOT undefined/5h", () => {
|
|
77
|
+
// parseResetTime can't read the calendar weekly wording — Invalid Date →
|
|
78
|
+
// resetAt undefined. resolveExhaustUntil MUST floor it to +7d, otherwise
|
|
79
|
+
// markExhausted's ~5h default re-wedges the weekly-walled account.
|
|
80
|
+
const d = detectModelUnavailable("You've hit your limit · resets Jun 9, 5am (Australia/Melbourne)")
|
|
81
|
+
expect(d?.kind).toBe('quota_exhausted')
|
|
82
|
+
expect(d?.resetAt).toBeUndefined() // pins the parser-blind-spot premise
|
|
83
|
+
const u = resolveExhaustUntil(d?.resetAt?.getTime(), NOW)
|
|
84
|
+
expect(u).toBe(NOW + EXHAUST_WEEKLY_FLOOR_MS)
|
|
85
|
+
})
|
|
86
|
+
})
|