@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 +8 -0
- package/channels/telegram/adapter.js +11 -0
- package/package.json +1 -1
- package/test-provider-language.js +10 -1
- package/test-telegram-poll-recovery.js +113 -0
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
|
@@ -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
|
-
|
|
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);
|