switchroom 0.16.27 → 0.16.29
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/switchroom.js
CHANGED
|
@@ -51741,8 +51741,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
51741
51741
|
import { dirname, join } from "node:path";
|
|
51742
51742
|
|
|
51743
51743
|
// src/build-info.ts
|
|
51744
|
-
var VERSION = "0.16.
|
|
51745
|
-
var COMMIT_SHA = "
|
|
51744
|
+
var VERSION = "0.16.29";
|
|
51745
|
+
var COMMIT_SHA = "aa0851be";
|
|
51746
51746
|
|
|
51747
51747
|
// src/cli/resolve-version.ts
|
|
51748
51748
|
function readPackageVersion() {
|
|
@@ -78143,7 +78143,7 @@ async function onHermesMessage(ctx, raw) {
|
|
|
78143
78143
|
break;
|
|
78144
78144
|
}
|
|
78145
78145
|
case "session.status": {
|
|
78146
|
-
const sessionId = String(params.session_id ?? "");
|
|
78146
|
+
const sessionId = String(params.session_id ?? ctx.activeSessionId ?? "");
|
|
78147
78147
|
if (!isKnownSession(config, sessionId)) {
|
|
78148
78148
|
sendResponse(ctx, rpcErr(id, -32602, `Unknown session: ${sessionId}`));
|
|
78149
78149
|
break;
|
|
@@ -78161,7 +78161,15 @@ async function onHermesMessage(ctx, raw) {
|
|
|
78161
78161
|
case "session.create":
|
|
78162
78162
|
case "session.resume":
|
|
78163
78163
|
case "session.activate": {
|
|
78164
|
-
|
|
78164
|
+
let sessionId = String(params.session_id ?? params.name ?? "");
|
|
78165
|
+
if (!sessionId && ctx.activeSessionId)
|
|
78166
|
+
sessionId = ctx.activeSessionId;
|
|
78167
|
+
if (!sessionId) {
|
|
78168
|
+
const all = await handleGetAgents(config);
|
|
78169
|
+
const latest = all.sort((a, b) => (b.lastTurnAt ?? 0) - (a.lastTurnAt ?? 0))[0];
|
|
78170
|
+
if (latest)
|
|
78171
|
+
sessionId = latest.name;
|
|
78172
|
+
}
|
|
78165
78173
|
if (!isKnownSession(config, sessionId)) {
|
|
78166
78174
|
sendResponse(ctx, rpcErr(id, -32602, `Unknown session: ${sessionId}`));
|
|
78167
78175
|
break;
|
|
@@ -78270,6 +78278,12 @@ async function onHermesMessage(ctx, raw) {
|
|
|
78270
78278
|
sendResponse(ctx, rpcOk(id, { ok: true }));
|
|
78271
78279
|
break;
|
|
78272
78280
|
}
|
|
78281
|
+
case "pet.info":
|
|
78282
|
+
case "pet.info.meta":
|
|
78283
|
+
case "pet.gallery": {
|
|
78284
|
+
sendResponse(ctx, rpcOk(id, { enabled: false }));
|
|
78285
|
+
break;
|
|
78286
|
+
}
|
|
78273
78287
|
case "setup.status": {
|
|
78274
78288
|
sendResponse(ctx, rpcOk(id, { provider_configured: true }));
|
|
78275
78289
|
break;
|
|
@@ -22593,7 +22593,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
|
|
|
22593
22593
|
import { dirname as dirname4, join as join2 } from "node:path";
|
|
22594
22594
|
|
|
22595
22595
|
// src/build-info.ts
|
|
22596
|
-
var VERSION = "0.16.
|
|
22596
|
+
var VERSION = "0.16.29";
|
|
22597
22597
|
|
|
22598
22598
|
// src/cli/resolve-version.ts
|
|
22599
22599
|
function readPackageVersion() {
|
package/package.json
CHANGED
|
@@ -56164,11 +56164,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
56164
56164
|
}
|
|
56165
56165
|
|
|
56166
56166
|
// ../src/build-info.ts
|
|
56167
|
-
var VERSION = "0.16.
|
|
56168
|
-
var COMMIT_SHA = "
|
|
56169
|
-
var COMMIT_DATE = "2026-06-
|
|
56170
|
-
var LATEST_PR =
|
|
56171
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
56167
|
+
var VERSION = "0.16.29";
|
|
56168
|
+
var COMMIT_SHA = "aa0851be";
|
|
56169
|
+
var COMMIT_DATE = "2026-06-30T06:08:57Z";
|
|
56170
|
+
var LATEST_PR = 2685;
|
|
56171
|
+
var COMMITS_AHEAD_OF_TAG = 0;
|
|
56172
56172
|
|
|
56173
56173
|
// gateway/boot-version.ts
|
|
56174
56174
|
function formatRelativeAgo(iso) {
|
|
@@ -57685,6 +57685,19 @@ var TOPIC_ID = process.env.TELEGRAM_TOPIC_ID ? Number(process.env.TELEGRAM_TOPIC
|
|
|
57685
57685
|
var AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === "true";
|
|
57686
57686
|
var bot = new import_grammy11.Bot(TOKEN);
|
|
57687
57687
|
installTgPostLogger(bot);
|
|
57688
|
+
var lastGetUpdatesHeartbeatMs = Date.now();
|
|
57689
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
57690
|
+
try {
|
|
57691
|
+
const result = await prev(method, payload, signal);
|
|
57692
|
+
if (method === "getUpdates")
|
|
57693
|
+
lastGetUpdatesHeartbeatMs = Date.now();
|
|
57694
|
+
return result;
|
|
57695
|
+
} catch (err) {
|
|
57696
|
+
if (method === "getUpdates")
|
|
57697
|
+
lastGetUpdatesHeartbeatMs = Date.now();
|
|
57698
|
+
throw err;
|
|
57699
|
+
}
|
|
57700
|
+
});
|
|
57688
57701
|
var GRAMMY_VERSION = (() => {
|
|
57689
57702
|
try {
|
|
57690
57703
|
const raw = readFileSync37(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
|
|
@@ -69756,7 +69769,14 @@ var POLL_HEALTH_THRESHOLD = Number(process.env.SWITCHROOM_POLL_HEALTH_THRESHOLD
|
|
|
69756
69769
|
var pollHealthCheck = null;
|
|
69757
69770
|
if (POLL_HEALTH_INTERVAL_MS > 0) {
|
|
69758
69771
|
pollHealthCheck = createPollHealthCheck({
|
|
69759
|
-
ping: () =>
|
|
69772
|
+
ping: async () => {
|
|
69773
|
+
await bot.api.getMe();
|
|
69774
|
+
const staleMs = Date.now() - lastGetUpdatesHeartbeatMs;
|
|
69775
|
+
const staleThresholdMs = POLL_HEALTH_INTERVAL_MS * POLL_HEALTH_THRESHOLD;
|
|
69776
|
+
if (staleMs > staleThresholdMs) {
|
|
69777
|
+
throw new Error(`getUpdates heartbeat stale: last seen ${Math.round(staleMs / 1000)}s ago (threshold ${Math.round(staleThresholdMs / 1000)}s) \u2014 runner loop frozen`);
|
|
69778
|
+
}
|
|
69779
|
+
},
|
|
69760
69780
|
onStall: async () => {
|
|
69761
69781
|
recoverFromPollStall({ agentName: process.env.SWITCHROOM_AGENT_NAME ?? "-" });
|
|
69762
69782
|
},
|
|
@@ -785,6 +785,26 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
|
|
|
785
785
|
const bot = new Bot(TOKEN)
|
|
786
786
|
installTgPostLogger(bot)
|
|
787
787
|
|
|
788
|
+
// ─── getUpdates heartbeat ─────────────────────────────────────────────────
|
|
789
|
+
// Tracks the last time getUpdates completed (success OR error). Used by
|
|
790
|
+
// the poll health check as a secondary stall signal: if getMe succeeds but
|
|
791
|
+
// getUpdates hasn't responded in a while, the grammy runner loop is frozen
|
|
792
|
+
// without the network being down (2026-06-30: clerk/klanker deaf for 2h
|
|
793
|
+
// after a single timeout — getMe stayed green so the getMe-only check
|
|
794
|
+
// never fired). Initialized to now so the first health-check interval
|
|
795
|
+
// doesn't false-positive before the runner makes its first poll.
|
|
796
|
+
let lastGetUpdatesHeartbeatMs = Date.now()
|
|
797
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
798
|
+
try {
|
|
799
|
+
const result = await prev(method, payload, signal)
|
|
800
|
+
if (method === 'getUpdates') lastGetUpdatesHeartbeatMs = Date.now()
|
|
801
|
+
return result
|
|
802
|
+
} catch (err) {
|
|
803
|
+
if (method === 'getUpdates') lastGetUpdatesHeartbeatMs = Date.now()
|
|
804
|
+
throw err
|
|
805
|
+
}
|
|
806
|
+
})
|
|
807
|
+
|
|
788
808
|
const GRAMMY_VERSION: string = (() => {
|
|
789
809
|
try {
|
|
790
810
|
const raw = readFileSync(new URL('../../node_modules/grammy/package.json', import.meta.url), 'utf8')
|
|
@@ -23319,7 +23339,23 @@ const POLL_HEALTH_THRESHOLD = Number(
|
|
|
23319
23339
|
let pollHealthCheck: PollHealthCheckHandle | null = null
|
|
23320
23340
|
if (POLL_HEALTH_INTERVAL_MS > 0) {
|
|
23321
23341
|
pollHealthCheck = createPollHealthCheck({
|
|
23322
|
-
ping: () =>
|
|
23342
|
+
ping: async () => {
|
|
23343
|
+
await bot.api.getMe()
|
|
23344
|
+
// Secondary: if getMe passes but getUpdates hasn't responded in
|
|
23345
|
+
// `threshold × interval` ms, the grammy runner loop is frozen without
|
|
23346
|
+
// the network being down. Throw so the failure counter increments and
|
|
23347
|
+
// stall recovery fires after `failureThreshold` consecutive misses.
|
|
23348
|
+
// (2026-06-30 incident: one getUpdates TimeoutError → runner silently
|
|
23349
|
+
// froze; getMe stayed green; fleet deaf for 2 h until manual restart.)
|
|
23350
|
+
const staleMs = Date.now() - lastGetUpdatesHeartbeatMs
|
|
23351
|
+
const staleThresholdMs = POLL_HEALTH_INTERVAL_MS * POLL_HEALTH_THRESHOLD
|
|
23352
|
+
if (staleMs > staleThresholdMs) {
|
|
23353
|
+
throw new Error(
|
|
23354
|
+
`getUpdates heartbeat stale: last seen ${Math.round(staleMs / 1000)}s ago ` +
|
|
23355
|
+
`(threshold ${Math.round(staleThresholdMs / 1000)}s) — runner loop frozen`,
|
|
23356
|
+
)
|
|
23357
|
+
}
|
|
23358
|
+
},
|
|
23323
23359
|
onStall: async () => {
|
|
23324
23360
|
// Exit non-zero → _switchroom_supervise restarts the gateway sidecar
|
|
23325
23361
|
// with a fresh runner. Never awaits runnerHandle.stop() (it hangs on a
|
|
@@ -68,6 +68,70 @@ describe("createPollHealthCheck", () => {
|
|
|
68
68
|
hc.stop();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
it("detects stall when getMe passes but getUpdates heartbeat is stale", async () => {
|
|
72
|
+
// Simulates the 2026-06-30 incident: one getUpdates TimeoutError left the
|
|
73
|
+
// grammy runner loop frozen. getMe kept succeeding so the original
|
|
74
|
+
// getMe-only health check never fired. Fleet was deaf for 2 h.
|
|
75
|
+
// The fix: ping() also checks lastGetUpdatesHeartbeatMs; if stale, throws
|
|
76
|
+
// so the failure counter increments and stall recovery fires.
|
|
77
|
+
const onStall = vi.fn().mockResolvedValue(undefined);
|
|
78
|
+
let tickFn: () => void = () => {};
|
|
79
|
+
let lastGetUpdatesMs = Date.now() - 999_999; // very stale
|
|
80
|
+
const staleThresholdMs = 180_000; // 3 min (threshold × interval)
|
|
81
|
+
|
|
82
|
+
const hc = createPollHealthCheck({
|
|
83
|
+
ping: async () => {
|
|
84
|
+
// getMe succeeds (network fine):
|
|
85
|
+
// (no throw from network layer)
|
|
86
|
+
// heartbeat stale check (mirrors gateway.ts logic):
|
|
87
|
+
const staleMs = Date.now() - lastGetUpdatesMs;
|
|
88
|
+
if (staleMs > staleThresholdMs) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`getUpdates heartbeat stale: last seen ${Math.round(staleMs / 1000)}s ago — runner loop frozen`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
onStall,
|
|
95
|
+
failureThreshold: 3,
|
|
96
|
+
setIntervalFn: (fn) => { tickFn = fn; return 1 as unknown as ReturnType<typeof setInterval>; },
|
|
97
|
+
clearIntervalFn: () => {},
|
|
98
|
+
log: () => {},
|
|
99
|
+
});
|
|
100
|
+
hc.start();
|
|
101
|
+
tickFn(); await Promise.resolve();
|
|
102
|
+
tickFn(); await Promise.resolve();
|
|
103
|
+
tickFn(); await Promise.resolve();
|
|
104
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
105
|
+
expect(onStall).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does NOT stall when getUpdates heartbeat is fresh", async () => {
|
|
109
|
+
const onStall = vi.fn().mockResolvedValue(undefined);
|
|
110
|
+
let tickFn: () => void = () => {};
|
|
111
|
+
let lastGetUpdatesMs = Date.now(); // fresh
|
|
112
|
+
const staleThresholdMs = 180_000;
|
|
113
|
+
|
|
114
|
+
const hc = createPollHealthCheck({
|
|
115
|
+
ping: async () => {
|
|
116
|
+
const staleMs = Date.now() - lastGetUpdatesMs;
|
|
117
|
+
if (staleMs > staleThresholdMs) {
|
|
118
|
+
throw new Error("getUpdates heartbeat stale");
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
onStall,
|
|
122
|
+
failureThreshold: 3,
|
|
123
|
+
setIntervalFn: (fn) => { tickFn = fn; return 1 as unknown as ReturnType<typeof setInterval>; },
|
|
124
|
+
clearIntervalFn: () => {},
|
|
125
|
+
log: () => {},
|
|
126
|
+
});
|
|
127
|
+
hc.start();
|
|
128
|
+
for (let i = 0; i < 5; i++) {
|
|
129
|
+
tickFn(); await Promise.resolve();
|
|
130
|
+
}
|
|
131
|
+
expect(onStall).not.toHaveBeenCalled();
|
|
132
|
+
hc.stop();
|
|
133
|
+
});
|
|
134
|
+
|
|
71
135
|
it("stop() cancels the interval", () => {
|
|
72
136
|
const onStall = vi.fn();
|
|
73
137
|
let cleared = false;
|