switchroom 0.14.85 → 0.14.87
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 +551 -75
- 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
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.87";
|
|
52893
|
+
var COMMIT_SHA = "5ad3e254";
|
|
52894
|
+
var COMMIT_DATE = "2026-06-07T08:49:13Z";
|
|
52895
|
+
var LATEST_PR = 2227;
|
|
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
|
+
})
|