@inetafrica/open-claudia 3.0.41 → 3.0.42

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.42 — Telegram stops rebooting itself: the long-poll wedge now heals in place
4
+
5
+ - **The bot no longer restarts the whole process to recover from a stuck Telegram long-poll.** When the network stalls a poll mid-flight (a half-open socket after a laptop sleep/wake, or a NAT/IPv6 connection eviction), `node-telegram-bot-api` was left holding one reused internal poller whose abort flag got stuck "on". Restarting polling *in place* revived that same poisoned object, which fired a single `getUpdates` and then silently refused to schedule the next one — a dead loop that threw no error, so the heal looked like it "worked" while nothing was actually polling. The only thing that ever truly recovered was the 10-minute backstop calling `process.exit(1)` and letting the supervisor relaunch — i.e. the reboot storm you'd see. The heal now **discards the poisoned poller so a fresh one is built** (the update offset is preserved, so no messages are refetched or skipped), and it only declares polling "healthy again" once a `getUpdates` has genuinely **completed since the restart** — otherwise it re-arms another heal instead of blindly declaring victory. Net effect: transient poll wedges recover in seconds without a process restart; the 10-min self-exit becomes the rare true-last-resort it was meant to be.
6
+ - **Agent-space / OpenClaw:** no new deps, env, or schema change; Docker image builds FROM the existing `:base`. Change is confined to `channels/telegram/adapter.js` (the `_heal` path); covered by three new cases in `test-telegram-poll-recovery.js` (proven to fail before the fix and pass after — the poisoned-object revival and the false "healthy" signal). Existing pods pick it up on their next approved upgrade.
7
+
3
8
  ## v3.0.41 — Verbose off now means a clean chat: the "Let me…" narration no longer lingers
4
9
 
5
10
  - **With `/verbose` off, the interim progress message is now removed once a turn settles, so the chat is left with only the final answer.** During a multi-step turn the bot posts a throttled "working…" preview that shows the short "Let me check…/Let me look at…" narration emitted before each tool call. That preview used to *stay* — settling into a permanent message — regardless of `/verbose`, which only ever gated the extended-thinking (🧠) stream. So even with verbose off you'd be left with the narration trail as a leftover message that reads like thinking-out-loud. `finalizeStreamPreview` now deletes that preview when verbose is off; `/verbose on` still keeps the narration trail. Live progress during long turns is unchanged either way — the cleanup only affects what remains after the turn ends.
@@ -178,21 +178,44 @@ class TelegramAdapter {
178
178
  try {
179
179
  await this._stopPollingClean("stale Telegram long-poll recovery");
180
180
  await new Promise((r) => setTimeout(r, 500));
181
- // restart:false — the lib's restart path is another cancel-stop (zombie
182
- // factory); we already stopped cleanly, so only start if truly idle.
181
+ // THE wedge fix: the lib reuses this.bot._polling across start/stop and
182
+ // only reschedules the poll loop when `_abort === false`. A wedge (half-
183
+ // open socket after sleep/wake or a NAT/IPv6 eviction) can leave the old
184
+ // getUpdates unsettled past our 5s stop cap, so `_abort` stays stuck true
185
+ // — the restarted loop then fires ONE getUpdates and dies in its .finally,
186
+ // a silent dead loop that only process.exit ever cleared (the restart
187
+ // storm). Nulling forces startPolling to build a FRESH object with
188
+ // `_abort=false`; the offset survives via the shared bot.options.polling
189
+ // params, so no updates are refetched or skipped.
190
+ this.bot._polling = null;
191
+ // The fresh object's _lastUpdate starts at 0; startPolling below blocks up
192
+ // to ~30s on the first long-poll, so stamp the watchdog grace baseline NOW
193
+ // — else _checkPollingHealth would fall back to a stale _pollStartedAt and
194
+ // false-fire mid-first-poll.
195
+ this._pollStartedAt = Date.now();
183
196
  await this.bot.startPolling({ restart: false });
184
197
  } catch (e) {
185
198
  return; // restart didn't take — the next hiccup reschedules a heal
186
199
  }
187
- // Quiet for 30s after a restart ⇒ genuinely recovered.
200
+ // Recovery is real only if a getUpdates has actually COMPLETED since the
201
+ // restart — not merely "no throw for 35s". A dead one-shot loop throws
202
+ // nothing, so the old blind timer declared it healthy and reset _wedgedSince,
203
+ // hiding the wedge until the 10-min backstop rebooted. Gate the reset on a
204
+ // completed poll; otherwise re-arm a heal.
205
+ const restartedAt = this._pollStartedAt;
188
206
  if (this._healthyTimer) clearTimeout(this._healthyTimer);
189
207
  this._healthyTimer = setTimeout(() => {
190
208
  this._healthyTimer = null;
191
- if (this._wedgedSince) console.log("Polling healthy again.");
192
- this._wedgedSince = 0;
193
- this._healBackoff = 0;
194
- this._deferredExitLogAt = 0;
195
- }, 30000);
209
+ const lastCompletedPoll = this.bot?._polling?._lastUpdate || 0;
210
+ if (lastCompletedPoll >= restartedAt) {
211
+ if (this._wedgedSince) console.log("Polling healthy again.");
212
+ this._wedgedSince = 0;
213
+ this._healBackoff = 0;
214
+ this._deferredExitLogAt = 0;
215
+ } else {
216
+ this._onPollingHiccup("restart completed but no getUpdates within 35s");
217
+ }
218
+ }, 35000);
196
219
  }
197
220
 
198
221
  // Telegram 409s a getUpdates call while ANOTHER one holds the token: a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.41",
3
+ "version": "3.0.42",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -65,7 +65,16 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
65
65
  // permanent message regardless of /verbose — only extended-thinking was gated.
66
66
  // finalizeStreamPreview now deletes the settled preview when /verbose is off, so
67
67
  // chat carries only the final answer; verbose ON still keeps the narration trail.
68
- assert.strictEqual(pkg.version, "3.0.41", "release version must remain unchanged without explicit approval");
68
+ // 3.0.42 approved by Sumeet 2026-07-18 ("Yes please because it's restarting way
69
+ // too much"): Telegram poll-recovery fix that stops the restart storm. A wedge
70
+ // (half-open getUpdates after sleep/wake or NAT/IPv6 eviction) left the lib's
71
+ // REUSED _polling object with _abort stuck true, so the in-place restart revived
72
+ // a dead one-shot loop — the heal "succeeded" yet nothing polled, and only the
73
+ // 10-min process.exit backstop recovered. The heal now nulls _polling so the lib
74
+ // builds a fresh object (offset survives via the shared params), and the
75
+ // "healthy" reset requires a getUpdates that actually COMPLETED since the restart
76
+ // (else it re-arms a heal) instead of blindly declaring victory after 35s.
77
+ assert.strictEqual(pkg.version, "3.0.42", "release version must remain unchanged without explicit approval");
69
78
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
70
79
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
71
80
  }
@@ -244,6 +244,156 @@ async function testWedgeExitFiresWhenIdle() {
244
244
  assert.strictEqual(exitCode, 1, "the idle wedge backstop must exit(1) for a clean restart");
245
245
  }
246
246
 
247
+ // THE RESTART STORM regression. A wedge (half-open getUpdates after sleep/wake
248
+ // or a NAT/IPv6 eviction) leaves the lib's REUSED _polling object with `_abort`
249
+ // stuck true: the hung request never settled within our stop cap, so stop()'s
250
+ // `.finally(() => _abort = false)` never ran. node-telegram-bot-api's
251
+ // startPolling reuses this.bot._polling when it's present, so restarting in
252
+ // place revives the SAME poisoned object — its loop fires one getUpdates and
253
+ // dies in .finally (sees _abort===true ⇒ no reschedule). A silent dead loop:
254
+ // the heal "succeeds" (no throw) yet nothing polls, so only the 10-min backstop's
255
+ // process.exit ever recovered (the reboot storm the user saw). The heal must
256
+ // null _polling so the lib constructs a FRESH object with _abort=false; the
257
+ // offset survives via the shared bot.options.polling params. Fails before the
258
+ // fix because the poisoned object is revived in place.
259
+ async function testHealNullsPoisonedPollingSoLibRebuildsFresh() {
260
+ const ancient = Date.now() - 5 * 60 * 1000;
261
+ const poisoned = { _abort: true, _lastUpdate: ancient };
262
+ const adapter = Object.create(TelegramAdapter.prototype);
263
+ adapter._healTimer = null;
264
+ adapter._healthyTimer = null;
265
+ adapter._wedgedSince = Date.now() - 60000;
266
+ adapter._healBackoff = 0;
267
+ adapter._pollStartedAt = ancient;
268
+ adapter._onPollingHiccup = () => {};
269
+ adapter._agent = { destroy() {} };
270
+ adapter.bot = {
271
+ _polling: poisoned,
272
+ async stopPolling() { /* wedge: the hung request never settles, _abort stays true */ },
273
+ async startPolling() {
274
+ // Faithful to the lib: reuse _polling if present, else construct fresh.
275
+ if (!this._polling) this._polling = { _abort: false, _lastUpdate: Date.now() };
276
+ },
277
+ };
278
+
279
+ const realSetTimeout = global.setTimeout;
280
+ const realClearTimeout = global.clearTimeout;
281
+ global.setTimeout = (fn, ms) => { if (ms === 500 || ms === 5000) fn(); return { ms }; };
282
+ global.clearTimeout = () => {};
283
+ try {
284
+ await adapter._heal();
285
+ } finally {
286
+ global.setTimeout = realSetTimeout;
287
+ global.clearTimeout = realClearTimeout;
288
+ }
289
+
290
+ assert.notStrictEqual(adapter.bot._polling, poisoned,
291
+ "heal must discard the poisoned _polling object, not revive it in place");
292
+ assert.strictEqual(adapter.bot._polling._abort, false,
293
+ "the rebuilt poller must start with _abort=false so its loop reschedules");
294
+ assert.ok(adapter.bot._polling._lastUpdate >= adapter._pollStartedAt,
295
+ "the fresh poller must be able to complete a getUpdates (a live loop, not a dead one-shot)");
296
+ }
297
+
298
+ // HONEST HEALTH SIGNAL regression. "No throw for 35s" is NOT recovery — a dead
299
+ // one-shot loop throws nothing. The old blind healthy-timer reset _wedgedSince
300
+ // unconditionally, so a restart that never completed a single getUpdates was
301
+ // declared healthy, hiding the wedge until the 10-min backstop rebooted. The
302
+ // check must require a getUpdates COMPLETED since the restart; if none did, it
303
+ // must re-arm a heal instead of declaring victory. Fails before the fix because
304
+ // the blind timer clears the wedge with no completed poll.
305
+ async function testHealthySignalReArmsWhenNoPollCompleted() {
306
+ const adapter = Object.create(TelegramAdapter.prototype);
307
+ adapter._healTimer = null;
308
+ adapter._healthyTimer = null;
309
+ adapter._wedgedSince = Date.now() - 60000;
310
+ adapter._healBackoff = 2;
311
+ adapter._deferredExitLogAt = 0;
312
+ adapter._pollStartedAt = 0;
313
+ adapter._agent = { destroy() {} };
314
+ adapter.bot = {
315
+ _polling: { _abort: true, _lastUpdate: 0 },
316
+ async stopPolling() {},
317
+ async startPolling() {
318
+ // Models a restart whose loop never completes a getUpdates: _lastUpdate
319
+ // stays 0 (pre-fix this is the revived poisoned object; post-fix a fresh
320
+ // object that simply hasn't succeeded yet).
321
+ if (!this._polling) this._polling = { _abort: false, _lastUpdate: 0 };
322
+ },
323
+ };
324
+ let healthyFn;
325
+ const hiccups = [];
326
+ adapter._onPollingHiccup = (m) => hiccups.push(m);
327
+
328
+ const realSetTimeout = global.setTimeout;
329
+ const realClearTimeout = global.clearTimeout;
330
+ global.setTimeout = (fn, ms) => {
331
+ if (ms === 500 || ms === 5000) fn();
332
+ else if (ms >= 30000) healthyFn = fn; // capture the healthy check (30s pre-fix, 35s post)
333
+ return { ms };
334
+ };
335
+ global.clearTimeout = () => {};
336
+ try {
337
+ await adapter._heal();
338
+ assert.strictEqual(typeof healthyFn, "function", "heal must arm a healthy check");
339
+ healthyFn();
340
+ } finally {
341
+ global.setTimeout = realSetTimeout;
342
+ global.clearTimeout = realClearTimeout;
343
+ }
344
+
345
+ assert.ok(adapter._wedgedSince > 0,
346
+ "a restart with no completed getUpdates must NOT clear the wedge");
347
+ assert.match(hiccups[0] || "", /no getUpdates/i,
348
+ "an unrecovered restart must re-arm a heal, not declare victory");
349
+ }
350
+
351
+ // Companion happy-path guard: once a getUpdates DOES complete after the restart,
352
+ // the same healthy check clears the wedge and resets backoff (and must NOT
353
+ // re-arm a heal). Passes before and after the fix — it guards the fix from
354
+ // over-correcting into a bot that never declares itself recovered.
355
+ async function testHealthySignalClearsWedgeOnceAPollCompletes() {
356
+ const adapter = Object.create(TelegramAdapter.prototype);
357
+ adapter._healTimer = null;
358
+ adapter._healthyTimer = null;
359
+ adapter._wedgedSince = Date.now() - 60000;
360
+ adapter._healBackoff = 3;
361
+ adapter._deferredExitLogAt = 12345;
362
+ adapter._pollStartedAt = 0;
363
+ adapter._agent = { destroy() {} };
364
+ adapter.bot = {
365
+ _polling: { _abort: true, _lastUpdate: 0 },
366
+ async stopPolling() {},
367
+ async startPolling() {
368
+ // A fresh, healthy poller that completes a getUpdates just after restart.
369
+ if (!this._polling) this._polling = { _abort: false, _lastUpdate: Date.now() + 1 };
370
+ },
371
+ };
372
+ let healthyFn;
373
+ const hiccups = [];
374
+ adapter._onPollingHiccup = (m) => hiccups.push(m);
375
+
376
+ const realSetTimeout = global.setTimeout;
377
+ const realClearTimeout = global.clearTimeout;
378
+ global.setTimeout = (fn, ms) => {
379
+ if (ms === 500 || ms === 5000) fn();
380
+ else if (ms >= 30000) healthyFn = fn;
381
+ return { ms };
382
+ };
383
+ global.clearTimeout = () => {};
384
+ try {
385
+ await adapter._heal();
386
+ healthyFn();
387
+ } finally {
388
+ global.setTimeout = realSetTimeout;
389
+ global.clearTimeout = realClearTimeout;
390
+ }
391
+
392
+ assert.strictEqual(adapter._wedgedSince, 0, "a completed getUpdates since restart must clear the wedge");
393
+ assert.strictEqual(adapter._healBackoff, 0, "recovery resets heal backoff");
394
+ assert.deepStrictEqual(hiccups, [], "a genuinely recovered restart must NOT re-arm a heal");
395
+ }
396
+
247
397
  (async () => {
248
398
  await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
249
399
  await testHealStopsPlainlyDestroysAgentAndRestartsIdle();
@@ -251,6 +401,9 @@ async function testWedgeExitFiresWhenIdle() {
251
401
  await test409ConflictSeversGhostSocketAndRestartsIdle();
252
402
  await testWedgeExitDefersWhileTurnInFlight();
253
403
  await testWedgeExitFiresWhenIdle();
404
+ await testHealNullsPoisonedPollingSoLibRebuildsFresh();
405
+ await testHealthySignalReArmsWhenNoPollCompleted();
406
+ await testHealthySignalClearsWedgeOnceAPollCompletes();
254
407
  console.log("telegram poll recovery OK");
255
408
  })().catch((err) => {
256
409
  console.error(err.stack || err);