@inetafrica/open-claudia 3.1.26 → 3.1.27

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.1.27 — The outage pause stops feeding the wedge-exit clock (the fleet-wide Telegram reboot loop)
4
+
5
+ - **Symptom.** Every Telegram-channel bot was rebooting itself in a loop while no other bot was: rashmi 214 restarts in 7d15h, euvy 210 in 7d17h, ruk-ai 62 in 44h, rahil-coders 11 — 4 of 4 pods running the `telegram` channel. All 12 pods running only `kazee, spaces, web` sat at 0. All 16 share one node and one egress IP, so node, address and NetworkPolicy preset were all controlled for; the *unrestricted* bot (euvy, allow-all egress) restarted most, which retired the egress theory rather than confirming it. The channel was the only discriminator.
6
+ - **Root cause.** `_wedgedSince` is stamped on the first polling hiccup and cleared in exactly one place — `_armHealthyCheck`, on a completed `getUpdates`. `_enterOutagePause()` cleared `_healthyTimer` (the only thing that can reach that reset) and never touched `_wedgedSince`. So every second spent inside the outage pause — a state whose entire documented purpose is *"a reboot can't fix a dead network... never exits"* — still counted toward the 10-minute reboot budget. A link that flapped a few times inside ten minutes rebooted the pod anyway, moments after this very method had twice decided a reboot could not help. Both bots exited almost exactly 10 minutes after their FIRST hiccup regardless of what happened in between: ruk-ai 07:41:02 → 07:51:11 = 10m09s across three outage pauses; euvy 07:33:37 → 07:43:42 = 10m05s.
7
+ - **Fix.** `_enterOutagePause()` now retires the wedge clock (`_wedgedSince = 0`, `_healBackoff = 0`). A failed *fresh* probe is positive evidence that the streak so far was the network, not an in-loop poll wedge — and the exit backstop exists only for the in-loop case. Clearing is safe because `_onPollingHiccup` no-ops while paused: a real in-loop wedge that survives the resume starts an honest new streak.
8
+ - **Proof, not assertion.** New regression test `testOutagePauseDoesNotFeedTheWedgeExitClock` in `test-telegram-poll-recovery.js` replays the production sequence line for line (hiccup → three genuine outage pauses with successful re-probes → ruk-ai's exact 10m09s mark on a reachable link). It **fails against unmodified code** with the reboot firing, and passes with the fix. `testWedgeExitFiresWhenIdle` still passes, so the genuine in-loop backstop is intact, not disabled.
9
+ - **Still open, deliberately not fixed here.** Why the path to `api.telegram.org` flaps at all is unexplained — the probe genuinely fails, three confirmed ~30s outages inside ten minutes on ruk-ai. This change stops the pointless reboots; it does not restore connectivity during a flap. Separately worth asking whether the wedge-exit backstop earns its keep at all: `_heal()` already destroys the agent, nulls `bot._polling` and dials a fresh socket — everything a new process does for the poll path — and 210 restarts never helped.
10
+
3
11
  ## v3.1.26 — Claude Opus 5 in the model picker
4
12
 
5
13
  - **`claude-opus-5` added to the Claude provider model list** (`CLAUDE_MODELS`), slotted between `claude-fable-5` and `claude-opus-4-8`. Opus 5 launched 2026-07-24 (near-Fable-5 capability at roughly half the price; Anthropic's new default on Max). Verified live before listing: a real CLI round-trip with `--model claude-opus-5` succeeded. Free-text `/model claude-opus-5` already worked (the `/model` handler doesn't validate against the list); this puts it on the picker buttons for `/model` and `/login`. Default model unchanged (`claude-opus-4-8`).
@@ -281,6 +281,17 @@ class TelegramAdapter {
281
281
  if (this._pausedForOutage) return;
282
282
  this._pausedForOutage = true;
283
283
  if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
284
+ // Retire the wedge clock. A failed fresh probe is positive evidence that the
285
+ // streak so far was the network, not an in-loop poll wedge — and the exit
286
+ // backstop exists ONLY for the in-loop case. Leaving it stamped counted every
287
+ // paused second toward the 10-min reboot budget, so a link that flapped a few
288
+ // times inside ten minutes rebooted the pod anyway, right after this very
289
+ // method decided a reboot couldn't help (the fleet-wide loop of 2026-08-01:
290
+ // rashmi 214 restarts, euvy 210, ruk-ai 62 — every Telegram pod, no kazee one).
291
+ // Clearing here is safe because _onPollingHiccup no-ops while paused: if a real
292
+ // in-loop wedge survives the resume, the next hiccup starts an honest new streak.
293
+ this._wedgedSince = 0;
294
+ this._healBackoff = 0;
284
295
  console.error(`${this._stamp()} Telegram unreachable — host-network outage; pausing polls, re-probing every 30s (no reboot, no reconnect hammering).`);
285
296
  // Stop the fast retry loop now (clean stop: no zombie loop, socket severed).
286
297
  Promise.resolve(this._stopPollingClean("host-network outage pause")).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.1.26",
3
+ "version": "3.1.27",
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": {
@@ -322,7 +322,16 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
322
322
  // 3.1.26 approved by Sumeet 2026-07-25 ("Do it"): add claude-opus-5 (launched
323
323
  // 2026-07-24) to the Claude provider model picker; verified live via a CLI
324
324
  // round-trip with --model claude-opus-5 before listing.
325
- assert.strictEqual(pkg.version, "3.1.26", "release version must remain unchanged without explicit approval");
325
+ // 3.1.27 approved by Sumeet 2026-08-02 ("Yes") after he asked for proof rather
326
+ // than assertion ("so are you sure these are the issues and the fix or any
327
+ // tests you can run to confirm?"): the outage pause stops feeding the
328
+ // wedge-exit clock. _enterOutagePause cleared _healthyTimer — the only thing
329
+ // that resets _wedgedSince — but never _wedgedSince itself, so every second
330
+ // spent in a state whose whole purpose is "the network is down, a reboot
331
+ // cannot help" still counted toward the 10-minute reboot budget. Reproduced by
332
+ // a new regression test that replays the prod log line for line and fails
333
+ // against unmodified code.
334
+ assert.strictEqual(pkg.version, "3.1.27", "release version must remain unchanged without explicit approval");
326
335
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
327
336
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
328
337
  }
@@ -511,6 +511,118 @@ async function testHealthySignalClearsWedgeOnceAPollCompletes() {
511
511
  assert.deepStrictEqual(hiccups, [], "a genuinely recovered restart must NOT re-arm a heal");
512
512
  }
513
513
 
514
+ // THE FLAPPING-NETWORK REBOOT LOOP (live 2026-08-01: rashmi 214 restarts/7d15h, euvy
515
+ // 210/7d17h, ruk-ai 62/44h — all four Telegram pods, none of the twelve kazee-only ones).
516
+ // _wedgedSince is stamped on the FIRST hiccup and cleared in exactly ONE place:
517
+ // _armHealthyCheck, on a COMPLETED getUpdates. _enterOutagePause clears _healthyTimer —
518
+ // the only thing that can reset it — and never touches _wedgedSince. So every second
519
+ // spent inside the outage pause, a state whose whole purpose is "the network is down, a
520
+ // reboot cannot help, never exit", still counts toward the 10-minute reboot budget. A
521
+ // link that flaps a few times inside ten minutes therefore reboots the pod anyway, even
522
+ // though the code twice decided a reboot was the wrong answer. In prod both bots exited
523
+ // almost exactly 10 min after their FIRST hiccup regardless of what happened in between
524
+ // (ruk-ai 07:41:02→07:51:11 = 10m09s with three outage pauses; euvy 07:33:37→07:43:42 =
525
+ // 10m05s). Fails before the fix: the exit fires on accumulated flap time.
526
+ async function testOutagePauseDoesNotFeedTheWedgeExitClock() {
527
+ const realNow = Date.now;
528
+ let now = 1_700_000_000_000;
529
+ Date.now = () => now;
530
+
531
+ let exited = false;
532
+ let reachable = true;
533
+ let tickFn = null;
534
+
535
+ const adapter = Object.create(TelegramAdapter.prototype);
536
+ adapter._healTimer = null;
537
+ adapter._healthyTimer = null;
538
+ adapter._conflictSince = 0;
539
+ adapter._conflictTimer = null;
540
+ adapter._wedgedSince = 0;
541
+ adapter._healBackoff = 0;
542
+ adapter._deferredExitLogAt = 0;
543
+ adapter._lastHiccupLog = 0;
544
+ adapter._pausedForOutage = false;
545
+ adapter._outageProbeTimer = null;
546
+ adapter._pollStartedAt = 0;
547
+ adapter._anyTurnActive = () => false; // idle throughout
548
+ adapter._probeTelegramReachable = async () => reachable;
549
+ adapter._agent = { destroy() {} };
550
+ adapter.bot = {
551
+ _polling: { _abort: false, _lastUpdate: 0 },
552
+ async stopPolling() {},
553
+ // A loop that never manages to complete a getUpdates while the link is flapping.
554
+ async startPolling() { if (!this._polling) this._polling = { _abort: false, _lastUpdate: 0 }; },
555
+ };
556
+
557
+ const realExit = process.exit;
558
+ process.exit = () => { exited = true; throw new Error("__exit__"); };
559
+ const realSetTimeout = global.setTimeout;
560
+ const realClearTimeout = global.clearTimeout;
561
+ global.setTimeout = (fn, ms) => {
562
+ if (ms === 500 || ms === 5000) fn(); // stop-clean internals fire inline
563
+ else if (ms === 30000) tickFn = fn; // capture the outage re-probe tick
564
+ return { ms };
565
+ };
566
+ global.clearTimeout = () => {};
567
+
568
+ const heal = async () => {
569
+ adapter._healTimer = null;
570
+ try { await adapter._heal(); }
571
+ catch (e) { if (e.message !== "__exit__") throw e; }
572
+ };
573
+
574
+ try {
575
+ // T+0: the link starts flapping. First hiccup stamps the wedge clock.
576
+ adapter._onPollingHiccup("EFATAL: Error: socket hang up");
577
+ const firstHiccup = now;
578
+ assert.strictEqual(adapter._wedgedSince, firstHiccup, "the first hiccup stamps the wedge clock");
579
+
580
+ // T+3m: a fresh connection fails too ⇒ genuine host outage ⇒ pause, never reboot.
581
+ now += 3 * 60 * 1000;
582
+ reachable = false;
583
+ await heal();
584
+ assert.strictEqual(adapter._pausedForOutage, true, "an unreachable host must enter the outage pause");
585
+ assert.strictEqual(exited, false, "the outage pause must not reboot");
586
+
587
+ // T+3m30s: link returns, polling resumes — but the loop still can't complete a poll.
588
+ now += 30 * 1000;
589
+ reachable = true;
590
+ assert.strictEqual(typeof tickFn, "function", "the pause must schedule a slow re-probe");
591
+ await tickFn();
592
+ assert.strictEqual(adapter._pausedForOutage, false, "a successful re-probe leaves the pause");
593
+
594
+ // T+6m / T+9m: it flaps twice more, each correctly handled as an outage.
595
+ for (const _ of [1, 2]) {
596
+ now += 2 * 60 * 1000 + 30 * 1000;
597
+ adapter._onPollingHiccup("EFATAL: Error: socket hang up");
598
+ reachable = false;
599
+ tickFn = null;
600
+ await heal();
601
+ assert.strictEqual(exited, false, "each outage must be paused through, never rebooted");
602
+ now += 30 * 1000;
603
+ reachable = true;
604
+ if (tickFn) await tickFn();
605
+ }
606
+
607
+ // T+10m+: the link is up right now, but the accumulated flap time has blown the
608
+ // 10-minute budget. The bot must NOT reboot: nearly all of that window was spent in
609
+ // a state it had already, correctly, decided a reboot could not fix.
610
+ now = firstHiccup + 10 * 60 * 1000 + 9 * 1000; // ruk-ai's exact 10m09s
611
+ reachable = true;
612
+ adapter._onPollingHiccup("EFATAL: Error: socket hang up");
613
+ await heal();
614
+
615
+ assert.strictEqual(exited, false,
616
+ "time spent in a known host-network outage must NOT count toward the wedge-exit clock — " +
617
+ "this is the fleet-wide reboot loop (rashmi 214, euvy 210, ruk-ai 62 restarts)");
618
+ } finally {
619
+ Date.now = realNow;
620
+ process.exit = realExit;
621
+ global.setTimeout = realSetTimeout;
622
+ global.clearTimeout = realClearTimeout;
623
+ }
624
+ }
625
+
514
626
  (async () => {
515
627
  await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
516
628
  await testHealStopsPlainlyDestroysAgentAndRestartsIdle();
@@ -523,6 +635,7 @@ async function testHealthySignalClearsWedgeOnceAPollCompletes() {
523
635
  await testHealNullsPoisonedPollingSoLibRebuildsFresh();
524
636
  await testHealthySignalReArmsWhenNoPollCompleted();
525
637
  await testHealthySignalClearsWedgeOnceAPollCompletes();
638
+ await testOutagePauseDoesNotFeedTheWedgeExitClock();
526
639
  console.log("telegram poll recovery OK");
527
640
  })().catch((err) => {
528
641
  console.error(err.stack || err);